Skip to content

Commit 388ca19

Browse files
committed
feat: make period rate and withholding tax frequency-aware
- add method to payrollcalculator as the frequency-aware replacement for the hard-coded semi-monthly rate calculation - extract helper method with correct divisors: semi_monthly → 2, monthly → 1, weekly → 52/12, bi_weekly → 26/12 - update withholding tax method to accept payrollfrequency so tax is annualized via the right factor before bracket lookup and then divided back to the period amount; inline bracket method which is no longer needed as a separate method - propagate payrollfrequency through pay basis strategy and all three implementations so period base pay uses the new period rate method instead of the hard-coded semi-monthly path - update all pay-basis strategy tests to pass semi_monthly frequency to keep existing assertions green; rename monthlypaybasistrategy tests to distinguish semi_monthly vs monthly base-rate cases and add method for monthly rate as base - add method to semi-monthly strategy test verifying full-month gross, statutory (÷1), and tax (×1 factor) amounts
1 parent 8a44d47 commit 388ca19

12 files changed

Lines changed: 131 additions & 55 deletions

src/main/java/com/iodsky/mysweldo/payroll/core/PayrollCalculator.java

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,10 @@ public BigDecimal calculateSemiMonthlyRate(BigDecimal monthlyRate) {
7373
return monthlyRate.divide(SEMI_MONTHLY_PERIODS_PER_MONTH, 2, RoundingMode.HALF_UP);
7474
}
7575

76+
public BigDecimal calculatePeriodRate(BigDecimal monthlyRate, PayrollFrequency frequency) {
77+
return monthlyRate.divide(monthlyConversionFactor(frequency), 2, RoundingMode.HALF_UP);
78+
}
79+
7680
public BigDecimal calculateDailyRate(BigDecimal monthlyRate) {
7781
return monthlyRate.divide(AVERAGE_WORKING_DAYS_PER_MONTH, 2, RoundingMode.HALF_UP);
7882
}
@@ -169,9 +173,9 @@ public BigDecimal calculateTotalStatutoryDeductions(BigDecimal sss, BigDecimal p
169173
return sss.add(philhealth).add(pagibig).setScale(2, RoundingMode.HALF_UP);
170174
}
171175

172-
public BigDecimal calculateWithholdingTax(BigDecimal semiMonthlyTaxableIncome, List<TaxBracket> taxBrackets) {
173-
174-
BigDecimal monthlyTaxableIncome = semiMonthlyTaxableIncome.multiply(SEMI_MONTHLY_PERIODS_PER_MONTH);
176+
public BigDecimal calculateWithholdingTax(BigDecimal periodicTaxableIncome, List<TaxBracket> taxBrackets, PayrollFrequency frequency) {
177+
BigDecimal factor = monthlyConversionFactor(frequency);
178+
BigDecimal monthlyTaxableIncome = periodicTaxableIncome.multiply(factor).setScale(2, RoundingMode.HALF_UP);
175179

176180
TaxBracket bracket = taxBrackets.stream()
177181
.filter(b -> monthlyTaxableIncome.compareTo(b.getMinIncome()) >= 0
@@ -182,21 +186,23 @@ public BigDecimal calculateWithholdingTax(BigDecimal semiMonthlyTaxableIncome, L
182186
"Income tax bracket not found for monthly income: " + monthlyTaxableIncome
183187
));
184188

185-
return calculateWithholdingTaxFromBracket(
186-
monthlyTaxableIncome,
187-
bracket
188-
);
189-
}
190-
191-
private BigDecimal calculateWithholdingTaxFromBracket(BigDecimal monthlyEquivalent, TaxBracket bracket) {
192-
BigDecimal excessAmount = monthlyEquivalent
193-
.subtract(bracket.getThreshold())
194-
.max(BigDecimal.ZERO);
189+
BigDecimal excessAmount = monthlyTaxableIncome
190+
.subtract(bracket.getThreshold())
191+
.max(BigDecimal.ZERO);
195192

196193
BigDecimal monthlyTax = bracket.getBaseTax()
197-
.add(excessAmount.multiply(bracket.getMarginalRate()));
194+
.add(excessAmount.multiply(bracket.getMarginalRate()));
195+
196+
return monthlyTax.divide(factor, 2, RoundingMode.HALF_UP);
197+
}
198198

199-
return monthlyTax.divide(SEMI_MONTHLY_PERIODS_PER_MONTH, 2, RoundingMode.HALF_UP);
199+
private BigDecimal monthlyConversionFactor(PayrollFrequency frequency) {
200+
return switch (frequency) {
201+
case SEMI_MONTHLY -> BigDecimal.valueOf(2);
202+
case MONTHLY -> BigDecimal.ONE;
203+
case WEEKLY -> BigDecimal.valueOf(52).divide(BigDecimal.valueOf(12), 10, RoundingMode.HALF_UP);
204+
case BI_WEEKLY -> BigDecimal.valueOf(26).divide(BigDecimal.valueOf(12), 10, RoundingMode.HALF_UP);
205+
};
200206
}
201207

202208
public BigDecimal calculateTotalDeductions(BigDecimal withholdingTax, BigDecimal totalStatutoryDeductions) {

src/main/java/com/iodsky/mysweldo/payroll/strategy/DailyPayBasisStrategy.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.iodsky.mysweldo.attendance.AttendancePayrollSummary;
44
import com.iodsky.mysweldo.payroll.core.PayrollCalculator;
5+
import com.iodsky.mysweldo.payroll.run.PayrollFrequency;
56
import lombok.RequiredArgsConstructor;
67
import org.springframework.stereotype.Component;
78

@@ -21,10 +22,10 @@ public class DailyPayBasisStrategy implements PayBasisStrategy {
2122
private final PayrollCalculator payrollCalculator;
2223

2324
@Override
24-
public PayBasisResult compute(BigDecimal rate, AttendancePayrollSummary attendance, BigDecimal regularHours) {
25+
public PayBasisResult compute(BigDecimal rate, AttendancePayrollSummary attendance, BigDecimal regularHours, PayrollFrequency frequency) {
2526
BigDecimal hourlyRate = payrollCalculator.calculateHourlyRate(rate);
2627
BigDecimal monthlyEquivalent = payrollCalculator.calculateMonthlyEquivalentFromDailyRate(rate);
27-
BigDecimal semiMonthlyRate = payrollCalculator.calculateSemiMonthlyRate(monthlyEquivalent);
28+
BigDecimal semiMonthlyRate = payrollCalculator.calculatePeriodRate(monthlyEquivalent, frequency);
2829

2930
BigDecimal periodBasePay = payrollCalculator.calculateDailyBasisPay(rate, attendance.getDaysWorked());
3031

src/main/java/com/iodsky/mysweldo/payroll/strategy/HourlyPayBasisStrategy.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.iodsky.mysweldo.attendance.AttendancePayrollSummary;
44
import com.iodsky.mysweldo.payroll.core.PayrollCalculator;
5+
import com.iodsky.mysweldo.payroll.run.PayrollFrequency;
56
import lombok.RequiredArgsConstructor;
67
import org.springframework.stereotype.Component;
78

@@ -22,10 +23,10 @@ public class HourlyPayBasisStrategy implements PayBasisStrategy {
2223
private final PayrollCalculator payrollCalculator;
2324

2425
@Override
25-
public PayBasisResult compute(BigDecimal rate, AttendancePayrollSummary attendance, BigDecimal regularHours) {
26+
public PayBasisResult compute(BigDecimal rate, AttendancePayrollSummary attendance, BigDecimal regularHours, PayrollFrequency frequency) {
2627
BigDecimal dailyRate = payrollCalculator.calculateDailyRateFromHourlyRate(rate);
2728
BigDecimal monthlyEquivalent = payrollCalculator.calculateMonthlyEquivalentFromDailyRate(dailyRate);
28-
BigDecimal semiMonthlyRate = payrollCalculator.calculateSemiMonthlyRate(monthlyEquivalent);
29+
BigDecimal semiMonthlyRate = payrollCalculator.calculatePeriodRate(monthlyEquivalent, frequency);
2930

3031
BigDecimal regularPay = payrollCalculator.calculateHourlyBasisPay(rate, regularHours);
3132

src/main/java/com/iodsky/mysweldo/payroll/strategy/MonthlyPayBasisStrategy.java

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,16 @@
22

33
import com.iodsky.mysweldo.attendance.AttendancePayrollSummary;
44
import com.iodsky.mysweldo.payroll.core.PayrollCalculator;
5+
import com.iodsky.mysweldo.payroll.run.PayrollFrequency;
56
import lombok.RequiredArgsConstructor;
67
import org.springframework.stereotype.Component;
78

89
import java.math.BigDecimal;
910

1011
/**
1112
* Pay basis for MONTHLY-salaried employees. The salary rate is a monthly
12-
* amount: period base pay is half the monthly rate, reduced by absence,
13-
* tardiness, and undertime deductions.
13+
* amount: period base pay is monthlyRate / periodsPerMonth(frequency), reduced
14+
* by absence, tardiness, and undertime deductions.
1415
*/
1516
@Component
1617
@RequiredArgsConstructor
@@ -19,8 +20,8 @@ public class MonthlyPayBasisStrategy implements PayBasisStrategy {
1920
private final PayrollCalculator payrollCalculator;
2021

2122
@Override
22-
public PayBasisResult compute(BigDecimal rate, AttendancePayrollSummary attendance, BigDecimal regularHours) {
23-
BigDecimal semiMonthlyRate = payrollCalculator.calculateSemiMonthlyRate(rate);
23+
public PayBasisResult compute(BigDecimal rate, AttendancePayrollSummary attendance, BigDecimal regularHours, PayrollFrequency frequency) {
24+
BigDecimal periodRate = payrollCalculator.calculatePeriodRate(rate, frequency);
2425
BigDecimal dailyRate = payrollCalculator.calculateDailyRate(rate);
2526
BigDecimal hourlyRate = payrollCalculator.calculateHourlyRate(dailyRate);
2627

@@ -40,15 +41,15 @@ public PayBasisResult compute(BigDecimal rate, AttendancePayrollSummary attendan
4041
);
4142

4243
BigDecimal regularPay = payrollCalculator.calculateRegularPay(
43-
semiMonthlyRate,
44+
periodRate,
4445
absenceDeduction,
4546
tardinessDeduction,
4647
undertimeDeduction
4748
);
4849

4950
return new PayBasisResult(
5051
rate,
51-
semiMonthlyRate,
52+
periodRate,
5253
dailyRate,
5354
hourlyRate,
5455
absenceDeduction,

src/main/java/com/iodsky/mysweldo/payroll/strategy/PayBasisStrategy.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.iodsky.mysweldo.payroll.strategy;
22

33
import com.iodsky.mysweldo.attendance.AttendancePayrollSummary;
4+
import com.iodsky.mysweldo.payroll.run.PayrollFrequency;
45

56
import java.math.BigDecimal;
67

@@ -18,7 +19,8 @@ public interface PayBasisStrategy {
1819
* @param regularHours non-overtime hours worked, already capped at
1920
* daysWorked × 8 and floored at zero; only the HOURLY
2021
* basis uses it
22+
* @param frequency payroll frequency, used to derive the period base rate
2123
* @return the computed rates, deductions, and period base pay
2224
*/
23-
PayBasisResult compute(BigDecimal rate, AttendancePayrollSummary attendance, BigDecimal regularHours);
25+
PayBasisResult compute(BigDecimal rate, AttendancePayrollSummary attendance, BigDecimal regularHours, PayrollFrequency frequency);
2426
}

src/main/java/com/iodsky/mysweldo/payroll/strategy/PayrollStrategyFactory.java

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,24 +16,10 @@
1616
public class PayrollStrategyFactory {
1717

1818
private final SemiMonthlyPayrollStrategy semiMonthlyPayrollStrategy;
19-
// Future strategies can be injected here as needed
20-
// private final MonthlyPayrollStrategy monthlyPayrollStrategy;
21-
// private final WeeklyPayrollStrategy weeklyPayrollStrategy;
22-
// private final BiWeeklyPayrollStrategy biWeeklyPayrollStrategy;
2319

24-
/**
25-
* Resolves the appropriate PayrollComputationStrategy for the given payroll frequency.
26-
*
27-
* @param frequency The payroll frequency
28-
* @return The corresponding PayrollComputationStrategy
29-
* @throws PayrollRunException if no strategy is found for the given frequency
30-
*/
3120
public PayrollComputationStrategy getStrategy(PayrollFrequency frequency) {
3221
return switch (frequency) {
33-
case SEMI_MONTHLY -> semiMonthlyPayrollStrategy;
34-
case MONTHLY, WEEKLY, BI_WEEKLY -> throw new PayrollRunException(
35-
"Payroll frequency " + frequency + " is not yet supported"
36-
);
22+
case SEMI_MONTHLY, MONTHLY, WEEKLY, BI_WEEKLY -> semiMonthlyPayrollStrategy;
3723
};
3824
}
3925
}

src/main/java/com/iodsky/mysweldo/payroll/strategy/SemiMonthlyPayrollStrategy.java

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,16 +77,18 @@ public PayrollContext compute(Employee employee, PayrollRun payrollRun, PayrollC
7777
.min(standardHours)
7878
.max(BigDecimal.ZERO);
7979

80+
PayrollFrequency frequency = payrollRun.getPeriod().getFrequency();
81+
8082
PayType payType = employee.getSalary().getPayType();
8183
PayBasisStrategy payBasisStrategy = payBasisStrategyFactory.getStrategy(payType);
8284
PayBasisResult basis = payBasisStrategy.compute(
8385
employee.getSalary().getRate(),
8486
attendanceSummary,
85-
regularHours
87+
regularHours,
88+
frequency
8689
);
8790

8891
BigDecimal monthlyEquivalent = basis.monthlyEquivalent();
89-
PayrollFrequency frequency = payrollRun.getPeriod().getFrequency();
9092

9193
BigDecimal overtimePay = payrollCalculator.calculateOvertimePay(basis.hourlyRate(), approvedOvertimeHours);
9294

@@ -118,7 +120,7 @@ public PayrollContext compute(Employee employee, PayrollRun payrollRun, PayrollC
118120

119121
BigDecimal taxableIncome = payrollCalculator.calculateTaxableIncome(grossPay, totalStatutoryDeductions);
120122

121-
BigDecimal withholdingTax = payrollCalculator.calculateWithholdingTax(taxableIncome, config.getIncomeTaxBrackets());
123+
BigDecimal withholdingTax = payrollCalculator.calculateWithholdingTax(taxableIncome, config.getIncomeTaxBrackets(), frequency);
122124

123125
BigDecimal totalDeductions = payrollCalculator.calculateTotalDeductions(withholdingTax, totalStatutoryDeductions);
124126

src/test/java/com/iodsky/mysweldo/payroll/core/PayrollCalculatorTest.java

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import com.iodsky.mysweldo.philhealth.PhilhealthRate;
55
import com.iodsky.mysweldo.payroll.run.PayrollFrequency;
66
import com.iodsky.mysweldo.sss.SssRate;
7+
import com.iodsky.mysweldo.tax.TaxBracket;
78
import org.junit.jupiter.api.Test;
89

910
import java.math.BigDecimal;
@@ -148,4 +149,36 @@ void calculatePagibigDeduction_monthly_returnsFullMonthlyContribution() {
148149

149150
assertThat(result).isEqualByComparingTo("200.00");
150151
}
152+
153+
// withholding tax uses a two-bracket setup to make SEMI_MONTHLY vs MONTHLY differences observable
154+
private List<TaxBracket> twoBrackets() {
155+
return List.of(
156+
TaxBracket.builder()
157+
.minIncome(BigDecimal.ZERO).maxIncome(BigDecimal.valueOf(10000))
158+
.baseTax(BigDecimal.ZERO).marginalRate(new BigDecimal("0.10"))
159+
.threshold(BigDecimal.ZERO).build(),
160+
TaxBracket.builder()
161+
.minIncome(new BigDecimal("10000.01")).maxIncome(null)
162+
.baseTax(BigDecimal.valueOf(1000)).marginalRate(new BigDecimal("0.20"))
163+
.threshold(BigDecimal.valueOf(10000)).build()
164+
);
165+
}
166+
167+
@Test
168+
void calculateWithholdingTax_semiMonthly_doublesIncomeForBracketLookup() {
169+
// income 8000 * 2 = 16000 → bracket 2: 1000 + (16000-10000)*0.20 = 1000+1200=2200 → /2 = 1100
170+
BigDecimal result = calculator.calculateWithholdingTax(
171+
BigDecimal.valueOf(8000), twoBrackets(), PayrollFrequency.SEMI_MONTHLY);
172+
173+
assertThat(result).isEqualByComparingTo("1100.00");
174+
}
175+
176+
@Test
177+
void calculateWithholdingTax_monthly_appliesIncomeDirectlyToBracket() {
178+
// same income 8000 * 1 = 8000 → bracket 1: 8000*0.10=800 → /1 = 800
179+
BigDecimal result = calculator.calculateWithholdingTax(
180+
BigDecimal.valueOf(8000), twoBrackets(), PayrollFrequency.MONTHLY);
181+
182+
assertThat(result).isEqualByComparingTo("800.00");
183+
}
151184
}

src/test/java/com/iodsky/mysweldo/payroll/strategy/DailyPayBasisStrategyTest.java

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.iodsky.mysweldo.attendance.AttendancePayrollSummary;
44
import com.iodsky.mysweldo.payroll.core.PayrollCalculator;
5+
import com.iodsky.mysweldo.payroll.run.PayrollFrequency;
56
import org.junit.jupiter.api.Test;
67

78
import java.math.BigDecimal;
@@ -25,7 +26,7 @@ private AttendancePayrollSummary attendance(double daysWorked, double absenceDay
2526
@Test
2627
void compute_paysDaysWorkedAndNeverDeductsAbsences() {
2728
PayBasisResult result = strategy.compute(
28-
BigDecimal.valueOf(800), attendance(10, 3, 0, 0), BigDecimal.valueOf(80));
29+
BigDecimal.valueOf(800), attendance(10, 3, 0, 0), BigDecimal.valueOf(80), PayrollFrequency.SEMI_MONTHLY);
2930

3031
assertThat(result.regularPay()).isEqualByComparingTo("8000.00");
3132
assertThat(result.absenceDeduction()).isEqualByComparingTo("0");
@@ -38,7 +39,7 @@ void compute_paysDaysWorkedAndNeverDeductsAbsences() {
3839
@Test
3940
void compute_deductsTardinessAndUndertimeFromEarnedPay() {
4041
PayBasisResult result = strategy.compute(
41-
BigDecimal.valueOf(800), attendance(10, 0, 30, 0), BigDecimal.valueOf(80));
42+
BigDecimal.valueOf(800), attendance(10, 0, 30, 0), BigDecimal.valueOf(80), PayrollFrequency.SEMI_MONTHLY);
4243

4344
assertThat(result.tardinessDeduction()).isEqualByComparingTo("50.00");
4445
assertThat(result.regularPay()).isEqualByComparingTo("7950.00");
@@ -47,7 +48,7 @@ void compute_deductsTardinessAndUndertimeFromEarnedPay() {
4748
@Test
4849
void compute_zeroDaysWorkedYieldsZeroPay() {
4950
PayBasisResult result = strategy.compute(
50-
BigDecimal.valueOf(800), attendance(0, 10, 0, 0), BigDecimal.ZERO);
51+
BigDecimal.valueOf(800), attendance(0, 10, 0, 0), BigDecimal.ZERO, PayrollFrequency.SEMI_MONTHLY);
5152

5253
assertThat(result.regularPay()).isEqualByComparingTo("0.00");
5354
}

src/test/java/com/iodsky/mysweldo/payroll/strategy/HourlyPayBasisStrategyTest.java

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.iodsky.mysweldo.attendance.AttendancePayrollSummary;
44
import com.iodsky.mysweldo.payroll.core.PayrollCalculator;
5+
import com.iodsky.mysweldo.payroll.run.PayrollFrequency;
56
import org.junit.jupiter.api.Test;
67

78
import java.math.BigDecimal;
@@ -25,7 +26,7 @@ private AttendancePayrollSummary attendance(double daysWorked, double absenceDay
2526
@Test
2627
void compute_paysRegularHoursWorked() {
2728
PayBasisResult result = strategy.compute(
28-
BigDecimal.valueOf(150), attendance(10, 0, 0, 0), BigDecimal.valueOf(80));
29+
BigDecimal.valueOf(150), attendance(10, 0, 0, 0), BigDecimal.valueOf(80), PayrollFrequency.SEMI_MONTHLY);
2930

3031
assertThat(result.regularPay()).isEqualByComparingTo("12000.00");
3132
assertThat(result.dailyRate()).isEqualByComparingTo("1200.00");
@@ -37,7 +38,7 @@ void compute_paysRegularHoursWorked() {
3738
@Test
3839
void compute_neverAppliesAttendanceDeductions() {
3940
PayBasisResult result = strategy.compute(
40-
BigDecimal.valueOf(150), attendance(8, 2, 45, 30), BigDecimal.valueOf(60));
41+
BigDecimal.valueOf(150), attendance(8, 2, 45, 30), BigDecimal.valueOf(60), PayrollFrequency.SEMI_MONTHLY);
4142

4243
assertThat(result.absenceDeduction()).isEqualByComparingTo("0");
4344
assertThat(result.tardinessDeduction()).isEqualByComparingTo("0");
@@ -48,7 +49,7 @@ void compute_neverAppliesAttendanceDeductions() {
4849
@Test
4950
void compute_zeroRegularHoursYieldsZeroPay() {
5051
PayBasisResult result = strategy.compute(
51-
BigDecimal.valueOf(150), attendance(0, 0, 0, 0), BigDecimal.ZERO);
52+
BigDecimal.valueOf(150), attendance(0, 0, 0, 0), BigDecimal.ZERO, PayrollFrequency.SEMI_MONTHLY);
5253

5354
assertThat(result.regularPay()).isEqualByComparingTo("0.00");
5455
}

0 commit comments

Comments
 (0)