COMPENSATION_ARCHITECTURE_REVIEW
yy# Compensation Architecture Review — Workforce · HR · Compensation · Payroll
Status: Proposal for review — no production code until a direction is approved.
Author: Architecture review (requested 2026-06-22).
Companion ADR: ADR-028-unified-compensation-model.md (Proposed).
Goal is not to "make payroll work." Goal is one coherent compensation architecture that stays correct, auditable, and scalable as DemozPay grows into a full HR + Workforce + Payroll platform — for Ethiopian compliance today and international expansion later.
0. TL;DR
We have two disconnected models of recurring compensation:
| Workforce model | Payroll model | |
|---|---|---|
| Tables | Allowance/EmployeeAllowance, Deduction/EmployeeDeduction | PayrollSalaryComponent → PayrollSalaryStructure → PayrollEmployeeCompensation (+ override) |
| Has UI? | Yes (onboarding wizard, employee edit, settings cards) | No (use cases exist but no controller; only the DB seed authors it) |
| Read by the payroll engine? | No | Yes |
| Compliance richness | Tax/pension flags + exemption caps (taxExemptCapSantim, pensionExemptCapSantim) | Tax/pension flags, no caps; has formulas/%-of-base + versioning |
Net effect: what HR enters at onboarding is silently ignored by payroll. This is not a bug — it is two bounded contexts modelling the same concept and never agreeing.
Recommendation: Option C — collapse both into a single Compensation bounded context with one effective-dated, per-employee CompensationItem model (earnings and deductions as signed, typed "pay components", SAP-wage-type style). Salary structures become an optional authoring template that materialises items (a copy at assignment time — not a live runtime indirection). Payroll becomes a pure calculation engine that reads the employee's effective items as-of the period, applies statutory rules + per-run inputs, and snapshots the full breakdown onto the immutable PayrollEntry. Both legacy models are removed.
This satisfies every stated principle: single source of truth, no duplicated comp data, no sync jobs, no bidirectional mapping, clear bounded contexts, immutable history.
1. Current architecture analysis
1.1 The two recurring-compensation models (the core problem)
Workforce model — catalog + per-employee assignment, effective-dated, ADR-009 soft-end via effectiveTo:
- Catalog:
Allowance(schema.prisma:736),Deduction(schema.prisma:863). - Assignment:
EmployeeAllowance(schema.prisma:780) with per-row compliance overrides + audit (EmployeeAllowanceOverride:824);EmployeeDeduction(schema.prisma:916) with cross-domainupstreamRefType/Id. - Authored by: onboarding wizard
CompensationStep→EmployeeWizard.dispatchCatalogAssignments(apps/employer-web/src/components/employees/onboarding/EmployeeWizard.tsx:425-447) callingemployeeAllowancesApi.assign()/employeeDeductionsApi.assign(); also the employee edit page (apps/employer-web/src/app/employees/list/[id]/page.tsx) and settings cards. - API:
POST /employees/:id/allowances,POST /employees/:id/deductions(+ PATCH,/end).
Payroll model — versioned component library → structure template → employee assignment:
PayrollSalaryComponent(schema.prisma:2666, types BASE / TAXABLE_ALLOWANCE / NON_TAXABLE_ALLOWANCE / OVERTIME / BONUS …),PayrollSalaryStructure(:2697),PayrollSalaryStructureLineItem(:2720),PayrollEmployeeCompensation(:2736),PayrollEmployeeCompensationOverride(:2760).- Override waterfall: employee override → structure-line override → component default (
compute-employee-earnings.usecase.ts:107-171). - Authored by: nothing in production. Use cases exist (
CreateSalaryComponentUseCase,CreateSalaryStructureUseCase,AssignEmployeeCompensationUseCase, activate/retire) and are exported fromPayrollModule.register(), but no HTTP controller exposes them. Onlyapps/api/prisma/seed.ts:851-934writes these rows.
1.2 What the engine actually reads
CalculatePayrollRun (packages/payroll/backend/application/calculate-payroll-run.usecase.ts) reads, per employee:
- Recurring earnings via
EmployeeSalaryPort→EmployeeSalaryAdapter.loadForPeriod(apps/api/src/payroll/employee-salary.adapter.ts) →ComputeEmployeeEarningsUseCase→ Payroll model only (EmployeeCompensation/SalaryStructure/SalaryComponent). Returnsnullwhen no ACTIVE compensation; strict mode then throwsMissingEmployeeCompensationError(422).EmployeeAllowance/EmployeeDeductionare never queried (zero references inpackages/payrollorapps/api/src/payroll). - Statutory:
TaxRuleRepository,PensionRuleRepository,ProtectionPolicy(min-net floor). - Per-run inputs:
PayrollOneTimeEarning(:1886),PayrollOvertimeEntry(:1828),PayrollAdjustmentDRAFT phase viaDraftDeductionAdapter(:2471). - Recurring legal/long-running deductions:
PayrollDeductionMandate(:2540), and engine-internalPayrollDeductionCarryForward(:2604). - Cross-domain repayments:
DeductionsPort→deductions.adapter.tsqueries onlyewaRequest+loan(notEmployeeDeduction).
1.3 Why payroll appears to "work"
The seed hand-builds the Payroll compensation chain (BASE-only) for seeded employees (seed.ts:836-934), so those employees calculate on base salary. The legacy fallback flag PAYROLL_LEGACY_BASE_SALARY_FALLBACK is not set, so any real employee onboarded through the UI — who has EmployeeAllowance/EmployeeDeduction rows but no PayrollEmployeeCompensation — would fail calculate with 422. The system only looks healthy because testing uses seeded staff.
1.4 Snapshot / immutability gap
EmployeeSalaryAdapter computes compensationId + structureId + the full component breakdown, but PayrollRunEntry (schema.prisma:2773) does not persist them. Only grossSantim, netSantim, frozen taxableGrossSantim/pensionableGrossSantim, proration, and a deductions JSON array are stored (payroll-entry.ts; calculate-payroll-run.usecase.ts:706-721). Tax/pension rule versions are snapshotted on the run (appliedTaxRuleId/appliedPensionRuleId), but the earnings breakdown is not — so a historical payslip's line-item composition cannot be reconstructed if a structure is later edited/retired. This violates the "historical snapshots never change" principle at the line-item level.
2. Problems in the current design
- Duplicated concept, no SoT. Recurring comp is modelled twice (§1.1). Neither is authoritative; they never reconcile.
- The model HR uses is the model payroll ignores. Onboarding writes Workforce; engine reads Payroll. Maximum-impact disconnect.
- The model payroll reads has no authoring path. Production cannot create
PayrollEmployeeCompensationexcept via seed → onboarded employees are uncomputable in strict mode. - Compliance richness is split. Exemption caps (Ethiopia transport/per-diem tax exemptions) live only on the Workforce
Allowance; formulas/%-of-base + versioning live only on the Payroll component. Neither model is complete. - No line-item history snapshot (§1.4).
- Catalog duplication.
Allowance+Deduction+PayrollSalaryComponentare three catalogs of "kinds of money", with no FK between them; codes can diverge. - Ambiguous compliance resolution. An allowance can be non-taxable at catalog level, taxable via
EmployeeAllowanceoverride, while the employee has a compliance override too — three layers, no documented precedence that the engine honours (because the engine never reads two of them). - Earnings and deductions modelled asymmetrically even though they are the same idea with a sign (a recurring monthly amount that hits gross/net with tax/pension treatment). SAP/SuccessFactors treat both as "wage types/pay components".
2.1 What is NOT duplication (keep these)
The per-run / legal / engine-internal inputs are legitimately distinct and should remain (renamed/aligned, not merged into recurring comp):
| Entity | Why it is distinct |
|---|---|
PayrollOneTimeEarning | Non-recurring, period-scoped, own DRAFT→APPROVED→POSTED→VOIDED lifecycle. |
PayrollOvertimeEntry | Derived from hours × policy, with a base-salary snapshot. Not a fixed recurring amount. |
PayrollDeductionMandate | Court orders / garnishments: lifetime caps (totalAwarded/totalApplied), suspend/resume, legal precedence. |
PayrollDeductionCarryForward | Engine-internal: residual deferred by the min-net floor. Never user-authored. |
PayrollAdjustment | Post-approval corrections against a frozen run (append-only, reversible). |
The duplication is only in recurring compensation: {EmployeeAllowance, EmployeeDeduction} vs {SalaryComponent, SalaryStructure, EmployeeCompensation}.
3. Research findings — how mature platforms model compensation
Two dominant archetypes:
Archetype A — per-employee pay components on the HR master (engine reads them).
- SAP HCM: Infotypes 0008 Basic Pay, 0014 Recurring Payments/Deductions, 0015 Additional Payments. Recurring earnings and deductions are effective-dated "wage types" (signed) on the employee. Payroll is a calculation schema over wage types. One model, on the employee, effective-dated.
- SuccessFactors Employee Central: "Compensation Information" holds recurring pay components on the employee; EC Payroll maps pay components → wage types. SoT = the employee's pay components.
- ADP Workforce Now / UKG: earning & deduction codes (catalog) + per-employee recurring assignments + one-time entries. Calc reads assignments.
- BambooHR: flat compensation records (rate + type) on the employee; payroll partner reads them.
Archetype B — salary structures/templates assigned to employees.
- Odoo Payroll: employee Contract (wage) + Salary Structure + Salary Rules; allowances/inputs computed by rules.
- ERPNext Payroll: Salary Structure (earnings/deductions components) + Salary Structure Assignment (base + variable per employee). Structure is a reusable template.
- Workday: Compensation Package → Plans (salary/allowance/bonus); an employee's effective compensation is the SoT; payroll consumes pay components mapped from comp.
Convergent lessons:
- The employee's effective compensation is the single source of truth. Where structures/templates exist (B), they are an authoring convenience that resolves/materialises into per-employee components — payroll never depends on the template at runtime; it reads the employee's resolved components.
- Earnings and deductions share one "pay component / wage type" abstraction (signed, typed, with tax/social treatment). They are not separate subsystems.
- Catalog (component type) vs assignment (per-employee value) is the right and only legitimate split.
- Payroll is a pure calculation engine over (a) the employee's effective components, (b) versioned statutory rules, (c) period inputs (one-off, overtime, corrections). It snapshots the full resolved result for immutable history.
- One-time pay, overtime, garnishments, corrections are first-class but separate from recurring comp — exactly DemozPay's existing per-run entities.
Ethiopian compliance mapping (must be first-class in the model):
- PAYE progressive brackets → versioned
TaxRule(already present, snapshot on run). ✅ - Pension 7% employee / 11% employer (Proc. 715/2011, 908/2015) → versioned
PensionRule+ pensionable base flag per component. ✅ (keep) - Allowance tax exemptions (e.g. transport/per-diem caps) →
taxExemptCapSantim/pensionExemptCapSantim— exists only on WorkforceAllowancetoday; must be preserved in the unified component type. ⚠️ (the Payroll model lacks this) - Overtime multipliers (Labour Proc. 1156/2019: 1.25 / 1.5 / 2.0) →
OvertimePolicy+OvertimeEntry. ✅ (keep) - Court orders / cost-sharing garnishments →
DeductionMandate. ✅ (keep) - Pre-tax vs post-tax deductions →
taxStageon the WorkforceDeductioncatalog; must be preserved.
International-expansion mapping: multi-currency (Money is currency-aware), pluggable + versioned statutory rules, extensible component-type taxonomy, per-country compliance flags. The unified component model + pluggable rule engine is the abstraction that scales across jurisdictions.
4. Recommended architecture
Option A — Minimal: repoint the engine at the Workforce model
Make the engine read EmployeeAllowance/EmployeeDeduction directly (new AllowancePort, extend DeductionsPort); demote/retire the unused SalaryComponent/Structure/EmployeeCompensation chain. No sync — a read repoint, not a bridge.
- Advantages: smallest change; uses the model HR already populates + that already has UI; ships fastest; deletes dead code.
- Disadvantages: keeps two catalogs of "kinds of money" (
Allowance/Deductionvs the now-orphaned component types); loses the Payroll model's formula/%-of-base + versioning; earnings/deductions stay asymmetric; no structures/bands for enterprise. - Technical debt: medium — the catalog split and the asymmetry remain; future enterprise features (grades/bands, %-of-base) need re-introduction.
- Scalability: fine for SMEs; weak for enterprise banding.
- Maintenance cost: low short-term, medium long-term (two catalogs to keep aligned by convention).
Option B — Refactor onto the Payroll model (Payroll's model wins)
Keep SalaryComponent/Structure/EmployeeCompensation as the one model; build the missing authoring UI/controllers; migrate Workforce allowances/deductions into it; delete EmployeeAllowance/EmployeeDeduction.
- Domain ownership: Payroll owns recurring comp + calculation.
- Migration effort: high — build full authoring surface, migrate data, re-point onboarding UI, add exemption caps the model currently lacks.
- Long-term maintainability: good (one model, versioned, formula-capable) — but couples "what an employee earns" (an HR concern) into the Payroll context, and the structure→assignment→override runtime indirection is heavier than SMEs need.
- Future flexibility: strong for enterprise structures; over-engineered for a 5-person startup.
Option C — Recommended: a unified Compensation bounded context
A new, deliberately-designed model owned by a Compensation context (neither legacy model's hand-me-down). Both legacy models are removed.
Core model — CompensationItem (the one source of truth):
- Per-employee, effective-dated (
effectiveFrom/effectiveTo), append-only/versioned (ADR-009). - Unifies earnings and deductions as typed pay components (
category: EARNING | DEDUCTION), each with:componentTypeId(→ catalog),amountKind(FIXED | PERCENT_OF_BASE | FORMULA),amountSantim/rateBps/formulaSpec, and resolved compliance treatment (taxable,pensionable,taxStagefor deductions, exemption caps). - Carries the Ethiopia-critical exemption caps (from the Workforce model) and the formula/%-of-base capability (from the Payroll model).
Catalog — CompensationComponentType (merge of Allowance + Deduction + SalaryComponent):
- One tenant catalog of "kinds of money" with
category, default compliance flags, default caps,taxStage. Replaces three catalogs.
Templates — CompensationTemplate (optional, demoted structure):
- A reusable bundle of component types + default amounts (for grades/bands/bulk onboarding). Applying a template materialises
CompensationItemrows for the employee (a copy). Payroll never resolves a template at runtime. Not needed for SMEs; available for enterprise. This is the only surviving role ofSalaryStructure, and it is one-directional and copy-on-apply (no live indirection, no sync).
Payroll = pure engine:
-
Reads the employee's effective
CompensationItems as-of the period + statutory rules + per-run inputs (one-time, overtime, mandates, carry-forward, adjustments — unchanged). -
Snapshots the full resolved earnings + deductions breakdown (and the compensation version) onto
PayrollRunEntry(closes §1.4). -
Domain ownership: Compensation owns recurring pay (items + catalog + templates); Workforce/HR owns employee identity/employment + writes compensation via the Compensation API at onboarding; Payroll owns calculation + immutable runs and only reads compensation.
-
Migration effort: high but staged (see §8); one-time data migration, no ongoing sync.
-
Long-term maintainability: highest — one model, symmetric earnings/deductions, one catalog, clean context boundaries.
-
Future flexibility: SME-simple (flat per-employee items at onboarding) and enterprise-ready (templates/bands, %-of-base, formulas, multi-currency, pluggable jurisdiction rules).
Why C over A and B: A leaves the catalog split + asymmetry (debt); B forces an HR concern (what an employee earns) to live in the Payroll context and keeps a runtime template indirection SMEs don't need. C puts compensation in its own context, makes earnings/deductions symmetric (industry consensus), preserves both models' best parts (caps and formulas), and reduces three catalogs to one — while keeping payroll a pure, snapshotting engine.
5. Domain ownership
Rules: Payroll reads Compensation; it never writes it. HR/onboarding writes Compensation via its API; never writes Payroll tables. Cross-domain (Lending/EWA) stays event-driven (ADR-011), unchanged.
6. Entity relationship diagram (target)
Entities removed: Allowance, EmployeeAllowance, EmployeeAllowanceOverride, Deduction, EmployeeDeduction, PayrollSalaryComponent, PayrollSalaryStructure, PayrollSalaryStructureLineItem, PayrollEmployeeCompensation, PayrollEmployeeCompensationOverride → replaced by CompensationComponentType + CompensationItem (+ optional CompensationTemplate/…Line). Audit moves to a single CompensationItemEvent append-only log.
Entities kept (Payroll): PayrollOneTimeEarning, PayrollOvertimeEntry, PayrollDeductionMandate, PayrollDeductionCarryForward, PayrollAdjustment, PayrollRun, PayrollEntry (+ snapshot columns), all versioned statutory rules.
7. Data flow
8. Migration strategy (from current implementation)
Staged, reversible, no big-bang, no ongoing sync. Each phase ships and is verified (NOSUPERUSER RLS check per repo rule) before the next.
- Phase 0 — Approve architecture (this doc + ADR-028). No code.
- Phase 1 — Compensation context skeleton. New tables
CompensationComponentType,CompensationItem,CompensationItemEvent(forced RLS, ADR-009). Domain + application + ports + Prisma adapters + controllers. Reuse/port the existing Payroll compensation use cases (they already encode versioning/override logic — the closest existing code to the target). No UI change yet; behind a flag. - Phase 2 — One-time data migration (idempotent script). Project existing data into
CompensationItem:EmployeeAllowance→CompensationItem(category=EARNING)(preserve amounts, effective dates, compliance overrides, caps).EmployeeDeduction(non-cross-domain kinds) →CompensationItem(category=DEDUCTION); cross-domain (LOAN/EWA) stay as cross-domain repayment flows, not comp items.- Seed-created
PayrollEmployeeCompensation/SalaryStructure→ materialise intoCompensationItem. - Validate: every ACTIVE employee ends with ≥1 ACTIVE
CompensationItem(else they'd fail strict-mode calculate).
- Phase 3 — Repoint onboarding + HR UI to the Compensation API (the wizard
CompensationStep, employee edit page, settings cards). HR now writes the SoT directly. Old endpoints become read-only/deprecated. - Phase 4 — Repoint the engine.
EmployeeSalaryAdapter/ComputeEmployeeEarningsreadCompensationIteminstead ofEmployeeCompensation/Structure. Add full snapshot columns toPayrollRunEntry(earningsBreakdownJSON +compensationVersionRef). Verify historical runs unaffected. - Phase 5 — Decommission. After a dual-read soak + reconciliation report, drop
EmployeeAllowance/EmployeeDeduction/Allowance/DeductionandSalaryComponent/Structure/EmployeeCompensation(archive, never hard-delete financial history — follow ADR-009; oldPayrollEntrysnapshots already hold the frozen numbers). - Phase 6 (optional, later) — Templates & bands.
CompensationTemplatematerialisation for bulk onboarding + enterprise grades.
9. Risks & trade-offs
| Risk | Mitigation |
|---|---|
| Data migration correctness (money) | Idempotent script; santim-only (ADR-005); per-tenant reconciliation report (sum of items == prior expected gross); dry-run + diff before cutover. |
| Strict-mode 422 for employees without items | Phase-2 validation gate: every ACTIVE employee must have ≥1 ACTIVE item before Phase 4 cutover. |
| Breaking historical immutability | Past PayrollEntry numbers are already frozen; add snapshot columns forward-only; never recompute closed runs. |
| RLS regressions | New tables forced-RLS from day one; verify on a NOSUPERUSER role (repo standing rule), not the local SUPERUSER. |
| Scope creep into per-run entities | Explicitly out of scope: one-time/overtime/mandate/carry-forward/adjustment keep their tables and lifecycles. |
| Big refactor risk | Phased + flagged; dual-read soak; each phase independently shippable & reversible. |
| Over-engineering for SMEs | Templates are optional; the default path is flat per-employee items entered once at onboarding. |
| Cross-domain coupling | Lending/EWA repayments remain outbox-driven (ADR-011); they are not comp items. |
Trade-off accepted: Option C is more upfront work than A. We choose it because the alternative is paying the duplication tax (two catalogs, asymmetry, no caps-or-formulas-in-one-place, line-item snapshot gap) on every future payroll feature for years.
10. ADR
See ADR-028-unified-compensation-model.md (Proposed) — the decision record for Option C, with context, decision, consequences, and the explicit list of removed/kept entities.
11. Phased implementation plan (Phase 1 → N)
| Phase | Deliverable | Exit criteria |
|---|---|---|
| 0 | Approve this review + ADR-028 | Stakeholder sign-off on Option C (or chosen option) |
| 1 | Compensation context (schema, domain, app, ports, adapters, controllers) behind a flag | Boots; DI verified by booting (not just build); RLS verified as NOSUPERUSER; unit tests |
| 2 | Idempotent migration script (legacy → CompensationItem) + reconciliation report | Dry-run diff clean; every ACTIVE employee has ≥1 ACTIVE item |
| 3 | Onboarding + HR + settings UI repointed to Compensation API | HR enters allowances/deductions once; reflected in Compensation; old endpoints deprecated |
| 4 | Engine reads CompensationItem; PayrollEntry full snapshot columns | A run on a UI-onboarded employee includes their allowances/deductions; payslip line-items reproducible from snapshot; historical runs unchanged |
| 5 | Decommission legacy tables (archive per ADR-009) | No reads of legacy comp tables; CI guard added |
| 6 (later) | Templates / grades / bands; %-of-base + formula authoring UI | Bulk onboarding + enterprise banding |
Implementation begins only after Phase 0 approval.
Appendix — answers to the specific challenge questions
- Should Workforce own employee allowances? No. Workforce owns identity/employment; recurring pay belongs in a Compensation context that HR writes to.
- Should Payroll own recurring compensation? No. Payroll should be a pure calculation engine that reads compensation and snapshots results. Owning recurring comp couples an HR concern into payroll.
- Should HR own compensation? HR/onboarding authors compensation (writes via the Compensation API) but the system of record is the Compensation context, not an HR-private store.
- Should
EmployeeCompensationbecome the single source of truth? Conceptually yes — but as a flat, per-employeeCompensationItem, not as today'sEmployeeCompensation→Structure→Componentruntime indirection. Structures survive only as optional copy-on-apply templates. - Are SalaryStructures the right abstraction for SMEs + enterprise + Ethiopia? Not as a mandatory runtime layer. As an optional template that materialises items, yes — SMEs skip it; enterprises use it for bands.
- Unnecessary duplication? Yes, between
{EmployeeAllowance, EmployeeDeduction}and{SalaryComponent, SalaryStructure, EmployeeCompensation}— collapse to one. No, between those andOneTimeEarning/OvertimeEntry/Mandate/CarryForward/Adjustment— keep. - Can it be simplified? Yes: 3 catalogs → 1, 2 recurring-comp models → 1, earnings + deductions → one signed component, structure runtime indirection → optional template.
- What should disappear?
Allowance,EmployeeAllowance(+Override),Deduction,EmployeeDeduction,PayrollSalaryComponent,PayrollSalaryStructure(+LineItem),PayrollEmployeeCompensation(+Override)— replaced byCompensationComponentType+CompensationItem(+ optionalCompensationTemplate).