Skip to main content

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 modelPayroll model
TablesAllowance/EmployeeAllowance, Deduction/EmployeeDeductionPayrollSalaryComponentPayrollSalaryStructurePayrollEmployeeCompensation (+ 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?NoYes
Compliance richnessTax/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-domain upstreamRefType/Id.
  • Authored by: onboarding wizard CompensationStepEmployeeWizard.dispatchCatalogAssignments (apps/employer-web/src/components/employees/onboarding/EmployeeWizard.tsx:425-447) calling employeeAllowancesApi.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 from PayrollModule.register(), but no HTTP controller exposes them. Only apps/api/prisma/seed.ts:851-934 writes these rows.

1.2 What the engine actually reads

CalculatePayrollRun (packages/payroll/backend/application/calculate-payroll-run.usecase.ts) reads, per employee:

  1. Recurring earnings via EmployeeSalaryPortEmployeeSalaryAdapter.loadForPeriod (apps/api/src/payroll/employee-salary.adapter.ts) → ComputeEmployeeEarningsUseCasePayroll model only (EmployeeCompensation/SalaryStructure/SalaryComponent). Returns null when no ACTIVE compensation; strict mode then throws MissingEmployeeCompensationError (422). EmployeeAllowance/EmployeeDeduction are never queried (zero references in packages/payroll or apps/api/src/payroll).
  2. Statutory: TaxRuleRepository, PensionRuleRepository, ProtectionPolicy (min-net floor).
  3. Per-run inputs: PayrollOneTimeEarning (:1886), PayrollOvertimeEntry (:1828), PayrollAdjustment DRAFT phase via DraftDeductionAdapter (:2471).
  4. Recurring legal/long-running deductions: PayrollDeductionMandate (:2540), and engine-internal PayrollDeductionCarryForward (:2604).
  5. Cross-domain repayments: DeductionsPortdeductions.adapter.ts queries only ewaRequest + loan (not EmployeeDeduction).

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

  1. Duplicated concept, no SoT. Recurring comp is modelled twice (§1.1). Neither is authoritative; they never reconcile.
  2. The model HR uses is the model payroll ignores. Onboarding writes Workforce; engine reads Payroll. Maximum-impact disconnect.
  3. The model payroll reads has no authoring path. Production cannot create PayrollEmployeeCompensation except via seed → onboarded employees are uncomputable in strict mode.
  4. 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.
  5. No line-item history snapshot (§1.4).
  6. Catalog duplication. Allowance + Deduction + PayrollSalaryComponent are three catalogs of "kinds of money", with no FK between them; codes can diverge.
  7. Ambiguous compliance resolution. An allowance can be non-taxable at catalog level, taxable via EmployeeAllowance override, 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).
  8. 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):

EntityWhy it is distinct
PayrollOneTimeEarningNon-recurring, period-scoped, own DRAFT→APPROVED→POSTED→VOIDED lifecycle.
PayrollOvertimeEntryDerived from hours × policy, with a base-salary snapshot. Not a fixed recurring amount.
PayrollDeductionMandateCourt orders / garnishments: lifetime caps (totalAwarded/totalApplied), suspend/resume, legal precedence.
PayrollDeductionCarryForwardEngine-internal: residual deferred by the min-net floor. Never user-authored.
PayrollAdjustmentPost-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:

  1. 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.
  2. Earnings and deductions share one "pay component / wage type" abstraction (signed, typed, with tax/social treatment). They are not separate subsystems.
  3. Catalog (component type) vs assignment (per-employee value) is the right and only legitimate split.
  4. 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.
  5. 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 Workforce Allowance today; 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 garnishmentsDeductionMandate. ✅ (keep)
  • Pre-tax vs post-tax deductionstaxStage on the Workforce Deduction catalog; 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.


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/Deduction vs 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.

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, taxStage for 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 CompensationItem rows 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 of SalaryStructure, 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:
    • EmployeeAllowanceCompensationItem(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 into CompensationItem.
    • 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/ComputeEmployeeEarnings read CompensationItem instead of EmployeeCompensation/Structure. Add full snapshot columns to PayrollRunEntry (earningsBreakdown JSON + compensationVersionRef). Verify historical runs unaffected.
  • Phase 5 — Decommission. After a dual-read soak + reconciliation report, drop EmployeeAllowance/EmployeeDeduction/Allowance/Deduction and SalaryComponent/Structure/EmployeeCompensation (archive, never hard-delete financial history — follow ADR-009; old PayrollEntry snapshots already hold the frozen numbers).
  • Phase 6 (optional, later) — Templates & bands. CompensationTemplate materialisation for bulk onboarding + enterprise grades.

9. Risks & trade-offs

RiskMitigation
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 itemsPhase-2 validation gate: every ACTIVE employee must have ≥1 ACTIVE item before Phase 4 cutover.
Breaking historical immutabilityPast PayrollEntry numbers are already frozen; add snapshot columns forward-only; never recompute closed runs.
RLS regressionsNew tables forced-RLS from day one; verify on a NOSUPERUSER role (repo standing rule), not the local SUPERUSER.
Scope creep into per-run entitiesExplicitly out of scope: one-time/overtime/mandate/carry-forward/adjustment keep their tables and lifecycles.
Big refactor riskPhased + flagged; dual-read soak; each phase independently shippable & reversible.
Over-engineering for SMEsTemplates are optional; the default path is flat per-employee items entered once at onboarding.
Cross-domain couplingLending/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)

PhaseDeliverableExit criteria
0Approve this review + ADR-028Stakeholder sign-off on Option C (or chosen option)
1Compensation context (schema, domain, app, ports, adapters, controllers) behind a flagBoots; DI verified by booting (not just build); RLS verified as NOSUPERUSER; unit tests
2Idempotent migration script (legacy → CompensationItem) + reconciliation reportDry-run diff clean; every ACTIVE employee has ≥1 ACTIVE item
3Onboarding + HR + settings UI repointed to Compensation APIHR enters allowances/deductions once; reflected in Compensation; old endpoints deprecated
4Engine reads CompensationItem; PayrollEntry full snapshot columnsA run on a UI-onboarded employee includes their allowances/deductions; payslip line-items reproducible from snapshot; historical runs unchanged
5Decommission legacy tables (archive per ADR-009)No reads of legacy comp tables; CI guard added
6 (later)Templates / grades / bands; %-of-base + formula authoring UIBulk 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 EmployeeCompensation become the single source of truth? Conceptually yes — but as a flat, per-employee CompensationItem, not as today's EmployeeCompensation→Structure→Component runtime 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 and OneTimeEarning/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 by CompensationComponentType + CompensationItem (+ optional CompensationTemplate).