fix: 수수료 계산 전수감사 — 1200%룰 단위버그 외 5건 수정

적대적 코드감사(4영역)에서 검증된 정확성 결함 수정:
- fix(HIGH): 1200%룰 단위 불일치. regulatory_limit.limit_value(PERCENT:1200,35)를
  KRW 금액과 직접 비교 → 사실상 "1200원/35원 룰"로 망가져 거의 모든 계약이 위반
  처리. 연환산보험료×비율/100로 KRW 환산 후 비교. RegulatoryLimitChecker(api/batch)
  양쪽, ContractMapper 주입(시그니처 무변경).
- fix(HIGH): 음수 gross(환수>지급) 세금 계산이 음수 원천세 생성 → net 왜곡 +
  분기 원천세신고 SUM 잠식. gross<=0 시 세금0·net=원금 가드. Tax/BatchTaxCalculator.
- fix(HIGH): AwardService 시상 threshold_amount 미적용 → 기준 미달자에게 시상
  전액 지급. threshold 미만 차단 + FIXED award_fixed null 가드.
- fix(MED): 차등환수 최저구간 months_from 1→0(V109). 체결 1개월내 즉시해지가
  grade=null로 환수 0원 처리되던 누락 보정.
- fix(MED): company_rate/payout_rate NULL→0 폴백 무경보. log.warn 추가로
  데이터 공백에 의한 과소지급 추적 가능.

테스트 4건 추가(단위변환/음수세금/시상threshold 경계). 전체 build+test GREEN: 152건 0실패.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
GA Pro
2026-06-03 10:19:53 +09:00
parent 3d50286f4a
commit eac0b813d6
9 changed files with 200 additions and 23 deletions
@@ -91,8 +91,19 @@ public class AwardService {
AwardRuleVO rule = ruleMapper.selectActiveRule(req.getAwardType(), LocalDate.now());
if (rule == null) throw new BizException(ErrorCode.NOT_FOUND);
// threshold_amount 이상 실적 달성 시에만 시상 발동 (스펙 7-7). 미달 시 시상 없음.
if (rule.getThresholdAmount() != null
&& req.getBaseAmount().compareTo(rule.getThresholdAmount()) < 0) {
throw new BizException(ErrorCode.BAD_REQUEST,
String.format("시상 기준 실적 미달: 실적 %s < 기준 %s",
req.getBaseAmount(), rule.getThresholdAmount()));
}
BigDecimal awardAmount;
if ("FIXED".equals(rule.getCalcType())) {
if (rule.getAwardFixed() == null) {
throw new BizException(ErrorCode.BAD_REQUEST, "FIXED 시상 룰에 award_fixed 가 설정되지 않았습니다");
}
awardAmount = rule.getAwardFixed().setScale(2, RoundingMode.HALF_UP);
} else {
// RATE: base × award_rate
@@ -2,12 +2,15 @@ package com.ga.api.service.regulatory;
import com.ga.core.mapper.ledger.MaintainLedgerMapper;
import com.ga.core.mapper.ledger.RecruitLedgerMapper;
import com.ga.core.mapper.product.ContractMapper;
import com.ga.core.mapper.regulatory.RegulatoryLimitMapper;
import com.ga.core.vo.product.ContractVO;
import com.ga.core.vo.regulatory.RegulatoryLimitVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
@@ -17,11 +20,13 @@ public class RegulatoryLimitChecker {
private static final String TOTAL_RATIO = "TOTAL_RATIO";
private static final String FIRST_YEAR_RATIO = "FIRST_YEAR_RATIO";
private static final String UNIT_PERCENT = "PERCENT";
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private final RegulatoryLimitMapper limitMapper;
private final RecruitLedgerMapper recruitLedgerMapper;
private final MaintainLedgerMapper maintainLedgerMapper;
private final ContractMapper contractMapper;
/** 누적 수수료율 한도 체크 (1200%). 신계약 + 유지 원장 합산. */
public ViolationResult checkContractTotal(Long contractId, BigDecimal additionalAmount, LocalDate baseDate) {
@@ -31,19 +36,20 @@ public class RegulatoryLimitChecker {
return ViolationResult.ok(additionalAmount, null, TOTAL_RATIO);
}
BigDecimal limitKrw = resolveLimitKrw(limit, contractId);
BigDecimal currentTotal = sumPaidByContract(contractId);
BigDecimal projected = currentTotal.add(additionalAmount);
if (projected.compareTo(limit.getLimitValue()) > 0) {
if (projected.compareTo(limitKrw) > 0) {
return ViolationResult.violated(
currentTotal,
limit.getLimitValue(),
limitKrw,
TOTAL_RATIO,
String.format("누적 수수료 한도 초과: 현재 %s, 추가 %s, 한도 %s %s",
currentTotal, additionalAmount, limit.getLimitValue(), limit.getUnit())
String.format("누적 수수료 한도 초과: 현재 %s, 추가 %s, 한도 %s(%s %s)",
currentTotal, additionalAmount, limitKrw, limit.getLimitValue(), limit.getUnit())
);
}
return ViolationResult.ok(currentTotal, limit.getLimitValue(), TOTAL_RATIO);
return ViolationResult.ok(currentTotal, limitKrw, TOTAL_RATIO);
}
/** 1차년 수수료율 한도 체크. 신계약 원장 1차년도만 (유지는 1차년 개념 없음). */
@@ -54,19 +60,32 @@ public class RegulatoryLimitChecker {
return ViolationResult.ok(additionalAmount, null, FIRST_YEAR_RATIO);
}
BigDecimal limitKrw = resolveLimitKrw(limit, contractId);
BigDecimal currentTotal = sumFirstYearPaidByContract(contractId);
BigDecimal projected = currentTotal.add(additionalAmount);
if (projected.compareTo(limit.getLimitValue()) > 0) {
if (projected.compareTo(limitKrw) > 0) {
return ViolationResult.violated(
currentTotal,
limit.getLimitValue(),
limitKrw,
FIRST_YEAR_RATIO,
String.format("1차년 수수료 한도 초과: 현재 %s, 추가 %s, 한도 %s %s",
currentTotal, additionalAmount, limit.getLimitValue(), limit.getUnit())
String.format("1차년 수수료 한도 초과: 현재 %s, 추가 %s, 한도 %s(%s %s)",
currentTotal, additionalAmount, limitKrw, limit.getLimitValue(), limit.getUnit())
);
}
return ViolationResult.ok(currentTotal, limit.getLimitValue(), FIRST_YEAR_RATIO);
return ViolationResult.ok(currentTotal, limitKrw, FIRST_YEAR_RATIO);
}
/** 비율 한도(PERCENT)를 계약 연환산보험료 기준 KRW 한도로 환산. */
private BigDecimal resolveLimitKrw(RegulatoryLimitVO limit, Long contractId) {
if (!UNIT_PERCENT.equalsIgnoreCase(limit.getUnit())) {
return limit.getLimitValue();
}
ContractVO contract = contractMapper.selectById(contractId);
BigDecimal annualPremium = (contract != null && contract.getPremium() != null)
? contract.getPremium() : BigDecimal.ZERO;
return annualPremium.multiply(limit.getLimitValue())
.divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
}
private BigDecimal sumPaidByContract(Long contractId) {
@@ -16,6 +16,12 @@ public class TaxCalculator {
private final SystemConfigService configService;
public TaxBreakdown calculate(BigDecimal amount, TaxType taxType, VatType vatType) {
// 음수/0 정산(환수 우위 등)은 원천징수 대상이 아님 → 세금 0, net=원금(차월 이월).
if (amount == null || amount.signum() <= 0) {
BigDecimal base = amount != null ? amount : BigDecimal.ZERO;
return new TaxBreakdown(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, base);
}
BigDecimal incomeRate = resolveIncomeRate(taxType);
BigDecimal localRate = configService.getDecimal("TAX_RATE_LOCAL_INCOME", new BigDecimal("10.0"));
BigDecimal vatRate = configService.getDecimal("TAX_RATE_VAT", new BigDecimal("10.0"));
@@ -13,11 +13,16 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import com.ga.common.exception.BizException;
import java.math.BigDecimal;
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.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@@ -74,4 +79,47 @@ class AwardServiceTest {
AwardLedgerVO saved = captor.getValue();
assertThat(saved.getAwardAmount()).isEqualByComparingTo("200000.00");
}
@Test
@DisplayName("createLedger: 실적이 threshold_amount 미달이면 시상 발동 안 함(예외) — 원장 미생성")
void createLedger_belowThreshold_rejected() {
AwardRuleVO rule = new AwardRuleVO();
rule.setCalcType("RATE");
rule.setAwardRate(new BigDecimal("0.05"));
rule.setThresholdAmount(new BigDecimal("1000000")); // 기준 100만
when(ruleMapper.selectActiveRule(eq("PERFORMANCE"), any())).thenReturn(rule);
AwardLedgerSaveReq req = new AwardLedgerSaveReq();
req.setAgentId(1L);
req.setSettleMonth("202506");
req.setAwardType("PERFORMANCE");
req.setBaseAmount(new BigDecimal("800000")); // 기준 미달
assertThatThrownBy(() -> service.createLedger(req))
.isInstanceOf(BizException.class);
verify(ledgerMapper, never()).insert(any());
}
@Test
@DisplayName("createLedger: 실적이 threshold 이상이면 정상 발동 (경계 = 이상)")
void createLedger_atThreshold_ok() {
AwardRuleVO rule = new AwardRuleVO();
rule.setCalcType("RATE");
rule.setAwardRate(new BigDecimal("0.05"));
rule.setThresholdAmount(new BigDecimal("1000000"));
when(ruleMapper.selectActiveRule(eq("PERFORMANCE"), any())).thenReturn(rule);
AwardLedgerSaveReq req = new AwardLedgerSaveReq();
req.setAgentId(1L);
req.setSettleMonth("202506");
req.setAwardType("PERFORMANCE");
req.setBaseAmount(new BigDecimal("1000000")); // 기준 정확히 충족
ArgumentCaptor<AwardLedgerVO> captor = ArgumentCaptor.forClass(AwardLedgerVO.class);
when(ledgerMapper.insert(captor.capture())).thenReturn(1);
service.createLedger(req);
assertThat(captor.getValue().getAwardAmount()).isEqualByComparingTo("50000.00");
}
}