Payroll Engine
Module path: apps/backend/src/payroll/
Feature branch: 057-payroll-engine
Spec: specs/057-payroll-engine/spec.md
Design authority: docs/design/hr-payroll-attendance.md (§11, Phase B)
Calculates pay for the contracts of one employer entity, from manual inputs (attendance is optional), using registered strategies. Guatemala ships as an uncertified reference package; every other country is custom tier. Close freezes a run in the database, writes provision balances, and stops HR from rewriting the month.
It ships no payslips, bank files, statutory reports or certification (Phase C), and no clocks (058).
Three rules bind everything here:
- The core never branches on country code. Country knowledge lives in
infrastructure/jurisdictions/<code>/and instatutory_parameterrows. - No user-authored formula is evaluated. A pay item is a registered calculation type with plain JSON params.
- A closed run is never mutated. The database refuses it, not only the code.
Architecture
apps/backend/src/payroll/
payroll.module.ts # imports HrModule (directory + access log), Bull queue
payroll-closed-period.module.ts # the one thing HR imports from payroll
domain/
payroll-package.port.ts # the SPI: ConceptStrategy, JurisdictionPayrollPackage, registry token
closed-payroll-period.port.ts # HR's question: does a closed run cover these dates?
approved-attendance.port.ts # owned here, implemented by 058; v1 binds a null adapter
calculate-queue.port.ts # calculation leaves the request through this
*-repository.domain.ts, errors/, decimal.ts, local-date.ts
application/
payable-days.ts # the suspension matrix, applied once
concept-guard.ts # unknown types and formula/sql params refused — on save AND calculate
package-runner.ts # applies a package to one contract; pure
run-membership.ts # who is on a run (FR-005, D21)
calculate-run.service.ts # the job: all-or-nothing, every failure named
payroll-run.service.ts # lifecycle: create, inputs, calculate, approve, close
payroll-period.service.ts, payroll-results.service.ts, payroll-concept.service.ts
infrastructure/
jurisdictions/{gt,custom}/ # the packages; registry.ts is the explicit map
jurisdictions/_sdk/ # golden-case runner and helpers strategies share
bullmq/ # payroll.calculate-run
*.repository.ts, closed-payroll-period.adapter.ts, null-approved-attendance.adapter.ts
interfaces/ # four controllers, DTOs, HTTP error mapping
domain/ and application/ never import infrastructure/ — the application layer injects PAYROLL_PACKAGE_REGISTRY, it does not import the packages (no-country-branching.spec.ts fails otherwise).
Payroll reads HR only through the directory
EmployeeDirectoryPort is payroll's one way into HR. 057 grew it by two reads rather than reaching around it:
getContractsForPeriod(business, entity, from, to)— every contract in force on at least one day, with every suspension, compensation entry and recurring concept overlapping the range. A fixed number of statements whatever the headcount. WithincludeCompensation: falseit reads no pay and writes no access-log entry — that is how run creation builds the input grid.getEmployerEntity(business, id)— the country that picks the package.
The access log is the one other HR import (SensitiveAccessLogService): one log for salaries and for the pay calculated from them. directory-only.spec.ts fails on any other HR import or any query of an HR table.
A run, end to end
open → calculating → review → approved → closed
↘ (failure) open, with last_calculation_error
review → open (an input changed; results discarded)
- Create (
POST /payroll/periods/:id/runs) freezesmembers,exclusions(frequency_mismatch) and the package code and version. A regular run takes contracts of the period's rhythm; an adjustment starts empty and admits a contract with its first input; a statutory-bonus run takes everyone in force; a settlement takes contracts ending in the period. - Inputs (
PUT …/inputs) — days, overtime hours, unpaid absence hours, one-off amounts, as decimal strings.manualandattendancerows of the same type coexist and are summed. - Calculate (
POST …/calculate) returns 202 and queuespayroll.calculate-runkeyed by run id. The job: refuse a re-released package (package_version_changed), resolve parameters as of the period end (a missing required one fails the run), read the directory once, run the package for every member, and either write every result in one transaction and move toreview— or write nothing and return toopennaming every failing contract. - Approve needs
PayrollRunApprove; close needsPayrollRunClose. Close writes provision balances in the same transaction.
Suspension
payable-days.ts is the only place the matrix (specs/057-payroll-engine/contracts/suspension-matrix.md) is applied. Every reason — unpaid leave, disciplinary, medical, other — removes its days from payable days in v1. A fully suspended contract still gets a row, with zeros and fully_suspended = true; omission would look like "they left".
Provision balances
One row per contract, provision type and run — cumulative, insert-only. A close adds the run's provision lines to accrued_amount and the lines its package declares in settlesProvision to paid_amount (an aguinaldo run pays out the aguinaldo accrual). Keyed by run, not period, because a regular run and a bonus run close in the same December.
Packages
A package is an object in infrastructure/jurisdictions/registry.ts: its system concepts, strategy classes, the bases it accumulates (bases) and which of them each concept feeds, which concepts a run type applies, its statutory bonus codes, the parameters it requires, and its deduction cap. GET /payroll/packages serves the bonus codes and bases so the PWA offers them as lists instead of free text. Adding a country is a folder and a registry line — never an if in core.
Guatemala (GT) | Custom (CUSTOM) | |
|---|---|---|
| Salary | gt.ordinario (30-day basis, jornada hours) | custom.ordinario |
| Statutory | IGSS, IRTRA, INTECAP, ISR projection, incentive bonus, four provisions | none |
| Runs | regular, adjustment, aguinaldo, Bono 14, settlement (thin) | regular, adjustment |
| Validated | no — every figure is a fixture | n/a |
Rates are rows. Every Guatemalan figure is a statutory_parameter row with an effective range and a source note that says fixture. no-hardcoded-rates.spec.ts fails if a seeded rate appears as a literal in a strategy.
Merchant items. Any business may register FIXED_AMOUNT, PERCENT_OF_BASE or HOURLY_MULTIPLIER items and attach them to people through HR recurring concepts. A formula or sql key anywhere in the params is refused on save and again on calculate. Package codes (gt.*, custom.*) cannot be redefined. A recurring concept whose code nobody registered fails the run by name.
Base names are a closed list. A PERCENT_OF_BASE base and every contributesToBases entry must be a base some package declares, checked on save; on calculate they must be in the employer's own package, or that contract fails with UnknownPayrollBaseError. Without this, a misspelt base read as zero and a base from another country fed nothing — silently wrong pay.
The module is opt-in. payrollModule is in no business type's defaults. Migration …-payroll-module-opt-in switched off the rows the old default had turned on; rows a business enabled by hand (is_manual_override) were kept.
Golden cases
infrastructure/jurisdictions/gt/golden-cases/*.json — one contract each, directory snapshot inline, every expected line on amount, units, rate and base, and every total. The runner drives the same runContract production does. Figures were worked out by hand before the runner first ran. package-seed-parity.integration.spec.ts holds the fixture parameter file and the package's concept list equal to the seeded rows, so the cases and production cannot drift apart. Cases marked pending-accountant must produce lines and are reported as pending, never as passes.
Closed means closed
- Database. Triggers raise
PY001on any update or delete of a closed run and on any insert, update or delete of its inputs, results or lines; provision balances refuse every update and delete. The repository turnsPY001into a 409. - HR. Compensation, contract start and termination, suspension record and reinstatement, and recurring add and end ask
ClosedPayrollPeriodPortbefore they persist, and refuse with 409closed_payroll_periodnaming the period when the write's dates reach into one with a closed run. HR import applies the same rule. HR importsPayrollClosedPeriodModule, notPayrollModule, so there is no cycle.
Test cleanup deletes closed runs with SET LOCAL session_replication_role = replica, which disables triggers and needs a superuser. The application role in staging and production is not one.
Permissions
| Resource | Granted by | Reveals amounts |
|---|---|---|
PayrollRun (prepare) | administrator bundle | no |
PayrollRunApprove | administrator bundle | Read, audited |
PayrollRunClose | administrator bundle | Read, audited |
administrator and legacy admin share one bundle (payrollAdministration in business.rules.ts); owners reach payroll through the bypass; no other role holds any payroll resource. payroll-route-permissions.spec.ts enumerates every UniqueRoleName, so a new bundle that picks payroll up fails it. The menu shows payroll to the same three roles.
GET /payroll/capabilities tells the PWA what the caller may do — { prepare, approve, close, readAmounts } — so it omits actions rather than showing ones that 403. Build its subject the way RolesGuard does: bundle grants on Create and Update carry createdBy/updatedBy conditions, and a subject without them reports false for every administrator.
The amount routes use @PermissionAnyOf — approve or close — on RolesGuard. A caller who can only prepare gets a 403 before any payroll code runs. Every amount read writes to sensitive_access_log (payroll_employee, payroll_employee_line, payroll_run_snapshot) before the body is produced, and a failed write fails the read. Platform roles (root, admin, super, support) are denied approve and close outright, as 056 denies them compensation. No MCP tool exposes payroll (payroll-exclusion.spec.ts).
Gaps recorded, not closed
- Owners bypass CASL and can use every payroll route. Inherited from the platform; their reads are logged.
- Nothing grants a payroll resource to one user. A payroll clerk who prepares but cannot approve is expressible in the resources but not grantable today.
- Hidden tabs are cosmetic in the PWA; the API is the control.
- The Guatemala package is unvalidated. The UI says so; no text claims IGSS or SAT compliance.
Things that will bite you
- Columns named
quantityare typednumberbyfix-domain-types.ts, whatever table they are in. Payroll's are spelledunitsfor that reason. - Bull refuses a job whose id matches a retained completed job. The calculate queue removes finished jobs; the run's own status is the idempotency that outlives the job.
- A Jest run of one file that boots
HrModulehangs after passing (an open handle). In a multi-file run Jest's worker shutdown ends it.
Tests worth knowing about
Every gate below was seen to fail with its defect planted before it was trusted.
| Test | Gate |
|---|---|
payable-days.spec.ts, suspension-on-run.integration.spec.ts | a suspension-blind engine pays suspended people |
closed-run-immutable.integration.spec.ts | raw SQL against a closed run |
closed-payroll-freeze.integration.spec.ts | HR back-dating into a closed month |
golden-cases.spec.ts | a wrong IGSS rate, a mutated expectation |
concept-guard.spec.ts, custom-concepts.integration.spec.ts | formula/sql params, unknown types, on save and on calculate |
calculate-idempotent.integration.spec.ts | a recalculation that appends |
calculate-statement-count.integration.spec.ts, directory-period.integration.spec.ts | an N+1 |
access-log-before-amounts.integration.spec.ts | a log written after the amounts |
payroll-route-permissions.spec.ts | a prepare-only caller reaching amounts; a missing location skip |
payroll-exclusion.spec.ts, directory-only.spec.ts, no-country-branching.spec.ts, no-hardcoded-rates.spec.ts | the architecture |