feat: 수수료 갭 보완 4종 — 지급보류/환수원천세역분개/연체이자/간이지급명세서 (V124~V126)

감사(서브에이전트 3영역)로 발견한 실제 결함/규제 갭만 선별 보완.

- A 지급보류(HOLD) 지급배치 누락 버그: PaymentBatchItemMapper.insertFromSettleMaster
  status NOT IN('PAID','HOLD') — 보류건이 지급배치에 포함되어 실제 이체되던 결함 수정
- B 환수 원천세 역분개: withholding_tax_adjustment(V124) 멱등 적재 +
  분기 원천세신고 집계 netting(payment UNION ALL adjustment). settle_master 금액흐름 불변
- C 환수채권 연체이자: clawback_receivable.accrued_interest + clawback_interest(V125) +
  ClawbackInterestCalculator(연율/1200), config 기본 0 = 안전폴백, 기존 FIFO 상계가 이자 회수
- D 사업소득 간이지급명세서(월별): commission_statement(MONTHLY) 위 읽기전용 리포트 +
  메뉴 REPORT_SIMPLIFIED_PAYMENT(V126) + 프론트 SimplifiedPaymentReport

검증: ./gradlew build 전모듈 GREEN, 신규 단위테스트 6건, ga-frontend tsc -b PASS,
Flyway V124~V126 운영DB 적용(v126), 적대리뷰 APPROVED(LOW 1건 GREATEST 클램프 즉수정),
B/C/D 라이브DB 검증(롤백) + D admin e2e list/export 200.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
GA Pro
2026-06-08 21:22:13 +09:00
parent ff6d399dd3
commit fbbabf2d04
29 changed files with 832 additions and 19 deletions
@@ -0,0 +1,37 @@
package com.ga.batch.settlement.domain;
import com.ga.common.system.SystemConfigService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
/**
* 환수채권 연체이자 계산기 (V125).
* 당월 적립 연체이자 = 잔액 × (연이자율 / 100 / 12), HALF_UP scale 0(원).
* 이자율 0(기본값) 이면 0 반환 → 기존 동작과 동일(안전 폴백).
*
* 적립은 매 정산월 1개월분이며, 잔액 기준이므로 월복리로 누적된다.
*/
@Component
@RequiredArgsConstructor
public class ClawbackInterestCalculator {
private final SystemConfigService configService;
/** 당월 적립 연체이자. 잔액·이자율이 0 이하이면 0. */
public BigDecimal monthlyInterest(BigDecimal balance) {
BigDecimal annualRate = configService.getDecimal("CLAWBACK_OVERDUE_INTEREST_RATE", BigDecimal.ZERO);
if (annualRate.signum() <= 0 || balance == null || balance.signum() <= 0) {
return BigDecimal.ZERO;
}
return balance.multiply(annualRate)
.divide(BigDecimal.valueOf(1200), 0, RoundingMode.HALF_UP);
}
/** 연체이자 거치기간(개월) — 발생 후 N개월 경과부터 적립. 기본 0. */
public int graceMonths() {
return configService.getInt("CLAWBACK_INTEREST_GRACE_MONTHS", 0);
}
}
@@ -2,8 +2,12 @@ package com.ga.batch.settlement.step;
import com.ga.batch.settlement.SettlementContext;
import com.ga.batch.settlement.domain.BatchTaxCalculator;
import com.ga.batch.settlement.domain.ClawbackInterestCalculator;
import com.ga.batch.settlement.domain.TaxBreakdown;
import com.ga.common.util.DateUtil;
import com.ga.common.util.MoneyUtil;
import com.ga.core.mapper.commission.ClawbackInterestMapper;
import com.ga.core.vo.commission.ClawbackInterestVO;
import com.ga.core.mapper.commission.AdvancePaymentMapper;
import com.ga.core.mapper.commission.AdvanceRecoveryLedgerMapper;
import com.ga.core.mapper.commission.AwardLedgerMapper;
@@ -28,6 +32,7 @@ import com.ga.core.mapper.settle.ChargebackMapper;
import com.ga.core.mapper.settle.CommissionStatementMapper;
import com.ga.core.mapper.settle.OverrideSettleMapper;
import com.ga.core.mapper.settle.SettleMasterMapper;
import com.ga.core.mapper.tax.WithholdingTaxAdjustmentMapper;
import com.ga.core.vo.commission.AdvancePaymentVO;
import com.ga.core.vo.commission.AdvanceRecoveryLedgerVO;
import com.ga.core.vo.commission.ClawbackReceivableResp;
@@ -43,6 +48,7 @@ import com.ga.core.vo.settle.CommissionStatementVO;
import com.ga.core.vo.settle.DeductionStatus;
import com.ga.core.vo.settle.SettleMasterVO;
import com.ga.core.vo.settle.StatementType;
import com.ga.core.vo.tax.WithholdingTaxAdjustmentVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.StepContribution;
@@ -90,6 +96,9 @@ public class AggregateStep implements Tasklet {
private final BatchTaxCalculator taxCalculator;
private final ClawbackReceivableMapper receivableMapper;
private final ClawbackRecoveryMapper recoveryMapper;
private final WithholdingTaxAdjustmentMapper adjustmentMapper;
private final ClawbackInterestMapper interestMapper;
private final ClawbackInterestCalculator interestCalculator;
// 설계사 지급성 9종 원장 Mapper
private final CollectionCommissionLedgerMapper collectionMapper;
@@ -192,6 +201,13 @@ public class AggregateStep implements Tasklet {
// MOD-2 멱등 reverse: 선지급 상계 이력 역산 후 advance_payment balance 복원
reverseAdvanceRecovery(ctx.getSettleMonth());
// V124 멱등 reverse: 당월 환수 원천세 역분개 전량 삭제 후 루프에서 재적재
adjustmentMapper.deleteBySettleMonth(ctx.getSettleMonth());
// V125 환수채권 연체이자: 당월 적립분 reverse 후 미결 채권에 1개월분 재적립
// (balance 에 포함 → 이후 agent 루프의 FIFO 상계가 이자까지 회수)
reverseAndAccrueClawbackInterest(ctx.getSettleMonth());
for (Long agentId : agentIds) {
BigDecimal recruit = recruitMap.getOrDefault(agentId, BigDecimal.ZERO);
BigDecimal maintain = maintainMap.getOrDefault(agentId, BigDecimal.ZERO);
@@ -305,6 +321,21 @@ public class AggregateStep implements Tasklet {
applyRecovery(agentId, ctx.getSettleMonth(), recovered);
}
// V124 환수 원천세 역분개: gross<0(상계되지 않은 환수분)에 대응하는 기납부 원천세를
// 역분개로 적재 → 분기 원천세신고 과대신고 해소. settle_master 금액 흐름은 불변.
if (gross.signum() < 0) {
BigDecimal reversalBase = gross.negate();
TaxBreakdown rev = taxCalculator.calculate(reversalBase, taxType, VatType.NONE);
WithholdingTaxAdjustmentVO adj = new WithholdingTaxAdjustmentVO();
adj.setAgentId(agentId);
adj.setSettleMonth(ctx.getSettleMonth());
adj.setReason("CHARGEBACK");
adj.setBaseAmount(reversalBase);
adj.setIncomeTaxAdj(rev.incomeTax().negate());
adj.setLocalTaxAdj(rev.localTax().negate());
adjustmentMapper.upsert(adj);
}
// 차감 적용 완료 처리
if (!agentDeductions.isEmpty()) {
agentDeductions.forEach(d -> appliedDeductionIds.add(d.getDeductionId()));
@@ -472,6 +503,40 @@ public class AggregateStep implements Tasklet {
settleMonth, sumByReceivable.size());
}
/**
* V125 환수채권 연체이자 멱등 적립.
* 1) 당월(settleMonth) 적립 이력을 역산 — balance/accrued_interest 에서 차감 후 이력 삭제(재실행 안전).
* 2) 이자율>0 이면 거치기간 경과 미결 채권에 1개월분 연체이자를 balance/accrued_interest 에 가산.
* balance 에 포함되므로 이후 FIFO 상계가 이자까지 회수한다.
* 이자율 0(기본값) 이면 적립 없음 → 기존 동작과 동일.
*/
private void reverseAndAccrueClawbackInterest(String settleMonth) {
// 1) 멱등 reverse: 당월 적립분 차감 후 삭제
List<ClawbackInterestVO> prev = interestMapper.selectBySettleMonth(settleMonth);
for (ClawbackInterestVO iv : prev) {
receivableMapper.applyInterest(iv.getReceivableId(), iv.getInterestAmount().negate());
}
if (!prev.isEmpty()) {
interestMapper.deleteBySettleMonth(settleMonth);
}
// 2) 적립: 거치기간(grace) 경과한 미결 채권 대상. beforeMonth = settleMonth - (grace+1)
int grace = interestCalculator.graceMonths();
String beforeMonth = DateUtil.addMonth(settleMonth, -(grace + 1));
for (ClawbackReceivableVO rcv : receivableMapper.selectAccruable(beforeMonth)) {
BigDecimal interest = interestCalculator.monthlyInterest(rcv.getBalance());
if (interest.signum() <= 0) continue;
receivableMapper.applyInterest(rcv.getReceivableId(), interest);
ClawbackInterestVO iv = new ClawbackInterestVO();
iv.setReceivableId(rcv.getReceivableId());
iv.setSettleMonth(settleMonth);
iv.setInterestAmount(interest);
interestMapper.insertOne(iv);
}
}
/**
* FIFO 상계 회수 적용: 오래된 origin_settle_month 순으로 채권을 소진하며
* recoveredTotal 만큼 clawback_receivable balance를 차감하고
@@ -0,0 +1,56 @@
package com.ga.batch.settlement.domain;
import com.ga.common.system.SystemConfigService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
/**
* US-C 환수채권 연체이자 계산 검증.
*/
@ExtendWith(MockitoExtension.class)
class ClawbackInterestCalculatorTest {
@Mock SystemConfigService configService;
@Test
@DisplayName("이자율 0(기본값)이면 적립 0 — 안전 폴백")
void zero_rate_no_interest() {
when(configService.getDecimal(eq("CLAWBACK_OVERDUE_INTEREST_RATE"), any(BigDecimal.class)))
.thenReturn(BigDecimal.ZERO);
ClawbackInterestCalculator calc = new ClawbackInterestCalculator(configService);
assertThat(calc.monthlyInterest(new BigDecimal("1000000"))).isEqualByComparingTo("0");
}
@Test
@DisplayName("연 12% → 월 1% — 잔액 1,000,000 의 1개월 연체이자 = 10,000")
void twelve_percent_monthly() {
when(configService.getDecimal(eq("CLAWBACK_OVERDUE_INTEREST_RATE"), any(BigDecimal.class)))
.thenReturn(new BigDecimal("12"));
ClawbackInterestCalculator calc = new ClawbackInterestCalculator(configService);
// 1,000,000 × 12 / 1200 = 10,000
assertThat(calc.monthlyInterest(new BigDecimal("1000000"))).isEqualByComparingTo("10000");
}
@Test
@DisplayName("잔액 0/null 이면 이자 0")
void zero_balance_no_interest() {
when(configService.getDecimal(eq("CLAWBACK_OVERDUE_INTEREST_RATE"), any(BigDecimal.class)))
.thenReturn(new BigDecimal("12"));
ClawbackInterestCalculator calc = new ClawbackInterestCalculator(configService);
assertThat(calc.monthlyInterest(BigDecimal.ZERO)).isEqualByComparingTo("0");
assertThat(calc.monthlyInterest(null)).isEqualByComparingTo("0");
}
}
@@ -0,0 +1,81 @@
package com.ga.batch.settlement.domain;
import com.ga.common.system.SystemConfigService;
import com.ga.core.vo.org.TaxType;
import com.ga.core.vo.org.VatType;
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.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;
/**
* US-B 환수 원천세 역분개 검증.
*
* 환수(클로백) 우위로 gross<0 인 월에는 기납부 원천세를 역분개(음수)하여
* 분기 원천세신고 누적이 정확히 net-off 되어야 한다. AggregateStep 은 reversalBase=|gross| 에
* BatchTaxCalculator.calculate 를 적용하고 그 결과를 negate 하여 적재한다 — 본 테스트는 그 계산을 검증한다.
*/
@ExtendWith(MockitoExtension.class)
class WithholdingReversalTest {
@Mock SystemConfigService configService;
BatchTaxCalculator taxCalculator;
@BeforeEach
void setUp() {
taxCalculator = new BatchTaxCalculator(configService);
// 세율: configService 는 기본값(2번째 인자)을 그대로 반환 (사업 3.3% / 지방 10%)
lenient().when(configService.getDecimal(anyString(), any(BigDecimal.class)))
.thenAnswer(inv -> inv.getArgument(1));
}
@Test
@DisplayName("역분개액 = |gross| 기준 원천세를 음수로 — 1,000,000 → 소득세 -33,000 / 지방세 -3,300")
void reversal_amounts() {
BigDecimal reversalBase = new BigDecimal("1000000"); // |gross| (환수 우위분)
TaxBreakdown rev = taxCalculator.calculate(reversalBase, TaxType.BUSINESS_INCOME, VatType.NONE);
BigDecimal incomeAdj = rev.incomeTax().negate();
BigDecimal localAdj = rev.localTax().negate();
assertThat(incomeAdj).isEqualByComparingTo("-33000"); // 1,000,000 × 3.3%
assertThat(localAdj).isEqualByComparingTo("-3300"); // 33,000 × 10%
}
@Test
@DisplayName("양수월 원천징수 + 환수월 역분개 = 분기 누적 원천세 0 (net-off)")
void quarter_nets_off() {
// M월: gross +1,000,000 정상 지급 → 원천징수
TaxBreakdown paid = taxCalculator.calculate(new BigDecimal("1000000"),
TaxType.BUSINESS_INCOME, VatType.NONE);
// M+2월: 환수만 발생 gross -1,000,000 → 역분개(음수)
TaxBreakdown rev = taxCalculator.calculate(new BigDecimal("1000000"),
TaxType.BUSINESS_INCOME, VatType.NONE);
BigDecimal quarterWithheld = paid.incomeTax().add(rev.incomeTax().negate());
BigDecimal quarterLocal = paid.localTax().add(rev.localTax().negate());
assertThat(quarterWithheld).isEqualByComparingTo("0");
assertThat(quarterLocal).isEqualByComparingTo("0");
}
@Test
@DisplayName("환수가 당월 양수분으로 완전 상계되면(gross>=0) 역분개 없음 — 당월 세금에 이미 반영")
void no_reversal_when_gross_non_negative() {
// gross = +500,000 (신규 1,500,000 환수 1,000,000) → 당월 과세 정상, 역분개 대상 아님
TaxBreakdown within = taxCalculator.calculate(new BigDecimal("500000"),
TaxType.BUSINESS_INCOME, VatType.NONE);
assertThat(within.incomeTax()).isEqualByComparingTo("16500"); // 당월분만 과세
// gross>=0 이므로 AggregateStep 의 역분개 블록(gross.signum()<0)은 진입하지 않는다.
}
}