Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ public interface FinancialCommitmentQueryClient {
/**
* 조회 기간에 납입 예정인 활성 금융상품을 반환한다.
* 납입일을 알 수 없는 활성 대출·적금도 목록에서 제외하지 않는다.
* 보험은 반복 납입이 감지된 항목만 생성하므로 납입일 미상 상태 자체가 없다.
* 보험은 실제 거래 설명의 상품명별로 분리하고 최신 출금일과 금액으로 다음 납입을 추정한다.
*/
List<FinancialCommitmentSummary> findFinancialCommitments(
Long userId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import java.math.RoundingMode;
import java.time.Clock;
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
Expand Down Expand Up @@ -107,10 +106,7 @@ private List<FinancialCommitmentSummary> buildLoanCommitments(Long userId, Local
return result;
}

/**
* 보험사 법인명·축약명 registry로 판정한 후보를 사용자별 표준 보험사명 하나로 묶는다.
* 출금계좌·계약·금액이 달라도 같은 보험사면 하나의 항목으로 합산하며, 반복 횟수나 동일 금액은 요구하지 않는다.
*/
/** 보험사 registry로 판정한 출금 거래를 실제 상품명별로 분리하고, 상품별 최신 납입액을 반환한다. */
private List<FinancialCommitmentSummary> buildInsuranceCommitments(Long userId, LocalDate fromDate, LocalDate toDate) {
LocalDate today = LocalDate.now(clock);
LocalDate observationEnd = toDate.isBefore(today) ? toDate : today;
Expand All @@ -119,36 +115,32 @@ private List<FinancialCommitmentSummary> buildInsuranceCommitments(Long userId,
List<InsuranceOutflowRow> rows = financialCommitmentMapper.findInsuranceOutflowCandidates(
userId, observationStart, observationEnd);

Map<String, List<InsuranceOutflowRow>> occurrencesByInsurer = new LinkedHashMap<>();
Map<String, List<InsuranceOutflowRow>> occurrencesByProduct = new LinkedHashMap<>();
for (InsuranceOutflowRow row : rows) {
String combinedDescription = combineDescriptions(row);
if (combinedDescription == null || row.getOutAmount() == null) {
continue;
}
InsuranceCompany.matchStandardName(combinedDescription).ifPresent(standardName ->
occurrencesByInsurer.computeIfAbsent(standardName, unused -> new ArrayList<>()).add(row));
InsuranceCompany.matchStandardName(combinedDescription).ifPresent(standardName -> {
String productName = findInsuranceProductName(row, standardName);
occurrencesByProduct.computeIfAbsent(productName, unused -> new ArrayList<>()).add(row);
});
}

List<FinancialCommitmentSummary> result = new ArrayList<>();
for (Map.Entry<String, List<InsuranceOutflowRow>> entry : occurrencesByInsurer.entrySet()) {
for (Map.Entry<String, List<InsuranceOutflowRow>> entry : occurrencesByProduct.entrySet()) {
List<InsuranceOutflowRow> occurrences = entry.getValue();

LocalDate latestTranDate = occurrences.stream()
.map(InsuranceOutflowRow::getTranDate)
.max(Comparator.naturalOrder())
InsuranceOutflowRow latestOccurrence = occurrences.stream()
.max(Comparator.comparing(InsuranceOutflowRow::getTranDate))
.orElseThrow();
LocalDate latestTranDate = latestOccurrence.getTranDate();
LocalDate nextPaymentDate = latestTranDate.plusMonths(1);
if (!withinRangeOrUnknown(nextPaymentDate, fromDate, toDate)) {
continue;
}

YearMonth latestMonth = YearMonth.from(latestTranDate);
BigDecimal monthlyTotal = occurrences.stream()
.filter(row -> YearMonth.from(row.getTranDate()).equals(latestMonth))
.map(InsuranceOutflowRow::getOutAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);

AmountResolution amount = resolveExpectedAmount(monthlyTotal);
AmountResolution amount = resolveExpectedAmount(latestOccurrence.getOutAmount());
result.add(new FinancialCommitmentSummary(
null, null, EXPENSE_TYPE_INSURANCE, entry.getKey(),
null, amount.amount(), null, null, nextPaymentDate, amount.status(), STATUS_ESTIMATED
Expand All @@ -157,6 +149,22 @@ private List<FinancialCommitmentSummary> buildInsuranceCommitments(Long userId,
return result;
}

/** desc1~desc4 중 판정된 보험사가 포함된 원문 필드를 상품명으로 사용한다. */
private static String findInsuranceProductName(InsuranceOutflowRow row, String standardInsurerName) {
for (String value : new String[]{row.getDesc1(), row.getDesc2(), row.getDesc3(), row.getDesc4()}) {
if (value == null || value.trim().isEmpty()) {
continue;
}
String candidate = value.trim();
if (InsuranceCompany.matchStandardName(candidate)
.filter(standardInsurerName::equals)
.isPresent()) {
return candidate;
}
}
return standardInsurerName;
}

/** desc1→desc2→desc3→desc4 순서로 trim한 비어 있지 않은 값을 |로 결합한다. 전부 비어 있으면 null. */
private static String combineDescriptions(InsuranceOutflowRow row) {
List<String> parts = new ArrayList<>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@

<!--
수시입출 계좌(deposit_type_code=11)의 관측 기간 내 일반 출금 거래를 반환한다.
반복 보험료 그룹핑·판정은 은행별 desc 필드 정규화가 필요해 Service에서 수행한다.
보험사 판정과 실제 상품명별 분리는 은행별 desc 필드 정규화가 필요해 Service에서 수행한다.
-->
<select id="findInsuranceOutflowCandidates"
resultType="com.ntropy.account.mapper.projection.InsuranceOutflowRow">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ void insuranceSingleOccurrenceWithKnownInsurerNameIsReportedImmediately() {
assertEquals(1, result.size());
FinancialCommitmentSummary summary = result.get(0);
assertEquals("INSURANCE_PREMIUM", summary.getExpenseType());
assertEquals("삼성생명", summary.getProductName());
assertEquals("삼성생명보험", summary.getProductName());
assertEquals(50000L, summary.getExpectedAmount());
assertEquals(LocalDate.of(2026, 8, 5), summary.getNextPaymentDate());
assertNull(summary.getAccountId());
Expand All @@ -265,25 +265,25 @@ void insuranceSingleOccurrenceWithKnownInsurerNameIsReportedImmediately() {
}

@Test
void insuranceSumsMultipleContractsAndWithdrawalAccountsForSameInsurerInLatestMonth() {
void insuranceSeparatesProductsFromSameInsurerWithoutSummingThem() {
InMemoryFinancialCommitmentMapper mapper = new InMemoryFinancialCommitmentMapper();
mapper.insurance.add(insuranceRow(30L, LocalDate.of(2026, 7, 3), "50000.00", "삼성생명보험", null, null, null));
mapper.insurance.add(insuranceRow(31L, LocalDate.of(2026, 7, 20), "30000.00", "삼성생명", null, null, null));
mapper.insurance.add(insuranceRow(30L, LocalDate.of(2026, 7, 3), "62000.00", null, "보험료", "삼성생명 실손보험", null));
mapper.insurance.add(insuranceRow(31L, LocalDate.of(2026, 7, 20), "43000.00", null, "보험료", "삼성생명 운전자보험", null));
FinancialCommitmentService service = new FinancialCommitmentService(mapper, FIXED_CLOCK);

List<FinancialCommitmentSummary> result = service.findFinancialCommitments(
1L, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31));

assertEquals(1, result.size());
FinancialCommitmentSummary summary = result.get(0);
assertEquals("삼성생명", summary.getProductName());
assertEquals(80000L, summary.getExpectedAmount());
assertEquals(LocalDate.of(2026, 8, 20), summary.getNextPaymentDate());
assertNull(summary.getAccountId());
assertEquals(2, result.size());
assertEquals(Set.of("삼성생명 실손보험", "삼성생명 운전자보험"),
result.stream().map(FinancialCommitmentSummary::getProductName).collect(Collectors.toSet()));
assertEquals(Set.of(62000L, 43000L),
result.stream().map(FinancialCommitmentSummary::getExpectedAmount).collect(Collectors.toSet()));
assertTrue(result.stream().allMatch(summary -> summary.getAccountId() == null));
}

@Test
void insuranceOnlySumsSameCalendarMonthAsLatestOccurrence() {
void insuranceUsesLatestOccurrenceAmountForSameProductWithoutSumming() {
InMemoryFinancialCommitmentMapper mapper = new InMemoryFinancialCommitmentMapper();
mapper.insurance.add(insuranceRow(30L, LocalDate.of(2026, 6, 5), "999999.00", "삼성생명", null, null, null));
mapper.insurance.add(insuranceRow(30L, LocalDate.of(2026, 7, 20), "50000.00", "삼성생명", null, null, null));
Expand All @@ -309,9 +309,9 @@ void insuranceKeepsDifferentInsurersSeparate() {

assertEquals(2, result.size());
FinancialCommitmentSummary samsung = result.stream()
.filter(s -> "삼성생명".equals(s.getProductName())).findFirst().orElseThrow();
.filter(s -> "삼성생명보험".equals(s.getProductName())).findFirst().orElseThrow();
FinancialCommitmentSummary db = result.stream()
.filter(s -> "DB손보".equals(s.getProductName())).findFirst().orElseThrow();
.filter(s -> "DB손해보험㈜".equals(s.getProductName())).findFirst().orElseThrow();
assertEquals(50000L, samsung.getExpectedAmount());
assertEquals(40000L, db.getExpectedAmount());
}
Expand All @@ -328,7 +328,7 @@ void insurancePublicInsuranceIsIncludedAsInsurancePremium() {

assertEquals(2, result.size());
assertTrue(result.stream().allMatch(s -> "INSURANCE_PREMIUM".equals(s.getExpenseType())));
assertEquals(Set.of("국민건강보험", "국민연금"),
assertEquals(Set.of("국민건강보험공단", "국민연금"),
result.stream().map(FinancialCommitmentSummary::getProductName).collect(Collectors.toSet()));
}

Expand Down Expand Up @@ -421,7 +421,7 @@ void insuranceItemsWithNullAccountIdAndSameNextPaymentDateAreSortedByProductName

assertEquals(2, result.size());
assertTrue(result.stream().allMatch(s -> s.getAccountId() == null));
assertEquals(List.of("DB손보", "삼성생명"),
assertEquals(List.of("DB손해보험", "삼성생명보험"),
result.stream().map(FinancialCommitmentSummary::getProductName).toList());
}

Expand Down
Loading