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:
@@ -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를 차감하고
|
||||
|
||||
+56
@@ -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)은 진입하지 않는다.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user