Skip to main content

Business Rules: The PH-Compliance Payroll Engine

The domain rules the payroll engine implements, with the real numbers. Every figure here was verified against the code and seed migrations it cites — chiefly PayrollSettings.java (whose field initializers double as the statutory defaults), PayrollServiceImpl.java, DailyPayCalculator.java, WithholdingTaxCalculator.java and LeaveRequestServiceImpl.java.

Almost everything below is a default, not a constant. The values live in the single-row payroll_settings table (admin-editable under Payroll → Settings, versioned and lockable); the entity's initializers are the fallback when no row exists. A full column dump of that table is checked in at ../payroll_settings.txt. Where a rule is not configurable, this doc says so. A sample of the end product — a generated payslip PDF — is in payslip-sampler/Payslip_EMP-3-202512-77889_LIM_LIMA%20HACK.pdf.

1. Pay schedule

RuleDefaultWhere
Pay frequencySEMI_MONTHLY (also supported: WEEKLY, MONTHLY)payFrequency
Cutoff windows1–15 and 16–30 (end days clamp to the month's length) — semi-monthly onlyfirstCutoff*, secondCutoff*
Paydays20th (first cutoff) and 5th of the next month (second cutoff), clamped — semi-monthly onlypayDateFirstCutoff/SecondCutoff
Pay period anchornone; required for WEEKLYpayPeriodAnchorDate
Pay day lag5 days after the period ends (weekly, monthly)payDateLagDays
Working days per month22workingDaysPerMonth
Hours per day8hoursPerDay

Weekly periods aren't calendar-month aligned, so the day-of-month cutoff windows can't describe them. WEEKLY instead uses payPeriodAnchorDate — the end date of any one real pay period, whose day-of-week fixes the recurring cutoff end (periods repeat every 7 days from it). Saving WEEKLY without an anchor is rejected, and the compliance checklist flags it as well: without it, the month's final cutoff has to be inferred from each period's own length, which silently skips the statutory contribution true-up (§ contribution splitting) when a period runs short or long.

BI_WEEKLY was removed in V93. BIR's Annex E prescribes withholding tables for daily, weekly, semi-monthly and monthly pay only — there is no bi-weekly table, so bi-weekly borrowed the semi-monthly one: 26 periods a year taxed on 24-period brackets, a systematic per-period under-withholding that only the year-end true-up corrected. Any stored BI_WEEKLY was remapped to SEMI_MONTHLY (same two-cutoffs-a-month cadence, same table it was already using). The supported set now maps 1:1 onto its BIR table.

Pay is fundamentally hours-based, not fixed-salary: a payslip's earned pay is the sum of per-day pay over the period's approved timesheets (§2–3). Rates resolve per employee (resolveHourlyRate/resolveMonthlyRate):

  • Hourly rate: employee.hourly_rate if set, else derived from salary as basic_salary × 2 ÷ working_days_per_month ÷ hours_per_day — note employee.basic_salary stores the semi-monthly amount, so ×2 is the monthly salary.
  • Monthly rate (used for statutory contribution brackets and unworked holiday pay): gross_semi_monthly_rate × 2, else basic_salary × 2.
  • An employee with all three rates zero fails payslip generation loudly.

2. Attendance

Implemented in DailyPayCalculator + the timesheet filtering in PayrollServiceImpl.createPayslipForEmployee:

  • Only qualifying timesheets pay. The default timesheetPayPolicy is SUBMITTED_AND_APPROVED: a timesheet must have been submitted and approved to earn anything — and unqualified entries don't count as attendance for holiday-eligibility checks either.
  • Work starts 08:00 (default workStartTime). Clock hours already reduce pay 1:1; on top of that the late policy applies:
    • Lateness within the 15-minute grace period is forgiven — the missed minutes are paid back in full.
    • Lateness beyond grace is charged at the configured rounding granularity (lateDeductionPolicy: per-minute by default; optionally rounded up to 15-minute or 60-minute blocks), so the charge can exceed the actual minutes late.
    • A clock-in a full shift-length after workStartTime is treated as a different (e.g. night) shift, not tardiness.
  • Unpaid break: with the default AUTO policy, 60 minutes (standardUnpaidBreakDurationMinutes) are deducted from any shift of ≥ 6 hours that has a time-in/out pair. MANUAL assumes hours already exclude the break; NONE pays breaks.
  • Overtime pays only if approved: there must be an Approved overtime_request for that exact date, and the approved hours are floored down to the 15-minute increment (minimumOvertimeIncrementMinutes) — e.g. 1.20h (72 min) pays as 1.00h. Additional gates (all settings): OT on non-working holidays requires allowOvertimeDuringHoliday (default on), OT during an approved leave requires allowOvertimeDuringLeave (default off), OT on a work-suspension date requires allowOvertimeDuringSuspension (default off).
  • Night hours are the overlap of the shift with the configured night window (§3); a time-out ≤ time-in is treated as a shift crossing midnight.

3. Premium pay matrix

Defaults follow Labor Code Arts. 86–94. Day types come from the ph_holidays calendar (REGULAR, SPECIAL_NON_WORKING, SPECIAL_WORKING — the latter pays as an ordinary day); rest days are per-employee (employee.rest_day_of_week) or the settings default (Sunday).

Day workedMultiplier (default)
Ordinary day1.00
Rest day1.30
Special non-working day1.30
Special non-working day + rest day1.50
Regular holiday2.00
Regular holiday + rest day2.60
Special working day1.00

On top of the day multiplier:

  • Overtime factor: ×1.25 on ordinary days (overtimeMultiplier); ×1.30 on any premium day — rest day, special day or regular holiday (premiumDayOvertimeMultiplier).
  • Night differential: +10% (nightDiffMultiplier) for hours falling in the 22:00–06:00 window (nightShiftStart/End), toggleable via nightDiffEnabledHours.

The factors stack multiplicatively (DailyPayCalculator.computeDay): each night OT hour pays hourlyRate × dayMultiplier × otFactor × (1 + 0.10). Worked example at ₱100/hour, working overtime into the night of a regular holiday:

₱100 × 2.00 (regular holiday) × 1.30 (premium-day OT) × 1.10 (night diff) = ₱286 → 286%

An ordinary-day night OT hour is 1.00 × 1.25 × 1.10 = 137.5%. The payslip reports the premium over base separately (holiday_premium_pay, rest_day_premium_pay, night_diff_pay) so the breakdown reconciles.

4. Unworked holidays and work suspensions

For each in-period holiday the employee did not work (PayrollServiceImpl §"unworked holidays"):

  • Regular holiday: paid at 100% of the daily rate (Labor Code Art. 94) — default on (payUnworkedRegularHoliday).
  • Special non-working day: statutorily no-work-no-pay — default off (payUnworkedSpecialHoliday); a company may switch it on.
  • The daily rate divisor follows absentDeductionPolicy: actual calendar days of the month (default), a flat 30 (MONTHLY_DIVISOR), or the configured 22 working days (WORKING_DAY).
  • Eligibility (holidayEligibilityPolicy, default ALWAYS_PAY): NEVER_PAY, REQUIRE_PAID_LEAVE (a covering approved paid leave), or REQUIRE_PREVIOUS_DAY_ATTENDANCE — worked (or, with payHolidayIfOnLeave, on approved leave) on the nearest prior ordinary working day, walked within a 7-day lookback; payHolidayIfAbsentPreviousDay (default on) waives the check entirely.
  • When two calendar entries share a date, the regular holiday wins (it carries the higher premium).
  • Work suspensions (work_suspensions, V74) gate overtime pay on the suspended date (§2); they do not themselves generate pay.

Holidays interact with leave via HOLIDAY_TAKES_PRIORITY — see §10.

5. Statutory contributions (SSS, PhilHealth, Pag-IBIG)

All three are monthly obligations resolved from versioned bracket tables by monthly salary + effective date (erd.md §2). Before any payslip is written, validateContributionBracketCoverage verifies every employee has a covering bracket in all three tables and fails the run loudly if not.

SSS — 2026 table (V45)

61 brackets in ₱500 steps of Monthly Salary Credit (MSC), effective 2026-01-01, each storing the share breakdown outright:

  • Regular: employer 10% of MSC, employee 5% of MSC. Regular MSC is capped at ₱20,000.
  • EC (Employees' Compensation, employer-only): ₱10 for MSC below ₱15,000, ₱30 from ₱15,000 up.
  • MPF (Mandatory Provident Fund): applies to the MSC portion above ₱20,000, up to the ₱35,000 total-MSC ceiling — employer 10% / employee 5% of that portion. Top bracket (salary ≥ ₱34,750): EE ₱1,000 regular + ₱750 MPF; ER ₱2,000 regular + ₱30 EC + ₱1,500 MPF.

PhilHealth — 2026 (V12, re-seeded by V50)

5% total premium, split 2.5% employee / 2.5% employer, with fixed brackets at the extremes: salary ≤ ₱10,000 → fixed ₱250/₱250; ₱10,000.01–₱99,999.99 → 2.5% each side; ≥ ₱100,000 → fixed ₱2,500/₱2,500. In the bracket table a stored share < 10 means a percentage, otherwise pesos; percentage results are additionally clamped by the settings floor ₱250 / ceiling ₱2,500 per side.

Pag-IBIG — 2026 (V12)

Salary ≤ ₱1,500 → employee 1% / employer 2%; ₱1,500.01–₱9,999.99 → 2% / 2%; from ₱10,000 the fund-salary cap makes it a fixed ₱200 / ₱200 (also enforced as the settings caps pagibigContributionCap/pagibigEmployerCap).

How a monthly amount lands on per-cutoff payslips

contributionDeductionSchedule, default SPLIT_PER_CUTOFF (resolveContributionDeduction): non-final cutoffs of a month carry an even share (monthly ÷ standard payslips-per-month), and the month's final cutoff carries whatever remains of the true monthly amount (monthly total minus what this employee's earlier payslips that month already carried). The true-up makes the month reconcile exactly even when a month has an unusual cutoff count (e.g. a 5-Friday month under WEEKLY). Alternatives: FULL_EVERY_PAYSLIP and LAST_CUTOFF_OF_MONTH. Employer shares are computed and reconciled the same way and shown informationally on the payslip.

Which cutoff is the month's final one (isFinalCutoffOfMonth) decides where that true-up lands, so it is derived from the configured schedule, not from the individual period:

FrequencyFinal cutoff when
MONTHLYalways
SEMI_MONTHLYthe period ends on or after secondCutoffStartDay
WEEKLYthe next period end on the anchor grid falls in a different month

With no anchor configured, weekly falls back to projecting the period's own length forward — the pre-V90 behaviour, and the reason the anchor matters: a single short or long off-cycle period could otherwise leave a month with no final cutoff at all, so that month's contributions were never trued up. New weekly runs are therefore checked against the schedule at creation and rejected with 422 unless the caller confirms the mismatch (confirmScheduleMismatch), which keeps deliberate off-cycle runs possible. GET /api/payroll/next-period returns the period the schedule calls for.

Each run also snapshots its pay frequency at creation (payroll.pay_frequency, V91), so switching the setting mid-year can't reinterpret an existing run on regeneration or in year-end reporting. Runs created before V91 have no snapshot and fall back to the current setting.

Custom employee deductions and the pay cycle

An EmployeeDeduction carries an amount basis (amount_basis, V92):

  • PER_PAYSLIP (default, and how every pre-V92 row behaves) — the full amount is deducted on every payroll run whose period overlaps the assignment. The figure means "per cutoff", so it must be re-entered if the pay frequency changes.
  • PER_MONTH — the figure is a monthly obligation, split across the month's cutoffs and trued up on the final one, exactly like the statutory contributions above. The month's total stays correct at any pay frequency. Requires the deduction to be recurring: a one-time deduction deactivates after its single application, so the remainder would never be collected.

Each agency's table can be version-pinned (sss_table_version etc.): a pin that parses as a 4-digit year forces that year's cohort; blank means the payroll period end picks the cohort by effective_date.

6. Withholding tax on compensation

Table-driven BIR computation (erd.md §2; V46 + V63):

  • Per-pay-period prescribed tables. Brackets carry a pay_period (DAILY/WEEKLY/SEMI_MONTHLY/MONTHLY/ANNUAL); the calculator uses the table matching the configured pay frequency — a semi-monthly payroll uses the semi-monthly table, never "monthly ÷ 2". Since V93 the mapping is 1:1: every supported pay frequency has its own BIR-prescribed table, so no frequency borrows another's brackets. DAILY brackets are seeded as admin-editable reference data but unreachable from payroll — there is no daily pay frequency. ANNUAL is used only by the year-end adjustment.
  • Formula: tax = base_tax + rate_on_excess% × (taxable_income − taxable_income_from), on the bracket containing the income.
  • Two TRAIN cohorts are seeded: effective 2018-01-01 (TRAIN phase 1, 20/25/30/32/35% tiers) and 2023-01-01 (phase 2, 15/20/25/30/35% — still current in 2026). Historical payroll periods resolve to the correct cohort by date.
  • Taxable income per cutoff = gross income + taxable benefit excess (§8) − the employee-share SSS/PhilHealth/Pag-IBIG actually deducted on that payslip.
  • Year-end true-up (RR 11-2018) — default on (yearEndTaxAdjustmentEnabled): on the year's final cutoff of December, withholding is recomputed from the annual table on the full year's taxable compensation, minus everything withheld so far; the payslip's year_end_tax_adjustment records the delta. A negative delta is a refund of over-withheld tax through the payslip.
  • BMBE exemption: if the company profile marks the employer as a BMBE-registered income-tax-exempt entity, withholding is zero — but taxable income is still computed and reported on the forms.

Downstream, payslip data feeds the BIR 1601-C monthly remittance — field by field in reference/compliance/bir-1601c.md — plus the alphalist/2316 aggregates in the reporting services.

7. 13th month pay and bonuses

The TRAIN-law ₱90,000 annual non-taxable cap (thirteenthMonthNonTaxableCap, configurable) applies to 13th-month pay and other benefits. When a bonus is entered, BonusServiceImpl.getTaxPreview splits it against the cap: "used cap" is the employee's other bonus rows in the same calendar year (the same basis the year-end alphalist/2316 use), the amount up to the remaining cap is non-taxable, the excess is taxable. The DOLE 13th-month report derives the entitlement from payslip history (PayslipRepository.findThirteenthMonthBaseForAllEmployees).

8. De minimis benefits (V57, RR 29-2025 ceilings)

Employee allowances are paid in full, but only the portion within the ceiling is tax-exempt; the excess over the (monthly-prorated) ceiling becomes taxable compensation (calculateTaxableBenefitExcess). Ceilings are rows in de_minimis_benefit_types — admin-editable, seeded as:

CodeBenefitCeiling (default)PeriodMapped allowance column
RICERice subsidy₱2,500monthlyemployee.rice_subsidy
LAUNDRYLaundry allowance₱400monthly— (documented only)
UNIFORMUniform & clothing allowance₱8,000annualemployee.clothing_allowance
MEDICAL_CASHMedical cash allowance to dependents₱2,000semestral— (documented only)
GIFTSChristmas & anniversary gifts₱6,000annual— (documented only)
PHONEPhone allowance₱3,000monthlyemployee.phone_allowance

Only the three mapped rows drive computation (the unmapped rows document statutory ceilings for benefits the system doesn't itemize). PHONE is flagged non-statutory — a phone allowance is company policy, not a BIR de-minimis item; its generous default ceiling keeps typical amounts non-taxable.

The ceilings cover a calendar month, not a payslip, so they are applied against the month's running total: each payslip is taxed only on the share of its allowance that pushes the month past the ceiling. Testing each payslip independently would grant the whole ceiling on every cutoff — twice a month under semi-monthly pay and four or five times under weekly.

9. Expanded withholding tax (EWT)

A registry separate from compensation withholding, for income payments to suppliers/payees (EwtServiceImpl): each transaction records payee, period month, ATC code (from the transaction or the payee's default), rate, and tax withheld — computed as income_payment × rate ÷ 100 when not entered explicitly. The registry feeds the BIR forms in EwtFormsServiceImpl: 0619-E (monthly), 1601-EQ (quarterly), and 2307 certificates (per payee per quarter, grouped by ATC). Related compensation-side form: reference/compliance/bir-1601c.md.

10. Leave

From LeaveRequestServiceImpl:

  • Balances are credit pools, not accruals. A leave_balance row is created on first use per (employee, type), initialized to the leave type's max_credits. There is no accrual scheduler — balances only go down, until an admin adjusts them.
  • Insufficient balance is rejected twice: at request creation and again at approval (the balance may have been consumed in between). Both throw a 400 with the leave type named.
  • The balance decrements on approval, not on request.
  • Chargeable days: under the default holidayLeaveInteractionPolicy = HOLIDAY_TAKES_PRIORITY, holidays inside the requested range don't consume balance — the employee gets holiday pay for those dates regardless of the leave. Under LEAVE_TAKES_PRIORITY / COMPANY_POLICY, every calendar day in the range is charged.
  • is_paid matters at payroll time: only approved paid leave satisfies the REQUIRE_PAID_LEAVE holiday-eligibility policy (§4), and leave days do not generate hours-based pay by themselves.

11. Rounding

MoneyRounder applies the configured defaultRoundingMode to monetary payslip amounts — default NEAREST_CENTAVO (half-up to 2 decimals); alternatives: NEAREST_PESO, NEAREST_QUARTER (₱0.25), NEAREST_HALF (₱0.50), and BANKERS (half-even). Intermediate math runs at 4-decimal scale and is only rounded at the payslip-amount boundary.

12. The payroll lifecycle

Statuses on the payroll run: Draft → Pending → Approved/Rejected → Processed (with Cancelled available before Processed). Generation is once-only per run; corrections mean delete/cancel and regenerate (backend/payroll-regeneration.md). The payslip PDF is rendered client-side from the payslip data plus the payslip_template_settings branding (generatePayslipPdf.ts).