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
| Rule | Default | Where |
|---|---|---|
| Pay frequency | SEMI_MONTHLY (also supported: WEEKLY, MONTHLY) | payFrequency |
| Cutoff windows | 1–15 and 16–30 (end days clamp to the month's length) — semi-monthly only | firstCutoff*, secondCutoff* |
| Paydays | 20th (first cutoff) and 5th of the next month (second cutoff), clamped — semi-monthly only | payDateFirstCutoff/SecondCutoff |
| Pay period anchor | none; required for WEEKLY | payPeriodAnchorDate |
| Pay day lag | 5 days after the period ends (weekly, monthly) | payDateLagDays |
| Working days per month | 22 | workingDaysPerMonth |
| Hours per day | 8 | hoursPerDay |
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_rateif set, else derived from salary asbasic_salary × 2 ÷ working_days_per_month ÷ hours_per_day— noteemployee.basic_salarystores 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, elsebasic_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
timesheetPayPolicyisSUBMITTED_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
workStartTimeis treated as a different (e.g. night) shift, not tardiness.
- Unpaid break: with the default
AUTOpolicy, 60 minutes (standardUnpaidBreakDurationMinutes) are deducted from any shift of ≥ 6 hours that has a time-in/out pair.MANUALassumes hours already exclude the break;NONEpays breaks. - Overtime pays only if approved: there must be an
Approvedovertime_requestfor 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 requiresallowOvertimeDuringHoliday(default on), OT during an approved leave requiresallowOvertimeDuringLeave(default off), OT on a work-suspension date requiresallowOvertimeDuringSuspension(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 worked | Multiplier (default) |
|---|---|
| Ordinary day | 1.00 |
| Rest day | 1.30 |
| Special non-working day | 1.30 |
| Special non-working day + rest day | 1.50 |
| Regular holiday | 2.00 |
| Regular holiday + rest day | 2.60 |
| Special working day | 1.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 vianightDiffEnabledHours.
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, defaultALWAYS_PAY):NEVER_PAY,REQUIRE_PAID_LEAVE(a covering approved paid leave), orREQUIRE_PREVIOUS_DAY_ATTENDANCE— worked (or, withpayHolidayIfOnLeave, 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:
| Frequency | Final cutoff when |
|---|---|
MONTHLY | always |
SEMI_MONTHLY | the period ends on or after secondCutoffStartDay |
WEEKLY | the 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.DAILYbrackets are seeded as admin-editable reference data but unreachable from payroll — there is no daily pay frequency.ANNUALis 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'syear_end_tax_adjustmentrecords 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:
| Code | Benefit | Ceiling (default) | Period | Mapped allowance column |
|---|---|---|---|---|
RICE | Rice subsidy | ₱2,500 | monthly | employee.rice_subsidy |
LAUNDRY | Laundry allowance | ₱400 | monthly | — (documented only) |
UNIFORM | Uniform & clothing allowance | ₱8,000 | annual | employee.clothing_allowance |
MEDICAL_CASH | Medical cash allowance to dependents | ₱2,000 | semestral | — (documented only) |
GIFTS | Christmas & anniversary gifts | ₱6,000 | annual | — (documented only) |
PHONE | Phone allowance | ₱3,000 | monthly | employee.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_balancerow is created on first use per (employee, type), initialized to the leave type'smax_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. UnderLEAVE_TAKES_PRIORITY/COMPANY_POLICY, every calendar day in the range is charged. is_paidmatters at payroll time: only approved paid leave satisfies theREQUIRE_PAID_LEAVEholiday-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).