diff --git a/api/src/test/java/com/ntropy/integration/DiagnosisFinalizationManualVerificationTest.java b/api/src/test/java/com/ntropy/integration/DiagnosisFinalizationManualVerificationTest.java index 7ccc0435..1fc0acd0 100644 --- a/api/src/test/java/com/ntropy/integration/DiagnosisFinalizationManualVerificationTest.java +++ b/api/src/test/java/com/ntropy/integration/DiagnosisFinalizationManualVerificationTest.java @@ -58,9 +58,9 @@ * *

단위 테스트는 in-memory Mapper로 애플리케이션 로직만 검증하지만, 이 테스트는 * (1) 시점 기반 잔액 재구성의 상관 서브쿼리·DATE_ADD 활성 판정, (2) 월별 소비 집계에서 - * 계좌 상태 필터 제거, (3) 확정된 행을 보호하는 조건부 upsert SET절, (4) 이슈 #143의 LOAN - * 원금 제외·이자 포함 집계 규칙(TXN_ANALYSIS 없이 loan_interest_amount에서 직접 계산, out_amount와의 - * 불일치 무시) - 네 가지 모두 실제 MySQL에서 의도한 대로 동작하는지 검증합니다. RUN_DIAGNOSIS_FINALIZATION_TEST=true일 + * 계좌 상태 필터 제거, (3) 확정된 행을 보호하는 조건부 upsert SET절, (4) LOAN 집계 규칙(TXN_ANALYSIS를 + * ORDINARY와 동일하게 신뢰하되 금액은 out_amount 전액, #169 최종 정책) - 네 가지 모두 실제 MySQL에서 + * 의도한 대로 동작하는지 검증합니다. RUN_DIAGNOSIS_FINALIZATION_TEST=true일 * 때만 실행합니다. account-service/diagnosis-service/defense-service를 모두 참조해야 해서 * 세 모듈을 전부 의존성으로 갖는 api 모듈에 둡니다(DiagnosisDefenseWiringTest와 동일한 이유).

* @@ -113,11 +113,12 @@ void recalculatePastMonth_finalizesWithRealDataAndFeedsDefenseMode() throws Exce assertEquals(1_950_000L, finalized.getLiquidAssets(), "시점 기반 자산 재구성 결과가 다릅니다"); assertEquals(0L, finalized.getSafeAssets()); // 비활성화된 계좌 B(100,000)가 소비 집계에서 빠지지 않았는지 확인한다. - // 650,000(기존 ORDINARY 소비) + 30,000(이슈 #143: LOAN 이자만, 원금·out_amount 불일치 무시) = 680,000. - assertEquals(680_000L, finalized.getTotalExpense(), "비활성 계좌의 과거 소비 또는 LOAN 이자 반영이 누락됐습니다"); - // 400,000(기존 FIXED) + 30,000(LOAN 이자는 예정된 반복 상환이라 고정지출로도 집계) = 430,000. - assertEquals(430_000L, finalized.getFixedExpense()); - assertEquals(2_320_000L, finalized.getNetCashFlow()); + // 650,000(기존 ORDINARY 소비) + 320,000(LOAN 원금+이자 전액, #169 후속 정책 변경: + // c-1 200,000 + c-2 120,000) = 970,000. + assertEquals(970_000L, finalized.getTotalExpense(), "비활성 계좌의 과거 소비 또는 LOAN 상환액 반영이 누락됐습니다"); + // 400,000(기존 FIXED) + 320,000(LOAN 상환액은 예정된 반복 상환이라 고정지출로도 집계) = 720,000. + assertEquals(720_000L, finalized.getFixedExpense()); + assertEquals(2_030_000L, finalized.getNetCashFlow()); // 확정된 행을 다른 값으로 다시 upsert 시도한다. 애플리케이션 사전 체크를 우회해 // SQL의 조건부 SET절 자체가 실제 MySQL에서 보호하는지 직접 검증한다. @@ -138,15 +139,16 @@ void recalculatePastMonth_finalizesWithRealDataAndFeedsDefenseMode() throws Exce context.getBean(LocalDiagnosisAnalysisQueryClient.class); DiagnosisAnalysisSummary analysis = analysisQueryClient.getMonthlyAnalysis(USER_ID, targetMonth); assertTrue(analysis.isFinalized()); - assertEquals(680_000L, analysis.getTotalExpense()); + assertEquals(970_000L, analysis.getTotalExpense()); assertFalse(analysis.getCategoryExpenses().isEmpty()); - // 이슈 #143: LOAN 이자(30,000 + 0)만 category='FINANCE'로 합산되고 원금은 어디에도 섞이지 않는다. + // #169 후속 정책 변경: LOAN 상환액(200,000 + 120,000, 원금 포함)이 모두 + // category='FINANCE'로 합산된다. long financeCategoryAmount = analysis.getCategoryExpenses().stream() .filter(category -> "FINANCE".equals(category.getCategory())) .mapToLong(category -> category.getAmount() == null ? 0L : category.getAmount()) .findFirst() .orElse(-1L); - assertEquals(30_000L, financeCategoryAmount, "LOAN 이자 카테고리(FINANCE) 합계가 다릅니다"); + assertEquals(320_000L, financeCategoryAmount, "LOAN 상환액 카테고리(FINANCE) 합계가 다릅니다"); long categoryExpenseSum = analysis.getCategoryExpenses().stream() .mapToLong(category -> category.getAmount() == null ? 0L : category.getAmount()) .sum(); @@ -264,9 +266,11 @@ private void seedFixtures( insertTransaction(statement, accountB, day15, 100_000, 0, 500_000, "e2e-138-b-1"); insertClassification(statement, true, "INSURANCE", "FIXED"); - // 계좌 C: LOAN 전용 계좌. 이슈 #143 - LOAN은 TXN_ANALYSIS 행 없이(무분석 상태) 원금은 - // 제외하고 loan_interest_amount만 결정적으로 소비에 반영돼야 한다. deposit_type_code=40이라 - // liquidAssets/safeAssets에는 섞이지 않는다(FinancialPositionService 참고). + // 계좌 C: LOAN 전용 계좌. LOAN도 이제 ORDINARY와 동일하게 TXN_ANALYSIS를 그대로 + // 신뢰하므로(ai-service TransactionPreClassificationService가 이미 분류·저장했다고 + // 가정), 두 거래 모두 TRUE/FINANCE/FIXED로 분류해둔다. 금액은 out_amount 전액이 + // 소비로 반영된다(원금 포함, #169 최종 정책). + // deposit_type_code=40이라 liquidAssets/safeAssets에는 섞이지 않는다(FinancialPositionService 참고). statement.execute( "INSERT INTO ACCOUNT (codef_connection_id, user_id, organization_code, account_group, " + "deposit_type_code, account_no_masked, account_no_hash, balance, currency_code, " @@ -278,12 +282,14 @@ private void seedFixtures( // out_amount(200,000)가 원금+이자 합(180,000)과 다른 실제 CODEF 케이스를 재현한다 - CODEF가 // resTranAmount를 총상환액으로 별도 제공하고 원금·이자와 반드시 일치하지 않을 수 있다는 전제(참고 - // LoanTransactionResponseParser.repaymentAmount). out_amount는 무시하고 loan_interest_amount - // (30,000)만 소비로 반영돼야 한다. TXN_ANALYSIS 행은 만들지 않는다(무분석 상태). + // LoanTransactionResponseParser.repaymentAmount). #169 최종 정책으로 원금+이자 합과의 + // 불일치 여부와 무관하게 out_amount(200,000)를 그대로 소비로 반영한다. insertLoanTransaction(statement, accountC, day5, 200_000, 150_000, 30_000, 4_850_000, "e2e-143-c-1"); + insertClassification(statement, true, "FINANCE", "FIXED"); - // 원금만 있고 이자가 null인 거래 - 총상환액(120,000) 전체가 소비로 반영되면 안 되고 0을 기여해야 한다. + // 원금만 있고 이자가 null인 거래 - 원금도 이제 소비이므로 out_amount(120,000) 전액을 반영한다. insertLoanTransaction(statement, accountC, day8, 120_000, 120_000, null, 4_730_000, "e2e-143-c-2"); + insertClassification(statement, true, "FINANCE", "FIXED"); } } diff --git a/services/account-service/src/main/java/com/ntropy/account/mapper/MonthlyExpenseMapper.java b/services/account-service/src/main/java/com/ntropy/account/mapper/MonthlyExpenseMapper.java index 51410ae1..d3bf0eaa 100644 --- a/services/account-service/src/main/java/com/ntropy/account/mapper/MonthlyExpenseMapper.java +++ b/services/account-service/src/main/java/com/ntropy/account/mapper/MonthlyExpenseMapper.java @@ -16,14 +16,11 @@ public interface MonthlyExpenseMapper { /** * 특정 기간의 총소비 금액을 조회합니다. - * loanDisbursementKeywords는 LOAN 신규·실행·증액(대출금 지급) 판정에 사용되며, - * 호출 측은 com.ntropy.common.domain.LoanDisbursementKeywords.KEYWORDS를 전달해야 합니다. */ Long findTotalExpense( @Param("userId") Long userId, @Param("startDate") LocalDate startDate, - @Param("endDate") LocalDate endDate, - @Param("loanDisbursementKeywords") List loanDisbursementKeywords + @Param("endDate") LocalDate endDate ); /** @@ -32,8 +29,7 @@ Long findTotalExpense( List findCategoryExpenses( @Param("userId") Long userId, @Param("startDate") LocalDate startDate, - @Param("endDate") LocalDate endDate, - @Param("loanDisbursementKeywords") List loanDisbursementKeywords + @Param("endDate") LocalDate endDate ); /** @@ -42,7 +38,6 @@ List findCategoryExpenses( Long findFixedExpense( @Param("userId") Long userId, @Param("startDate") LocalDate startDate, - @Param("endDate") LocalDate endDate, - @Param("loanDisbursementKeywords") List loanDisbursementKeywords + @Param("endDate") LocalDate endDate ); } \ No newline at end of file diff --git a/services/account-service/src/main/java/com/ntropy/account/service/MonthlyExpenseService.java b/services/account-service/src/main/java/com/ntropy/account/service/MonthlyExpenseService.java index 6bfd3d5e..d2f62746 100644 --- a/services/account-service/src/main/java/com/ntropy/account/service/MonthlyExpenseService.java +++ b/services/account-service/src/main/java/com/ntropy/account/service/MonthlyExpenseService.java @@ -10,7 +10,6 @@ import org.springframework.transaction.annotation.Transactional; import com.ntropy.account.mapper.MonthlyExpenseMapper; -import com.ntropy.common.domain.LoanDisbursementKeywords; import com.ntropy.common.dto.account.CategoryExpenseAmount; import com.ntropy.common.dto.account.MonthlyExpenseSummary; @@ -59,24 +58,21 @@ public MonthlyExpenseSummary findMonthlyExpense( monthlyExpenseMapper.findTotalExpense( userId, startDate, - endDate, - LoanDisbursementKeywords.KEYWORDS + endDate ); Long fixedExpense = monthlyExpenseMapper.findFixedExpense( userId, startDate, - endDate, - LoanDisbursementKeywords.KEYWORDS + endDate ); List rows = monthlyExpenseMapper.findCategoryExpenses( userId, startDate, - endDate, - LoanDisbursementKeywords.KEYWORDS + endDate ); Map categoryExpenses = diff --git a/services/account-service/src/main/resources/mapper/account/MonthlyExpenseMapper.xml b/services/account-service/src/main/resources/mapper/account/MonthlyExpenseMapper.xml index 92a6eb71..ecc34cf9 100644 --- a/services/account-service/src/main/resources/mapper/account/MonthlyExpenseMapper.xml +++ b/services/account-service/src/main/resources/mapper/account/MonthlyExpenseMapper.xml @@ -6,34 +6,28 @@ - + CASE - WHEN transaction_row.transaction_category = 'LOAN' - THEN GREATEST(COALESCE(transaction_row.loan_interest_amount, 0), 0) WHEN transaction_row.transaction_category = 'INSTALLMENT' THEN GREATEST(COALESCE(transaction_row.in_amount, 0), 0) ELSE transaction_row.out_amount @@ -41,151 +35,72 @@ - - CASE - WHEN transaction_row.transaction_category IN ('LOAN', 'INSTALLMENT') THEN 'FINANCE' - ELSE analysis_row.category - END - - - - - - REGEXP_REPLACE( - COALESCE(transaction_row.loan_transaction_type_name, ''), - '[[:space:]]+', - '' - ) NOT LIKE CONCAT('%', #{keyword}, '%') - - - - - - transaction_row.transaction_category = 'LOAN' - AND transaction_row.out_amount > 0 - AND transaction_row.loan_interest_amount > 0 - AND () - - - - - transaction_row.transaction_category = 'INSTALLMENT' - AND transaction_row.in_amount > 0 - - - - - ( - () - OR () - OR ( - transaction_row.transaction_category NOT IN ('LOAN', 'INSTALLMENT') - AND analysis_row.is_consumption = TRUE - ) - ) - - - - - ( - () - OR () - OR ( - transaction_row.transaction_category NOT IN ('LOAN', 'INSTALLMENT') - AND analysis_row.is_consumption = TRUE - AND analysis_row.expense_type = 'FIXED' - ) - ) - - - diff --git a/services/account-service/src/test/java/com/ntropy/account/integration/expense/MonthlyExpenseMapperLoanRuleManualVerificationTest.java b/services/account-service/src/test/java/com/ntropy/account/integration/expense/MonthlyExpenseMapperLoanRuleManualVerificationTest.java index e4201874..8c1e7568 100644 --- a/services/account-service/src/test/java/com/ntropy/account/integration/expense/MonthlyExpenseMapperLoanRuleManualVerificationTest.java +++ b/services/account-service/src/test/java/com/ntropy/account/integration/expense/MonthlyExpenseMapperLoanRuleManualVerificationTest.java @@ -32,11 +32,14 @@ import com.zaxxer.hikari.HikariDataSource; /** - * 이슈 #143/#169: LOAN 거래 원금 제외·이자 포함 규칙, INSTALLMENT 적립액(in_amount) 집계 규칙, - * FinancialCommitmentMapper의 최근 정상 상환 선택 규칙이 실제 MySQL에서 의도한 대로 - * 동작하는지 검증하는 수동 테스트입니다. - * {@code MonthlyExpenseMapperContractTest}는 XML 텍스트만 확인하므로 GREATEST/CASE/LEFT JOIN/ - * REGEXP_REPLACE 같은 실제 SQL 문법 오류나 계산값 자체는 여기서만 잡을 수 있습니다. + * 이슈 #143/#148/#169 최종: LOAN·INSTALLMENT의 소비 집계가 ORDINARY와 동일하게 + * TXN_ANALYSIS(is_consumption/category/expense_type)를 그대로 신뢰하고, 금액만 거래 + * 유형별 원천(LOAN은 out_amount, INSTALLMENT는 in_amount)에서 가져오는지 검증하는 수동 + * 테스트입니다. 지급 판정 로직 자체는 더 이상 이 매퍼의 관심사가 아니며(ai-service + * TransactionPreClassificationService, #148의 책임), FinancialCommitmentMapper의 "최근 + * 정상 상환 선택" 로직만 별도로 지급 거래 키워드를 계속 사용하므로 그 부분만 함께 검증한다. + * {@code MonthlyExpenseMapperContractTest}는 XML 텍스트만 확인하므로 CASE/INNER JOIN 같은 + * 실제 SQL 문법 오류나 계산값 자체는 여기서만 잡을 수 있습니다. * RUN_MONTHLY_EXPENSE_LOAN_RULE_TEST=true일 때만 실행합니다. */ class MonthlyExpenseMapperLoanRuleManualVerificationTest { @@ -45,10 +48,10 @@ class MonthlyExpenseMapperLoanRuleManualVerificationTest { private static final YearMonth TARGET_MONTH = YearMonth.of(2031, 1); @Test - void loanInterestOnlyRuleAppliesConsistentlyAcrossTotalCategoryAndFixedExpense() throws Exception { + void loanAndInstallmentConsumptionFollowsTxnAnalysisJustLikeOrdinary() throws Exception { assumeTrue( "true".equalsIgnoreCase(System.getenv("RUN_MONTHLY_EXPENSE_LOAN_RULE_TEST")), - "실제 MySQL이 필요한 이슈 #143 LOAN 소비 규칙 수동 검증용 테스트" + "실제 MySQL이 필요한 LOAN/INSTALLMENT 소비 규칙 수동 검증용 테스트" ); try (AnnotationConfigApplicationContext context = @@ -61,37 +64,44 @@ void loanInterestOnlyRuleAppliesConsistentlyAcrossTotalCategoryAndFixedExpense() context.getBean(FinancialCommitmentMapper.class); LocalDate startDate = TARGET_MONTH.atDay(1); LocalDate endDate = TARGET_MONTH.plusMonths(1).atDay(1); - List loanDisbursementKeywords = LoanDisbursementKeywords.KEYWORDS; - Long totalExpense = mapper.findTotalExpense(USER_ID, startDate, endDate, loanDisbursementKeywords); - Long fixedExpense = mapper.findFixedExpense(USER_ID, startDate, endDate, loanDisbursementKeywords); + Long totalExpense = mapper.findTotalExpense(USER_ID, startDate, endDate); + Long fixedExpense = mapper.findFixedExpense(USER_ID, startDate, endDate); List categoryRows = - mapper.findCategoryExpenses(USER_ID, startDate, endDate, loanDisbursementKeywords); + mapper.findCategoryExpenses(USER_ID, startDate, endDate); Map categories = categoryRows.stream() .collect(Collectors.toMap(CategoryExpenseAmount::getCategory, CategoryExpenseAmount::getExpenseAmount)); - // T1(FOOD/VARIABLE 50,000) + T2(HOUSING/FIXED 200,000) + T3(비소비, 제외) - // + T4(LOAN 원금+이자 중 이자 50,000) + T5(LOAN 원금만, 이자 null → 0) - // + T6(LOAN 이자만 30,000) + T7(LOAN out_amount 불일치, 이자 20,000만 반영) - // + T8(LOAN 이자 음수 → 제외) + T9(대상월 밖 LOAN, 제외) - // + T10(INSTALLMENT, TXN_ANALYSIS 없음, in_amount 150,000 반영) - // + T11(대출실행, 양수 이자가 있어도 제외) - // + T12(INSTALLMENT, TXN_ANALYSIS 있음, in_amount 80,000 반영, 중복 합산 없음) - // + T13(INSTALLMENT, in_amount=0 → 제외) + T14(대상월 밖 INSTALLMENT, 제외) - assertEquals(580_000L, totalExpense, - "총소비: LOAN 원금은 제외하고 이자만, INSTALLMENT는 out_amount가 아닌 in_amount만 반영해야 합니다"); - assertEquals(530_000L, fixedExpense, - "고정지출: HOUSING(FIXED)·LOAN 이자(FINANCE)·INSTALLMENT(FINANCE)는 포함, " + // T1(ORDINARY FOOD/VARIABLE 50,000) + T2(ORDINARY HOUSING/FIXED 200,000) + // + T3(ORDINARY 비소비, 제외) + // + T4(LOAN, TXN_ANALYSIS TRUE/FINANCE/FIXED, out_amount 300,000 전액 반영 - + // 원금 포함, #169 최종 정책) + // + T5(LOAN, TXN_ANALYSIS FALSE/NULL/NULL - ai-service가 이미 지급 거래로 + // 판정해둔 상태, out_amount가 있어도 제외) + // + T6(LOAN, TXN_ANALYSIS 없음 - 배치 전이라 ORDINARY처럼 그 달 집계에서 제외) + // + T7(INSTALLMENT, TXN_ANALYSIS TRUE/FINANCE/FIXED, in_amount 150,000 반영 - + // out_amount는 0이라 무시됨) + // + T8(INSTALLMENT, TXN_ANALYSIS 없음 - 제외) + // + T9(대상월 밖 LOAN, TXN_ANALYSIS 있어도 날짜 필터로 제외) + // LOAN+INSTALLMENT 합계 = T4(300,000) + T7(150,000) = 450,000 + assertEquals(700_000L, totalExpense, + "총소비: LOAN/INSTALLMENT는 TXN_ANALYSIS.is_consumption을 그대로 따르고, " + + "금액은 LOAN=out_amount·INSTALLMENT=in_amount여야 합니다"); + assertEquals(650_000L, fixedExpense, + "고정지출: HOUSING(FIXED)·LOAN(FIXED)·INSTALLMENT(FIXED)는 포함, " + "FOOD(VARIABLE)는 제외해야 합니다"); - assertEquals(Map.of("FOOD", 50_000L, "HOUSING", 200_000L, "FINANCE", 330_000L), categories, - "카테고리별 소비: LOAN 이자·INSTALLMENT는 모두 FINANCE로 합산되어야 합니다"); + assertEquals(Map.of("FOOD", 50_000L, "HOUSING", 200_000L, "FINANCE", 450_000L), categories, + "카테고리별 소비: LOAN·INSTALLMENT는 TXN_ANALYSIS.category(FINANCE)를 그대로 써야 합니다"); assertEquals(totalExpense, categories.values().stream().mapToLong(Long::longValue).sum(), "카테고리별 합계는 총소비와 일치해야 합니다"); + // FinancialCommitmentMapper의 "최근 정상 상환 선택"은 TXN_ANALYSIS와 무관하게 + // loan_transaction_type_name 키워드로 독립 판정한다(방어모드 예상 납입액 추정용, + // 이번 정책 변경의 영향을 받지 않는 별도 로직). List loanCommitments = financialCommitmentMapper.findLoanCommitmentCandidates( USER_ID, - loanDisbursementKeywords + LoanDisbursementKeywords.KEYWORDS ); assertEquals(1, loanCommitments.size(), "LOAN 계좌별 후보는 한 건이어야 합니다"); LoanCommitmentCandidateRow latestRepayment = loanCommitments.get(0); @@ -141,65 +151,56 @@ private void seedFixtures(DataSource dataSource) throws Exception { insertOrdinaryTransaction(statement, ordinaryAccount, day5, 80_000, "e2e-143-t3"); insertClassification(statement, false, null, null); - // T4: LOAN 원금+이자, out_amount와 원금+이자 합이 일치 - 이자 50,000만 반영. + // T4: LOAN, 일간 배치가 이미 정상 상환으로 분류·저장(TRUE/FINANCE/FIXED) - out_amount + // 300,000 전액이 소비로 반영돼야 한다(원금 포함, #169 최종 정책). insertLoanTransaction(statement, loanAccount, day5, 300_000, 250_000, 50_000, "e2e-143-t4"); + insertClassification(statement, true, "FINANCE", "FIXED"); - // T5: LOAN 원금만(이자 null) - 총상환액 전체가 소비로 반영되면 안 된다(0 기여). - insertLoanTransaction(statement, loanAccount, day5, 250_000, 250_000, null, "e2e-143-t5"); + // T5: LOAN, 일간 배치가 이미 지급 거래로 분류·저장(FALSE/NULL/NULL) - out_amount가 + // 있어도 is_consumption=FALSE이므로 제외돼야 한다. + insertLoanTransaction(statement, loanAccount, day5, 1_000_000, 1_000_000, null, "e2e-143-t5"); + insertClassification(statement, false, null, null); - // T6: LOAN 이자만 - 이자 전액 반영. - insertLoanTransaction(statement, loanAccount, day5, 30_000, null, 30_000, "e2e-143-t6"); + // T6: LOAN, TXN_ANALYSIS 없음(배치 전) - ORDINARY와 동일하게 그 달 집계에서 + // 제외돼야 한다(배치 지연에 따른 일시적 누락은 이제 이 쿼리가 신경 쓰지 않는다). + insertLoanTransaction(statement, loanAccount, day5, 500_000, 500_000, null, "e2e-143-t6"); - // T7: LOAN out_amount(999,000)가 원금+이자 합(120,000)과 다름 - out_amount 무시하고 이자 20,000만 반영. - insertLoanTransaction(statement, loanAccount, day5, 999_000, 100_000, 20_000, "e2e-143-t7"); + // T7: INSTALLMENT, TXN_ANALYSIS 있음(TRUE/FINANCE/FIXED) - out_amount는 0이므로 + // in_amount(150,000)만 반영돼야 한다. + insertInstallmentTransaction(statement, installmentAccount, day5, 150_000, "e2e-169-t7"); + insertClassification(statement, true, "FINANCE", "FIXED"); - // T8: LOAN 이자가 음수(비정상 값) - 집계 대상에서 제외돼 총소비를 깎지 않아야 한다. - insertLoanTransaction(statement, loanAccount, day5, 95_000, 100_000, -5_000, "e2e-143-t8"); + // T8: INSTALLMENT, TXN_ANALYSIS 없음(배치 전) - 제외돼야 한다. + insertInstallmentTransaction(statement, installmentAccount, day5, 80_000, "e2e-169-t8"); - // T9: 대상월 밖 LOAN 거래 - 날짜 필터로 제외되어야 한다. + // T9: 대상월 밖 LOAN 거래(TXN_ANALYSIS 있음) - 날짜 필터로 제외되어야 한다. insertLoanTransaction(statement, loanAccount, previousMonthDay5, 999_999, null, 999_999, "e2e-143-t9"); - - // T10: INSTALLMENT 거래, TXN_ANALYSIS 없음 - out_amount가 아닌 in_amount(150,000)가 - // TXN_ANALYSIS 유무와 무관하게 반영되어야 한다(이슈 #169, 기존에는 0원으로 집계되던 버그). - insertInstallmentTransaction(statement, installmentAccount, day5, 150_000, "e2e-169-t10"); - - // T11: 대출실행 거래 - 양수 이자가 있어도 상환 거래가 아니므로 제외돼야 한다. - insertLoanTransaction( - statement, loanAccount, day5, 1_040_000, 1_000_000, 40_000, - "대출실행", "e2e-143-t11" - ); - - // T12: INSTALLMENT 거래, TXN_ANALYSIS 있음(TRUE/FINANCE/FIXED) - 분석 행이 있어도 - // in_amount(80,000)가 정확히 한 번만 합산되어야 한다(중복 집계 없음). - insertInstallmentTransaction(statement, installmentAccount, day5, 80_000, "e2e-169-t12"); insertClassification(statement, true, "FINANCE", "FIXED"); - // T13: INSTALLMENT 거래, in_amount=0 - 집계에 기여하지 않아야 한다. - insertInstallmentTransaction(statement, installmentAccount, day5, 0, "e2e-169-t13"); - - // T14: 대상월 밖 INSTALLMENT 거래 - 날짜 필터로 제외되어야 한다. - insertInstallmentTransaction(statement, installmentAccount, previousMonthDay5, 999_999, "e2e-169-t14"); + // 아래는 FinancialCommitmentMapper 전용 픽스처다. TXN_ANALYSIS와 무관하게 + // loan_transaction_type_name 키워드로만 "최근 정상 상환"을 고르므로 분석 행을 + // 만들지 않는다(만들지 않아도 위 월간 소비 집계에는 영향이 없다 - TXN_ANALYSIS가 + // 없으면 자동으로 제외되기 때문). - // T15: FinancialCommitmentMapper가 선택해야 할 최근 정상 상환. 이자가 NULL이어서 - // 월간 소비에는 기여하지 않지만 대출 납입 예정 후보에는 포함된다. + // T10: FinancialCommitmentMapper가 선택해야 할 최근 정상 상환(out_amount 123,000). insertLoanTransaction( statement, loanAccount, day5, 123_000, 123_000, null, - "정상상환", "e2e-169-t15" + "정상상환", "e2e-169-t10" ); - // T16~T18: T15보다 나중에 저장된 공백 포함 지급 거래. 월간 소비에서 제외되어야 하고, - // FinancialCommitmentMapper도 이 거래들을 건너뛰어 T15를 최근 정상 상환으로 선택해야 한다. + // T11~T13: T10보다 나중에 저장된 공백 포함 지급 거래. FinancialCommitmentMapper가 + // 이 거래들을 건너뛰고 T10을 최근 정상 상환으로 선택해야 한다. insertLoanTransaction( statement, loanAccount, day5, 2_100_000, 2_000_000, 100_000, - "신 규", "e2e-169-t16" + "신 규", "e2e-169-t11" ); insertLoanTransaction( statement, loanAccount, day5, 2_100_000, 2_000_000, 100_000, - "실 행", "e2e-169-t17" + "실 행", "e2e-169-t12" ); insertLoanTransaction( statement, loanAccount, day5, 2_100_000, 2_000_000, 100_000, - "증 액", "e2e-169-t18" + "증 액", "e2e-169-t13" ); } } diff --git a/services/account-service/src/test/java/com/ntropy/account/mapper/MonthlyExpenseMapperContractTest.java b/services/account-service/src/test/java/com/ntropy/account/mapper/MonthlyExpenseMapperContractTest.java index 5de1da22..b87ca413 100644 --- a/services/account-service/src/test/java/com/ntropy/account/mapper/MonthlyExpenseMapperContractTest.java +++ b/services/account-service/src/test/java/com/ntropy/account/mapper/MonthlyExpenseMapperContractTest.java @@ -16,8 +16,8 @@ void totalExpenseQueryDoesNotFilterByCurrentAccountStatus() throws IOException { String query = selectBody(readMapper(), "findTotalExpense"); assertTrue(!query.contains("status = 'ACTIVE'")); - assertTrue(query.contains("")); - assertTrue(query.contains("")); + assertTrue(query.contains("analysis_row.is_consumption = TRUE")); + assertTrue(query.contains("")); } @Test @@ -25,9 +25,9 @@ void categoryExpensesQueryDoesNotFilterByCurrentAccountStatus() throws IOExcepti String query = selectBody(readMapper(), "findCategoryExpenses"); assertTrue(!query.contains("status = 'ACTIVE'")); - assertTrue(query.contains("")); - assertTrue(query.contains("")); - assertTrue(query.contains("")); + assertTrue(query.contains("analysis_row.is_consumption = TRUE")); + assertTrue(query.contains("analysis_row.category AS category")); + assertTrue(query.contains("")); } @Test @@ -35,112 +35,64 @@ void fixedExpenseQueryDoesNotFilterByCurrentAccountStatusAndScopesToFixedType() String query = selectBody(readMapper(), "findFixedExpense"); assertTrue(!query.contains("status = 'ACTIVE'")); - assertTrue(query.contains("")); - assertTrue(query.contains("")); + assertTrue(query.contains("analysis_row.is_consumption = TRUE")); + assertTrue(query.contains("analysis_row.expense_type = 'FIXED'")); + assertTrue(query.contains("")); } /** - * 이슈 #143/#169: 세 쿼리 모두 같은 loanAwareAmount fragment로 금액을 계산해야 계산식이 갈라지지 않는다. - * TXN_ANALYSIS 행이 없는 LOAN·INSTALLMENT 거래도 걸려야 하므로 INNER JOIN이 아닌 LEFT JOIN을 써야 한다. + * 이슈 #143/#169 최종: 세 쿼리 모두 같은 expenseAmount fragment로 금액을 계산해야 계산식이 + * 갈라지지 않는다. LOAN·INSTALLMENT의 소비 판정도 이제 TXN_ANALYSIS를 그대로 신뢰하므로 + * (ORDINARY와 동일 경로), TXN_ANALYSIS가 없는 거래는 그 달 집계에서 빠지는 것이 의도된 + * 동작이라 INNER JOIN을 쓴다. */ @Test - void allThreeQueriesShareTheSameLoanAmountAndUseLeftJoinForOptionalAnalysis() throws IOException { + void allThreeQueriesShareTheSameAmountFragmentAndUseInnerJoinOnAnalysis() throws IOException { String mapper = readMapper(); for (String selectId : new String[] {"findTotalExpense", "findCategoryExpenses", "findFixedExpense"}) { String query = selectBody(mapper, selectId); - assertTrue(query.contains(""), - selectId + "는 공통 loanAwareAmount fragment를 재사용해야 합니다"); - assertTrue(query.contains("LEFT JOIN TXN_ANALYSIS analysis_row"), - selectId + "는 TXN_ANALYSIS가 없는 LOAN·INSTALLMENT 거래도 포함하도록 LEFT JOIN을 써야 합니다"); + assertTrue(query.contains(""), + selectId + "는 공통 expenseAmount fragment를 재사용해야 합니다"); + assertTrue(query.contains("INNER JOIN TXN_ANALYSIS analysis_row"), + selectId + "는 TXN_ANALYSIS가 없는 거래를 배제하도록 INNER JOIN을 써야 합니다"); + assertFalse(query.contains("LEFT JOIN TXN_ANALYSIS"), + selectId + "는 더 이상 TXN_ANALYSIS 유무와 무관한 LOAN/INSTALLMENT 특례가 없어야 합니다"); } } + /** + * INSTALLMENT는 적립액이 in_amount에 들어오므로(out_amount는 항상 0) 별도 분기가 필요하다. + * LOAN은 이제 일반 out_amount 규칙과 같아 별도 분기가 없어야 한다(원금 포함, #169 최종 정책). + */ @Test - void loanAwareAmountExcludesPrincipalAndClampsInterestAtZero() throws IOException { - String fragment = sqlFragmentBody(readMapper(), "loanAwareAmount"); - - assertTrue(fragment.contains("transaction_category = 'LOAN'")); - assertTrue(fragment.contains("loan_interest_amount")); - assertTrue(fragment.contains("GREATEST("), - "음수 loan_interest_amount가 총소비를 깎지 않도록 0으로 클램프해야 합니다"); - assertFalse(fragment.contains("loan_principal_amount"), - "LOAN 원금(loan_principal_amount)은 소비 금액 계산에 쓰지 않아야 합니다"); - } - - @Test - void loanAwareAmountUsesInAmountForInstallment() throws IOException { - String fragment = sqlFragmentBody(readMapper(), "loanAwareAmount"); + void expenseAmountOnlyBranchesForInstallmentInAmount() throws IOException { + String fragment = sqlFragmentBody(readMapper(), "expenseAmount"); assertTrue(fragment.contains("transaction_category = 'INSTALLMENT'")); - assertTrue(fragment.contains("in_amount"), - "INSTALLMENT 적립액은 out_amount가 아닌 in_amount에서 가져와야 합니다"); - } - - @Test - void loanAwareCategoryFixesLoanAndInstallmentToFinance() throws IOException { - String fragment = sqlFragmentBody(readMapper(), "loanAwareCategory"); - - assertTrue(fragment.contains("'LOAN'")); - assertTrue(fragment.contains("'INSTALLMENT'")); - assertTrue(fragment.contains("'FINANCE'")); - assertTrue(fragment.contains("analysis_row.category")); - } - - @Test - void loanDisbursementExclusionUsesSharedKeywordParameterNotLiterals() throws IOException { - String fragment = sqlFragmentBody(readMapper(), "loanDisbursementExclusion"); - - assertTrue(fragment.contains("")); - } - - @Test - void installmentConsumptionFilterRequiresPositiveInAmount() throws IOException { - String fragment = sqlFragmentBody(readMapper(), "installmentConsumptionFilter"); - - assertTrue(fragment.contains("transaction_row.transaction_category = 'INSTALLMENT'")); - assertTrue(fragment.contains("transaction_row.in_amount > 0")); + assertTrue(fragment.contains("in_amount")); + assertFalse(fragment.contains("'LOAN'"), + "LOAN은 out_amount를 그대로 쓰는 ELSE 분기와 동일하므로 별도 WHEN 분기가 없어야 합니다"); + assertFalse(fragment.contains("loan_interest_amount"), + "금액 계산은 더 이상 loan_interest_amount를 쓰지 않아야 합니다"); + assertFalse(fragment.contains("loan_principal_amount"), + "금액 계산은 loan_principal_amount 컬럼을 직접 쓰지 않아야 합니다"); } + /** + * LOAN 지급(신규·실행·증액) 판정과 카테고리·고정지출 강제는 이제 account-service SQL이 아니라 + * ai-service TransactionPreClassificationService(#148)의 책임이다. 중복 판정 로직이 SQL에 + * 남아있지 않은지 확인한다. + */ @Test - void consumptionFilterIncludesEligibleLoanOrInstallmentOrClassifiedOther() throws IOException { - String fragment = sqlFragmentBody(readMapper(), "consumptionFilter"); - - assertTrue(fragment.contains("")); - assertTrue(fragment.contains("")); - assertTrue(fragment.contains("transaction_row.transaction_category NOT IN ('LOAN', 'INSTALLMENT')")); - assertTrue(fragment.contains("analysis_row.is_consumption = TRUE")); - } + void mapperNoLongerDuplicatesLoanClassificationLogic() throws IOException { + String mapper = readMapper(); - @Test - void fixedConsumptionFilterIncludesEligibleLoanOrInstallmentAndKeepsFixedTypeForOther() throws IOException { - String fragment = sqlFragmentBody(readMapper(), "fixedConsumptionFilter"); - - assertTrue(fragment.contains("")); - assertTrue(fragment.contains("")); - assertTrue(fragment.contains("transaction_row.transaction_category NOT IN ('LOAN', 'INSTALLMENT')")); - assertTrue(fragment.contains("analysis_row.is_consumption = TRUE")); - assertTrue(fragment.contains("analysis_row.expense_type = 'FIXED'")); + assertFalse(mapper.contains("loanDisbursementKeywords"), + "LOAN 지급 판정 키워드 파라미터는 더 이상 이 매퍼에서 쓰이지 않아야 합니다"); + assertFalse(mapper.contains("REGEXP_REPLACE"), + "지급 거래 정규화/제외 로직은 더 이상 SQL에 없어야 합니다"); + assertFalse(mapper.contains("'FINANCE'"), + "category='FINANCE' 강제는 더 이상 SQL에 없어야 합니다(TXN_ANALYSIS.category를 그대로 씀)"); } private static String sqlFragmentBody(String mapper, String sqlId) { diff --git a/services/account-service/src/test/java/com/ntropy/account/service/MonthlyExpenseServiceTest.java b/services/account-service/src/test/java/com/ntropy/account/service/MonthlyExpenseServiceTest.java index 21de0d26..6ca33db4 100644 --- a/services/account-service/src/test/java/com/ntropy/account/service/MonthlyExpenseServiceTest.java +++ b/services/account-service/src/test/java/com/ntropy/account/service/MonthlyExpenseServiceTest.java @@ -101,19 +101,19 @@ private static class InMemoryMonthlyExpenseMapper implements MonthlyExpenseMappe @Override public Long findTotalExpense( - Long userId, LocalDate startDate, LocalDate endDate, List loanDisbursementKeywords) { + Long userId, LocalDate startDate, LocalDate endDate) { return totalExpense; } @Override public List findCategoryExpenses( - Long userId, LocalDate startDate, LocalDate endDate, List loanDisbursementKeywords) { + Long userId, LocalDate startDate, LocalDate endDate) { return categoryExpenses; } @Override public Long findFixedExpense( - Long userId, LocalDate startDate, LocalDate endDate, List loanDisbursementKeywords) { + Long userId, LocalDate startDate, LocalDate endDate) { return fixedExpense; } } diff --git a/services/ai-service/src/main/java/com/ntropy/ai/service/TransactionPreClassificationService.java b/services/ai-service/src/main/java/com/ntropy/ai/service/TransactionPreClassificationService.java index 166e0ad6..c692ebcc 100644 --- a/services/ai-service/src/main/java/com/ntropy/ai/service/TransactionPreClassificationService.java +++ b/services/ai-service/src/main/java/com/ntropy/ai/service/TransactionPreClassificationService.java @@ -85,8 +85,8 @@ public Optional classify( * * 그 외 LOAN 거래는 정상 상환으로 보고 FINANCE / FIXED 소비로 분류합니다. * 여기서는 분류 정보(is_consumption/category/expense_type)만 저장하며 금액은 - * 저장하지 않습니다. 월간 집계 금액(원금 제외, 이자만 소비)은 MonthlyExpenseMapper가 - * ACCOUNT_TRANSACTION에서 별도로 결정적으로 계산합니다. + * 저장하지 않습니다. 월간 집계 금액(원금 포함 out_amount 전액, #169 후속 정책 변경)은 + * MonthlyExpenseMapper가 ACCOUNT_TRANSACTION에서 별도로 결정적으로 계산합니다. */ if ("LOAN".equals(transactionCategory)) { if (LoanDisbursementKeywords.matches(