fix: 잔여 백로그 전건 처리 — 회계 예수금/1200%clip/환수가드/net경보 (PL 자율진행)

- 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) <noreply@anthropic.com>
This commit is contained in:
GA Pro
2026-06-03 11:27:53 +09:00
parent 60c4cbae78
commit e2fdc88754
8 changed files with 207 additions and 30 deletions
@@ -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);
@@ -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)
@@ -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());
}
}
@@ -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();
@@ -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<InstallmentPlanVO> 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<InstallmentPlanVO> 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);
@@ -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<List<LedgerVO>> ledgerCaptor = ArgumentCaptor.forClass(List.class);
when(recruitMapper.batchInsert(ledgerCaptor.capture())).thenReturn(1);
step.execute(null, null);
// 초과분 100,000(=450,000350,000) 이 분급으로 이연 생성
ArgumentCaptor<BigDecimal> 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");
}
}
@@ -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;
@@ -97,32 +97,48 @@
recruit_ledger + maintain_ledger UNION → contract_id 포함.
AccountingEntryGenerateStep에서 호출.
-->
<!--
분개 구조 (원천세 반영, 차변합=대변합 균형):
(차) 8350 지급수수료 gross / (대) 2530 미지급금 net + (대) 2540 예수금 원천세
ledger 1건당 net leg(8350/2530) + tax leg(8350/2540, 원천세>0 일 때만) 로 분리 생성.
net = agent_amount COALESCE(tax_amount, 0)
-->
<select id="selectBySettleMonth" resultType="com.ga.core.vo.accounting.ContractAccountingEntryVO">
SELECT l.contract_id,
NULL::BIGINT AS payment_id,
CURRENT_DATE AS entry_date,
'8350' AS debit_account,
'2530' AS credit_account,
l.agent_amount AS amount,
CONCAT('모집수수료 ', l.settle_month) AS description,
l.settle_month AS settle_month,
NULL AS journal_no
<!-- 모집수수료: 미지급금(net) -->
SELECT l.contract_id, NULL::BIGINT AS payment_id, CURRENT_DATE AS entry_date,
'8350' AS debit_account, '2530' AS credit_account,
(l.agent_amount - COALESCE(l.tax_amount, 0)) AS amount,
CONCAT('모집수수료 미지급금 ', l.settle_month) AS description,
l.settle_month AS settle_month, NULL AS journal_no
FROM recruit_ledger l
WHERE l.settle_month = #{settleMonth}
AND l.agent_amount > 0
WHERE l.settle_month = #{settleMonth} AND l.agent_amount > 0
UNION ALL
SELECT l.contract_id,
NULL::BIGINT AS payment_id,
CURRENT_DATE AS entry_date,
'8350' AS debit_account,
'2530' AS credit_account,
l.agent_amount AS amount,
CONCAT('유지수수료 ', l.settle_month) AS description,
l.settle_month AS settle_month,
NULL AS journal_no
<!-- 모집수수료: 원천세 예수금 -->
SELECT l.contract_id, NULL::BIGINT, CURRENT_DATE,
'8350', '2540', l.tax_amount,
CONCAT('모집수수료 원천세 ', l.settle_month),
l.settle_month, NULL
FROM recruit_ledger l
WHERE l.settle_month = #{settleMonth} AND l.agent_amount > 0
AND COALESCE(l.tax_amount, 0) > 0
UNION ALL
<!-- 유지수수료: 미지급금(net) -->
SELECT l.contract_id, NULL::BIGINT, CURRENT_DATE,
'8350', '2530',
(l.agent_amount - COALESCE(l.tax_amount, 0)),
CONCAT('유지수수료 미지급금 ', l.settle_month),
l.settle_month, NULL
FROM maintain_ledger l
WHERE l.settle_month = #{settleMonth}
AND l.agent_amount > 0
WHERE l.settle_month = #{settleMonth} AND l.agent_amount > 0
UNION ALL
<!-- 유지수수료: 원천세 예수금 -->
SELECT l.contract_id, NULL::BIGINT, CURRENT_DATE,
'8350', '2540', l.tax_amount,
CONCAT('유지수수료 원천세 ', l.settle_month),
l.settle_month, NULL
FROM maintain_ledger l
WHERE l.settle_month = #{settleMonth} AND l.agent_amount > 0
AND COALESCE(l.tax_amount, 0) > 0
</select>
</mapper>