Skip to main content

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.code unique per tenant; uppercase identifier; immutable post-ACTIVE.
  • SalaryStructure must 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 componentId actually present in the structure.
  • All three aggregates: no mutation post-ACTIVE except retire().
  • Money is bigint santim end-to-end. PERCENTAGE_OF_BASE resolves 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 inputSource
taxableGrossSantimΣ amounts of components flagged taxable: true
pensionableGrossSantimΣ amounts of components flagged pensionable: true
nonTaxableGrossSantimΣ amounts of components flagged taxable: false
componentsfull 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

TableTenant scopeRLS
payroll_salary_componentyes (or 'GLOBAL')yes
payroll_salary_structureyesyes
payroll_salary_structure_line_iteminherits via FK cascadeinherits
payroll_employee_compensationyesyes
payroll_employee_compensation_overrideinherits via FK cascadeinherits

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

CapabilityStatusNotes
3 aggregates with state machines + invariantsLIVE11 specs, 28 P1 tests
ComputeEmployeeEarnings (FIXED + PERCENTAGE_OF_BASE)LIVE
Calculator integration (taxable / pensionable bases)LIVEcalculate-with-compensation.spec.ts
Override waterfall (employee → structure-line → component default)LIVE
Per-component breakdown to calculatorLIVE
Prisma persistence (5 tables + 2 enums + RLS)LIVE in code; MIGRATION NOT YET APPLIED to any environmentmigration ships in 20260529500000_payroll_compensation
FORMULA amount kindSTUBaggregate stores the spec; evaluator throws → P2
Outbox events for compensation lifecyclePARTIALuse cases bound but apps/api outbox emission for component/structure/compensation events NOT YET wired (Payroll-P5)
Reviewer-vs-approver separation for component/structure changesSTUBP9 (RBAC)
HTTP routes for component / structure / compensation CRUDPLANNEDuse cases bound in DI; routes not exposed (P9 / API hardening)
Per-period override (one-off bonus for a single payslip)PLANNEDP2 / OneTimeEarning aggregate
Net-to-gross simulationPLANNEDP2
Pension subtracted from taxable basePLANNEDP2 — the breakdown fields P1 produces are the enabling input
Overtime multipliers (×1.25 / ×1.5 / ×2)PLANNEDP2

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

AggregateOwnsMutated by
PayrollRunPayrollEntry[], applied rule snapshot, statususe cases only
PayrollEntrygross / net / deductions / disbursement statusalways via PayrollRun
TaxRulebracket schedule, lifecycle, effective rangeCreate/Activate/Retire use cases
PensionRulecontribution rates, lifecycle, effective rangeCreate/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 schedule
  • PensionRule — 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:

  1. Resolve the ACTIVE TaxRule for run.period.paymentDate.
  2. Resolve the ACTIVE PensionRule for the same date.
  3. Compute tax + employee pension per entry.
  4. Persist appliedTaxRuleId + appliedPensionRuleId on the run.
  5. 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:

  1. Fetch gross from EmployeeSalaryPort.
  2. Collect ad-hoc deductions from DeductionsPort (EWA + loan installments + employer rows).
  3. Compute employee pension at pensionRule.compute(gross).
  4. Compute personal income tax at taxRule.computeTax(gross).
  5. 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

TableTenant scopeRLSNotes
payroll_runyesyes+ 2 new snapshot columns: appliedTaxRuleId, appliedPensionRuleId
payroll_run_entryinherited via FKinheritedunchanged
payroll_tax_ruleyes (or 'GLOBAL')yesnew in Payroll-E1; brackets as JSONB
payroll_pension_ruleyes (or 'GLOBAL')yesnew 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

ItemStatusSource of evidence
Effective-dated tax + pension rule registryLIVEtax-rule.spec.ts, pension-rule.spec.ts, calculate-with-rules.spec.ts
Historical replay determinismLIVEcalculate-with-rules.spec.ts → historical replay
Per-entry tax + pension deductionsLIVEsame
Overlap-rejection on rule activationLIVEcalculate-with-rules.spec.ts → overlaps ACTIVE rule
GLOBAL fallbackLIVEcalculate-with-rules.spec.ts → falls through to GLOBAL
Pilot Ethiopian seed (FITP 979/2016 + Pension 715/2011)LIVE as DATA20260529400000_payroll_tax_pension_rules/migration.sql
HTTP routes for rule CRUDNOT YET (use cases bound; controller routes pending Payroll-E2)
LOCKED immutable state + corrections aggregatePLANNED (Payroll-E2)
EWA + lending consumers of payroll.deductions_taken.v1PLANNED (Payroll-E3)
Bank batch ID + reconciliation hooksPLANNED (Payroll-E4)
Payslip generation + reports + exportsPLANNED (Payroll-E5)
Reviewer-vs-approver separation of dutiesPLANNED (Payroll-E2)
Net-to-gross simulationPLANNED (Payroll-E2)
Pension deducted from taxable basePLANNED (Payroll-E2)
Basic-salary vs allowance separationPLANNED (Payroll-E2)
Payroll.deductions_taken.v1 consumer offset tracking, DLQ, replayPLANNED (Payroll-E3)

The rule engine is the foundation; everything above it depends on it landing first, which it has.