Payroll API
The largest module: payroll runs and payslips, the settings singleton, the versioned statutory rate tables, and PH-compliance outputs. The business rules these endpoints implement (premium matrix, contribution math, tax true-up): ../business-rules.md. Regenerating payroll after a rate fix: ../backend/payroll-regeneration.md. Conventions and Swagger: conventions.md, README.md.
Payroll runs — /api/payroll
| Method | Path | Permission | Purpose |
|---|---|---|---|
| GET | /api/payroll | payroll.dashboard.view or payroll.manage or payroll.approve | Paged/filtered runs |
| GET | /api/payroll/{id} | same three | One run (with approvals) |
| POST | /api/payroll | payroll.manage | Create a run (payrollRunDate, periodStartDate, periodEndDate) |
| POST | /api/payroll/{id}/generate-payslips | payroll.manage | Run the engine for every active employee |
| PATCH | /api/payroll/{id}/decision | payroll.approve | Approve / reject a pending run |
| PATCH | /api/payroll/{id}/status | payroll.manage | Mark processed / cancel |
| DELETE | /api/payroll/{id} | payroll.manage | Delete a run (body requires a reason) |
Status lifecycle (verified in PayrollServiceImpl.java):
Draft ──generate-payslips──▶ Pending ──decision──▶ Approved ──status──▶ Processed
└────────▶ Rejected
Any non-Processed run ──status {"status":"Cancelled","reason":"..."}──▶ Cancelled
Guard rails, all 400s: creation rejects an end date before the start date and
any period overlapping an existing non-Cancelled/Rejected run; decision
accepts only Approved/Rejected and only from Pending; Processed is
reachable only from Approved; cancelling requires a reason and is refused
for Processed; deletion is refused for Processed runs and for any run that
already has payslips ("cancel it instead"). Cancellations are written to the
audit log; every status change lands in payroll-changes.
Deep-dive: POST /api/payroll/{id}/generate-payslips
No request body. Preconditions: the run must be in Draft and have no
payslips yet. The engine then loads every non-archived, non-INACTIVE
employee and computes a payslip per employee — attendance, holiday/suspension
premiums, de minimis, statutory contributions, withholding tax, and any
applicable custom deductions — then flips the run to Pending and logs a
history row per payslip. Response: 200 with the updated PayrollDto.
The loud contribution-coverage failure: before a single payslip is written, the engine verifies that every employee's monthly rate is covered by an SSS, PhilHealth, and Pag-IBIG bracket in the rate-table version that applies to the period. Any gap aborts the entire run — nothing partial is saved — with a message that lists every failing employee and table:
Cannot generate payslips: 10012 (Ana Reyes): no SSS contribution bracket covers monthly salary 250000.00; 10047 (Ben Cruz): no Pag-IBIG contribution bracket covers monthly salary 180000.00
This (like the "already generated" and "no active employees" guards) is thrown
as IllegalStateException, which has no dedicated handler in
GlobalControllerAdvice — so it surfaces as HTTP 500 with the full
message, not a 400. Fix the rate table (see
rate tables below) and re-run.
Payslips — /api/payslips, history — /api/payslip-history
PayslipController.java, PayslipHistoryController.java
| Method | Path | Permission | Purpose |
|---|---|---|---|
| GET | /api/payslips/me | employee.payslip.view | Own payslips |
| GET | /api/payslips/me/years | employee.payslip.view | Years with payslips (for the picker) |
| GET | /api/payslips/me/ytd | employee.payslip.view | Own year-to-date totals |
| GET | /api/payslips/me/{id} | employee.payslip.view | Own single payslip |
| GET | /api/payslips | payroll.payslip.view or payroll.manage or payroll.approve | Paged/filtered list |
| GET | /api/payslips/{id} | same three | One payslip |
| GET | /api/payslip-history?payslipId= | same three | Change history of one payslip |
Payroll transactions — /api/payroll-transactions
PayrollTransactionController.java — ad-hoc pay adjustments outside the engine.
| Method | Path | Permission |
|---|---|---|
GET /, GET /types, GET /{id} | /api/payroll-transactions... | payroll.transactions.view or payroll.transactions.manage |
POST, PUT /{id}, DELETE /{id} | /api/payroll-transactions... | payroll.transactions.manage |
Payroll changes — /api/payroll-changes
PayrollChangesController.java — audit trail of run-level field changes.
| Method | Path | Permission |
|---|---|---|
GET /api/payroll-changes?payrollId= | payroll.dashboard.view or payroll.manage or payroll.approve |
Payroll settings — /api/payroll-settings (+ changes, template)
PayrollSettingsController.java, PayrollSettingsChangesController.java, PayslipTemplateSettingsController.java
| Method | Path | Permission | Purpose |
|---|---|---|---|
| GET | /api/payroll-settings | payroll.settings.manage or payroll.manage or payroll.ewt.manage or hr.employees.manage | The settings singleton |
| GET | /api/payroll-settings/employee-view | any authenticated user | Employee-safe subset (pay schedule + payslip display) |
| GET | /api/payroll-settings/compliance-checklist | payroll.settings.manage | Automated compliance checks over current settings |
| PUT | /api/payroll-settings | payroll.settings.manage | Replace the singleton |
| GET | /api/payroll-settings-changes | payroll.settings.manage | Field-level change history |
| GET | /api/payslip-template-settings | any authenticated user | Payslip PDF template preferences |
| PUT | /api/payslip-template-settings | payroll.settings.manage | Update template preferences |
Deep-dive: GET / PUT /api/payroll-settings
The settings object is a singleton — one row that parameterizes the whole
engine. The DTO (~60 fields; see PayrollSettingsDto in Swagger for all of
them) groups into: working-time basics (workingDaysPerMonth, hoursPerDay,
workStartTime…), premium multipliers (overtimeMultiplier,
nightDiffMultiplier, regularHolidayMultiplier, specialDayMultiplier,
rest-day combinations…), night-shift window, statutory caps/floors
(philhealthPremiumFloor/Ceiling, pagibigContributionCap,
thirteenthMonthNonTaxableCap…), pay schedule (payFrequency, cutoff and
pay-date days), rate-table version pins (sssTableVersion etc. — see rate
tables below), display text, and feature switches such as
customDeductionsEnabled (a master switch that makes payslip generation skip
all employee deductions without touching the
assignments).
PUT takes the full DTO back (fetch, modify, send — it is not a PATCH)
and requires payroll.settings.manage. Every field change is diffed and
recorded, retrievable via GET /api/payroll-settings-changes. Settings are
read at generation time: changing them affects the next
generate-payslips, never already-generated payslips (see the
regeneration runbook).
Bonuses — /api/bonuses
BonusController.java
— class-level payroll.bonus.manage on every endpoint: GET /,
GET /tax-preview, GET /{id}, POST, PUT /{id}, DELETE /{id}.
/tax-preview computes the tax impact of a prospective bonus before saving.
Deductions & de minimis
DeductionTypeController.java, EmployeeDeductionController.java, DeMinimisBenefitTypeController.java
| Prefix | Endpoints | Permission |
|---|---|---|
/api/deduction-types | GET, POST, PUT /{id}, DELETE /{id} | class-level payroll.deduction.manage |
/api/employee-deductions | GET (paged/filtered), GET /{id}, POST, PUT /{id}, DELETE /{id} | class-level payroll.deduction.manage |
/api/de-minimis-benefit-types | GET | payroll.settings.manage or payroll.manage |
/api/de-minimis-benefit-types | POST, PUT /{id}, DELETE /{id} | payroll.settings.manage |
Employee deductions are per-employee recurring assignments (type + amount +
active window) applied during payslip generation when they overlap the pay
period — and only while customDeductionsEnabled is on.
Reimbursements
ReimbursementRequestController.java, ReimbursementTransactionController.java
| Method | Path | Permission | Purpose |
|---|---|---|---|
| GET | /api/reimbursement-requests/me | employee.reimbursement.request | Own requests |
POST / PUT /{id} / DELETE /{id} | /api/reimbursement-requests... | employee.reimbursement.request | File / edit / cancel own |
GET, GET /{id} | /api/reimbursement-requests... | payroll.reimbursement.manage | Paged list / one request |
| PATCH | /api/reimbursement-requests/{id}/decision | payroll.reimbursement.manage | Approve / reject |
GET, GET /payment-methods, GET /{id} | /api/reimbursement-transactions... | payroll.reimbursement.transactions.view or ...manage | Payout records |
POST, PUT /{id}, DELETE /{id} | /api/reimbursement-transactions... | payroll.reimbursement.transactions.manage | Record / edit payouts |
Statutory rate tables (versioned cohorts)
Four controllers with an identical surface — only the bracket row shape and permission differ:
| Prefix | Permission (class-level, all endpoints) |
|---|---|
/api/sss-contribution-rates | payroll.sss.rates.manage |
/api/philhealth-contribution-rates | payroll.philhealth.rates.manage |
/api/pagibig-contribution-rates | payroll.pagibig.rates.manage |
/api/withholding-tax-brackets | payroll.withholdingtax.rates.manage |
Each exposes: GET / (all rows), GET /{id}, POST, PUT /{id},
DELETE /{id}, GET /effective-dates, POST /cohorts/duplicate,
PUT /cohorts.
Cohort semantics: every bracket row carries an effectiveDate; all rows
sharing one date form a cohort — one immutable-in-spirit version of the
official table. At generation time the engine resolves the cohort in effect
for the pay period (a version can also be pinned in
payroll settings), falling back to
the earliest cohort for dates before the first version. So a yearly rate
change is: duplicate the current cohort to the new date, then edit rows.
GET /effective-dates— the list of cohort dates (version picker).POST /cohorts/duplicate—{"sourceEffectiveDate":"2025-01-01","targetEffectiveDate":"2026-01-01"}copies every bracket to the new date in one transaction, so a new version starts from full coverage.PUT /cohorts—{"effectiveDate":"2026-01-01","rows":[...]}atomically replaces the entire cohort (used by CSV import; a partial import can never leave coverage gaps).
Deep-dive: POST /api/sss-contribution-rates
One bracket row in the official SSS breakdown shape (SssContributionRateRequest.java — every field required, amounts ≥ 0):
{
"salaryBracketFrom": 20250.00,
"salaryBracketTo": 20749.99,
"mscRegular": 20000.00,
"mscMpf": 500.00,
"regularEmployerShare": 1900.00,
"regularEmployeeShare": 900.00,
"ecEmployerShare": 30.00,
"mpfEmployerShare": 47.50,
"mpfEmployeeShare": 22.50,
"effectiveDate": "2025-01-01"
}
Response 200 with the saved row (adds id). An employee whose monthly rate
falls in [salaryBracketFrom, salaryBracketTo] under the resolved cohort gets
these shares; leave no salary gaps between rows or generation will fail loudly
(see above).
Government forms, filings, EWT, TIN compliance
GovernmentFormsController.java, GovernmentFilingController.java, EwtController.java, EwtFormsController.java, TinComplianceController.java
| Method | Path | Permission | Purpose |
|---|---|---|---|
| GET | /api/government-forms/bir-2316, /bir-1601c, /alphalist-dat, /thirteenth-month, /remittance, /dole-thirteenth-month, /payroll-register, /dtr | hr.employees.view or payroll.manage | Generated statutory forms/exports (1601-C field mapping: ../reference/compliance/bir-1601c.md) |
| GET | /api/government-filings | hr.employees.view or payroll.manage | Filing log |
| POST | /api/government-filings (multipart) | payroll.manage | Record a filing + upload the receipt file |
| GET | /api/government-filings/{id}/receipt | hr.employees.view or payroll.manage | Download the receipt |
| DELETE | /api/government-filings/{id} | payroll.manage | Remove a filing record |
| GET/POST/PUT/DELETE | /api/ewt-payees, /api/ewt-transactions | class-level payroll.ewt.manage | Expanded-withholding-tax registry |
| GET | /api/government-forms/ewt/0619e, /1601eq, /2307, /2307/payees | class-level payroll.ewt.manage | EWT return forms |
| GET/POST/PUT/DELETE | /api/tin-compliance (paged GET, /{id}) | class-level payroll.tin.compliance.manage | TIN validation worklist |
Holidays — /api/holidays
| Method | Path | Permission |
|---|---|---|
| GET | /api/holidays | payroll.holidays.manage or payroll.manage or employee.holidays.view |
POST, PUT /{id}, DELETE /{id} | /api/holidays... | payroll.holidays.manage |
| POST | /api/holidays/generate | payroll.holidays.manage — seed a year's PH holiday calendar |
When a regular holiday and a special day fall on the same date, the engine pays the regular-holiday premium (the higher one).
Company profile — /api/company-profile
CompanyProfileController.java:
GET (any authenticated user), PUT (payroll.manage). Holds employer identity
for forms plus flags like BMBE income-tax exemption, which the engine reads at
generation time.
Reports — /api/reports
ReportingController.java — read-only aggregates for dashboards; all GET:
| Path | Permission |
|---|---|
/api/reports/payroll-monthly | payroll.dashboard.view or hr.employees.view |
/api/reports/headcount-by-department, /bir-alphalist | hr.employees.view |
/api/reports/year-end-tax-adjustment?year= | hr.employees.view or payroll.manage |
/api/reports/leave-by-status | hr.leave.requests.view |
/api/reports/overtime-monthly | hr.overtime.requests.view or payroll.dashboard.view |
/api/reports/reimbursement-by-status | payroll.reimbursement.manage |
/api/reports/recruitment-summary | hr.recruitment.view |
/api/reports/hr-analytics/* (summary, workforce, attendance-monthly, headcount-movement, leave, leave-monthly, overtime-by-department, drilldown) | hr.dashboard.view |