Compensation Architecture — Validation / Red-Team Pass (v2)
Status: Proposal for review — no production code. Stress-tests COMPENSATION_ARCHITECTURE_REVIEW.md and revises the recommendation.
Date: 2026-06-22.
Bottom line up front: After actively trying to disprove it, I no longer recommend Option C as written. Option C was partly right (the earnings duplication is real; payroll must be a pure, fully-snapshotting engine) and partly wrong (it over-merged — collapsing earnings and deductions, ignoring Benefits, and attaching compensation to the person instead of an employment/contract). The revised recommendation is Option C2 (§9).
1. Challenge Option C (argue against my own proposal)
Pretend a second senior architect with enterprise-payroll scars reviews Option C. Where does it break?
1.1 "Earnings and deductions are the same thing with a sign" — the weakest claim
- Workday does not do this. Compensation (what you earn) and Payroll Deductions are different functional areas with different owners. Deductions largely originate in Benefits (elections → contributions) or as payroll deductions (garnishments, loans). Merging them into one
CompensationItemimports a model SAP/Workday deliberately separate. - SAP is more nuanced: infotype 0014 is "Recurring Payments/Deductions" — so flat recurring +/- share a table. But SAP keeps 0008 Basic Pay (grade/structured base) separate from 0014, 0015 Additional Payments (one-time) separate again, and employer contributions are computed by the schema, never stored as employee wage types. So even SAP's "unification" is only of flat recurring items — not of base, not of one-time, not of contributions.
- A union deduction, a court garnishment, a SACCO contribution, a loan repayment, and a housing allowance are not variants of one concept. They differ in owner, authorization, lifecycle, remittance, and tax treatment. Treating them as
category: EARNING|DEDUCTIONon one row hides those differences and will leak complexity later (e.g. garnishment caps, third-party remittance, benefit enrollment) into a model that started "elegant."
1.2 Missing Benefits bounded context
- Option C has no Benefits concept. Yet pension is exactly a benefit: 7% employee contribution (a deduction) + 11% employer contribution (a cost, remitted, not in net). Today the pension rule computes both and the entry snapshots employer-pension (
pension-rule.ts:26,51;payroll-entry.ts:32;schema.prisma:1733,2783). Insurance, provident funds, medical, gratuity all follow this employee-contribution + employer-contribution shape. Cramming these intoCompensationItem(DEDUCTION)loses the employer side and the plan/enrollment lifecycle. Enterprise HR, banking, public sector, universities, hospitals all run rich benefits — this is not optional at scale.
1.3 Compensation attached to the person, not an employment/contract
- Option C puts items on
Employee. Real-world breakers:- Hospitals/universities: a consultant with a hospital appointment and a university lectureship — two contracts, two pay packages, possibly two legal entities.
- Multiple legal entities: group employers split an employee across entities; each needs its own comp + payslip + filing.
- Manufacturing: an employee moves between cost centers/shifts.
- SAP (PA assignments), Workday (multiple Positions/Jobs per Worker), Oracle (assignments) all attach compensation to an assignment/contract, not the bare person. Option C cannot represent two concurrent packages without redesign.
1.4 "SalaryStructure → optional copy-on-apply template" — too weak for the sectors that need structure
- Collective bargaining / public sector / universities / government: pay is grade × step with negotiated scales. A CBA raise means "all Grade 7 Step 3 get the new rate." A dumb copy template can't propagate that — you'd re-stamp thousands of employees. These sectors need dynamic grade inheritance with controlled, effective-dated propagation, which Option C explicitly discarded.
1.5 Manufacturing / hourly / shift — earnings aren't all "fixed recurring items"
- Shift premiums, piece rates, hourly wages are derived from time & attendance, not stored as fixed
CompensationItems. Option C's recurring-item model assumes monthly fixed amounts; it has no first-class link to Time & Attendance. (Overtime already hints at this — it's hours-derived and kept separate, correctly.)
1.6 Banking / executive comp — bonus & deferred plans are their own world
- Incentive plans, deferred comp, clawbacks, stock/awards have eligibility rules, vesting, and accruals. Workday models these as distinct compensation plan types, not flat items. Option C's single item type would force plan logic into ad-hoc fields.
Verdict on §1: Option C's merge instinct optimizes for fewest tables, which is the wrong objective. The places it breaks (benefits, contracts, grades, time-derived pay, plans) are exactly where DemozPay is heading as it moves up-market.
2. Are these actually different business concepts? (no merging by resemblance)
For each entity: purpose · lifecycle · owner · frequency · bounded context · verdict.
| Entity | Purpose | Lifecycle | Owner | Frequency | Context | Same as others? |
|---|---|---|---|---|---|---|
| Salary Component (base) | The contracted base/grade pay | Set at hire; changes on promotion/raise (effective-dated) | Compensation | Recurring | Compensation | Distinct from allowances (base is the anchor; grade-driven) |
| Allowance | Employer-granted recurring earning (housing, transport…) | Granted; revised; ended; effective-dated | Compensation | Recurring | Compensation | Same concept as an "earning component"; today's EmployeeAllowance ≈ a SalaryComponent(TAXABLE/NON_TAXABLE_ALLOWANCE). Real duplication — unify (earnings side only). |
| Reimbursement | Repay an expense the employee incurred | Claim → approve → pay; usually non-taxable, non-recurring | Expenses/Finance (feeds Payroll) | One-time | Finance/Expenses (not Compensation) | Different: cost reimbursement, not compensation; don't merge into allowances |
| Taxable Benefit | Non-cash benefit with imputed taxable value (car, housing-in-kind) | Enroll/assign; valued each period | Benefits | Recurring | Benefits | Different: affects taxable base without cash to net; not an allowance |
| Benefit | A plan the employee is enrolled in (insurance, provident, pension) | Plan defined → employee enrolled → generates EE + ER amounts | Benefits | Recurring | Benefits | Different: produces both an employee contribution and an employer contribution |
| Employee Contribution | The employee's share of a benefit/statutory (pension 7%) | Derived from a plan/rule each period | Benefits / Payroll-rules | Recurring | Benefits / Payroll | A deduction, but owned by the benefit/rule, not a granted comp item |
| Employer Contribution | The employer's share (pension 11%, insurance) | Derived from plan/rule each period | Benefits / Payroll-rules | Recurring | Benefits / Payroll | Not in net at all — a cost + remittance. Cannot live in a "deduction from pay" model |
| Deduction (voluntary recurring) | Union dues, SACCO, savings, cooperative | Instruct → active → end; effective-dated | Third-party deductions (Payroll-owned instruction) | Recurring | Payroll (deduction instruction) | Different from earnings; similar in shape to a mandate → generalize the mandate concept to cover voluntary recurring, not court-only |
| Payroll Mandate | Court order / garnishment | Awarded (with lifetime cap) → active → suspend/resume → complete | Payroll/Legal | Recurring until cap | Payroll | Distinct: legal precedence, totalAwarded/totalApplied, beneficiary. Keep. |
| One-Time Earning | Bonus, retro pay, correction, top-up | Draft → approve → posted to one run → void | Payroll (or Compensation for bonus plans) | One-off (period-scoped) | Payroll | Distinct from recurring comp. Keep. |
| Overtime | Pay derived from hours × policy | Record hours → approve → posted; base snapshot | Payroll + Time & Attendance | Per period, variable | Payroll / T&A | Distinct: time-derived, not a fixed item. Keep. |
| Payroll Adjustment | Post-approval correction on a frozen run | Draft → approve → posted → reverse | Payroll | One-off correction | Payroll | Distinct: corrective, append-only, reversible. Keep. |
| Compensation Item (proposed) | Per-employee effective earning line | Author → active → retire | Compensation | Recurring | Compensation | Valid only for the earnings side (base + allowances + recurring earnings). Should not absorb deductions/benefits. |
Conclusion of §2: The only genuine duplication is EmployeeAllowance ⇄ earnings-typed SalaryComponent/EmployeeCompensation (both are recurring earnings). Everything else is a distinct concept that merely contains money. The original Option C wrongly merged deductions, benefits, reimbursements, and contributions into one item. Merge earnings; keep deductions/benefits/contributions/one-offs/mandates/adjustments separate, each in its rightful context.
3. Ethiopian payroll validation
| Item | Where it lives in the revised model | Natural fit? |
|---|---|---|
| Transport / Housing / Position / Hardship / Telephone / Fuel / Acting allowance | Compensation earning components, each with taxable/pensionable + exemption caps (taxExemptCapSantim) | ✅ — caps are first-class (today only Workforce Allowance has them; must be preserved) |
| Taxable vs non-taxable allowance | Per-component taxable flag + cap (e.g. transport exempt up to a threshold, excess taxable) | ✅ — needs partial taxability (cap → split taxable/exempt), which a single boolean can't express. Requires cap-aware split logic (gap in both current models) |
| Income tax (PAYE) | Payroll rule (versioned brackets), snapshotted on run | ✅ already |
| Pension 7% EE / 11% ER | Benefits/Payroll rule — EE contribution (deduction) + ER contribution (cost) | ✅ already computed by PayrollPensionRule; generalize to a Benefits pattern |
| Court orders | Mandate (cap, beneficiary, precedence) | ✅ keep |
| Loan deductions | Lending context via outbox; appears as a deduction line | ✅ keep (cross-domain) |
| Cooperative / SACCO / Union deductions | Voluntary recurring deduction instruction (generalized mandate) with third-party beneficiary/remittance | ⚠️ today's EmployeeDeduction has no remittance/beneficiary; needs a third-party-payee concept |
| Absence deductions | Computed by engine from unpaid days (already) | ✅ keep |
| Advances / salary advance | Lending/EWA-style repayment OR mandate | ✅ keep (cross-domain) |
| Recurring deductions (generic) | Voluntary recurring deduction instruction | ✅ |
Ethiopian findings: The revised model supports all of these only if we add (a) cap-aware partial taxability for allowances (transport/per-diem exemptions are capped, not all-or-nothing), and (b) a third-party payee/remittance attribute on deductions (union/SACCO/court remit to an external party). Neither current model handles these cleanly. Option C as written would have hidden them inside a generic item — worse than naming them.
4. International / future validation
| Requirement | Revised model support | Notes |
|---|---|---|
| Monthly / weekly / hourly payroll | Pay-frequency on the contract; engine period-driven | Needs contract-level frequency (today on Employee) |
| Shift premiums | Time & Attendance → earnings (like overtime) | Needs a T&A integration boundary; not fixed items |
| Commissions | Compensation plan (variable) or one-time earning | Plan type if recurring/target-based |
| Stock / equity comp | Separate comp plan (vesting, accrual) | Do not model as a salary line |
| Bonus plans | Comp plan (eligibility, targets) → posts one-time earnings | Workday-style plan |
| Salary sacrifice | Pre-tax deduction linked to a benefit | Needs taxStage=PRE_TAX + benefit link |
| Retroactive / back pay | One-time earning + retro recompute against frozen snapshots | Snapshot design (§5) makes this safe |
| Garnishments | Mandate (cap, precedence) | Keep |
| Multiple currencies | Money is currency-aware; rules per jurisdiction | Currency on contract/legal entity |
| Multiple legal entities | Contract/assignment per legal entity | Option C can't; revised model adds contract layer |
| Multiple contracts per employee | Contract/assignment layer | Same |
International finding: the make-or-break is the contract/employment-assignment layer and treating bonus/stock/benefits as distinct plan types, not salary lines. Option C (flat items on the person) fails multi-contract and multi-legal-entity without redesign — a hard requirement for hospitals, universities, banking groups, and any cross-border expansion.
5. Snapshot design — the most important decision (expanded)
Agreed: this matters more than entity merging. If history is perfectly reproducible, we can evolve the live models freely. Today PayrollRunEntry stores only totals + a deductions JSON + frozen taxable/pensionable bases + rule-id pointers — the earnings breakdown, rates, contributions, and inputs are not snapshotted.
How enterprise systems do it
- SAP "Payroll Results" (cluster RT/Results Tables): every period, per employee, SAP freezes the complete result — every wage type with amount, rate, and number (quantity), plus the inputs and the schema/rule versions used. Re-running is an explicit retro that creates a new result; the old result is never mutated. This is the gold standard: a payslip is reproducible decades later from its own stored result.
- Workday: "Payroll Results" + "Pay Calculation Results" retain per-worker, per-run results with full result lines and audit; off-cycle/retro create new results.
- Principle: a payroll run is an immutable financial document, not a view over current config.
Recommended snapshot strategy for DemozPay
Make PayrollEntry a self-contained, reproducible result — store everything needed to reprint and re-verify without touching any live table:
- Earnings lines (NEW): for each component —
componentTypeId, code, name,amountKind, rate/quantity used, resolvedamountSantim,taxable,pensionable, exemption-cap applied, taxable-vs-exempt split. - Deduction lines (expand): type, source (statutory/benefit/mandate/loan/voluntary),
amountSantim, basis, pre/post-tax stage, beneficiary, upstream ref. - Tax: bracket schedule version id, taxable base, computed tax, per-bracket breakdown.
- Contributions: employee and employer (pension now; benefits later) with rate + base.
- Inputs: working days, unpaid days, hours by category, base snapshot, absence divisor, protection-floor min-net, currency.
- Rule/version pointers: tax rule, pension rule, protection policy, overtime policy, compensation version (the missing pointer today).
- Formula results: any
FORMULA/PERCENT_OF_BASEresolved values with their inputs.
Storage: a versioned result document per entry (structured JSON column(s) on PayrollEntry, or child PayrollEntryLine tables for queryability) with a snapshotSchemaVersion. Never recompute a closed run; retro/correction creates new rows (already the PayrollAdjustment pattern). This is forward-only and additive — low-risk to introduce regardless of which model wins.
This is the single highest-value, lowest-risk change and should arguably ship first, independent of the merge debate.
6. Templates vs dynamic inheritance
| Approach | How | Pros | Cons |
|---|---|---|---|
| Dumb template (copy-on-apply) — Option C as written | Apply structure → copy values to employee items | Simple; explicit; trivially snapshot-friendly; no runtime indirection | No propagation — a grade-wide CBA raise must re-stamp every employee; drift between template and reality |
| Dynamic inheritance (Workday grades/profiles, SAP pay-scale + indirect valuation) | Employee references grade/step; values resolved from the scale at run | DRY; central, effective-dated propagation; ideal for CBA/public sector/grades | Harder to reason about "what did this person actually earn"; must resolve-and-freeze for history; eligibility rules add complexity |
| Hybrid (recommended): resolve-at-assignment + effective-dated grade with controlled propagation | Grade defines defaults; assigning a grade resolves concrete components; a grade change is an effective-dated event that re-resolves affected employees on/after its date | Central updates and concrete per-employee truth; snapshot-safe (run freezes resolved values) | More design than dumb copy |
Finding: Pure copy templates are too weak for the grade/CBA-driven sectors (public, universities, hospitals, banking). Pure dynamic inheritance is hard to snapshot. The right answer is resolve-and-freeze: grades/scales provide effective-dated defaults; the employee's effective components are concrete and authoritative; the run snapshot freezes them. So "structure as template" should become "grade/scale with effective-dated propagation," not a one-shot copy.
7. Bounded contexts — every entity exactly one owner
Ownership rules:
- HR/Workforce owns the person + contract/assignment (NEW) + org catalogs. Does not own money amounts.
- Compensation owns recurring earnings + grades/scales. Earnings only.
- Benefits (NEW) owns plans + enrollments → employee and employer contributions.
- Time & Attendance owns hours/shifts → feeds overtime/premiums.
- Leave owns absences → feeds proration.
- Performance owns bonus/incentive eligibility → posts one-time earnings.
- Payroll owns statutory rules, deduction instructions (mandates + voluntary + third-party payee), one-offs/adjustments/carry-forward, and the immutable run snapshot. Calculates money; owns no HR master data.
- Finance/Accounting owns the ledger, remittances, reimbursements/expenses.
- Lending/EWA own their repayments (cross-domain via outbox, ADR-011).
This is more contexts than Option C, not fewer — but each entity has exactly one owner, and payroll calculates rather than owns master data (your stated ideal).
8. Migration risk (realistic estimates)
Estimates assume the revised target (C2). Relative effort, not calendar promises.
| Dimension | Complexity | Notes |
|---|---|---|
| DB migration | High | New: Contract/Assignment, EarningComponentType, CompensationItem, Benefits (Plan/Enrollment), generalized deduction instruction + third-party payee, PayrollEntryLine/snapshot columns. Migrate EmployeeAllowance→earnings items; split EmployeeDeduction by owner (voluntary→instruction, loan/EWA→stay cross-domain); materialize seed EmployeeCompensation→items; backfill a default single-contract per employee. Idempotent, santim-exact, per-tenant reconciliation. |
| API migration | High | New Compensation + Benefits + Contract controllers; generalize deduction endpoints; deprecate EmployeeAllowance/EmployeeDeduction endpoints; engine read-path swap. |
| Frontend migration | Medium-High | Onboarding wizard + employee edit + settings repointed; new contract selector; benefits enrollment UI (later phase). |
| Testing | High | Golden-master: run payroll for a fixture tenant before and after and assert identical net/tax/pension per employee; snapshot reproducibility tests; RLS-as-NOSUPERUSER; multi-contract + benefits unit/integration. |
| Rollout | Phased + flagged | Dual-read soak: engine can read new model behind a flag while old endpoints still write; reconcile; cut over per tenant. |
| Rollback | Per-phase | Snapshot columns are additive (no rollback risk). Until decommission (final phase), legacy tables remain for instant revert. Point of no return only at the explicit decommission phase, gated on a clean reconciliation report. |
Sequencing insight: the snapshot work (§5) and the contract layer are independent of the merge and de-risk everything else — do them first.
9. Final recommendation (honest, post-challenge)
My recommendation changed. I do not still recommend Option C as written. Option C correctly identified (a) the real earnings duplication and (b) that payroll must be a pure, fully-snapshotting engine — but it over-merged: collapsing deductions/benefits/contributions/reimbursements into one signed item, ignoring Benefits, using dumb-copy templates, and binding compensation to the person instead of a contract.
Recommended: Option C2 — "Compensation + Benefits + Contract, earnings-only unification, snapshot-first"
- Unify only the earnings side. One
CompensationItem(base + allowances + recurring earnings), per contract, effective-dated, with cap-aware partial taxability. ReplacesEmployeeAllowanceand the earnings role ofSalaryComponent/SalaryStructure/EmployeeCompensation. - Do not merge deductions into compensation. Decompose by owner: statutory (Payroll rules), benefits (Benefits context, EE + ER contributions), legal (Mandate), voluntary recurring with third-party payee (generalized deduction instruction), loans/EWA (cross-domain). Replaces
EmployeeDeductionby routing each kind to its rightful owner. - Introduce a Benefits context for plans → employee + employer contributions (generalizing today's pension rule).
- Introduce a contract/employment-assignment layer so compensation attaches to a contract (multi-contract, multi-legal-entity, per-frequency).
- Grades/pay-scales with resolve-and-freeze, not dumb-copy templates (CBA/public-sector/grade support).
- Snapshot-first: make
PayrollEntrya complete, reproducible result document (§5) — ship this first, independent of everything else. - Keep one-time earnings, overtime, mandates, carry-forwards, adjustments — distinct concepts, distinct owners.
Why C2 is superior after trying to disprove it
- Single source of truth — per concept. Earnings have one home (Compensation); each deduction kind has one owner; contributions live in Benefits. "One SoT" applied per business concept, which is stronger than one mega-table.
- No duplication, no sync, no bidirectional mapping — same win as C, achieved by routing concepts to owners rather than collapsing them.
- Clear bounded contexts; payroll owns no HR master data — your stated ideal, fully met.
- Strongest auditability — the snapshot design (the part you flagged as most important) is elevated to first-class and ships first.
- 10-year fit — survives benefits, grades/CBA, multi-contract, multi-legal-entity, time-derived pay, bonus/equity plans, and multi-country, precisely the cases that broke Option C.
- Still simple for startups — an SME uses one contract, a handful of earning items, a couple of deduction instructions, and the statutory rules; Benefits/grades/multi-contract are present but optional.
What I explicitly retract from the original proposal
- ❌ "Earnings and deductions are one signed
CompensationItem." — wrong; separate them. - ❌ "Benefits can be a deduction item." — wrong; Benefits is its own context with employer contributions.
- ❌ "SalaryStructure → dumb copy template." — too weak; use resolve-and-freeze grades.
- ❌ "Compensation on the employee." — should be on a contract/assignment.
- ✅ Retained: unify the earnings duplication; payroll as a pure, fully-snapshotting engine; keep one-off/overtime/mandate/carry-forward/adjustment distinct.
Next step
If you broadly agree with C2, I'll revise ADR-028 to record C2 (it's still Proposed), produce the detailed C2 ER + phase plan, and we sequence snapshot-first. If any part of this validation is itself wrong (e.g. you don't foresee multi-contract, or Benefits is out of scope for years), tell me and I'll right-size the recommendation — the architecture should match DemozPay's real 10-year trajectory, not a generic enterprise checklist.