You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
262 lines
13 KiB
262 lines
13 KiB
package com.mydreamjob.salarycalculator;
|
|
|
|
import com.mydreamjob.common.exception.BusinessException;
|
|
import com.mydreamjob.common.exception.ErrorCode;
|
|
import org.springframework.stereotype.Component;
|
|
|
|
import java.math.BigDecimal;
|
|
import java.math.RoundingMode;
|
|
import java.util.List;
|
|
|
|
@Component
|
|
public class SalaryCalculationEngine {
|
|
|
|
public static final String TAX_RULES_VERSION = "CN_IIT_2019_V1";
|
|
|
|
private static final BigDecimal ZERO = new BigDecimal("0");
|
|
private static final BigDecimal MONTHLY_BASIC_DEDUCTION = new BigDecimal("5000");
|
|
private static final BigDecimal MAX_INPUT_AMOUNT = new BigDecimal("10000000");
|
|
private static final BigDecimal MAX_ILLNESS_FEE = new BigDecimal("10000");
|
|
private static final BigDecimal MAX_PENSION_RATE = new BigDecimal("0.20");
|
|
private static final BigDecimal MAX_MEDICAL_RATE = new BigDecimal("0.20");
|
|
private static final BigDecimal MAX_UNEMPLOYMENT_RATE = new BigDecimal("0.10");
|
|
private static final BigDecimal MAX_HOUSING_FUND_RATE = new BigDecimal("0.12");
|
|
private static final int MONEY_SCALE = 2;
|
|
|
|
private static final List<TaxBracket> ANNUAL_BRACKETS = List.of(
|
|
new TaxBracket(new BigDecimal("36000"), new BigDecimal("0.03"), new BigDecimal("0")),
|
|
new TaxBracket(new BigDecimal("144000"), new BigDecimal("0.10"), new BigDecimal("2520")),
|
|
new TaxBracket(new BigDecimal("300000"), new BigDecimal("0.20"), new BigDecimal("16920")),
|
|
new TaxBracket(new BigDecimal("420000"), new BigDecimal("0.25"), new BigDecimal("31920")),
|
|
new TaxBracket(new BigDecimal("660000"), new BigDecimal("0.30"), new BigDecimal("52920")),
|
|
new TaxBracket(new BigDecimal("960000"), new BigDecimal("0.35"), new BigDecimal("85920")),
|
|
new TaxBracket(null, new BigDecimal("0.45"), new BigDecimal("181920")));
|
|
|
|
public SalaryCalculationResult calculate(SalaryCalculationInput input, SalaryCityPolicy policy) {
|
|
if (input == null || policy == null) {
|
|
throw invalidParameter("input and city policy are required");
|
|
}
|
|
int monthIndex = input.getMonthIndex() == null ? 1 : input.getMonthIndex();
|
|
BigDecimal gross = requiredMoney(input.getGrossMonthlySalary(), "grossMonthlySalary");
|
|
BigDecimal specialDeduction = optionalMoney(
|
|
input.getSpecialAdditionalDeduction(), "specialAdditionalDeduction", MAX_INPUT_AMOUNT);
|
|
SalaryOverrideParameters overrides = input.getOverrides();
|
|
|
|
if (gross.compareTo(ZERO) <= 0 || gross.compareTo(MAX_INPUT_AMOUNT) > 0) {
|
|
throw invalidParameter("grossMonthlySalary must be within (0, 10000000]");
|
|
}
|
|
if (monthIndex < 1 || monthIndex > 12) {
|
|
throw invalidParameter("monthIndex must be between 1 and 12");
|
|
}
|
|
|
|
BigDecimal pensionRate = effectiveRate(
|
|
overrides == null ? null : overrides.getPensionRate(),
|
|
policy.getPensionRate(), MAX_PENSION_RATE, "pensionRate");
|
|
BigDecimal medicalRate = effectiveRate(
|
|
overrides == null ? null : overrides.getMedicalRate(),
|
|
policy.getMedicalRate(), MAX_MEDICAL_RATE, "medicalRate");
|
|
BigDecimal unemploymentRate = effectiveRate(
|
|
overrides == null ? null : overrides.getUnemploymentRate(),
|
|
policy.getUnemploymentRate(), MAX_UNEMPLOYMENT_RATE, "unemploymentRate");
|
|
BigDecimal housingFundRate = effectiveRate(
|
|
overrides == null ? null : overrides.getHousingFundRate(),
|
|
policy.getHousingFundRate(), MAX_HOUSING_FUND_RATE, "housingFundRate");
|
|
BigDecimal illnessFee = effectiveFee(
|
|
overrides == null ? null : overrides.getSeriousIllnessMonthlyFee(),
|
|
policy.getSeriousIllnessMonthlyFee());
|
|
|
|
BigDecimal socialSecurityBase = effectiveBase(
|
|
overrides == null ? null : overrides.getSocialSecurityBase(),
|
|
gross,
|
|
policy.getSocialSecurityBaseMin(),
|
|
policy.getSocialSecurityBaseMax(),
|
|
"socialSecurityBase");
|
|
BigDecimal housingFundBase = effectiveBase(
|
|
overrides == null ? null : overrides.getHousingFundBase(),
|
|
gross,
|
|
policy.getHousingFundBaseMin(),
|
|
policy.getHousingFundBaseMax(),
|
|
"housingFundBase");
|
|
|
|
if (socialSecurityBase.compareTo(ZERO) == 0
|
|
&& (pensionRate.signum() != 0 || medicalRate.signum() != 0
|
|
|| unemploymentRate.signum() != 0)) {
|
|
throw invalidParameter("socialSecurityBase may be zero only when social insurance rates are zero");
|
|
}
|
|
if (housingFundBase.compareTo(ZERO) == 0 && housingFundRate.signum() != 0) {
|
|
throw invalidParameter("housingFundBase may be zero only when housingFundRate is zero");
|
|
}
|
|
|
|
BigDecimal pension = socialSecurityBase.multiply(pensionRate);
|
|
BigDecimal medical = socialSecurityBase.multiply(medicalRate);
|
|
BigDecimal unemployment = socialSecurityBase.multiply(unemploymentRate);
|
|
BigDecimal housingFund = housingFundBase.multiply(housingFundRate);
|
|
BigDecimal monthlySocialInsurance = pension.add(medical).add(unemployment);
|
|
|
|
BigDecimal cumulativeGross = gross.multiply(BigDecimal.valueOf(monthIndex));
|
|
BigDecimal cumulativeSocialInsurance = monthlySocialInsurance.multiply(BigDecimal.valueOf(monthIndex));
|
|
BigDecimal cumulativeHousingFund = housingFund.multiply(BigDecimal.valueOf(monthIndex));
|
|
BigDecimal cumulativeIllness = illnessFee.multiply(BigDecimal.valueOf(monthIndex));
|
|
BigDecimal cumulativeBasicDeduction = MONTHLY_BASIC_DEDUCTION.multiply(BigDecimal.valueOf(monthIndex));
|
|
BigDecimal cumulativeSpecialDeduction = specialDeduction.multiply(BigDecimal.valueOf(monthIndex));
|
|
|
|
BigDecimal cumulativeTaxable = nonNegative(cumulativeGross
|
|
.subtract(cumulativeSocialInsurance)
|
|
.subtract(cumulativeHousingFund)
|
|
.subtract(cumulativeIllness)
|
|
.subtract(cumulativeBasicDeduction)
|
|
.subtract(cumulativeSpecialDeduction));
|
|
BigDecimal cumulativeTaxDue = taxFor(cumulativeTaxable);
|
|
|
|
int priorMonths = monthIndex - 1;
|
|
BigDecimal priorTaxable = nonNegative(gross.multiply(BigDecimal.valueOf(priorMonths))
|
|
.subtract(monthlySocialInsurance.multiply(BigDecimal.valueOf(priorMonths)))
|
|
.subtract(housingFund.multiply(BigDecimal.valueOf(priorMonths)))
|
|
.subtract(illnessFee.multiply(BigDecimal.valueOf(priorMonths)))
|
|
.subtract(MONTHLY_BASIC_DEDUCTION.multiply(BigDecimal.valueOf(priorMonths)))
|
|
.subtract(specialDeduction.multiply(BigDecimal.valueOf(priorMonths))));
|
|
BigDecimal priorTax = taxFor(priorTaxable);
|
|
BigDecimal currentTax = nonNegative(cumulativeTaxDue.subtract(priorTax));
|
|
BigDecimal netSalary = nonNegative(gross
|
|
.subtract(pension)
|
|
.subtract(medical)
|
|
.subtract(unemployment)
|
|
.subtract(housingFund)
|
|
.subtract(illnessFee)
|
|
.subtract(currentTax));
|
|
|
|
return SalaryCalculationResult.builder()
|
|
.cityCode(policy.getCityCode())
|
|
.cityName(policy.getCityName())
|
|
.dataLevel(policy.getDataLevel())
|
|
.policyVersion(policy.getPolicyVersion())
|
|
.policyEffectiveDate(policy.getPolicyEffectiveDate())
|
|
.taxRulesVersion(TAX_RULES_VERSION)
|
|
.estimate(true)
|
|
.monthIndex(monthIndex)
|
|
.grossMonthlySalary(money(gross))
|
|
.specialAdditionalDeduction(money(specialDeduction))
|
|
.socialSecurityBase(money(socialSecurityBase))
|
|
.housingFundBase(money(housingFundBase))
|
|
.pensionRate(rate(pensionRate))
|
|
.medicalRate(rate(medicalRate))
|
|
.unemploymentRate(rate(unemploymentRate))
|
|
.housingFundRate(rate(housingFundRate))
|
|
.seriousIllnessMonthlyFee(money(illnessFee))
|
|
.pension(money(pension))
|
|
.medical(money(medical))
|
|
.unemployment(money(unemployment))
|
|
.housingFund(money(housingFund))
|
|
.cumulativeGrossIncome(money(cumulativeGross))
|
|
.cumulativeSocialInsurance(money(cumulativeSocialInsurance))
|
|
.cumulativeHousingFund(money(cumulativeHousingFund))
|
|
.cumulativeSeriousIllness(money(cumulativeIllness))
|
|
.cumulativeBasicDeduction(money(cumulativeBasicDeduction))
|
|
.cumulativeSpecialAdditionalDeduction(money(cumulativeSpecialDeduction))
|
|
.cumulativeTaxableIncome(money(cumulativeTaxable))
|
|
.cumulativeTaxDue(money(cumulativeTaxDue))
|
|
.previouslyWithheldTax(money(priorTax))
|
|
.currentMonthTax(money(currentTax))
|
|
.netSalary(money(netSalary))
|
|
.build();
|
|
}
|
|
|
|
private BigDecimal effectiveBase(
|
|
BigDecimal override, BigDecimal gross, BigDecimal min, BigDecimal max, String field) {
|
|
validatePolicyRange(min, max, field);
|
|
if (override != null) {
|
|
BigDecimal value = requiredMoney(override, field);
|
|
if (value.compareTo(ZERO) < 0) {
|
|
throw invalidParameter(field + " must not be negative");
|
|
}
|
|
if (value.compareTo(ZERO) > 0 && (value.compareTo(min) < 0 || value.compareTo(max) > 0)) {
|
|
throw invalidParameter(field + " must be within city range or zero for an exempt contribution");
|
|
}
|
|
return value;
|
|
}
|
|
return clamp(gross, min, max);
|
|
}
|
|
|
|
private BigDecimal effectiveRate(
|
|
BigDecimal override, BigDecimal fallback, BigDecimal max, String field) {
|
|
BigDecimal value = override == null ? fallback : requiredRate(override, field);
|
|
if (value == null || value.compareTo(ZERO) < 0 || value.compareTo(max) > 0) {
|
|
throw invalidParameter(field + " is outside the allowed range");
|
|
}
|
|
return value;
|
|
}
|
|
|
|
private BigDecimal effectiveFee(BigDecimal override, BigDecimal fallback) {
|
|
BigDecimal value = override == null ? fallback : requiredMoney(override, "seriousIllnessMonthlyFee");
|
|
if (value == null || value.compareTo(ZERO) < 0 || value.compareTo(MAX_ILLNESS_FEE) > 0) {
|
|
throw invalidParameter("seriousIllnessMonthlyFee is outside the allowed range");
|
|
}
|
|
return value;
|
|
}
|
|
|
|
private BigDecimal taxFor(BigDecimal taxableIncome) {
|
|
if (taxableIncome.compareTo(ZERO) <= 0) {
|
|
return ZERO;
|
|
}
|
|
for (TaxBracket bracket : ANNUAL_BRACKETS) {
|
|
if (bracket.upperBound == null || taxableIncome.compareTo(bracket.upperBound) <= 0) {
|
|
return nonNegative(taxableIncome.multiply(bracket.rate).subtract(bracket.quickDeduction));
|
|
}
|
|
}
|
|
return ZERO;
|
|
}
|
|
|
|
private BigDecimal clamp(BigDecimal value, BigDecimal min, BigDecimal max) {
|
|
if (value.compareTo(min) < 0) {
|
|
return min;
|
|
}
|
|
return value.compareTo(max) > 0 ? max : value;
|
|
}
|
|
|
|
private void validatePolicyRange(BigDecimal min, BigDecimal max, String field) {
|
|
if (min == null || max == null || min.compareTo(ZERO) < 0 || max.compareTo(min) < 0) {
|
|
throw invalidParameter(field + " city range is invalid");
|
|
}
|
|
}
|
|
|
|
private BigDecimal requiredMoney(BigDecimal value, String field) {
|
|
if (value == null || value.scale() > MONEY_SCALE) {
|
|
throw invalidParameter(field + " must have at most two decimal places");
|
|
}
|
|
return value;
|
|
}
|
|
|
|
private BigDecimal optionalMoney(BigDecimal value, String field, BigDecimal max) {
|
|
BigDecimal effective = value == null ? ZERO : requiredMoney(value, field);
|
|
if (effective.compareTo(ZERO) < 0 || effective.compareTo(max) > 0) {
|
|
throw invalidParameter(field + " is outside the allowed range");
|
|
}
|
|
return effective;
|
|
}
|
|
|
|
private BigDecimal requiredRate(BigDecimal value, String field) {
|
|
if (value == null || value.scale() > 6) {
|
|
throw invalidParameter(field + " must have at most six decimal places");
|
|
}
|
|
return value;
|
|
}
|
|
|
|
private BigDecimal nonNegative(BigDecimal value) {
|
|
return value.compareTo(ZERO) < 0 ? ZERO : value;
|
|
}
|
|
|
|
private BigDecimal money(BigDecimal value) {
|
|
return value.setScale(MONEY_SCALE, RoundingMode.HALF_UP);
|
|
}
|
|
|
|
private BigDecimal rate(BigDecimal value) {
|
|
return value.setScale(6, RoundingMode.HALF_UP);
|
|
}
|
|
|
|
private BusinessException invalidParameter(String detail) {
|
|
return new BusinessException(ErrorCode.SALARY_INVALID_PARAMETER, detail);
|
|
}
|
|
|
|
private record TaxBracket(BigDecimal upperBound, BigDecimal rate, BigDecimal quickDeduction) {
|
|
}
|
|
}
|
|
|