diff --git a/src/main/java/com/concrete/buildup/domain/payroll/dto/InsuranceEligibility.java b/src/main/java/com/concrete/buildup/domain/payroll/dto/InsuranceEligibility.java new file mode 100644 index 00000000..fa60974a --- /dev/null +++ b/src/main/java/com/concrete/buildup/domain/payroll/dto/InsuranceEligibility.java @@ -0,0 +1,35 @@ +package com.concrete.buildup.domain.payroll.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 4대보험 가입 여부 DTO + * + *

근로자의 4대보험 가입 여부 정보를 담는 DTO입니다.

+ *

산재보험은 근로자 부담이 없으므로 포함하지 않습니다.

+ * + * @author Build-Up Team + * @since 1.0 + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "4대보험 가입 여부") +public class InsuranceEligibility { + + @Schema(description = "고용보험 가입 여부", example = "true", required = true) + private Boolean employmentInsurance; + + @Schema(description = "건강보험 가입 여부", example = "true", required = true) + private Boolean healthInsurance; + + @Schema(description = "국민연금 가입 여부", example = "false", required = true) + private Boolean nationalPension; + + // 산재보험은 근로자 부담이 없으므로 제외 +} \ No newline at end of file diff --git a/src/main/java/com/concrete/buildup/domain/payroll/dto/InsuranceRates.java b/src/main/java/com/concrete/buildup/domain/payroll/dto/InsuranceRates.java new file mode 100644 index 00000000..20b815f6 --- /dev/null +++ b/src/main/java/com/concrete/buildup/domain/payroll/dto/InsuranceRates.java @@ -0,0 +1,54 @@ +package com.concrete.buildup.domain.payroll.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +/** + * 4대보험 요율 DTO + * + *

4대보험의 근로자 부담 요율 정보를 담는 DTO입니다.

+ *

2025년 기준 요율: 국민연금 4.5%, 건강보험 3.545%, 장기요양 12.81%, 고용보험 0.9%

+ * + * @author Build-Up Team + * @since 1.0 + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "4대보험 요율 정보") +public class InsuranceRates { + + @Schema(description = "국민연금 요율 (2025년 기준: 0.045)", example = "0.045", required = true) + private BigDecimal nationalPension; + + @Schema(description = "건강보험 요율 (2025년 기준: 0.03545)", example = "0.03545", required = true) + private BigDecimal healthInsurance; + + @Schema(description = "장기요양보험 요율 (건강보험료의 비율, 2025년 기준: 0.1281)", example = "0.1281", required = true) + private BigDecimal longTermCare; + + @Schema(description = "고용보험 요율 (2025년 기준: 0.009)", example = "0.009", required = true) + private BigDecimal employmentInsurance; + + // 산재보험은 사업주 전액 부담이므로 제외 + + /** + * 2025년 기준 기본 요율로 초기화된 InsuranceRates 생성 + * + * @return 2025년 기준 요율 + */ + public static InsuranceRates defaultRates2025() { + return InsuranceRates.builder() + .nationalPension(new BigDecimal("0.045")) + .healthInsurance(new BigDecimal("0.03545")) + .longTermCare(new BigDecimal("0.1281")) + .employmentInsurance(new BigDecimal("0.009")) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/concrete/buildup/domain/payroll/dto/OvertimeConditions.java b/src/main/java/com/concrete/buildup/domain/payroll/dto/OvertimeConditions.java new file mode 100644 index 00000000..189a7414 --- /dev/null +++ b/src/main/java/com/concrete/buildup/domain/payroll/dto/OvertimeConditions.java @@ -0,0 +1,56 @@ +package com.concrete.buildup.domain.payroll.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 가산수당 발생/지급 조건 DTO + * + *

연장/야간/휴일 가산수당의 지급 조건을 판단하기 위한 정보를 담는 DTO입니다.

+ *

5인 이상 사업장은 가산수당 지급 의무, 5인 미만은 선택적 지급 가능합니다.

+ * + * @author Build-Up Team + * @since 1.0 + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "가산수당 발생/지급 조건") +public class OvertimeConditions { + + // ========== 사업장 규모 ========== + + @Schema(description = "상시근로자 수 (5인 이상이면 가산수당 의무 지급)", example = "10", required = true) + private Integer totalEmployees; + + // ========== 5인 미만 사업장의 자율 지급 정책 ========== + + @Schema(description = "연장수당 자율 지급 여부 (5인 미만일 때)", example = "true") + private Boolean autoPayOvertime; + + @Schema(description = "야간수당 자율 지급 여부 (5인 미만일 때)", example = "true") + private Boolean autoPayNight; + + @Schema(description = "휴일수당 자율 지급 여부 (5인 미만일 때)", example = "true") + private Boolean autoPayHoliday; + + // ========== 연장근로 발생 조건 ========== + + @Schema(description = "일 8시간 초과 여부", example = "true") + private Boolean exceedsDailyLimit; + + @Schema(description = "주 40시간 초과 여부", example = "false") + private Boolean exceedsWeeklyLimit; + + // ========== 야간/휴일 근로 존재 여부 ========== + + @Schema(description = "야간근로 존재 여부 (22:00~06:00 구간 근로)", example = "true") + private Boolean hasNightWork; + + @Schema(description = "휴일근로 존재 여부 (주휴일/법정공휴일 근로)", example = "false") + private Boolean hasHolidayWork; +} diff --git a/src/main/java/com/concrete/buildup/domain/payroll/dto/PayrollCalculationInput.java b/src/main/java/com/concrete/buildup/domain/payroll/dto/PayrollCalculationInput.java new file mode 100644 index 00000000..2edfb7d2 --- /dev/null +++ b/src/main/java/com/concrete/buildup/domain/payroll/dto/PayrollCalculationInput.java @@ -0,0 +1,89 @@ +package com.concrete.buildup.domain.payroll.dto; + +import com.concrete.buildup.domain.contract.enums.EmpType; +import com.concrete.buildup.domain.contract.enums.PayPeriod; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +/** + * 급여 계산 입력 DTO + * + *

급여 계산에 필요한 모든 입력 정보를 담는 DTO입니다.

+ *

v1.1 급여 계산 로직의 입력 파라미터로 사용됩니다.

+ * + * @author Build-Up Team + * @since 1.0 + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "급여 계산 입력 정보") +public class PayrollCalculationInput { + + // ========== 기본 정보 ========== + + @Schema(description = "고용 형태 (DAILY: 일용직, PERMANENT: 상용직)", example = "DAILY", required = true) + private EmpType empType; + + @Schema(description = "지급 주기 (DAILY: 일, WEEKLY: 주, MONTHLY: 월)", example = "DAILY", required = true) + private PayPeriod payPeriod; + + @Schema(description = "시급", example = "10000", required = true) + private BigDecimal hourlyRate; + + @Schema(description = "1일 소정근로시간", example = "8", required = true) + private BigDecimal dailyWorkHours; + + @Schema(description = "근로일수", example = "20", required = true) + private Integer workDays; + + // ========== 가산 시간 (발생 시간) ========== + + @Schema(description = "연장근로시간", example = "10") + private BigDecimal overtimeHours; + + @Schema(description = "야간근로시간", example = "5") + private BigDecimal nightHours; + + @Schema(description = "휴일근로시간", example = "8") + private BigDecimal holidayHours; + + // ========== 주휴수당 ========== + + @Schema(description = "주휴수당 대상 여부", example = "true") + private Boolean weeklyHolidayEligible; + + // ========== 4대보험 가입 여부 ========== + + @Schema(description = "보험 가입 여부", required = true) + private InsuranceEligibility insurance; + + // ========== 세금 관련 (상용직) ========== + + @Schema(description = "부양가족 수 (상용직 세금 계산용)", example = "2") + private Integer dependents; + + // ========== 요율 ========== + + @Schema(description = "보험 요율 정보", required = true) + private InsuranceRates rates; + + // ========== 가산수당 조건 ========== + + @Schema(description = "가산수당 발생/지급 조건", required = true) + private OvertimeConditions overtimeConditions; + + // ========== 일용직 세금 계산용 (추가) ========== + + @Schema(description = "당월 근로일수 누적 (일용직 4대보험 적용 판단용)", example = "8") + private Integer monthlyWorkDaysAccumulated; + + @Schema(description = "월 환산 소득 (일용직 4대보험 적용 판단용)", example = "2200000") + private BigDecimal monthlyEstimatedIncome; +} \ No newline at end of file diff --git a/src/main/java/com/concrete/buildup/domain/payroll/dto/PayrollCalculationResult.java b/src/main/java/com/concrete/buildup/domain/payroll/dto/PayrollCalculationResult.java new file mode 100644 index 00000000..0ccc08ac --- /dev/null +++ b/src/main/java/com/concrete/buildup/domain/payroll/dto/PayrollCalculationResult.java @@ -0,0 +1,122 @@ +package com.concrete.buildup.domain.payroll.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.util.Map; + +/** + * 급여 계산 결과 DTO + * + *

급여 계산의 최종 결과를 담는 DTO입니다.

+ *

지급 항목, 공제 항목, 실지급액을 모두 포함합니다.

+ * + * @author Build-Up Team + * @since 1.0 + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "급여 계산 결과") +public class PayrollCalculationResult { + + // ========== 지급 항목 ========== + + @Schema(description = "기본급 (시급 × 총 통상시간)", example = "1600000") + private BigDecimal basePay; + + @Schema(description = "연장수당 (시급 × 연장시간 × 1.5)", example = "150000") + private BigDecimal overtimePay; + + @Schema(description = "야간수당 (시급 × 야간시간 × 0.5)", example = "50000") + private BigDecimal nightPay; + + @Schema(description = "휴일수당 (시급 × 휴일시간 × 1.5)", example = "120000") + private BigDecimal holidayPay; + + @Schema(description = "주휴수당 (일용직 조건 충족 시)", example = "80000") + private BigDecimal weeklyHolidayPay; + + @Schema(description = "총 지급액 (모든 지급 항목의 합)", example = "2000000") + private BigDecimal totalPay; + + // ========== 공제 항목 ========== + + @Schema(description = "소득세 (일용직: 일별 계산, 상용직: 간이세액표)", example = "33000") + private BigDecimal incomeTax; + + @Schema(description = "지방소득세 (주민세, 소득세의 10%)", example = "3300") + private BigDecimal residentTax; + + @Schema(description = "고용보험 (과세대상금액 × 0.9%)", example = "18000") + private BigDecimal employmentInsurance; + + @Schema(description = "건강보험 (과세대상금액 × 3.545%)", example = "70900") + private BigDecimal healthInsurance; + + @Schema(description = "장기요양보험 (건강보험료 × 12.81%)", example = "9084") + private BigDecimal longTermCareInsurance; + + @Schema(description = "국민연금 (과세대상금액 × 4.5%)", example = "90000") + private BigDecimal nationalPension; + + @Schema(description = "총 공제액 (모든 공제 항목의 합)", example = "224284") + private BigDecimal totalDeduction; + + // ========== 최종 금액 ========== + + @Schema(description = "실지급액 (총 지급액 - 총 공제액)", example = "1775716") + private BigDecimal netPay; + + // ========== 계산 상세 (디버깅/로그용) ========== + + @Schema(description = "계산 상세 정보 (디버깅용)", example = "{\"totalWorkHours\": 160, \"taxableIncome\": 2000000}") + private Map calculationDetails; + + /** + * 총 지급액 계산 + * + * @return 모든 지급 항목의 합 + */ + public BigDecimal calculateTotalPay() { + BigDecimal total = BigDecimal.ZERO; + if (basePay != null) total = total.add(basePay); + if (overtimePay != null) total = total.add(overtimePay); + if (nightPay != null) total = total.add(nightPay); + if (holidayPay != null) total = total.add(holidayPay); + if (weeklyHolidayPay != null) total = total.add(weeklyHolidayPay); + return total; + } + + /** + * 총 공제액 계산 + * + * @return 모든 공제 항목의 합 + */ + public BigDecimal calculateTotalDeduction() { + BigDecimal total = BigDecimal.ZERO; + if (incomeTax != null) total = total.add(incomeTax); + if (residentTax != null) total = total.add(residentTax); + if (employmentInsurance != null) total = total.add(employmentInsurance); + if (healthInsurance != null) total = total.add(healthInsurance); + if (longTermCareInsurance != null) total = total.add(longTermCareInsurance); + if (nationalPension != null) total = total.add(nationalPension); + return total; + } + + /** + * 실지급액 계산 + * + * @return 총 지급액 - 총 공제액 + */ + public BigDecimal calculateNetPay() { + BigDecimal total = totalPay != null ? totalPay : calculateTotalPay(); + BigDecimal deduction = totalDeduction != null ? totalDeduction : calculateTotalDeduction(); + return total.subtract(deduction); + } +} \ No newline at end of file diff --git a/src/main/java/com/concrete/buildup/domain/payroll/service/SalaryGenerationService.java b/src/main/java/com/concrete/buildup/domain/payroll/service/SalaryGenerationService.java index d223a6df..2fa08a64 100644 --- a/src/main/java/com/concrete/buildup/domain/payroll/service/SalaryGenerationService.java +++ b/src/main/java/com/concrete/buildup/domain/payroll/service/SalaryGenerationService.java @@ -12,6 +12,11 @@ import com.concrete.buildup.domain.payroll.repository.PayslipItemRepository; import com.concrete.buildup.domain.payroll.util.PayrollCalculator; import com.concrete.buildup.domain.payroll.util.PayrollPdfGenerator; +import com.concrete.buildup.domain.payroll.dto.PayrollCalculationInput; +import com.concrete.buildup.domain.payroll.dto.PayrollCalculationResult; +import com.concrete.buildup.domain.payroll.dto.OvertimeConditions; +import com.concrete.buildup.domain.payroll.dto.InsuranceEligibility; +import com.concrete.buildup.domain.payroll.dto.InsuranceRates; import com.concrete.buildup.domain.upload.service.S3Service; import com.concrete.buildup.domain.attendance.entity.Attendance; import com.concrete.buildup.domain.attendance.repository.AttendanceRepository; @@ -342,43 +347,90 @@ hourlyRate, new BigDecimal("40") } } - // 급여 항목별 계산 - BigDecimal basePay = payrollCalculator.calculateBasePay(hourlyRate, workHours); - BigDecimal nightPay = payrollCalculator.calculateNightWorkPay(hourlyRate, nightHours); - BigDecimal overtimePay = payrollCalculator.calculateOvertimePay(hourlyRate, overtimeHours); - BigDecimal holidayPay = payrollCalculator.calculateHolidayPay(hourlyRate, holidayHours); - // weeklyHolidayPay는 이미 위에서 계산됨 + // ========== v1.1 급여 계산 ========== - // 총 지급액 계산 - BigDecimal totalPay = basePay.add(nightPay).add(overtimePay) - .add(holidayPay).add(weeklyHolidayPay); - - // 비과세 소득 계산 - BigDecimal nonTaxIncome = payrollCalculator.calculateNonTaxableIncome(contractDetail); - - // 과세 소득 = 총 지급액 - 비과세 소득 - BigDecimal taxableIncome = totalPay.subtract(nonTaxIncome); - - // 세금 계산 - BigDecimal incomeTax = payrollCalculator.calculateIncomeTax(taxableIncome); - BigDecimal residentTax = payrollCalculator.calculateResidentTax(taxableIncome); - - // 4대보험 계산 + // 4대보험 가입 여부 조회 boolean hasNationalPension = Boolean.TRUE.equals(contractDetail.getIsNpsApplicable()); boolean hasHealthInsurance = Boolean.TRUE.equals(contractDetail.getIsNhiApplicable()); - boolean hasWorkersCompInsurance = Boolean.TRUE.equals(contractDetail.getIsWciApplicable()); boolean hasEmploymentInsurance = Boolean.TRUE.equals(contractDetail.getIsEoiApplicable()); - BigDecimal nationalPension = payrollCalculator.calculateNationalPension(totalPay, hasNationalPension); - BigDecimal healthInsurance = payrollCalculator.calculateHealthInsurance(totalPay, hasHealthInsurance); + // 가산수당 발생 조건 판단 + boolean exceedsDailyLimit = overtimeHours.compareTo(BigDecimal.ZERO) > 0; + boolean exceedsWeeklyLimit = false; // 주 40시간 초과 여부는 주차별 집계가 필요하므로 기본값 false + boolean hasNightWork = nightHours.compareTo(BigDecimal.ZERO) > 0; + boolean hasHolidayWork = holidayHours.compareTo(BigDecimal.ZERO) > 0; + + // 일용직의 경우: monthlyWorkDaysAccumulated는 workDays 값 사용 (4대보험 가입 조건 판단용) + // 상용직의 경우: monthlyWorkDaysAccumulated는 사용하지 않으므로 0으로 설정 + int workDaysCount = attendances.size(); + int monthlyWorkDaysAccumulated = contract.getEmpType() == com.concrete.buildup.domain.contract.enums.EmpType.DAILY + ? workDaysCount // 일용직: 해당 기간의 근로일수 사용 + : 0; // 상용직: 사용하지 않음 + + // v1.1 계산 입력 DTO 생성 + PayrollCalculationInput calculationInput = PayrollCalculationInput.builder() + .empType(contract.getEmpType()) + .payPeriod(payCycle) + .hourlyRate(hourlyRate) + .dailyWorkHours(new BigDecimal("8")) // 기본 일 근무시간 (계약상 기준) + .workDays(workDaysCount) + .overtimeHours(overtimeHours) + .nightHours(nightHours) + .holidayHours(holidayHours) + .weeklyHolidayEligible(true) // 주휴수당 자격 기본값 true + .insurance(InsuranceEligibility.builder() + .employmentInsurance(hasEmploymentInsurance) + .healthInsurance(hasHealthInsurance) + .nationalPension(hasNationalPension) + .build()) + .dependents(0) // 부양가족 수 기본값 0 (향후 Employee 정보에서 가져올 수 있음) + .rates(InsuranceRates.defaultRates2025()) + .overtimeConditions(OvertimeConditions.builder() + .totalEmployees(5) // DEFAULT: 5인 이상 사업장으로 가정 (가산수당 의무 지급) + .autoPayOvertime(true) // DEFAULT: 연장수당 자율 지급 + .autoPayNight(true) // DEFAULT: 야간수당 자율 지급 + .autoPayHoliday(true) // DEFAULT: 휴일수당 자율 지급 + .exceedsDailyLimit(exceedsDailyLimit) + .exceedsWeeklyLimit(exceedsWeeklyLimit) + .hasNightWork(hasNightWork) + .hasHolidayWork(hasHolidayWork) + .build()) + .monthlyWorkDaysAccumulated(monthlyWorkDaysAccumulated) // 일용직: workDays 값 사용, 상용직: 0 + .monthlyEstimatedIncome(BigDecimal.ZERO) // DEFAULT: 월 추정소득 (향후 계산 가능) + .build(); + + // v1.1 급여 계산 실행 + PayrollCalculationResult calculationResult = payrollCalculator.calculate(calculationInput); + + // 계산 결과 추출 + BigDecimal basePay = calculationResult.getBasePay(); + BigDecimal nightPay = calculationResult.getNightPay(); + BigDecimal overtimePay = calculationResult.getOvertimePay(); + BigDecimal holidayPay = calculationResult.getHolidayPay(); + // v1.1에서 계산된 주휴수당 사용 + weeklyHolidayPay = calculationResult.getWeeklyHolidayPay(); + BigDecimal totalPay = calculationResult.getTotalPay(); + + // 공제 항목 추출 + BigDecimal incomeTax = calculationResult.getIncomeTax(); + BigDecimal residentTax = calculationResult.getResidentTax(); + BigDecimal nationalPension = calculationResult.getNationalPension(); + BigDecimal healthInsurance = calculationResult.getHealthInsurance(); + BigDecimal longTermCareInsurance = calculationResult.getLongTermCareInsurance(); + BigDecimal employmentInsurance = calculationResult.getEmploymentInsurance(); + + // 실수령액 + BigDecimal netPay = calculationResult.getNetPay(); + + // 비과세 소득 (기존 로직 유지 - v1.1에는 없음) + BigDecimal nonTaxIncome = payrollCalculator.calculateNonTaxableIncome(contractDetail); + + // 산재보험은 v1.1에 없으므로 기존 로직 사용 + boolean hasWorkersCompInsurance = Boolean.TRUE.equals(contractDetail.getIsWciApplicable()); BigDecimal workersCompInsurance = payrollCalculator.calculateWorkersCompInsurance(totalPay, hasWorkersCompInsurance); - BigDecimal employmentInsurance = payrollCalculator.calculateEmploymentInsurance(totalPay, hasEmploymentInsurance); - // 실수령액 계산 - BigDecimal netPay = payrollCalculator.calculateNetPay( - totalPay, incomeTax, residentTax, - nationalPension, healthInsurance, workersCompInsurance, employmentInsurance - ); + log.info("[급여 생성] v1.1 계산 완료 - basePay: {}, overtimePay: {}, nightPay: {}, holidayPay: {}, weeklyHolidayPay: {}, totalPay: {}, netPay: {}", + basePay, overtimePay, nightPay, holidayPay, weeklyHolidayPay, totalPay, netPay); // 5. Payroll 엔티티 생성 LocalDate searchDate = targetMonth.atDay(1); // 지급 기준월 diff --git a/src/main/java/com/concrete/buildup/domain/payroll/util/PayrollCalculator.java b/src/main/java/com/concrete/buildup/domain/payroll/util/PayrollCalculator.java index 2fe0c378..01b59ea5 100644 --- a/src/main/java/com/concrete/buildup/domain/payroll/util/PayrollCalculator.java +++ b/src/main/java/com/concrete/buildup/domain/payroll/util/PayrollCalculator.java @@ -1,11 +1,16 @@ package com.concrete.buildup.domain.payroll.util; import com.concrete.buildup.domain.contract.entity.ContractDetail; +import com.concrete.buildup.domain.contract.enums.EmpType; +import com.concrete.buildup.domain.contract.enums.PayPeriod; +import com.concrete.buildup.domain.payroll.dto.*; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import java.math.BigDecimal; import java.math.RoundingMode; +import java.util.HashMap; +import java.util.Map; /** * 급여 계산 유틸리티 @@ -33,7 +38,8 @@ public class PayrollCalculator { private static final BigDecimal WORKERS_COMP_INSURANCE_RATE = new BigDecimal("0.007"); // 산재보험 0.7% (업종별 상이) private static final BigDecimal EMPLOYMENT_INSURANCE_RATE = new BigDecimal("0.009"); // 고용보험 0.9% - // 비과세 한도 (식대 등) + // 비과세 한도 (식대 등) - 향후 사용 예정 + @SuppressWarnings("unused") private static final BigDecimal NON_TAXABLE_LIMIT = new BigDecimal("200000"); // 월 20만원 /** @@ -270,4 +276,325 @@ public BigDecimal calculateNetPay(BigDecimal totalPay, BigDecimal incomeTax, Big return totalPay.subtract(totalDeduction).setScale(0, RoundingMode.FLOOR); } + + // ======================================== + // v1.1 급여 계산 통합 메서드 + // ======================================== + + /** + * v1.1 급여 계산 (통합 메서드) + * + *

모든 급여 계산 로직을 통합하여 처리합니다.

+ *

가산수당 조건 판정, 4대보험, 세금 계산을 포함합니다.

+ * + * @param input 급여 계산 입력 정보 + * @return 급여 계산 결과 + */ + public PayrollCalculationResult calculate(PayrollCalculationInput input) { + log.info("v1.1 급여 계산 시작 - empType: {}, payPeriod: {}", input.getEmpType(), input.getPayPeriod()); + + Map details = new HashMap<>(); + + // 1) 기본급 계산 + BigDecimal totalWorkHours = input.getDailyWorkHours().multiply(new BigDecimal(input.getWorkDays())); + BigDecimal basePay = input.getHourlyRate().multiply(totalWorkHours).setScale(0, RoundingMode.FLOOR); + details.put("totalWorkHours", totalWorkHours); + details.put("basePay", basePay); + + // 2) 가산수당 지급 가능 여부 판정 + boolean canPayOvertime = canPayOvertimeAllowance(input.getOvertimeConditions()); + boolean canPayNight = canPayNightAllowance(input.getOvertimeConditions()); + boolean canPayHoliday = canPayHolidayAllowance(input.getOvertimeConditions()); + + details.put("canPayOvertime", canPayOvertime); + details.put("canPayNight", canPayNight); + details.put("canPayHoliday", canPayHoliday); + + // 3) 조건 미충족 시 시간 무효화 + BigDecimal overtimeHours = canPayOvertime ? nvl(input.getOvertimeHours()) : BigDecimal.ZERO; + BigDecimal nightHours = canPayNight ? nvl(input.getNightHours()) : BigDecimal.ZERO; + BigDecimal holidayHours = canPayHoliday ? nvl(input.getHolidayHours()) : BigDecimal.ZERO; + + // 4) 가산수당 계산 (중복가산 규칙 반영) + BigDecimal overtimePay = input.getHourlyRate().multiply(overtimeHours).multiply(new BigDecimal("1.5")) + .setScale(0, RoundingMode.FLOOR); + BigDecimal nightPay = input.getHourlyRate().multiply(nightHours).multiply(new BigDecimal("0.5")) + .setScale(0, RoundingMode.FLOOR); + BigDecimal holidayPay = input.getHourlyRate().multiply(holidayHours).multiply(new BigDecimal("1.5")) + .setScale(0, RoundingMode.FLOOR); + + // 5) 주휴수당 계산 + BigDecimal weeklyHolidayPay = BigDecimal.ZERO; + if (Boolean.TRUE.equals(input.getWeeklyHolidayEligible())) { + if (EmpType.DAILY.equals(input.getEmpType())) { + // 일용직: 하루 일당 + weeklyHolidayPay = input.getHourlyRate().multiply(input.getDailyWorkHours()) + .setScale(0, RoundingMode.FLOOR); + } else { + // 상용직: 주 4회 * (시급 * 일 근무시간) + BigDecimal dailyPay = input.getHourlyRate().multiply(input.getDailyWorkHours()); + weeklyHolidayPay = dailyPay.multiply(new BigDecimal("4")).setScale(0, RoundingMode.FLOOR); + } + } + + // 6) 총 지급액 + BigDecimal totalPay = basePay.add(overtimePay).add(nightPay).add(holidayPay).add(weeklyHolidayPay); + BigDecimal taxableIncome = totalPay; // 비과세 미적용 + + details.put("taxableIncome", taxableIncome); + + // 7) 4대보험 계산 + InsuranceResult insurance = EmpType.DAILY.equals(input.getEmpType()) + ? calculateDailyInsurance(input, taxableIncome) + : calculatePermanentInsurance(input, taxableIncome); + + // 8) 세금 계산 + TaxResult tax = EmpType.DAILY.equals(input.getEmpType()) + ? calculateDailyTax(input, totalPay) + : calculatePermanentTax(input, taxableIncome); + + // 9) 총 공제액 및 실지급액 + BigDecimal totalDeduction = insurance.getTotal().add(tax.getTotal()); + BigDecimal netPay = totalPay.subtract(totalDeduction).setScale(0, RoundingMode.FLOOR); + + log.info("v1.1 급여 계산 완료 - totalPay: {}, totalDeduction: {}, netPay: {}", + totalPay, totalDeduction, netPay); + + return PayrollCalculationResult.builder() + .basePay(basePay) + .overtimePay(overtimePay) + .nightPay(nightPay) + .holidayPay(holidayPay) + .weeklyHolidayPay(weeklyHolidayPay) + .totalPay(totalPay) + .incomeTax(tax.getIncomeTax()) + .residentTax(tax.getResidentTax()) + .employmentInsurance(insurance.getEmploymentInsurance()) + .healthInsurance(insurance.getHealthInsurance()) + .longTermCareInsurance(insurance.getLongTermCareInsurance()) + .nationalPension(insurance.getNationalPension()) + .totalDeduction(totalDeduction) + .netPay(netPay) + .calculationDetails(details) + .build(); + } + + /** + * 연장수당 지급 가능 여부 판정 + */ + private boolean canPayOvertimeAllowance(OvertimeConditions conditions) { + boolean isMandatory = conditions.getTotalEmployees() >= 5; + boolean meetsCondition = Boolean.TRUE.equals(conditions.getExceedsDailyLimit()) + || Boolean.TRUE.equals(conditions.getExceedsWeeklyLimit()); + + if (isMandatory) { + return meetsCondition; + } else { + return Boolean.TRUE.equals(conditions.getAutoPayOvertime()) && meetsCondition; + } + } + + /** + * 야간수당 지급 가능 여부 판정 + */ + private boolean canPayNightAllowance(OvertimeConditions conditions) { + boolean isMandatory = conditions.getTotalEmployees() >= 5; + boolean hasNightWork = Boolean.TRUE.equals(conditions.getHasNightWork()); + + if (isMandatory) { + return hasNightWork; + } else { + return Boolean.TRUE.equals(conditions.getAutoPayNight()) && hasNightWork; + } + } + + /** + * 휴일수당 지급 가능 여부 판정 + */ + private boolean canPayHolidayAllowance(OvertimeConditions conditions) { + boolean isMandatory = conditions.getTotalEmployees() >= 5; + boolean hasHolidayWork = Boolean.TRUE.equals(conditions.getHasHolidayWork()); + + if (isMandatory) { + return hasHolidayWork; + } else { + return Boolean.TRUE.equals(conditions.getAutoPayHoliday()) && hasHolidayWork; + } + } + + /** + * 일용직 4대보험 계산 + */ + private InsuranceResult calculateDailyInsurance(PayrollCalculationInput input, BigDecimal taxableIncome) { + InsuranceEligibility eligibility = input.getInsurance(); + InsuranceRates rates = input.getRates(); + + // 월 근로일수 조회 (일용직 월급의 경우 8일 미만이면 전체 4대보험 미가입) + Integer monthlyDays = input.getMonthlyWorkDaysAccumulated() != null ? input.getMonthlyWorkDaysAccumulated() : 0; + + // 고용보험: 일용직 월급의 경우 월 8일 미만이면 미가입 + boolean employmentApplicable = Boolean.TRUE.equals(eligibility.getEmploymentInsurance()); + if (input.getPayPeriod() == PayPeriod.MONTHLY && monthlyDays < 8) { + employmentApplicable = false; + } + BigDecimal employmentIns = employmentApplicable + ? taxableIncome.multiply(rates.getEmploymentInsurance()).setScale(0, RoundingMode.FLOOR) + : BigDecimal.ZERO; + + // 월 소득 기준 판정 + BigDecimal monthlyIncome = input.getMonthlyEstimatedIncome() != null ? input.getMonthlyEstimatedIncome() : BigDecimal.ZERO; + + // 일용직: 월 8일 미만이면 건강보험과 국민연금 모두 미가입 + // 단, 국민연금은 월소득 220만원 이상이면 월 8일 미만이어도 가입 가능 (단, 월 8일 미만이면 무조건 미가입으로 처리) + boolean healthApplicable = Boolean.TRUE.equals(eligibility.getHealthInsurance()) && monthlyDays >= 8; + boolean pensionApplicable = Boolean.TRUE.equals(eligibility.getNationalPension()) && monthlyDays >= 8; + + // 건강보험 및 장기요양: 월소득이 있으면 월소득 기준, 없으면 당월 총 지급액 기준 + // 단, 일용직 월급의 경우 총지급액 기준으로 계산 + BigDecimal insuranceBase = taxableIncome; // 일용직은 총지급액 기준 + BigDecimal healthIns = healthApplicable + ? insuranceBase.multiply(rates.getHealthInsurance()).setScale(0, RoundingMode.FLOOR) + : BigDecimal.ZERO; + BigDecimal longTermCareIns = healthApplicable + ? healthIns.multiply(rates.getLongTermCare()).divide(new BigDecimal("10"), 0, RoundingMode.FLOOR).multiply(new BigDecimal("10")) + : BigDecimal.ZERO; + + // 국민연금: 일용직은 총지급액 기준 + BigDecimal nationalPension = pensionApplicable + ? insuranceBase.multiply(rates.getNationalPension()).setScale(0, RoundingMode.FLOOR) + : BigDecimal.ZERO; + + return new InsuranceResult(employmentIns, healthIns, longTermCareIns, nationalPension); + } + + /** + * 상용직 4대보험 계산 + */ + private InsuranceResult calculatePermanentInsurance(PayrollCalculationInput input, BigDecimal taxableIncome) { + InsuranceRates rates = input.getRates(); + + BigDecimal employmentIns = taxableIncome.multiply(rates.getEmploymentInsurance()).setScale(0, RoundingMode.FLOOR); + BigDecimal healthIns = taxableIncome.multiply(rates.getHealthInsurance()).setScale(0, RoundingMode.FLOOR); + BigDecimal longTermCareIns = healthIns.multiply(rates.getLongTermCare()).divide(new BigDecimal("10"), 0, RoundingMode.FLOOR).multiply(new BigDecimal("10")); + BigDecimal nationalPension = taxableIncome.multiply(rates.getNationalPension()).setScale(0, RoundingMode.FLOOR); + + return new InsuranceResult(employmentIns, healthIns, longTermCareIns, nationalPension); + } + + /** + * 일용직 세금 계산 (일별 계산) + */ + private TaxResult calculateDailyTax(PayrollCalculationInput input, BigDecimal totalPay) { + // 근로일수가 0이거나 null이면 세금 0원 반환 + if (input.getWorkDays() == null || input.getWorkDays() == 0) { + return new TaxResult(BigDecimal.ZERO, BigDecimal.ZERO); + } + + // 일급 = 총 지급액 / 근로일수 + BigDecimal dailyPay = totalPay.divide(new BigDecimal(input.getWorkDays()), 0, RoundingMode.FLOOR); + + BigDecimal totalIncomeTax = BigDecimal.ZERO; + BigDecimal totalResidentTax = BigDecimal.ZERO; + + // 각 근로일에 대해 세금 계산 + for (int i = 0; i < input.getWorkDays(); i++) { + // 과세표준 = max(0, 일급 - 150,000) + BigDecimal taxBase = dailyPay.subtract(new BigDecimal("150000")).max(BigDecimal.ZERO); + + // 산출세액 = 과세표준 × 6% + BigDecimal calculatedTax = taxBase.multiply(new BigDecimal("0.06")).setScale(0, RoundingMode.FLOOR); + + // 세액공제 = 산출세액 × 55% + BigDecimal taxCredit = calculatedTax.multiply(new BigDecimal("0.55")).setScale(0, RoundingMode.FLOOR); + + // 결정세액 = 산출세액 - 세액공제 + BigDecimal finalIncomeTax = calculatedTax.subtract(taxCredit); + + // 지방소득세 = 결정세액 × 10% + BigDecimal residentTax = finalIncomeTax.multiply(new BigDecimal("0.10")).setScale(0, RoundingMode.FLOOR); + + // 총 세금 + BigDecimal dailyTax = finalIncomeTax.add(residentTax); + + // 1,000원 미만 면제 + if (dailyTax.compareTo(new BigDecimal("1000")) < 0) { + dailyTax = BigDecimal.ZERO; + finalIncomeTax = BigDecimal.ZERO; + residentTax = BigDecimal.ZERO; + } + + totalIncomeTax = totalIncomeTax.add(finalIncomeTax); + totalResidentTax = totalResidentTax.add(residentTax); + } + + return new TaxResult(totalIncomeTax, totalResidentTax); + } + + /** + * 상용직 세금 계산 (간이세액표 - 현재는 3.3% 적용) + */ + private TaxResult calculatePermanentTax(PayrollCalculationInput input, BigDecimal taxableIncome) { + // TODO: 간이세액표 적용 (현재는 단순 3.3%) + BigDecimal incomeTax = taxableIncome.multiply(new BigDecimal("0.033")).setScale(0, RoundingMode.FLOOR); + BigDecimal residentTax = incomeTax.multiply(new BigDecimal("0.10")).setScale(0, RoundingMode.FLOOR); + + return new TaxResult(incomeTax, residentTax); + } + + /** + * Null-safe BigDecimal 반환 + */ + @SuppressWarnings("unused") // calculate 메서드에서 사용됨 + private BigDecimal nvl(BigDecimal value) { + return value != null ? value : BigDecimal.ZERO; + } + + // ========== 내부 DTO 클래스 ========== + + /** + * 보험 계산 결과 + */ + private static class InsuranceResult { + private final BigDecimal employmentInsurance; + private final BigDecimal healthInsurance; + private final BigDecimal longTermCareInsurance; + private final BigDecimal nationalPension; + + public InsuranceResult(BigDecimal employmentInsurance, BigDecimal healthInsurance, + BigDecimal longTermCareInsurance, BigDecimal nationalPension) { + this.employmentInsurance = employmentInsurance; + this.healthInsurance = healthInsurance; + this.longTermCareInsurance = longTermCareInsurance; + this.nationalPension = nationalPension; + } + + public BigDecimal getEmploymentInsurance() { return employmentInsurance; } + public BigDecimal getHealthInsurance() { return healthInsurance; } + public BigDecimal getLongTermCareInsurance() { return longTermCareInsurance; } + public BigDecimal getNationalPension() { return nationalPension; } + + public BigDecimal getTotal() { + return employmentInsurance.add(healthInsurance).add(longTermCareInsurance).add(nationalPension); + } + } + + /** + * 세금 계산 결과 + */ + private static class TaxResult { + private final BigDecimal incomeTax; + private final BigDecimal residentTax; + + public TaxResult(BigDecimal incomeTax, BigDecimal residentTax) { + this.incomeTax = incomeTax; + this.residentTax = residentTax; + } + + public BigDecimal getIncomeTax() { return incomeTax; } + public BigDecimal getResidentTax() { return residentTax; } + + public BigDecimal getTotal() { + return incomeTax.add(residentTax); + } + } } \ No newline at end of file diff --git a/src/main/resources/templates/contract/contract-pdf.html b/src/main/resources/templates/contract/contract-pdf.html index fe075779..e2cda895 100644 --- a/src/main/resources/templates/contract/contract-pdf.html +++ b/src/main/resources/templates/contract/contract-pdf.html @@ -54,7 +54,7 @@ } .signature-page { page-break-inside: avoid; - margin-top: 455px; + margin-top: 475px; } .signature-area { margin-top: 0; diff --git a/src/test/java/com/concrete/buildup/domain/payroll/util/PayrollCalculatorTest.java b/src/test/java/com/concrete/buildup/domain/payroll/util/PayrollCalculatorTest.java index 4c315d41..5d81cfc5 100644 --- a/src/test/java/com/concrete/buildup/domain/payroll/util/PayrollCalculatorTest.java +++ b/src/test/java/com/concrete/buildup/domain/payroll/util/PayrollCalculatorTest.java @@ -1,7 +1,15 @@ package com.concrete.buildup.domain.payroll.util; +import com.concrete.buildup.domain.contract.enums.EmpType; +import com.concrete.buildup.domain.contract.enums.PayPeriod; +import com.concrete.buildup.domain.payroll.dto.InsuranceEligibility; +import com.concrete.buildup.domain.payroll.dto.InsuranceRates; +import com.concrete.buildup.domain.payroll.dto.OvertimeConditions; +import com.concrete.buildup.domain.payroll.dto.PayrollCalculationInput; +import com.concrete.buildup.domain.payroll.dto.PayrollCalculationResult; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import java.math.BigDecimal; @@ -389,4 +397,509 @@ void realWorldScenario_PermanentWorker() { // 실수령액: 3,135,000 - (103,455 + 9,405 + 141,075 + 111,135 + 21,945 + 28,215) = 2,719,770원 assertThat(netPay).isEqualByComparingTo(new BigDecimal("2719770")); } + + // ======================================== + // v1.1 통합 계산 테스트 + // ======================================== + + @Nested + @DisplayName("v1.1 통합 급여 계산 테스트") + class CalculateV1_1Test { + + @Test + @DisplayName("일용직 일급 계산 - 기본 케이스 (가산수당 없음)") + void calculateDaily_Basic() { + // given - 일용직, 시급 12,000원, 8시간 근무, 가산수당 없음 + PayrollCalculationInput input = PayrollCalculationInput.builder() + .empType(EmpType.DAILY) + .payPeriod(PayPeriod.DAILY) + .hourlyRate(new BigDecimal("12000")) + .dailyWorkHours(new BigDecimal("8")) + .workDays(1) + .overtimeHours(BigDecimal.ZERO) + .nightHours(BigDecimal.ZERO) + .holidayHours(BigDecimal.ZERO) + .weeklyHolidayEligible(false) + .insurance(InsuranceEligibility.builder() + .employmentInsurance(false) + .healthInsurance(false) + .nationalPension(false) + .build()) + .dependents(0) + .rates(InsuranceRates.defaultRates2025()) + .overtimeConditions(OvertimeConditions.builder() + .totalEmployees(5) + .autoPayOvertime(true) + .autoPayNight(true) + .autoPayHoliday(true) + .exceedsDailyLimit(false) + .exceedsWeeklyLimit(false) + .hasNightWork(false) + .hasHolidayWork(false) + .build()) + .monthlyWorkDaysAccumulated(1) // 일용직 일급: workDays 값 사용 + .monthlyEstimatedIncome(BigDecimal.ZERO) + .build(); + + // when + PayrollCalculationResult result = calculator.calculate(input); + + // then + // 기본급: 12,000 * 8 = 96,000원 + assertThat(result.getBasePay()).isEqualByComparingTo(new BigDecimal("96000")); + assertThat(result.getOvertimePay()).isEqualByComparingTo(BigDecimal.ZERO); + assertThat(result.getNightPay()).isEqualByComparingTo(BigDecimal.ZERO); + assertThat(result.getHolidayPay()).isEqualByComparingTo(BigDecimal.ZERO); + assertThat(result.getWeeklyHolidayPay()).isEqualByComparingTo(BigDecimal.ZERO); + assertThat(result.getTotalPay()).isEqualByComparingTo(new BigDecimal("96000")); + + // 일용직 일별 세금: (96,000 - 150,000) < 0 이므로 0원 + assertThat(result.getIncomeTax()).isEqualByComparingTo(BigDecimal.ZERO); + assertThat(result.getResidentTax()).isEqualByComparingTo(BigDecimal.ZERO); + + // 4대보험: 미가입 + assertThat(result.getEmploymentInsurance()).isEqualByComparingTo(BigDecimal.ZERO); + assertThat(result.getHealthInsurance()).isEqualByComparingTo(BigDecimal.ZERO); + assertThat(result.getNationalPension()).isEqualByComparingTo(BigDecimal.ZERO); + + // 실수령액: 96,000원 + assertThat(result.getNetPay()).isEqualByComparingTo(new BigDecimal("96000")); + } + + @Test + @DisplayName("일용직 일급 계산 - 15만원 초과 시 세금 발생") + void calculateDaily_WithTax() { + // given - 일용직, 시급 20,000원, 10시간 근무 (200,000원) + PayrollCalculationInput input = PayrollCalculationInput.builder() + .empType(EmpType.DAILY) + .payPeriod(PayPeriod.DAILY) + .hourlyRate(new BigDecimal("20000")) + .dailyWorkHours(new BigDecimal("10")) + .workDays(1) + .overtimeHours(BigDecimal.ZERO) + .nightHours(BigDecimal.ZERO) + .holidayHours(BigDecimal.ZERO) + .weeklyHolidayEligible(false) + .insurance(InsuranceEligibility.builder() + .employmentInsurance(false) + .healthInsurance(false) + .nationalPension(false) + .build()) + .dependents(0) + .rates(InsuranceRates.defaultRates2025()) + .overtimeConditions(OvertimeConditions.builder() + .totalEmployees(5) + .autoPayOvertime(true) + .autoPayNight(true) + .autoPayHoliday(true) + .exceedsDailyLimit(false) + .exceedsWeeklyLimit(false) + .hasNightWork(false) + .hasHolidayWork(false) + .build()) + .monthlyWorkDaysAccumulated(1) // 일용직 일급: workDays 값 사용 + .monthlyEstimatedIncome(BigDecimal.ZERO) + .build(); + + // when + PayrollCalculationResult result = calculator.calculate(input); + + // then + // 기본급: 20,000 * 10 = 200,000원 + assertThat(result.getBasePay()).isEqualByComparingTo(new BigDecimal("200000")); + assertThat(result.getTotalPay()).isEqualByComparingTo(new BigDecimal("200000")); + + // 일용직 세금 계산: + // 과세표준 = 200,000 - 150,000 = 50,000원 + // 산출세액 = 50,000 × 6% = 3,000원 + // 세액공제 = 3,000 × 55% = 1,650원 + // 결정세액 (소득세) = 3,000 - 1,650 = 1,350원 + // 지방소득세 = 1,350 × 10% = 135원 + // 총 세금 = 1,350 + 135 = 1,485원 (1,000원 이상이므로 과세) + assertThat(result.getIncomeTax()).isEqualByComparingTo(new BigDecimal("1350")); + assertThat(result.getResidentTax()).isEqualByComparingTo(new BigDecimal("135")); + + // 실수령액: 200,000 - 1,350 - 135 = 198,515원 + assertThat(result.getNetPay()).isEqualByComparingTo(new BigDecimal("198515")); + } + + @Test + @DisplayName("일용직 일급 계산 - 가산수당 포함 (연장 + 야간)") + void calculateDaily_WithOvertimeAndNight() { + // given - 일용직, 시급 12,000원, 통상 8시간 + 연장 2시간 + 야간 3시간 + PayrollCalculationInput input = PayrollCalculationInput.builder() + .empType(EmpType.DAILY) + .payPeriod(PayPeriod.DAILY) + .hourlyRate(new BigDecimal("12000")) + .dailyWorkHours(new BigDecimal("8")) + .workDays(1) + .overtimeHours(new BigDecimal("2")) // 연장 2시간 + .nightHours(new BigDecimal("3")) // 야간 3시간 + .holidayHours(BigDecimal.ZERO) + .weeklyHolidayEligible(false) + .insurance(InsuranceEligibility.builder() + .employmentInsurance(false) + .healthInsurance(false) + .nationalPension(false) + .build()) + .dependents(0) + .rates(InsuranceRates.defaultRates2025()) + .overtimeConditions(OvertimeConditions.builder() + .totalEmployees(5) // 5인 이상 사업장 + .autoPayOvertime(true) + .autoPayNight(true) + .autoPayHoliday(true) + .exceedsDailyLimit(true) // 일 8시간 초과 + .exceedsWeeklyLimit(false) + .hasNightWork(true) + .hasHolidayWork(false) + .build()) + .monthlyWorkDaysAccumulated(1) // 일용직 일급: workDays 값 사용 + .monthlyEstimatedIncome(BigDecimal.ZERO) + .build(); + + // when + PayrollCalculationResult result = calculator.calculate(input); + + // then + // 기본급: 12,000 * 8 = 96,000원 + assertThat(result.getBasePay()).isEqualByComparingTo(new BigDecimal("96000")); + + // 연장수당: 12,000 * 2 * 1.5 = 36,000원 + assertThat(result.getOvertimePay()).isEqualByComparingTo(new BigDecimal("36000")); + + // 야간수당: 12,000 * 3 * 0.5 = 18,000원 + assertThat(result.getNightPay()).isEqualByComparingTo(new BigDecimal("18000")); + + // 총 지급액: 96,000 + 36,000 + 18,000 = 150,000원 + assertThat(result.getTotalPay()).isEqualByComparingTo(new BigDecimal("150000")); + + // 세금: 150,000원 = 공제 기준선이므로 0원 + assertThat(result.getIncomeTax()).isEqualByComparingTo(BigDecimal.ZERO); + assertThat(result.getResidentTax()).isEqualByComparingTo(BigDecimal.ZERO); + + // 실수령액: 150,000원 + assertThat(result.getNetPay()).isEqualByComparingTo(new BigDecimal("150000")); + } + + @Test + @DisplayName("일용직 일급 계산 - 5인 미만 사업장, 가산수당 자율 지급") + void calculateDaily_SmallBusiness_AutoPay() { + // given - 4인 사업장, 가산수당 자율 지급 + PayrollCalculationInput input = PayrollCalculationInput.builder() + .empType(EmpType.DAILY) + .payPeriod(PayPeriod.DAILY) + .hourlyRate(new BigDecimal("12000")) + .dailyWorkHours(new BigDecimal("8")) + .workDays(1) + .overtimeHours(new BigDecimal("2")) + .nightHours(new BigDecimal("2")) + .holidayHours(BigDecimal.ZERO) + .weeklyHolidayEligible(false) + .insurance(InsuranceEligibility.builder() + .employmentInsurance(false) + .healthInsurance(false) + .nationalPension(false) + .build()) + .dependents(0) + .rates(InsuranceRates.defaultRates2025()) + .overtimeConditions(OvertimeConditions.builder() + .totalEmployees(4) // 5인 미만 사업장 + .autoPayOvertime(true) // 연장수당 자율 지급 + .autoPayNight(true) // 야간수당 자율 지급 + .autoPayHoliday(true) + .exceedsDailyLimit(true) + .exceedsWeeklyLimit(false) + .hasNightWork(true) + .hasHolidayWork(false) + .build()) + .monthlyWorkDaysAccumulated(1) // 일용직 일급: workDays 값 사용 + .monthlyEstimatedIncome(BigDecimal.ZERO) + .build(); + + // when + PayrollCalculationResult result = calculator.calculate(input); + + // then - 5인 미만이지만 자율 지급하므로 가산수당 지급 + assertThat(result.getBasePay()).isEqualByComparingTo(new BigDecimal("96000")); + assertThat(result.getOvertimePay()).isEqualByComparingTo(new BigDecimal("36000")); // 12,000 * 2 * 1.5 + assertThat(result.getNightPay()).isEqualByComparingTo(new BigDecimal("12000")); // 12,000 * 2 * 0.5 + assertThat(result.getTotalPay()).isEqualByComparingTo(new BigDecimal("144000")); + } + + @Test + @DisplayName("일용직 일급 계산 - 5인 미만 사업장, 가산수당 미지급") + void calculateDaily_SmallBusiness_NoPay() { + // given - 4인 사업장, 가산수당 미지급 + PayrollCalculationInput input = PayrollCalculationInput.builder() + .empType(EmpType.DAILY) + .payPeriod(PayPeriod.DAILY) + .hourlyRate(new BigDecimal("12000")) + .dailyWorkHours(new BigDecimal("8")) + .workDays(1) + .overtimeHours(new BigDecimal("2")) + .nightHours(new BigDecimal("2")) + .holidayHours(BigDecimal.ZERO) + .weeklyHolidayEligible(false) + .insurance(InsuranceEligibility.builder() + .employmentInsurance(false) + .healthInsurance(false) + .nationalPension(false) + .build()) + .dependents(0) + .rates(InsuranceRates.defaultRates2025()) + .overtimeConditions(OvertimeConditions.builder() + .totalEmployees(4) // 5인 미만 사업장 + .autoPayOvertime(false) // 연장수당 미지급 + .autoPayNight(false) // 야간수당 미지급 + .autoPayHoliday(false) + .exceedsDailyLimit(true) + .exceedsWeeklyLimit(false) + .hasNightWork(true) + .hasHolidayWork(false) + .build()) + .monthlyWorkDaysAccumulated(1) // 일용직 일급: workDays 값 사용 + .monthlyEstimatedIncome(BigDecimal.ZERO) + .build(); + + // when + PayrollCalculationResult result = calculator.calculate(input); + + // then - 5인 미만이고 자율 미지급하므로 가산수당 0원 + assertThat(result.getBasePay()).isEqualByComparingTo(new BigDecimal("96000")); + assertThat(result.getOvertimePay()).isEqualByComparingTo(BigDecimal.ZERO); + assertThat(result.getNightPay()).isEqualByComparingTo(BigDecimal.ZERO); + assertThat(result.getTotalPay()).isEqualByComparingTo(new BigDecimal("96000")); + } + + @Test + @DisplayName("일용직 월급 계산 - 4대보험 가입 조건 충족 (월 8일, 월소득 220만원)") + void calculateDaily_Monthly_WithInsurance() { + // given - 일용직 월급, 월 10일 근무, 시급 25,000원 (월 200만원 예상) + PayrollCalculationInput input = PayrollCalculationInput.builder() + .empType(EmpType.DAILY) + .payPeriod(PayPeriod.MONTHLY) + .hourlyRate(new BigDecimal("25000")) + .dailyWorkHours(new BigDecimal("8")) + .workDays(10) // 월 10일 근무 + .overtimeHours(BigDecimal.ZERO) + .nightHours(BigDecimal.ZERO) + .holidayHours(BigDecimal.ZERO) + .weeklyHolidayEligible(false) + .insurance(InsuranceEligibility.builder() + .employmentInsurance(true) + .healthInsurance(true) + .nationalPension(true) + .build()) + .dependents(0) + .rates(InsuranceRates.defaultRates2025()) + .overtimeConditions(OvertimeConditions.builder() + .totalEmployees(5) + .autoPayOvertime(true) + .autoPayNight(true) + .autoPayHoliday(true) + .exceedsDailyLimit(false) + .exceedsWeeklyLimit(false) + .hasNightWork(false) + .hasHolidayWork(false) + .build()) + .monthlyWorkDaysAccumulated(10) // 월 10일 근무 + .monthlyEstimatedIncome(new BigDecimal("2500000")) // 월소득 250만원 (220만원 초과) + .build(); + + // when + PayrollCalculationResult result = calculator.calculate(input); + + // then + // 기본급: 25,000 * 8 * 10 = 2,000,000원 + assertThat(result.getBasePay()).isEqualByComparingTo(new BigDecimal("2000000")); + assertThat(result.getTotalPay()).isEqualByComparingTo(new BigDecimal("2000000")); + + // 4대보험: 월 8일 이상 + 월소득 220만원 이상 조건 충족 + // 고용보험: 2,000,000 * 0.009 = 18,000원 + assertThat(result.getEmploymentInsurance()).isEqualByComparingTo(new BigDecimal("18000")); + // 건강보험: 2,000,000 * 0.03545 = 70,900원 + assertThat(result.getHealthInsurance()).isEqualByComparingTo(new BigDecimal("70900")); + // 장기요양: 70,900 * 0.1281 = 9,082.29 → 9,080원 (10원 단위 절사) + assertThat(result.getLongTermCareInsurance()).isEqualByComparingTo(new BigDecimal("9080")); + // 국민연금: 2,000,000 * 0.045 = 90,000원 + assertThat(result.getNationalPension()).isEqualByComparingTo(new BigDecimal("90000")); + + // 총 공제액 계산 확인 + BigDecimal totalDeduction = result.calculateTotalDeduction(); + assertThat(totalDeduction.compareTo(BigDecimal.ZERO)).isGreaterThan(0); + } + + @Test + @DisplayName("일용직 월급 계산 - 4대보험 미가입 (월 8일 미만)") + void calculateDaily_Monthly_NoInsurance_LessThan8Days() { + // given - 일용직 월급, 월 5일 근무 + PayrollCalculationInput input = PayrollCalculationInput.builder() + .empType(EmpType.DAILY) + .payPeriod(PayPeriod.MONTHLY) + .hourlyRate(new BigDecimal("30000")) + .dailyWorkHours(new BigDecimal("8")) + .workDays(5) // 월 5일 근무 (8일 미만) + .overtimeHours(BigDecimal.ZERO) + .nightHours(BigDecimal.ZERO) + .holidayHours(BigDecimal.ZERO) + .weeklyHolidayEligible(false) + .insurance(InsuranceEligibility.builder() + .employmentInsurance(true) + .healthInsurance(true) + .nationalPension(true) + .build()) + .dependents(0) + .rates(InsuranceRates.defaultRates2025()) + .overtimeConditions(OvertimeConditions.builder() + .totalEmployees(5) + .autoPayOvertime(true) + .autoPayNight(true) + .autoPayHoliday(true) + .exceedsDailyLimit(false) + .exceedsWeeklyLimit(false) + .hasNightWork(false) + .hasHolidayWork(false) + .build()) + .monthlyWorkDaysAccumulated(5) // 월 5일 근무 + .monthlyEstimatedIncome(new BigDecimal("3000000")) // 월소득은 충분하지만 + .build(); + + // when + PayrollCalculationResult result = calculator.calculate(input); + + // then - 월 8일 미만이므로 4대보험 미가입 + assertThat(result.getEmploymentInsurance()).isEqualByComparingTo(BigDecimal.ZERO); + assertThat(result.getHealthInsurance()).isEqualByComparingTo(BigDecimal.ZERO); + assertThat(result.getLongTermCareInsurance()).isEqualByComparingTo(BigDecimal.ZERO); + assertThat(result.getNationalPension()).isEqualByComparingTo(BigDecimal.ZERO); + } + + @Test + @DisplayName("상용직 월급 계산 - 4대보험 전체 적용") + void calculatePermanent_Monthly_WithFullInsurance() { + // given - 상용직, 월 209시간, 시급 15,000원 + PayrollCalculationInput input = PayrollCalculationInput.builder() + .empType(EmpType.PERMANENT) + .payPeriod(PayPeriod.MONTHLY) + .hourlyRate(new BigDecimal("15000")) + .dailyWorkHours(new BigDecimal("8")) + .workDays(20) + .overtimeHours(BigDecimal.ZERO) + .nightHours(BigDecimal.ZERO) + .holidayHours(BigDecimal.ZERO) + .weeklyHolidayEligible(true) + .insurance(InsuranceEligibility.builder() + .employmentInsurance(true) + .healthInsurance(true) + .nationalPension(true) + .build()) + .dependents(1) // 부양가족 1명 + .rates(InsuranceRates.defaultRates2025()) + .overtimeConditions(OvertimeConditions.builder() + .totalEmployees(10) + .autoPayOvertime(true) + .autoPayNight(true) + .autoPayHoliday(true) + .exceedsDailyLimit(false) + .exceedsWeeklyLimit(false) + .hasNightWork(false) + .hasHolidayWork(false) + .build()) + .monthlyWorkDaysAccumulated(20) + .monthlyEstimatedIncome(new BigDecimal("3000000")) + .build(); + + // when + PayrollCalculationResult result = calculator.calculate(input); + + // then + // 기본급: 15,000 * 8 * 20 = 2,400,000원 + assertThat(result.getBasePay()).isEqualByComparingTo(new BigDecimal("2400000")); + + // 주휴수당: 주 4회 * (15,000 * 8) = 480,000원 + assertThat(result.getWeeklyHolidayPay()).isEqualByComparingTo(new BigDecimal("480000")); + + // 총 지급액: 2,400,000 + 480,000 = 2,880,000원 + assertThat(result.getTotalPay()).isEqualByComparingTo(new BigDecimal("2880000")); + + // 상용직 세금: 간이세액표 (현재는 3.3% 적용) + // 2,880,000 * 0.033 = 95,040원 + assertThat(result.getIncomeTax()).isEqualByComparingTo(new BigDecimal("95040")); + // 주민세: 95,040 * 0.1 = 9,504원 + assertThat(result.getResidentTax()).isEqualByComparingTo(new BigDecimal("9504")); + + // 4대보험 + // 고용보험: 2,880,000 * 0.009 = 25,920원 + assertThat(result.getEmploymentInsurance()).isEqualByComparingTo(new BigDecimal("25920")); + // 건강보험: 2,880,000 * 0.03545 = 102,096원 + assertThat(result.getHealthInsurance()).isEqualByComparingTo(new BigDecimal("102096")); + // 장기요양: 102,096 * 0.1281 = 13,078.49 → 13,070원 + assertThat(result.getLongTermCareInsurance()).isEqualByComparingTo(new BigDecimal("13070")); + // 국민연금: 2,880,000 * 0.045 = 129,600원 + assertThat(result.getNationalPension()).isEqualByComparingTo(new BigDecimal("129600")); + + // 실수령액 검증 + BigDecimal expectedDeduction = new BigDecimal("95040") + .add(new BigDecimal("9504")) + .add(new BigDecimal("25920")) + .add(new BigDecimal("102096")) + .add(new BigDecimal("13070")) + .add(new BigDecimal("129600")); + BigDecimal expectedNetPay = new BigDecimal("2880000").subtract(expectedDeduction); + assertThat(result.getNetPay()).isEqualByComparingTo(expectedNetPay); + } + + @Test + @DisplayName("휴일근로 + 야간근로 중복가산 계산") + void calculateDaily_HolidayAndNight_OverlappingAllowance() { + // given - 휴일에 야간근로 4시간 + PayrollCalculationInput input = PayrollCalculationInput.builder() + .empType(EmpType.DAILY) + .payPeriod(PayPeriod.DAILY) + .hourlyRate(new BigDecimal("10000")) + .dailyWorkHours(new BigDecimal("8")) + .workDays(1) + .overtimeHours(BigDecimal.ZERO) + .nightHours(new BigDecimal("4")) // 야간 4시간 + .holidayHours(new BigDecimal("4")) // 휴일 4시간 (야간과 중복) + .weeklyHolidayEligible(false) + .insurance(InsuranceEligibility.builder() + .employmentInsurance(false) + .healthInsurance(false) + .nationalPension(false) + .build()) + .dependents(0) + .rates(InsuranceRates.defaultRates2025()) + .overtimeConditions(OvertimeConditions.builder() + .totalEmployees(5) + .autoPayOvertime(true) + .autoPayNight(true) + .autoPayHoliday(true) + .exceedsDailyLimit(false) + .exceedsWeeklyLimit(false) + .hasNightWork(true) + .hasHolidayWork(true) + .build()) + .monthlyWorkDaysAccumulated(1) // 일용직 일급: workDays 값 사용 + .monthlyEstimatedIncome(BigDecimal.ZERO) + .build(); + + // when + PayrollCalculationResult result = calculator.calculate(input); + + // then + // 기본급: 10,000 * 8 = 80,000원 + assertThat(result.getBasePay()).isEqualByComparingTo(new BigDecimal("80000")); + + // 휴일수당: 10,000 * 4 * 1.5 = 60,000원 + assertThat(result.getHolidayPay()).isEqualByComparingTo(new BigDecimal("60000")); + + // 야간수당: 10,000 * 4 * 0.5 = 20,000원 (휴일과 중복되는 시간에 추가) + assertThat(result.getNightPay()).isEqualByComparingTo(new BigDecimal("20000")); + + // 총 지급액: 80,000 + 60,000 + 20,000 = 160,000원 + assertThat(result.getTotalPay()).isEqualByComparingTo(new BigDecimal("160000")); + } + } } \ No newline at end of file