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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
+34
-10
@@ -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"));
|
||||
|
||||
+41
-1
@@ -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;
|
||||
Reference in New Issue
Block a user