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:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user