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");
}
}
@@ -7,6 +7,7 @@ import com.ga.core.vo.ledger.LedgerVO;
import com.ga.core.vo.org.AgentVO;
import com.ga.core.vo.product.ContractResp;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
@@ -18,6 +19,7 @@ import java.time.LocalDate;
* - 유지수수료: 동일 공식, 회차에 따른 commission_year 만 다름
* - 모든 금액은 BigDecimal HALF_UP, 소수점 2자리
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class CommissionCalculator {
@@ -49,11 +51,19 @@ public class CommissionCalculator {
LocalDate baseDate = c.getContractDate() != null ? c.getContractDate() : LocalDate.now();
BigDecimal companyRate = ruleMapper.findCompanyRate(c.getProductId(), year, baseDate);
if (companyRate == null) companyRate = BigDecimal.ZERO;
if (companyRate == null) {
log.warn("[수수료율 누락] company_rate 없음 → 0 폴백(과소지급 위험): contractId={}, productId={}, year={}, baseDate={}",
c.getContractId(), c.getProductId(), year, baseDate);
companyRate = BigDecimal.ZERO;
}
BigDecimal payoutRate = ruleMapper.findPayoutRate(
agent.getGradeId(), c.getInsuranceType(), year, baseDate);
if (payoutRate == null) payoutRate = BigDecimal.ZERO;
if (payoutRate == null) {
log.warn("[수수료율 누락] payout_rate 없음 → 0 폴백(과소지급 위험): contractId={}, gradeId={}, insuranceType={}, year={}, baseDate={}",
c.getContractId(), agent.getGradeId(), c.getInsuranceType(), year, baseDate);
payoutRate = BigDecimal.ZERO;
}
BigDecimal premium = MoneyUtil.zero(c.getPremium());
BigDecimal companyAmount = MoneyUtil.pct(premium, companyRate);
@@ -2,17 +2,25 @@ package com.ga.batch.settlement.domain;
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;
/**
* 배치용 1200%룰 한도 체커. ga-api RegulatoryLimitChecker와 동일 로직.
*
* 한도 단위(unit) 처리:
* - PERCENT: regulatory_limit.limit_value 는 비율(1200, 35 등). 실제 KRW 한도는
* 연환산보험료 × 비율 / 100 으로 환산해 비교한다. (1200%룰의 본래 의미)
* - 그 외(KRW 등): limit_value 를 절대금액 한도로 직접 사용.
*/
@Component
@RequiredArgsConstructor
@@ -20,11 +28,13 @@ public class BatchRegulatoryLimitChecker {
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) {
@@ -34,19 +44,20 @@ public class BatchRegulatoryLimitChecker {
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차년 수수료율 한도 체크. */
@@ -57,19 +68,32 @@ public class BatchRegulatoryLimitChecker {
return ViolationResult.ok(additionalAmount, null, FIRST_YEAR_RATIO);
}
BigDecimal limitKrw = resolveLimitKrw(limit, contractId);
BigDecimal currentTotal = nullSafe(recruitLedgerMapper.sumFirstYearByContract(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) {
@@ -20,6 +20,13 @@ public class BatchTaxCalculator {
private final SystemConfigService configService;
public TaxBreakdown calculate(BigDecimal amount, TaxType taxType, VatType vatType) {
// 음수/0 정산(환수 우위 등)은 원천징수 대상이 아님 → 세금 0, net=원금(차월 이월).
// 음수 원천세가 분기 원천세신고 SUM에 전파되는 것을 차단.
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"));
@@ -69,6 +69,7 @@ class SettlementScenarioTest {
@Mock RegulatoryLimitMapper regulatoryLimitMapper;
@Mock RecruitLedgerMapper recruitLedgerMapper;
@Mock MaintainLedgerMapper maintainLedgerMapper;
@Mock com.ga.core.mapper.product.ContractMapper contractMapper;
@Mock SystemConfigService configService;
// ── 실제 계산 코드 (테스트 대상) ──
@@ -92,7 +93,7 @@ class SettlementScenarioTest {
void setUp() {
commissionCalculator = new CommissionCalculator(ruleMapper, agentMapper);
regulatoryChecker = new BatchRegulatoryLimitChecker(
regulatoryLimitMapper, recruitLedgerMapper, maintainLedgerMapper);
regulatoryLimitMapper, recruitLedgerMapper, maintainLedgerMapper, contractMapper);
installmentGenerator = new BatchInstallmentPlanGenerator(installmentRatioMapper);
chargebackCalculator = new BatchChargebackCalculator(chargebackGradeMapper);
taxCalculator = new BatchTaxCalculator(configService);
@@ -166,6 +167,33 @@ class SettlementScenarioTest {
assertThat(bad.currentTotal()).isEqualByComparingTo("450000");
}
@Test
@DisplayName("② 1200%룰 단위변환: PERCENT 한도(35%)는 연환산보험료 기준 KRW로 환산 비교")
void s2b_규제_PERCENT한도_연보험료기준_KRW환산() {
// 1차년 한도 35% (PERCENT). 실제 KRW 한도 = 연납보험료 1,000,000 × 35% = 350,000
RegulatoryLimitVO limit = new RegulatoryLimitVO();
limit.setLimitType("FIRST_YEAR_RATIO");
limit.setLimitValue(new BigDecimal("35"));
limit.setUnit("PERCENT");
when(regulatoryLimitMapper.selectActiveByType(eq("FIRST_YEAR_RATIO"), any(), anyString()))
.thenReturn(limit);
com.ga.core.vo.product.ContractVO cv = new com.ga.core.vo.product.ContractVO();
cv.setPremium(new BigDecimal("1000000"));
when(contractMapper.selectById(CONTRACT_ID)).thenReturn(cv);
when(recruitLedgerMapper.sumFirstYearByContract(CONTRACT_ID)).thenReturn(BigDecimal.ZERO);
// 모집수수료 450,000 > 한도 350,000 → 위반 (이전엔 35(KRW)와 비교돼 항상 위반이던 버그)
ViolationResult bad = regulatoryChecker.checkFirstYear(
CONTRACT_ID, new BigDecimal("450000"), CONTRACT_DATE);
assertThat(bad.violated()).isTrue();
assertThat(bad.limitAmount()).isEqualByComparingTo("350000.00");
// 모집수수료 300,000 ≤ 350,000 → 통과
ViolationResult ok = regulatoryChecker.checkFirstYear(
CONTRACT_ID, new BigDecimal("300000"), CONTRACT_DATE);
assertThat(ok.violated()).isFalse();
}
@Test
@DisplayName("③ 분급 7년 분할: 35/25/15/10/7/5/3 → 합계 정확히 450,000")
void s3_분급_7년_분할_합계100퍼센트() {
@@ -274,6 +302,18 @@ class SettlementScenarioTest {
assertThat(t.vat()).isEqualByComparingTo("0");
assertThat(t.netAmount()).isEqualByComparingTo("433665");
}
@Test
@DisplayName("음수 gross(환수>지급): 원천세 0, net=원금 — 음수 세금이 신고에 전파되지 않음")
void s9_세금_음수gross_세금0() {
TaxBreakdown t = taxCalculator.calculate(
new BigDecimal("-300000"), TaxType.BUSINESS_INCOME, VatType.TAXABLE);
assertThat(t.incomeTax()).isEqualByComparingTo("0");
assertThat(t.localTax()).isEqualByComparingTo("0");
assertThat(t.vat()).isEqualByComparingTo("0");
assertThat(t.netAmount()).isEqualByComparingTo("-300000");
}
}
// ── 헬퍼: 표준 7년 분급 비율 (35/25/15/10/7/5/3) ──
@@ -0,0 +1,12 @@
-- V109: 차등환수 최저구간 months_from 1 → 0 보정
--
-- 문제: 기존 시드(V28)의 최저 환수구간이 months_from=1 이라,
-- 체결 후 1개월 미만 즉시 해지/실효(ChronoUnit.MONTHS.between=0)는
-- selectByMonths(0) 매칭 행이 없어 grade=null → 환수 0원으로 처리됐다.
-- 가장 환수율이 높아야 할(100%) 초단기 해지가 오히려 환수 면제되는 결함.
-- 수정: 100% 전액환수 구간의 시작을 0개월로 내려 0~12개월을 모두 커버.
UPDATE chargeback_grade
SET months_from = 0
WHERE months_from = 1
AND chargeback_percent = 100;