ADR-028: Compensation architecture — "Startup C2" (pure payroll engine, one earnings source of truth, snapshot every run)
Status: Accepted (2026-06-22). Phase 1 BUILT + hardened (2026-06-24): snapshot, unified-earning read path, write path, and stabilization S1–S6 done behind PAYROLL_COMPENSATION_ITEMS_ENABLED (default OFF). Not yet enabled in production — gated on per-tenant migration + verification (see Production rollout runbook below).
Affects: the two-model split between Workforce (Allowance/EmployeeAllowance, Deduction/EmployeeDeduction) and Payroll (PayrollSalaryComponent/PayrollSalaryStructure/PayrollEmployeeCompensation).
Background: COMPENSATION_ARCHITECTURE_REVIEW.md (analysis), COMPENSATION_ARCHITECTURE_VALIDATION.md (red-team), COMPENSATION_ARCHITECTURE_DECISION.md (design of record + Phase 1).
Context
Recurring compensation is modelled twice (Workforce vs Payroll); the two never reconcile, so allowances/deductions entered at onboarding are ignored by the engine, and PayrollEntry snapshots only totals (no reproducible line-items). A full enterprise redesign (Option C2 — contracts, Benefits, grades) is correct in the abstract but premature for DemozPay's market (Ethiopian SMEs: one employer, one active contract, monthly payroll).
Architecture principle adopted: Design for enterprise, build only what the current product needs — every decision must (1) never require a major rewrite later, and (2) never add unnecessary complexity today.
Decision — "Startup C2"
Build now:
- Payroll is a pure calculation engine. It only reads compensation, calculates, snapshots the result, and produces payslips + accounting entries. It never owns compensation master data.
- One source of truth for recurring earnings. A new Compensation context:
Employee → CompensationProfile → CompensationItem[]. Items cover base salary + housing/transport/position/responsibility allowances + fixed monthly bonus. Each item has effective dates, taxable flag, partial/capped taxability, pensionable flag, recurrence, and calculation method (FIXED | PERCENT_OF_BASE). This replacesEmployeeAllowanceand the earnings role ofSalaryComponent/SalaryStructure/EmployeeCompensation. - Snapshot every payroll run.
PayrollEntrybecomes a complete, immutable result: every earning, deduction, tax, pension, employer contribution, calculation input, rule version, and formula result — reproducible years later even if the employee's data later changes. Ship this first. - Deductions stay in their own contexts. Statutory (tax/pension rules), mandates (court orders), cross-domain (loan/EWA via outbox), and voluntary recurring (
EmployeeDeduction) remain where they are. Payroll aggregates them via ports — it does not absorb them intoCompensationItem.
Explicitly deferred (extension points designed, not built):
- Employment contracts / multiple legal entities. Now:
Employee → CompensationProfile. Later:Employee → EmploymentContract → CompensationProfile— the profile is the seam; migration backfills one contract per profile. Build nothing multi-contract today. - Benefits platform (insurance, medical, provident, stock). Now: only statutory pension (EE + ER) + tax, computed by rules. The run snapshot carries a
contributionssection so Benefits later plugs into the same shape. Build no Benefit plans today. - Grade inheritance / CBA propagation. Copy-on-apply templates suffice later; items stay concrete and authoritative, so a future Grade just writes items. Build no grades today.
Consequences
Positive
- Single source of truth for earnings; no duplicate models, no synchronization, no bidirectional mapping.
- HR enters earnings once at onboarding; payroll uses exactly that.
- Reproducible payroll history (the highest-value, lowest-risk change), shipped first.
- Clear boundaries: Compensation owns earnings; Payroll calculates + snapshots; deductions stay with their owners.
- SME-simple today; contracts/Benefits/grades drop in later without a rewrite because the seams exist.
Negative / cost
- One-time, idempotent data migration (
EmployeeAllowance/base/seeded compensation →CompensationItem) with per-tenant reconciliation; staged + flagged rollout. Employee.baseSalarySantimbecomes derived-from-item (deprecated as a write target) — handled in migration, no two-way sync.
Neutral
- Per-run/overtime/mandate/carry-forward/adjustment subsystems unchanged.
- Reuses the existing (seed-only) payroll compensation use-case logic as the starting point for the new Compensation context.
Guardrails
ADR-005 (santim NUMERIC(20,0)), ADR-009 (no financial deletes — archive/effective-date), ADR-011 (cross-domain via outbox), ADR-013 (forced RLS — verify as NOSUPERUSER) all apply.
Implementation status (2026-06-24)
CompensationItem = payroll_compensation_item (FORCE RLS), EARNING-only, effective-dated, DRAFT → ACTIVE → RETIRED. Code map:
- Read (engine):
apps/api/src/payroll/employee-salary.adapter.tsresolves earnings from ACTIVE items as-of the payment date viaresolveCompensationEarnings(packages/payroll/backend/domain/compensation/compensation-item.ts) when the flag is on. When on, items are the SOLE authority — an employee with no items surfacesMissingEmployeeCompensationError(422), never a silent legacy fallback (S4). Flag off → legacy structure/allowance/base path, unchanged. - Write (authoring): aggregate
compensation-item-record.ts(all invariants: FIXED⇒amount≥0; PERCENT_OF_BASE⇒rateBps & kind≠BASE; CAPPED⇒cap; taxability derivestaxable; effective-range). Use cases create / update (DRAFT) / activate / retire / delete (DRAFT) / revise (version history — retires old, creates+activates new). Port + Prisma adapter (prisma-compensation-item.repository.ts) + tenant controller/api/payroll/compensation-items(@RequireOrgRole admin|owner, registered inSessionMiddleware.forRoutes). - No synchronization (S4): the ONLY writers of
payroll_compensation_itemare the authoring write path and the one-shot migration. No sync job, no dual-write, no bidirectional mapping — consistent with the "no synchronization" decision above. - RLS platform fix (S1): FORCE-RLS tables need
app.tenant_idon the query's connection.PrismaTransactionRunner+ a global GETTenantReadTransactionInterceptorpublish a GUC-pinned client via@demoz-pay/shared-tenant-context(runWithDbClient/getDbClient); repos read throughtenantClient(this.prisma). Verified under a NOSUPERUSER role (scripts/verify/rls-non-superuser-check.ts). - Migration (S5):
scripts/migrate-compensation-items.ts— tenant-by-tenant (GUC per tenant → runs under a non-superuser role), batched/resumable cursor (100k+), idempotent per(employee, sourceRef). Preserves effective dates, FIXED overrides, PERCENT_OF_BASE rates, CAPPED exemptions (from theAllowancecatalog cap), and history (RETIRED comps/ended allowances). FORMULA components are un-migratable (no target representation) and are reported as per-employee blockers — do not enable the flag for a tenant with unresolved blockers. - Golden master (S6):
compensation-item-golden-master.spec.tslocks gross/taxable/pensionable + derived PIT/pension/net for a realistic Ethiopian matrix (CAPPED transport, % bonus, exempt per-diem, revision, multi-base %).
Production rollout runbook (per environment, per tenant)
- Deploy the code with
PAYROLL_COMPENSATION_ITEMS_ENABLED=false(default). Behaviour is unchanged (legacy read path). - Migrate (dry-run):
dotenv -e .env -- ts-node --transpile-only -P apps/api/tsconfig.app.json scripts/migrate-compensation-items.ts --dry-run. Review the readiness report — resolve every blocker (FORMULA components, pension-cap allowances) before continuing. - Migrate (real): same command without
--dry-run. Idempotent + resumable; safe to re-run. Run under the app's NOSUPERUSER role to confirm RLS. - Verify a calculated run matches before/after for the tenant (golden compare): the migrated items must reproduce the interim gross/taxable/pensionable. Spot-check payslips.
- Enable
PAYROLL_COMPENSATION_ITEMS_ENABLED=truefor that environment. The engine now reads items authoritatively; any un-migrated ACTIVE employee fails calculate loudly (422) rather than paying from a divergent source. - Soak, then (later, separate change) decommission the legacy earnings tables and point the authoring UI at
/api/payroll/compensation-items.
Rollback: set the flag back to false — the legacy read path resumes; migrated items remain (harmless, unread).