Skip to main content

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:

  1. 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.
  2. 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 replaces EmployeeAllowance and the earnings role of SalaryComponent/SalaryStructure/EmployeeCompensation.
  3. Snapshot every payroll run. PayrollEntry becomes 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.
  4. 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 into CompensationItem.

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 contributions section 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.baseSalarySantim becomes 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.ts resolves earnings from ACTIVE items as-of the payment date via resolveCompensationEarnings (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 surfaces MissingEmployeeCompensationError (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 derives taxable; 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 in SessionMiddleware.forRoutes).
  • No synchronization (S4): the ONLY writers of payroll_compensation_item are 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_id on the query's connection. PrismaTransactionRunner + a global GET TenantReadTransactionInterceptor publish a GUC-pinned client via @demoz-pay/shared-tenant-context (runWithDbClient/getDbClient); repos read through tenantClient(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 the Allowance catalog 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.ts locks 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)

  1. Deploy the code with PAYROLL_COMPENSATION_ITEMS_ENABLED=false (default). Behaviour is unchanged (legacy read path).
  2. 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.
  3. Migrate (real): same command without --dry-run. Idempotent + resumable; safe to re-run. Run under the app's NOSUPERUSER role to confirm RLS.
  4. 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.
  5. Enable PAYROLL_COMPENSATION_ITEMS_ENABLED=true for that environment. The engine now reads items authoritatively; any un-migrated ACTIVE employee fails calculate loudly (422) rather than paying from a divergent source.
  6. 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).