Payroll Architecture
Status: Payroll-E1 + Payroll-P1 LIVE. Phases P2–P10 PLANNED. See the §Honest Status section at the bottom.
Payroll-P1 — Salary Structure Domain (LIVE)
Aggregates
SalaryComponent (catalog: "what kinds of pay elements exist")
│
▼
SalaryStructure (template: "L3 Engineer = BASE + POSITION + TRANSPORT")
│
▼
EmployeeCompensation (assignment: "emp-42 is on L3 effective 2025-07")
│
▼
ComputeEmployeeEarnings (materialiser: produces gross / taxable /
pensionable breakdown per period)
Invariants
SalaryComponent.codeunique per tenant; uppercase identifier; immutable post-ACTIVE.SalaryStructuremust contain at least one BASE component before it can be ACTIVATED.EmployeeCompensation: no two ACTIVE assignments overlap for the same employee.- Override references must point at a
componentIdactually present in the structure. - All three aggregates: no mutation post-ACTIVE except
retire(). - Money is bigint santim end-to-end.
PERCENTAGE_OF_BASEresolves against the structure's BASE line, not the employee's resolved gross (avoids circular dependency).
Lifecycle
DRAFT ──activate──▶ ACTIVE ──retire──▶ RETIRED
Same shape as TaxRule + PensionRule. RETIRED rows are preserved — historical payroll runs that referenced them continue to resolve correctly via the snapshot pointers.
Calculator integration
When EmployeeSalaryAdapter resolves an ACTIVE EmployeeCompensation for the employee on the payroll run's paymentDate, it forwards the materialised breakdown:
| Calculator input | Source |
|---|---|
taxableGrossSantim | Σ amounts of components flagged taxable: true |
pensionableGrossSantim | Σ amounts of components flagged pensionable: true |
nonTaxableGrossSantim | Σ amounts of components flagged taxable: false |
components | full per-line decomposition for audit |
CalculatePayrollRun uses taxableGrossSantim as the tax bracket input and pensionableGrossSantim as the pension contribution base. When the breakdown is null (legacy path: no compensation assigned), the calculator falls back to gross-as-base for both — which OVERSTATES PIT and MISCLASSIFIES non-taxable allowances. That fallback is documented as DANGEROUS and remains only while employers still use the legacy Employee.baseSalary column.
Persistence
| Table | Tenant scope | RLS |
|---|---|---|
payroll_salary_component | yes (or 'GLOBAL') | yes |
payroll_salary_structure | yes | yes |
payroll_salary_structure_line_item | inherits via FK cascade | inherits |
payroll_employee_compensation | yes | yes |
payroll_employee_compensation_override | inherits via FK cascade | inherits |
The RLS policy on payroll_salary_component allows both the tenant's own rows and 'GLOBAL' rows so the resolver sees platform defaults without needing BYPASSRLS.
Honest status — P1
| Capability | Status | Notes |
|---|---|---|
| 3 aggregates with state machines + invariants | LIVE | 11 specs, 28 P1 tests |
| ComputeEmployeeEarnings (FIXED + PERCENTAGE_OF_BASE) | LIVE | |
| Calculator integration (taxable / pensionable bases) | LIVE | calculate-with-compensation.spec.ts |
| Override waterfall (employee → structure-line → component default) | LIVE | |
| Per-component breakdown to calculator | LIVE | |
| Prisma persistence (5 tables + 2 enums + RLS) | LIVE in code; MIGRATION NOT YET APPLIED to any environment | migration ships in 20260529500000_payroll_compensation |
| FORMULA amount kind | STUB | aggregate stores the spec; evaluator throws → P2 |
| Outbox events for compensation lifecycle | PARTIAL | use cases bound but apps/api outbox emission for component/structure/compensation events NOT YET wired (Payroll-P5) |
| Reviewer-vs-approver separation for component/structure changes | STUB | P9 (RBAC) |
| HTTP routes for component / structure / compensation CRUD | PLANNED | use cases bound in DI; routes not exposed (P9 / API hardening) |
| Per-period override (one-off bonus for a single payslip) | PLANNED | P2 / OneTimeEarning aggregate |
| Net-to-gross simulation | PLANNED | P2 |
| Pension subtracted from taxable base | PLANNED | P2 — the breakdown fields P1 produces are the enabling input |
| Overtime multipliers (×1.25 / ×1.5 / ×2) | PLANNED | P2 |
What payroll is in DemozPay
Payroll is the source of truth for every salary-derived flow: gross salary, statutory deductions (tax + pension), employer deductions (EWA repayment, loan installments, attendance, court orders), net pay to the employee, and the employer-side liability for pension + tax remittance.
EWA and lending repayment automation depend on payroll. Without payroll
firing payroll.deductions_taken.v1 at approve time, those products
cannot settle their own outstanding rows — they remain manually
admin-triggered today.
Package layout
packages/payroll/backend/
domain/
pay-period.ts value object
deduction.ts value object
payroll-entry.ts per-employee line; lives inside PayrollRun
payroll-run.ts aggregate root
payroll-run-status.ts state machine
rules/
effective-date-range.ts
tax-bracket.ts
tax-rule.ts aggregate
pension-rule.ts aggregate
rule-status.ts DRAFT|ACTIVE|RETIRED
index.ts
events.ts outbox event schemas
errors.ts
application/
ports/
clock.port.ts
payroll-run.repository.ts
employee-salary.port.ts
deductions.port.ts EWA + lending outstanding lookup
disbursement.port.ts gateway bridge
outbox.port.ts
tax-rule.repository.ts NEW Payroll-E1
pension-rule.repository.ts NEW Payroll-E1
create-payroll-run.usecase.ts
calculate-payroll-run.usecase.ts integrates tax + pension Payroll-E1
approve-payroll-run.usecase.ts emits deductions_taken.v1 per entry
disburse-payroll-run.usecase.ts per-entry gateway submit, resumable
cancel-payroll-run.usecase.ts
create-tax-rule.usecase.ts NEW
activate-tax-rule.usecase.ts NEW (overlap guard)
create-pension-rule.usecase.ts NEW
activate-pension-rule.usecase.ts NEW
infrastructure/
system-clock.ts
in-memory-payroll-run.repository.ts
in-memory-tax-rule.repository.ts NEW
in-memory-pension-rule.repository.ts NEW
presentation/
tokens.ts DI symbols
payroll.module.ts .register() factory
payroll.controller.ts HTTP routes
dto/
apps/api/src/payroll/
prisma-payroll-run.repository.ts
prisma-tax-rule.repository.ts NEW
prisma-pension-rule.repository.ts NEW
employee-salary.adapter.ts reads Employee table
deductions.adapter.ts reads EWA + lending outstanding
payroll-disbursement.adapter.ts reuses EWA's IntegrationGateway
payroll-outbox.adapter.ts
payroll-api.module.ts
Aggregate boundaries
| Aggregate | Owns | Mutated by |
|---|---|---|
PayrollRun | PayrollEntry[], applied rule snapshot, status | use cases only |
PayrollEntry | gross / net / deductions / disbursement status | always via PayrollRun |
TaxRule | bracket schedule, lifecycle, effective range | Create/Activate/Retire use cases |
PensionRule | contribution rates, lifecycle, effective range | Create/Activate/Retire use cases |
PayrollEntry is intentionally NOT a top-level aggregate. Net-vs-gross
balance, deduction sums, and lifecycle transitions all need to commit
together with the parent run.
Lifecycle (Payroll-E1)
┌─── recalculate ──┐
▼ │
DRAFT ──calculate──▶ CALCULATED ──approve──▶ APPROVED ──disburse──▶ DISBURSING ──submit──▶ DISBURSED ──settle──▶ COMPLETED
│ │
└──cancel──▶ CANCELLED ┘
APPROVED is the recognition boundary. payroll.deductions_taken.v1
fires here, one event per entry. Once APPROVED, deductions are
considered settled by downstream consumers — the run can no longer be
recalculated.
LOCKED (an immutability boundary stronger than APPROVED) and
PayrollAdjustment (the append-only correction aggregate) land in
Payroll-E2.
Rule engine (the heart of Payroll-E1)
Two rule types, identical lifecycle
DRAFT ──activate──▶ ACTIVE ──retire──▶ RETIRED
TaxRule— versioned progressive bracket schedulePensionRule— versioned employee + employer contribution rates
Both are tenant-scoped with a 'GLOBAL' sentinel tenantId for
platform-default rules. The resolver prefers a tenant-specific ACTIVE
rule and falls back to GLOBAL.
Effective dating
Each rule carries a half-open [effectiveFrom, effectiveUntil) range.
effectiveUntil = null means open-ended.
A payment dated exactly at effectiveUntil resolves to the next
rule version, not this one. Reviewers must set the upper bound of a
retiring rule to the lower bound of its successor — the two together
form a contiguous timeline with no gaps and no overlap.
Activating a rule whose range overlaps another ACTIVE rule for the
same tenant is refused (see ActivateTaxRuleUseCase).
Snapshot (the historical replay invariant)
When CalculatePayrollRun runs:
- Resolve the ACTIVE
TaxRuleforrun.period.paymentDate. - Resolve the ACTIVE
PensionRulefor the same date. - Compute tax + employee pension per entry.
- Persist
appliedTaxRuleId+appliedPensionRuleIdon the run. - Persist the entries.
Historical replay reads the snapshot rather than the
currently-ACTIVE rule. A 2026 bracket change therefore CANNOT mutate a
January 2025 payroll. Verified by
calculate-with-rules.spec.ts → historical replay.
Calculator order of operations (pilot tier)
For each employee:
- Fetch gross from
EmployeeSalaryPort. - Collect ad-hoc deductions from
DeductionsPort(EWA + loan installments + employer rows). - Compute employee pension at
pensionRule.compute(gross). - Compute personal income tax at
taxRule.computeTax(gross). - Build the entry. Net = gross − Σdeductions. Net-negative refuses.
Pilot simplification — flagged in ETHIOPIAN_TAX_ENGINE.md: the
taxable base is the full gross. Ethiopian PIT subtracts the employee
pension contribution from the taxable base; that nesting lands in
Payroll-E2 alongside basic-salary vs allowance modelling.
Cross-domain (ADR-011)
The payroll package does NOT import @demoz-pay/ewa or
@demoz-pay/lending. It owns two ports:
EmployeeSalaryPort— gross salary lookup (apps/api binds → Employee table)DeductionsPort— outstanding deductions (apps/api binds → EWA + lending tables)
EWA + lending subscribe (Planned, Payroll-E3) to
payroll.deductions_taken.v1 to recognise their share of the
deduction.
Persistence
| Table | Tenant scope | RLS | Notes |
|---|---|---|---|
payroll_run | yes | yes | + 2 new snapshot columns: appliedTaxRuleId, appliedPensionRuleId |
payroll_run_entry | inherited via FK | inherited | unchanged |
payroll_tax_rule | yes (or 'GLOBAL') | yes | new in Payroll-E1; brackets as JSONB |
payroll_pension_rule | yes (or 'GLOBAL') | yes | new in Payroll-E1 |
The RLS policy on the two rule tables allows BOTH the tenant's own
rules AND 'GLOBAL' rows; the resolver consequently sees the GLOBAL
fallback without needing BYPASSRLS.
Money is always bigint santim. No floats. No Decimal(15,2). The
legacy Payroll + PayrollEntry Prisma models from the platform's
earliest schema remain in place as Deprecated until a cut-over
migration drops them.
Honest status
| Item | Status | Source of evidence |
|---|---|---|
| Effective-dated tax + pension rule registry | LIVE | tax-rule.spec.ts, pension-rule.spec.ts, calculate-with-rules.spec.ts |
| Historical replay determinism | LIVE | calculate-with-rules.spec.ts → historical replay |
| Per-entry tax + pension deductions | LIVE | same |
| Overlap-rejection on rule activation | LIVE | calculate-with-rules.spec.ts → overlaps ACTIVE rule |
| GLOBAL fallback | LIVE | calculate-with-rules.spec.ts → falls through to GLOBAL |
| Pilot Ethiopian seed (FITP 979/2016 + Pension 715/2011) | LIVE as DATA | 20260529400000_payroll_tax_pension_rules/migration.sql |
| HTTP routes for rule CRUD | NOT YET (use cases bound; controller routes pending Payroll-E2) | — |
LOCKED immutable state + corrections aggregate | PLANNED (Payroll-E2) | — |
EWA + lending consumers of payroll.deductions_taken.v1 | PLANNED (Payroll-E3) | — |
| Bank batch ID + reconciliation hooks | PLANNED (Payroll-E4) | — |
| Payslip generation + reports + exports | PLANNED (Payroll-E5) | — |
| Reviewer-vs-approver separation of duties | PLANNED (Payroll-E2) | — |
| Net-to-gross simulation | PLANNED (Payroll-E2) | — |
| Pension deducted from taxable base | PLANNED (Payroll-E2) | — |
| Basic-salary vs allowance separation | PLANNED (Payroll-E2) | — |
Payroll.deductions_taken.v1 consumer offset tracking, DLQ, replay | PLANNED (Payroll-E3) | — |
The rule engine is the foundation; everything above it depends on it landing first, which it has.