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에서 호출. --> +