From eac0b813d69ba4a5a72de69ec6dde018c1016db5 Mon Sep 17 00:00:00 2001 From: GA Pro Date: Wed, 3 Jun 2026 10:19:53 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=88=98=EC=88=98=EB=A3=8C=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0=20=EC=A0=84=EC=88=98=EA=B0=90=EC=82=AC=20=E2=80=94=20?= =?UTF-8?q?1200%=EB=A3=B0=20=EB=8B=A8=EC=9C=84=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EC=99=B8=205=EA=B1=B4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 적대적 코드감사(4영역)에서 검증된 정확성 결함 수정: - fix(HIGH): 1200%룰 단위 불일치. regulatory_limit.limit_value(PERCENT:1200,35)를 KRW 금액과 직접 비교 → 사실상 "1200원/35원 룰"로 망가져 거의 모든 계약이 위반 처리. 연환산보험료×비율/100로 KRW 환산 후 비교. RegulatoryLimitChecker(api/batch) 양쪽, ContractMapper 주입(시그니처 무변경). - fix(HIGH): 음수 gross(환수>지급) 세금 계산이 음수 원천세 생성 → net 왜곡 + 분기 원천세신고 SUM 잠식. gross<=0 시 세금0·net=원금 가드. Tax/BatchTaxCalculator. - fix(HIGH): AwardService 시상 threshold_amount 미적용 → 기준 미달자에게 시상 전액 지급. threshold 미만 차단 + FIXED award_fixed null 가드. - fix(MED): 차등환수 최저구간 months_from 1→0(V109). 체결 1개월내 즉시해지가 grade=null로 환수 0원 처리되던 누락 보정. - fix(MED): company_rate/payout_rate NULL→0 폴백 무경보. log.warn 추가로 데이터 공백에 의한 과소지급 추적 가능. 테스트 4건 추가(단위변환/음수세금/시상threshold 경계). 전체 build+test GREEN: 152건 0실패. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../api/service/commission/AwardService.java | 11 +++++ .../regulatory/RegulatoryLimitChecker.java | 39 +++++++++++---- .../com/ga/api/service/tax/TaxCalculator.java | 6 +++ .../service/commission/AwardServiceTest.java | 48 +++++++++++++++++++ .../settlement/calc/CommissionCalculator.java | 14 +++++- .../domain/BatchRegulatoryLimitChecker.java | 44 +++++++++++++---- .../settlement/domain/BatchTaxCalculator.java | 7 +++ .../scenario/SettlementScenarioTest.java | 42 +++++++++++++++- .../V109__차등환수_0개월구간_보정.sql | 12 +++++ 9 files changed, 200 insertions(+), 23 deletions(-) create mode 100644 ga-common/src/main/resources/db/migration/V109__차등환수_0개월구간_보정.sql diff --git a/ga-api/src/main/java/com/ga/api/service/commission/AwardService.java b/ga-api/src/main/java/com/ga/api/service/commission/AwardService.java index adc58fa..215eb61 100644 --- a/ga-api/src/main/java/com/ga/api/service/commission/AwardService.java +++ b/ga-api/src/main/java/com/ga/api/service/commission/AwardService.java @@ -91,8 +91,19 @@ public class AwardService { AwardRuleVO rule = ruleMapper.selectActiveRule(req.getAwardType(), LocalDate.now()); if (rule == null) throw new BizException(ErrorCode.NOT_FOUND); + // threshold_amount 이상 실적 달성 시에만 시상 발동 (스펙 7-7). 미달 시 시상 없음. + if (rule.getThresholdAmount() != null + && req.getBaseAmount().compareTo(rule.getThresholdAmount()) < 0) { + throw new BizException(ErrorCode.BAD_REQUEST, + String.format("시상 기준 실적 미달: 실적 %s < 기준 %s", + req.getBaseAmount(), rule.getThresholdAmount())); + } + BigDecimal awardAmount; if ("FIXED".equals(rule.getCalcType())) { + if (rule.getAwardFixed() == null) { + throw new BizException(ErrorCode.BAD_REQUEST, "FIXED 시상 룰에 award_fixed 가 설정되지 않았습니다"); + } awardAmount = rule.getAwardFixed().setScale(2, RoundingMode.HALF_UP); } else { // RATE: base × award_rate diff --git a/ga-api/src/main/java/com/ga/api/service/regulatory/RegulatoryLimitChecker.java b/ga-api/src/main/java/com/ga/api/service/regulatory/RegulatoryLimitChecker.java index ff185aa..f89f449 100644 --- a/ga-api/src/main/java/com/ga/api/service/regulatory/RegulatoryLimitChecker.java +++ b/ga-api/src/main/java/com/ga/api/service/regulatory/RegulatoryLimitChecker.java @@ -2,12 +2,15 @@ package com.ga.api.service.regulatory; import com.ga.core.mapper.ledger.MaintainLedgerMapper; import com.ga.core.mapper.ledger.RecruitLedgerMapper; +import com.ga.core.mapper.product.ContractMapper; import com.ga.core.mapper.regulatory.RegulatoryLimitMapper; +import com.ga.core.vo.product.ContractVO; import com.ga.core.vo.regulatory.RegulatoryLimitVO; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import java.math.BigDecimal; +import java.math.RoundingMode; import java.time.LocalDate; import java.time.format.DateTimeFormatter; @@ -17,11 +20,13 @@ public class RegulatoryLimitChecker { private static final String TOTAL_RATIO = "TOTAL_RATIO"; private static final String FIRST_YEAR_RATIO = "FIRST_YEAR_RATIO"; + private static final String UNIT_PERCENT = "PERCENT"; private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd"); private final RegulatoryLimitMapper limitMapper; private final RecruitLedgerMapper recruitLedgerMapper; private final MaintainLedgerMapper maintainLedgerMapper; + private final ContractMapper contractMapper; /** 누적 수수료율 한도 체크 (1200%). 신계약 + 유지 원장 합산. */ public ViolationResult checkContractTotal(Long contractId, BigDecimal additionalAmount, LocalDate baseDate) { @@ -31,19 +36,20 @@ public class RegulatoryLimitChecker { return ViolationResult.ok(additionalAmount, null, TOTAL_RATIO); } + BigDecimal limitKrw = resolveLimitKrw(limit, contractId); BigDecimal currentTotal = sumPaidByContract(contractId); BigDecimal projected = currentTotal.add(additionalAmount); - if (projected.compareTo(limit.getLimitValue()) > 0) { + if (projected.compareTo(limitKrw) > 0) { return ViolationResult.violated( currentTotal, - limit.getLimitValue(), + limitKrw, TOTAL_RATIO, - String.format("누적 수수료 한도 초과: 현재 %s, 추가 %s, 한도 %s %s", - currentTotal, additionalAmount, limit.getLimitValue(), limit.getUnit()) + String.format("누적 수수료 한도 초과: 현재 %s, 추가 %s, 한도 %s(%s %s)", + currentTotal, additionalAmount, limitKrw, limit.getLimitValue(), limit.getUnit()) ); } - return ViolationResult.ok(currentTotal, limit.getLimitValue(), TOTAL_RATIO); + return ViolationResult.ok(currentTotal, limitKrw, TOTAL_RATIO); } /** 1차년 수수료율 한도 체크. 신계약 원장 1차년도만 (유지는 1차년 개념 없음). */ @@ -54,19 +60,32 @@ public class RegulatoryLimitChecker { return ViolationResult.ok(additionalAmount, null, FIRST_YEAR_RATIO); } + BigDecimal limitKrw = resolveLimitKrw(limit, contractId); BigDecimal currentTotal = sumFirstYearPaidByContract(contractId); BigDecimal projected = currentTotal.add(additionalAmount); - if (projected.compareTo(limit.getLimitValue()) > 0) { + if (projected.compareTo(limitKrw) > 0) { return ViolationResult.violated( currentTotal, - limit.getLimitValue(), + limitKrw, FIRST_YEAR_RATIO, - String.format("1차년 수수료 한도 초과: 현재 %s, 추가 %s, 한도 %s %s", - currentTotal, additionalAmount, limit.getLimitValue(), limit.getUnit()) + String.format("1차년 수수료 한도 초과: 현재 %s, 추가 %s, 한도 %s(%s %s)", + currentTotal, additionalAmount, limitKrw, limit.getLimitValue(), limit.getUnit()) ); } - return ViolationResult.ok(currentTotal, limit.getLimitValue(), FIRST_YEAR_RATIO); + return ViolationResult.ok(currentTotal, limitKrw, FIRST_YEAR_RATIO); + } + + /** 비율 한도(PERCENT)를 계약 연환산보험료 기준 KRW 한도로 환산. */ + private BigDecimal resolveLimitKrw(RegulatoryLimitVO limit, Long contractId) { + if (!UNIT_PERCENT.equalsIgnoreCase(limit.getUnit())) { + return limit.getLimitValue(); + } + ContractVO contract = contractMapper.selectById(contractId); + BigDecimal annualPremium = (contract != null && contract.getPremium() != null) + ? contract.getPremium() : BigDecimal.ZERO; + return annualPremium.multiply(limit.getLimitValue()) + .divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); } private BigDecimal sumPaidByContract(Long contractId) { diff --git a/ga-api/src/main/java/com/ga/api/service/tax/TaxCalculator.java b/ga-api/src/main/java/com/ga/api/service/tax/TaxCalculator.java index a23ebc5..bdebee4 100644 --- a/ga-api/src/main/java/com/ga/api/service/tax/TaxCalculator.java +++ b/ga-api/src/main/java/com/ga/api/service/tax/TaxCalculator.java @@ -16,6 +16,12 @@ public class TaxCalculator { private final SystemConfigService configService; public TaxBreakdown calculate(BigDecimal amount, TaxType taxType, VatType vatType) { + // 음수/0 정산(환수 우위 등)은 원천징수 대상이 아님 → 세금 0, net=원금(차월 이월). + if (amount == null || amount.signum() <= 0) { + BigDecimal base = amount != null ? amount : BigDecimal.ZERO; + return new TaxBreakdown(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, base); + } + BigDecimal incomeRate = resolveIncomeRate(taxType); BigDecimal localRate = configService.getDecimal("TAX_RATE_LOCAL_INCOME", new BigDecimal("10.0")); BigDecimal vatRate = configService.getDecimal("TAX_RATE_VAT", new BigDecimal("10.0")); diff --git a/ga-api/src/test/java/com/ga/api/service/commission/AwardServiceTest.java b/ga-api/src/test/java/com/ga/api/service/commission/AwardServiceTest.java index e3c1305..3a4463d 100644 --- a/ga-api/src/test/java/com/ga/api/service/commission/AwardServiceTest.java +++ b/ga-api/src/test/java/com/ga/api/service/commission/AwardServiceTest.java @@ -13,11 +13,16 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import com.ga.common.exception.BizException; + import java.math.BigDecimal; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -74,4 +79,47 @@ class AwardServiceTest { AwardLedgerVO saved = captor.getValue(); assertThat(saved.getAwardAmount()).isEqualByComparingTo("200000.00"); } + + @Test + @DisplayName("createLedger: 실적이 threshold_amount 미달이면 시상 발동 안 함(예외) — 원장 미생성") + void createLedger_belowThreshold_rejected() { + AwardRuleVO rule = new AwardRuleVO(); + rule.setCalcType("RATE"); + rule.setAwardRate(new BigDecimal("0.05")); + rule.setThresholdAmount(new BigDecimal("1000000")); // 기준 100만 + when(ruleMapper.selectActiveRule(eq("PERFORMANCE"), any())).thenReturn(rule); + + AwardLedgerSaveReq req = new AwardLedgerSaveReq(); + req.setAgentId(1L); + req.setSettleMonth("202506"); + req.setAwardType("PERFORMANCE"); + req.setBaseAmount(new BigDecimal("800000")); // 기준 미달 + + assertThatThrownBy(() -> service.createLedger(req)) + .isInstanceOf(BizException.class); + verify(ledgerMapper, never()).insert(any()); + } + + @Test + @DisplayName("createLedger: 실적이 threshold 이상이면 정상 발동 (경계 = 이상)") + void createLedger_atThreshold_ok() { + AwardRuleVO rule = new AwardRuleVO(); + rule.setCalcType("RATE"); + rule.setAwardRate(new BigDecimal("0.05")); + rule.setThresholdAmount(new BigDecimal("1000000")); + when(ruleMapper.selectActiveRule(eq("PERFORMANCE"), any())).thenReturn(rule); + + AwardLedgerSaveReq req = new AwardLedgerSaveReq(); + req.setAgentId(1L); + req.setSettleMonth("202506"); + req.setAwardType("PERFORMANCE"); + req.setBaseAmount(new BigDecimal("1000000")); // 기준 정확히 충족 + + ArgumentCaptor captor = ArgumentCaptor.forClass(AwardLedgerVO.class); + when(ledgerMapper.insert(captor.capture())).thenReturn(1); + + service.createLedger(req); + + assertThat(captor.getValue().getAwardAmount()).isEqualByComparingTo("50000.00"); + } } diff --git a/ga-batch/src/main/java/com/ga/batch/settlement/calc/CommissionCalculator.java b/ga-batch/src/main/java/com/ga/batch/settlement/calc/CommissionCalculator.java index fb014f6..50b6206 100644 --- a/ga-batch/src/main/java/com/ga/batch/settlement/calc/CommissionCalculator.java +++ b/ga-batch/src/main/java/com/ga/batch/settlement/calc/CommissionCalculator.java @@ -7,6 +7,7 @@ import com.ga.core.vo.ledger.LedgerVO; import com.ga.core.vo.org.AgentVO; import com.ga.core.vo.product.ContractResp; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import java.math.BigDecimal; @@ -18,6 +19,7 @@ import java.time.LocalDate; * - 유지수수료: 동일 공식, 회차에 따른 commission_year 만 다름 * - 모든 금액은 BigDecimal HALF_UP, 소수점 2자리 */ +@Slf4j @Component @RequiredArgsConstructor public class CommissionCalculator { @@ -49,11 +51,19 @@ public class CommissionCalculator { LocalDate baseDate = c.getContractDate() != null ? c.getContractDate() : LocalDate.now(); BigDecimal companyRate = ruleMapper.findCompanyRate(c.getProductId(), year, baseDate); - if (companyRate == null) companyRate = BigDecimal.ZERO; + if (companyRate == null) { + log.warn("[수수료율 누락] company_rate 없음 → 0 폴백(과소지급 위험): contractId={}, productId={}, year={}, baseDate={}", + c.getContractId(), c.getProductId(), year, baseDate); + companyRate = BigDecimal.ZERO; + } BigDecimal payoutRate = ruleMapper.findPayoutRate( agent.getGradeId(), c.getInsuranceType(), year, baseDate); - if (payoutRate == null) payoutRate = BigDecimal.ZERO; + if (payoutRate == null) { + log.warn("[수수료율 누락] payout_rate 없음 → 0 폴백(과소지급 위험): contractId={}, gradeId={}, insuranceType={}, year={}, baseDate={}", + c.getContractId(), agent.getGradeId(), c.getInsuranceType(), year, baseDate); + payoutRate = BigDecimal.ZERO; + } BigDecimal premium = MoneyUtil.zero(c.getPremium()); BigDecimal companyAmount = MoneyUtil.pct(premium, companyRate); diff --git a/ga-batch/src/main/java/com/ga/batch/settlement/domain/BatchRegulatoryLimitChecker.java b/ga-batch/src/main/java/com/ga/batch/settlement/domain/BatchRegulatoryLimitChecker.java index 0524476..ae43036 100644 --- a/ga-batch/src/main/java/com/ga/batch/settlement/domain/BatchRegulatoryLimitChecker.java +++ b/ga-batch/src/main/java/com/ga/batch/settlement/domain/BatchRegulatoryLimitChecker.java @@ -2,17 +2,25 @@ package com.ga.batch.settlement.domain; import com.ga.core.mapper.ledger.MaintainLedgerMapper; import com.ga.core.mapper.ledger.RecruitLedgerMapper; +import com.ga.core.mapper.product.ContractMapper; import com.ga.core.mapper.regulatory.RegulatoryLimitMapper; +import com.ga.core.vo.product.ContractVO; import com.ga.core.vo.regulatory.RegulatoryLimitVO; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import java.math.BigDecimal; +import java.math.RoundingMode; import java.time.LocalDate; import java.time.format.DateTimeFormatter; /** * 배치용 1200%룰 한도 체커. ga-api RegulatoryLimitChecker와 동일 로직. + * + * 한도 단위(unit) 처리: + * - PERCENT: regulatory_limit.limit_value 는 비율(1200, 35 등). 실제 KRW 한도는 + * 연환산보험료 × 비율 / 100 으로 환산해 비교한다. (1200%룰의 본래 의미) + * - 그 외(KRW 등): limit_value 를 절대금액 한도로 직접 사용. */ @Component @RequiredArgsConstructor @@ -20,11 +28,13 @@ public class BatchRegulatoryLimitChecker { private static final String TOTAL_RATIO = "TOTAL_RATIO"; private static final String FIRST_YEAR_RATIO = "FIRST_YEAR_RATIO"; + private static final String UNIT_PERCENT = "PERCENT"; private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd"); private final RegulatoryLimitMapper limitMapper; private final RecruitLedgerMapper recruitLedgerMapper; private final MaintainLedgerMapper maintainLedgerMapper; + private final ContractMapper contractMapper; /** 누적 수수료율 한도 체크 (1200%). 신계약 + 유지 원장 합산. */ public ViolationResult checkContractTotal(Long contractId, BigDecimal additionalAmount, LocalDate baseDate) { @@ -34,19 +44,20 @@ public class BatchRegulatoryLimitChecker { return ViolationResult.ok(additionalAmount, null, TOTAL_RATIO); } + BigDecimal limitKrw = resolveLimitKrw(limit, contractId); BigDecimal currentTotal = sumPaidByContract(contractId); BigDecimal projected = currentTotal.add(additionalAmount); - if (projected.compareTo(limit.getLimitValue()) > 0) { + if (projected.compareTo(limitKrw) > 0) { return ViolationResult.violated( currentTotal, - limit.getLimitValue(), + limitKrw, TOTAL_RATIO, - String.format("누적 수수료 한도 초과: 현재 %s, 추가 %s, 한도 %s %s", - currentTotal, additionalAmount, limit.getLimitValue(), limit.getUnit()) + String.format("누적 수수료 한도 초과: 현재 %s, 추가 %s, 한도 %s(%s %s)", + currentTotal, additionalAmount, limitKrw, limit.getLimitValue(), limit.getUnit()) ); } - return ViolationResult.ok(currentTotal, limit.getLimitValue(), TOTAL_RATIO); + return ViolationResult.ok(currentTotal, limitKrw, TOTAL_RATIO); } /** 1차년 수수료율 한도 체크. */ @@ -57,19 +68,32 @@ public class BatchRegulatoryLimitChecker { return ViolationResult.ok(additionalAmount, null, FIRST_YEAR_RATIO); } + BigDecimal limitKrw = resolveLimitKrw(limit, contractId); BigDecimal currentTotal = nullSafe(recruitLedgerMapper.sumFirstYearByContract(contractId)); BigDecimal projected = currentTotal.add(additionalAmount); - if (projected.compareTo(limit.getLimitValue()) > 0) { + if (projected.compareTo(limitKrw) > 0) { return ViolationResult.violated( currentTotal, - limit.getLimitValue(), + limitKrw, FIRST_YEAR_RATIO, - String.format("1차년 수수료 한도 초과: 현재 %s, 추가 %s, 한도 %s %s", - currentTotal, additionalAmount, limit.getLimitValue(), limit.getUnit()) + String.format("1차년 수수료 한도 초과: 현재 %s, 추가 %s, 한도 %s(%s %s)", + currentTotal, additionalAmount, limitKrw, limit.getLimitValue(), limit.getUnit()) ); } - return ViolationResult.ok(currentTotal, limit.getLimitValue(), FIRST_YEAR_RATIO); + return ViolationResult.ok(currentTotal, limitKrw, FIRST_YEAR_RATIO); + } + + /** 비율 한도(PERCENT)를 계약 연환산보험료 기준 KRW 한도로 환산. */ + private BigDecimal resolveLimitKrw(RegulatoryLimitVO limit, Long contractId) { + if (!UNIT_PERCENT.equalsIgnoreCase(limit.getUnit())) { + return limit.getLimitValue(); + } + ContractVO contract = contractMapper.selectById(contractId); + BigDecimal annualPremium = (contract != null && contract.getPremium() != null) + ? contract.getPremium() : BigDecimal.ZERO; + return annualPremium.multiply(limit.getLimitValue()) + .divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); } private BigDecimal sumPaidByContract(Long contractId) { diff --git a/ga-batch/src/main/java/com/ga/batch/settlement/domain/BatchTaxCalculator.java b/ga-batch/src/main/java/com/ga/batch/settlement/domain/BatchTaxCalculator.java index c2dd78f..87753ba 100644 --- a/ga-batch/src/main/java/com/ga/batch/settlement/domain/BatchTaxCalculator.java +++ b/ga-batch/src/main/java/com/ga/batch/settlement/domain/BatchTaxCalculator.java @@ -20,6 +20,13 @@ public class BatchTaxCalculator { private final SystemConfigService configService; public TaxBreakdown calculate(BigDecimal amount, TaxType taxType, VatType vatType) { + // 음수/0 정산(환수 우위 등)은 원천징수 대상이 아님 → 세금 0, net=원금(차월 이월). + // 음수 원천세가 분기 원천세신고 SUM에 전파되는 것을 차단. + if (amount == null || amount.signum() <= 0) { + BigDecimal base = amount != null ? amount : BigDecimal.ZERO; + return new TaxBreakdown(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, base); + } + BigDecimal incomeRate = resolveIncomeRate(taxType); BigDecimal localRate = configService.getDecimal("TAX_RATE_LOCAL_INCOME", new BigDecimal("10.0")); BigDecimal vatRate = configService.getDecimal("TAX_RATE_VAT", new BigDecimal("10.0")); diff --git a/ga-batch/src/test/java/com/ga/batch/settlement/scenario/SettlementScenarioTest.java b/ga-batch/src/test/java/com/ga/batch/settlement/scenario/SettlementScenarioTest.java index 4e0e822..b057e7d 100644 --- a/ga-batch/src/test/java/com/ga/batch/settlement/scenario/SettlementScenarioTest.java +++ b/ga-batch/src/test/java/com/ga/batch/settlement/scenario/SettlementScenarioTest.java @@ -69,6 +69,7 @@ class SettlementScenarioTest { @Mock RegulatoryLimitMapper regulatoryLimitMapper; @Mock RecruitLedgerMapper recruitLedgerMapper; @Mock MaintainLedgerMapper maintainLedgerMapper; + @Mock com.ga.core.mapper.product.ContractMapper contractMapper; @Mock SystemConfigService configService; // ── 실제 계산 코드 (테스트 대상) ── @@ -92,7 +93,7 @@ class SettlementScenarioTest { void setUp() { commissionCalculator = new CommissionCalculator(ruleMapper, agentMapper); regulatoryChecker = new BatchRegulatoryLimitChecker( - regulatoryLimitMapper, recruitLedgerMapper, maintainLedgerMapper); + regulatoryLimitMapper, recruitLedgerMapper, maintainLedgerMapper, contractMapper); installmentGenerator = new BatchInstallmentPlanGenerator(installmentRatioMapper); chargebackCalculator = new BatchChargebackCalculator(chargebackGradeMapper); taxCalculator = new BatchTaxCalculator(configService); @@ -166,6 +167,33 @@ class SettlementScenarioTest { assertThat(bad.currentTotal()).isEqualByComparingTo("450000"); } + @Test + @DisplayName("② 1200%룰 단위변환: PERCENT 한도(35%)는 연환산보험료 기준 KRW로 환산 비교") + void s2b_규제_PERCENT한도_연보험료기준_KRW환산() { + // 1차년 한도 35% (PERCENT). 실제 KRW 한도 = 연납보험료 1,000,000 × 35% = 350,000 + RegulatoryLimitVO limit = new RegulatoryLimitVO(); + limit.setLimitType("FIRST_YEAR_RATIO"); + limit.setLimitValue(new BigDecimal("35")); + limit.setUnit("PERCENT"); + when(regulatoryLimitMapper.selectActiveByType(eq("FIRST_YEAR_RATIO"), any(), anyString())) + .thenReturn(limit); + com.ga.core.vo.product.ContractVO cv = new com.ga.core.vo.product.ContractVO(); + cv.setPremium(new BigDecimal("1000000")); + when(contractMapper.selectById(CONTRACT_ID)).thenReturn(cv); + when(recruitLedgerMapper.sumFirstYearByContract(CONTRACT_ID)).thenReturn(BigDecimal.ZERO); + + // 모집수수료 450,000 > 한도 350,000 → 위반 (이전엔 35(KRW)와 비교돼 항상 위반이던 버그) + ViolationResult bad = regulatoryChecker.checkFirstYear( + CONTRACT_ID, new BigDecimal("450000"), CONTRACT_DATE); + assertThat(bad.violated()).isTrue(); + assertThat(bad.limitAmount()).isEqualByComparingTo("350000.00"); + + // 모집수수료 300,000 ≤ 350,000 → 통과 + ViolationResult ok = regulatoryChecker.checkFirstYear( + CONTRACT_ID, new BigDecimal("300000"), CONTRACT_DATE); + assertThat(ok.violated()).isFalse(); + } + @Test @DisplayName("③ 분급 7년 분할: 35/25/15/10/7/5/3 → 합계 정확히 450,000") void s3_분급_7년_분할_합계100퍼센트() { @@ -274,6 +302,18 @@ class SettlementScenarioTest { assertThat(t.vat()).isEqualByComparingTo("0"); assertThat(t.netAmount()).isEqualByComparingTo("433665"); } + + @Test + @DisplayName("음수 gross(환수>지급): 원천세 0, net=원금 — 음수 세금이 신고에 전파되지 않음") + void s9_세금_음수gross_세금0() { + TaxBreakdown t = taxCalculator.calculate( + new BigDecimal("-300000"), TaxType.BUSINESS_INCOME, VatType.TAXABLE); + + assertThat(t.incomeTax()).isEqualByComparingTo("0"); + assertThat(t.localTax()).isEqualByComparingTo("0"); + assertThat(t.vat()).isEqualByComparingTo("0"); + assertThat(t.netAmount()).isEqualByComparingTo("-300000"); + } } // ── 헬퍼: 표준 7년 분급 비율 (35/25/15/10/7/5/3) ── diff --git a/ga-common/src/main/resources/db/migration/V109__차등환수_0개월구간_보정.sql b/ga-common/src/main/resources/db/migration/V109__차등환수_0개월구간_보정.sql new file mode 100644 index 0000000..3bb8013 --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V109__차등환수_0개월구간_보정.sql @@ -0,0 +1,12 @@ +-- V109: 차등환수 최저구간 months_from 1 → 0 보정 +-- +-- 문제: 기존 시드(V28)의 최저 환수구간이 months_from=1 이라, +-- 체결 후 1개월 미만 즉시 해지/실효(ChronoUnit.MONTHS.between=0)는 +-- selectByMonths(0) 매칭 행이 없어 grade=null → 환수 0원으로 처리됐다. +-- 가장 환수율이 높아야 할(100%) 초단기 해지가 오히려 환수 면제되는 결함. +-- 수정: 100% 전액환수 구간의 시작을 0개월로 내려 0~12개월을 모두 커버. + +UPDATE chargeback_grade + SET months_from = 0 + WHERE months_from = 1 + AND chargeback_percent = 100;