단위 테스트는 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(