From e2fdc8875439313796a5219aa378f3ad43436c6e Mon Sep 17 00:00:00 2001 From: GA Pro Date: Wed, 3 Jun 2026 11:27:53 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=9E=94=EC=97=AC=20=EB=B0=B1=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=20=EC=A0=84=EA=B1=B4=20=EC=B2=98=EB=A6=AC=20=E2=80=94?= =?UTF-8?q?=20=ED=9A=8C=EA=B3=84=20=EC=98=88=EC=88=98=EA=B8=88/1200%clip/?= =?UTF-8?q?=ED=99=98=EC=88=98=EA=B0=80=EB=93=9C/net=EA=B2=BD=EB=B3=B4=20(P?= =?UTF-8?q?L=20=EC=9E=90=EC=9C=A8=EC=A7=84=ED=96=89)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix(a, MED): 회계 분개에 원천세 예수금 leg 분리. (차)8350 지급수수료 gross / (대)2530 미지급금 net + (대)2540 예수금 원천세. 기존엔 미지급금에 gross 계상 + 원천세 분개 누락. account_code '2540' 예수금 추가(V110), 분개쿼리 2-leg 전환. - fix(b, HIGH): 1200%룰 1차년 한도초과 시 clip+이연. 한도잔여까지만 당월 지급, 초과분만 분급(7년)으로 이연. 전액 이연 시 당월 원장 미생성 + 세액 재계산. (기존: 전액지급 + 전액 분급계획 → 설계 불일치) - fix(e/f, HIGH/MED): 정착지원금/생보운영지원 환수액 ≤ 지원금 검증으로 net 음수 차단. AggregateStep net 음수(공제>지급) 경보 로깅 추가. - (g) 검토결과 무변경: TaxCalculator/BatchTaxCalculator는 의도된 동일로직 쌍둥이, V80 매직팩터는 샘플시드일 뿐 계산경로 아님. 두 쌍둥이 모두 음수가드 적용됨. - (d) 무변경: 승계 "최신 to_agent=현재귀속자"는 다단계체인에 정합, from==agent_id 가드는 체인 회귀 유발하므로 정책확정 전 보류(문서화). - test: CalcRecruitStepTest(clip+이연), SettlingSupportServiceTest(환수>지원 거부). - 전체 build+test GREEN: 155건 0실패. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../commission/LifeSupportService.java | 6 + .../commission/SettlingSupportService.java | 6 + .../SettlingSupportServiceTest.java | 20 ++++ .../batch/settlement/step/AggregateStep.java | 4 + .../settlement/step/CalcRecruitStep.java | 29 +++-- .../settlement/step/CalcRecruitStepTest.java | 103 ++++++++++++++++++ .../db/migration/V110__예수금계정_추가.sql | 9 ++ .../ContractAccountingEntryMapper.xml | 60 ++++++---- 8 files changed, 207 insertions(+), 30 deletions(-) create mode 100644 ga-batch/src/test/java/com/ga/batch/settlement/step/CalcRecruitStepTest.java create mode 100644 ga-common/src/main/resources/db/migration/V110__예수금계정_추가.sql diff --git a/ga-api/src/main/java/com/ga/api/service/commission/LifeSupportService.java b/ga-api/src/main/java/com/ga/api/service/commission/LifeSupportService.java index 9cff5c1..298ce80 100644 --- a/ga-api/src/main/java/com/ga/api/service/commission/LifeSupportService.java +++ b/ga-api/src/main/java/com/ga/api/service/commission/LifeSupportService.java @@ -85,6 +85,12 @@ public class LifeSupportService { .multiply(req.getSupportRate()) .setScale(2, RoundingMode.HALF_UP); BigDecimal clawback = req.getClawbackAmount() != null ? req.getClawbackAmount() : BigDecimal.ZERO; + // 환수액은 음수 불가, 지원수수료 초과 불가 (net 음수 방지) + if (clawback.signum() < 0 || clawback.compareTo(supportAmount) > 0) { + throw new BizException(ErrorCode.BAD_REQUEST, + String.format("환수액(%s)은 0 이상이며 지원수수료(%s)를 초과할 수 없습니다", + clawback, supportAmount)); + } // 실지급액 = support - clawback BigDecimal netAmount = supportAmount.subtract(clawback).setScale(2, RoundingMode.HALF_UP); diff --git a/ga-api/src/main/java/com/ga/api/service/commission/SettlingSupportService.java b/ga-api/src/main/java/com/ga/api/service/commission/SettlingSupportService.java index f4ce553..7118dcf 100644 --- a/ga-api/src/main/java/com/ga/api/service/commission/SettlingSupportService.java +++ b/ga-api/src/main/java/com/ga/api/service/commission/SettlingSupportService.java @@ -85,6 +85,12 @@ public class SettlingSupportService { @Transactional public Long createLedger(SettlingSupportLedgerSaveReq req) { BigDecimal clawback = req.getClawbackAmount() != null ? req.getClawbackAmount() : BigDecimal.ZERO; + // 환수액은 음수 불가, 지원금 초과 불가 (net 음수 방지) + if (clawback.signum() < 0 || clawback.compareTo(req.getSupportAmount()) > 0) { + throw new BizException(ErrorCode.BAD_REQUEST, + String.format("환수액(%s)은 0 이상이며 지원금(%s)을 초과할 수 없습니다", + clawback, req.getSupportAmount())); + } // 실지급액 = support_amount - clawback_amount BigDecimal netAmount = req.getSupportAmount() .subtract(clawback) diff --git a/ga-api/src/test/java/com/ga/api/service/commission/SettlingSupportServiceTest.java b/ga-api/src/test/java/com/ga/api/service/commission/SettlingSupportServiceTest.java index 2dd36e4..2fbdc6c 100644 --- a/ga-api/src/test/java/com/ga/api/service/commission/SettlingSupportServiceTest.java +++ b/ga-api/src/test/java/com/ga/api/service/commission/SettlingSupportServiceTest.java @@ -14,7 +14,13 @@ import org.mockito.junit.jupiter.MockitoExtension; import java.math.BigDecimal; +import com.ga.common.exception.BizException; + 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.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -62,4 +68,18 @@ class SettlingSupportServiceTest { assertThat(saved.getNetAmount()).isEqualByComparingTo("300000.00"); assertThat(saved.getClawbackAmount()).isEqualByComparingTo("0"); } + + @Test + @DisplayName("createLedger: 환수액이 지원금을 초과하면 거부(net 음수 방지) — 원장 미생성") + void createLedger_clawbackExceedsSupport_rejected() { + SettlingSupportLedgerSaveReq req = new SettlingSupportLedgerSaveReq(); + req.setAgentId(1L); + req.setSettleMonth("202506"); + req.setSupportAmount(new BigDecimal("300000")); + req.setClawbackAmount(new BigDecimal("400000")); // 지원금 초과 + + assertThatThrownBy(() -> service.createLedger(req)) + .isInstanceOf(BizException.class); + verify(ledgerMapper, never()).insert(any()); + } } diff --git a/ga-batch/src/main/java/com/ga/batch/settlement/step/AggregateStep.java b/ga-batch/src/main/java/com/ga/batch/settlement/step/AggregateStep.java index 4003c3d..df7575a 100644 --- a/ga-batch/src/main/java/com/ga/batch/settlement/step/AggregateStep.java +++ b/ga-batch/src/main/java/com/ga/batch/settlement/step/AggregateStep.java @@ -194,6 +194,10 @@ public class AggregateStep implements Tasklet { .reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal netAmount = tax.netAmount().subtract(deductionTotal); + if (netAmount.signum() < 0) { + log.warn("[Step8] net 음수(공제>지급): agentId={}, gross={}, deduction={}, net={} — 차월 이월 검토 필요", + agentId, gross, deductionTotal, netAmount); + } // settle_master UPSERT SettleMasterVO vo = new SettleMasterVO(); diff --git a/ga-batch/src/main/java/com/ga/batch/settlement/step/CalcRecruitStep.java b/ga-batch/src/main/java/com/ga/batch/settlement/step/CalcRecruitStep.java index ac83e63..6cdd6b3 100644 --- a/ga-batch/src/main/java/com/ga/batch/settlement/step/CalcRecruitStep.java +++ b/ga-batch/src/main/java/com/ga/batch/settlement/step/CalcRecruitStep.java @@ -23,6 +23,7 @@ import org.springframework.batch.core.step.tasklet.Tasklet; import org.springframework.batch.repeat.RepeatStatus; import org.springframework.stereotype.Component; +import java.math.BigDecimal; import java.util.ArrayList; import java.util.List; @@ -80,19 +81,31 @@ public class CalcRecruitStep implements Tasklet { c.getContractId(), led.getAgentAmount(), c.getContractDate()); if (firstYearCheck.violated()) { - log.warn("[Step4] 1200%룰 위반: contractId={}, msg={}", - c.getContractId(), firstYearCheck.message()); violationCount++; - // 1200%룰 위반 계약: 분급 계획 생성 (7년 35/25/15/10/7/5/3) - List plans = installmentGenerator.generate( - c.getContractId(), led.getAgentAmount(), - c.getContractDate(), c.getInsuranceType()); - installmentBatch.addAll(plans); - // 위반 계약도 원장에는 기록 (감사 목적) + // 1차년 한도 잔여분까지만 당월 지급, 초과분은 분급(7년)으로 이연. + BigDecimal allowed = MoneyUtil.sub( + firstYearCheck.limitAmount(), firstYearCheck.currentTotal()); + if (allowed.signum() < 0) allowed = BigDecimal.ZERO; + BigDecimal excess = MoneyUtil.sub(led.getAgentAmount(), allowed); + + if (excess.signum() > 0) { + // 초과분만 분급 계획 생성 (7년 35/25/15/10/7/5/3) + List plans = installmentGenerator.generate( + c.getContractId(), excess, + c.getContractDate(), c.getInsuranceType()); + installmentBatch.addAll(plans); + // 당월 원장은 한도까지만 지급(초과분 차감) + 세액 재계산 + led.setAgentAmount(allowed); + led.setTaxAmount(MoneyUtil.tax(allowed)); + log.warn("[Step4] 1200%룰 1차년 한도초과 → clip+이연: contractId={}, 지급(한도잔여)={}, 이연(분급)={}", + c.getContractId(), allowed, excess); + } } } + // 한도로 전액 이연되어 당월 지급액이 0이면 원장 미생성 + if (MoneyUtil.isZero(led.getAgentAmount())) continue; batch.add(led); if (batch.size() >= BATCH_SIZE) { recruitMapper.batchInsert(batch); diff --git a/ga-batch/src/test/java/com/ga/batch/settlement/step/CalcRecruitStepTest.java b/ga-batch/src/test/java/com/ga/batch/settlement/step/CalcRecruitStepTest.java new file mode 100644 index 0000000..a8272f3 --- /dev/null +++ b/ga-batch/src/test/java/com/ga/batch/settlement/step/CalcRecruitStepTest.java @@ -0,0 +1,103 @@ +package com.ga.batch.settlement.step; + +import com.ga.batch.settlement.SettlementContext; +import com.ga.batch.settlement.calc.CommissionCalculator; +import com.ga.batch.settlement.domain.BatchInstallmentPlanGenerator; +import com.ga.batch.settlement.domain.BatchRegulatoryLimitChecker; +import com.ga.batch.settlement.domain.ViolationResult; +import com.ga.core.mapper.installment.InstallmentPlanMapper; +import com.ga.core.mapper.ledger.RecruitLedgerMapper; +import com.ga.core.mapper.product.ContractMapper; +import com.ga.core.vo.installment.InstallmentPlanVO; +import com.ga.core.vo.ledger.LedgerVO; +import com.ga.core.vo.product.ContractResp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +/** + * 1200%룰 1차년 한도초과 → clip(한도까지만 지급) + 이연(초과분 분급) 검증. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class CalcRecruitStepTest { + + @Mock SettlementContext ctx; + @Mock ContractMapper contractMapper; + @Mock RecruitLedgerMapper recruitMapper; + @Mock CommissionCalculator calculator; + @Mock BatchRegulatoryLimitChecker regulatoryChecker; + @Mock BatchInstallmentPlanGenerator installmentGenerator; + @Mock InstallmentPlanMapper installmentPlanMapper; + + CalcRecruitStep step; + + static final String MONTH = "202401"; + static final Long CONTRACT_ID = 100L; + static final LocalDate CONTRACT_DATE = LocalDate.of(2024, 1, 15); + + @BeforeEach + void setUp() { + step = new CalcRecruitStep(ctx, contractMapper, recruitMapper, + calculator, regulatoryChecker, installmentGenerator, installmentPlanMapper); + when(ctx.getSettleMonth()).thenReturn(MONTH); + } + + @Test + @DisplayName("1차년 한도 350,000 < 모집수수료 450,000 → 350,000 지급 + 초과 100,000 분급 이연") + void firstYearLimit_clipAndDefer() { + ContractResp c = new ContractResp(); + c.setContractId(CONTRACT_ID); + c.setAgentId(10L); + c.setInsuranceType("WHOLE_LIFE"); + c.setContractDate(CONTRACT_DATE); + when(contractMapper.selectList(any())).thenReturn(List.of(c)); + + LedgerVO led = new LedgerVO(); + led.setContractId(CONTRACT_ID); + led.setAgentId(10L); + led.setAgentAmount(new BigDecimal("450000")); + led.setTaxAmount(new BigDecimal("14850")); + when(calculator.calcRecruitLedger(eq(c), eq(MONTH))).thenReturn(led); + + // 한도 350,000(=연납 1,000,000 × 35%), 기지급 0 → 위반 + when(regulatoryChecker.checkFirstYear(eq(CONTRACT_ID), any(BigDecimal.class), any(LocalDate.class))) + .thenReturn(ViolationResult.violated( + BigDecimal.ZERO, new BigDecimal("350000"), "FIRST_YEAR_RATIO", "초과")); + + when(installmentGenerator.generate(eq(CONTRACT_ID), any(BigDecimal.class), any(LocalDate.class), anyString())) + .thenReturn(List.of(new InstallmentPlanVO())); + + ArgumentCaptor> ledgerCaptor = ArgumentCaptor.forClass(List.class); + when(recruitMapper.batchInsert(ledgerCaptor.capture())).thenReturn(1); + + step.execute(null, null); + + // 초과분 100,000(=450,000−350,000) 이 분급으로 이연 생성 + ArgumentCaptor excessCaptor = ArgumentCaptor.forClass(BigDecimal.class); + org.mockito.Mockito.verify(installmentGenerator) + .generate(eq(CONTRACT_ID), excessCaptor.capture(), any(LocalDate.class), anyString()); + assertThat(excessCaptor.getValue()).isEqualByComparingTo("100000"); + + // 당월 원장은 한도까지만(350,000) + 세액 재계산(350,000 × 3.3% = 11,550) + LedgerVO saved = ledgerCaptor.getValue().get(0); + assertThat(saved.getAgentAmount()).isEqualByComparingTo("350000"); + assertThat(saved.getTaxAmount()).isEqualByComparingTo("11550.00"); + } +} diff --git a/ga-common/src/main/resources/db/migration/V110__예수금계정_추가.sql b/ga-common/src/main/resources/db/migration/V110__예수금계정_추가.sql new file mode 100644 index 0000000..8512ed1 --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V110__예수금계정_추가.sql @@ -0,0 +1,9 @@ +-- V110: 원천세 예수금 계정과목 추가 +-- +-- 회계 분개에 원천세 leg 를 분리(8350 지급수수료 / 2530 미지급금 net / 2540 예수금 원천세)하기 위해 +-- 예수금(withholding payable) 계정을 신설한다. +-- 기존: 미지급금에 gross 를 계상하고 원천세 분개가 누락 → 장부가 실지급(net)·세무신고와 불일치. + +INSERT INTO account_code (account_code, account_name, account_type, sort_order, description) +VALUES ('2540', '예수금', 'CREDIT', 25, '원천세 예수금 (소득세+지방소득세 원천징수분)') +ON CONFLICT (account_code) DO NOTHING; diff --git a/ga-core/src/main/resources/mapper/accounting/ContractAccountingEntryMapper.xml b/ga-core/src/main/resources/mapper/accounting/ContractAccountingEntryMapper.xml index 9eb4190..59ba454 100644 --- a/ga-core/src/main/resources/mapper/accounting/ContractAccountingEntryMapper.xml +++ b/ga-core/src/main/resources/mapper/accounting/ContractAccountingEntryMapper.xml @@ -97,32 +97,48 @@ recruit_ledger + maintain_ledger UNION → contract_id 포함. AccountingEntryGenerateStep에서 호출. --> +