Compensation Architecture — Decision of Record ("Startup C2")
Status: Accepted (2026-06-22). Authoritative design. Implementation phased — Phase 1 pending go-ahead.
Decision record: ADR-028.
Background: COMPENSATION_ARCHITECTURE_REVIEW.md · COMPENSATION_ARCHITECTURE_VALIDATION.md.
Principle: 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.
1. Scope decision (locked)
Build now (Phase 1):
- Payroll is a pure engine — reads compensation, calculates, snapshots, produces payslips + accounting entries.
- One source of truth for recurring earnings:
Employee → CompensationProfile → CompensationItem[]. - Snapshot every run —
PayrollEntrybecomes a complete, reproducible result. - Deductions stay in their own contexts; payroll aggregates them via ports.
Deferred (seams only, not built): employment contracts / multiple legal entities; Benefits platform; grade inheritance / CBA propagation.
2. Target model (Phase 1)
Why each piece earns its place now
CompensationProfile— the contract seam. 1:1 with Employee today (a thin container). Payroll resolves "active profile for employee as-of date", so whenEmploymentContractarrives later, only the resolver changes (profile points at the contract); items are untouched.CompensationItem— the single recurring-earnings record (base + allowances + fixed bonus). Concrete per-employee values (so a future Grade just writes items).categorycolumn exists but is constrained toEARNINGnow (keeps deductions out; documents intent).CompensationComponentType— one earnings catalog (replacesAllowance+ earningsSalaryComponent). Deduction catalog (Deduction) is untouched.- Partial taxability (
taxability+taxExemptCapSantim) — required now for Ethiopian transport/per-diem exemptions (exempt up to a cap, excess taxable). A boolean can't express it.
3. How the engine reads it (Phase 1)
CalculatePayrollRun keeps its structure; only the earnings source and snapshot change:
- Earnings:
EmployeeSalaryPort/ComputeEmployeeEarningsresolve the activeCompensationProfile+ its ACTIVECompensationItems as-of the period payment date, producing the gross + taxable/pensionable bases (applying capped-taxability splits). Replaces theEmployeeCompensation→Structure→Componentread. - Deductions (aggregated, not owned):
- Statutory tax/pension — existing rules (unchanged).
- Voluntary recurring — new
VoluntaryDeductionPortreading ACTIVEEmployeeDeduction(non-cross-domain kinds: union, SACCO, savings, insurance) → voluntary waterfall tier. This closes the "deductions ignored" half without merging them into compensation. - Loan/EWA — existing cross-domain
DeductionsPort(unchanged). - Mandates / carry-forward / draft adjustments — unchanged.
- Snapshot: write the full result document (§2
PayrollEntry) — forward-only, additive.
No synchronization anywhere: earnings have one home (Compensation), each deduction kind has one owner; payroll only reads.
4. Extension seams (the "no rewrite later" guarantee)
| Future need | Seam built in Phase 1 | Later change (no rewrite) |
|---|---|---|
| Employment contracts / multiple legal entities | CompensationProfile sits between Employee and Items; payroll resolves profile-as-of-date | Add EmploymentContract; backfill one per profile; repoint profile.employeeId → employmentContractId; resolver gains a contract hop |
| Benefits (insurance, provident, stock) | Snapshot contributions: {employee[], employer[]}; engine takes pluggable contribution providers (today: pension rule only) | Add Benefit plans/enrollments as new contribution providers feeding the same snapshot section |
| Grades / pay scales / CBA | Items are concrete + authoritative | Add Grade templates that write items (copy-on-apply); optionally effective-dated propagation later |
| Non-monthly / hourly / shift pay | recurrence enum + amountKind incl. seam for FORMULA; overtime already hours-derived | Add recurrences + Time & Attendance feed |
| Reimbursements / expenses | Kept out of compensation entirely | Finance/Expenses context feeds payroll as one-time, when built |
5. Phased roadmap
Phase 1 — Now (this decision)
- 1a (ship first): Payroll run snapshot. Expand
PayrollEntryto the full immutable result (§2). Independent of the merge; de-risks everything. Existing runs unaffected (additive, forward-only). - 1b: Compensation context. Schema (
CompensationProfile,CompensationItem,CompensationComponentType, forced RLS, ADR-009 effective-dating) + domain + application (author/retire item, resolve active profile+items as-of date) + Prisma adapters + authoring controllers. - 1c: Engine repoint. Earnings from
CompensationItems (with capped-taxability split); addVoluntaryDeductionPortso voluntary recurring deductions are aggregated. - 1d: UI repoint. Onboarding wizard + employee edit + settings author
CompensationItems (earnings); deduction authoring stays in its place. - 1e: Migration + reconciliation. Idempotent:
EmployeeAllowance→ earning items;Employee.baseSalarySantim→BASEitem (then deprecate the column as a write target); seededEmployeeCompensation/Structure→ items. Per-tenant golden-master check (net/tax/pension identical before/after). - 1f: Deprecate the seed-only earnings chain (
SalaryComponent/SalaryStructure/EmployeeCompensation) andEmployeeAllowance(archive per ADR-009; decommission after soak).
Phase 2 — Growing SMEs
Better authoring UX, effective-dated salary revisions, compensation history view, approval workflows (maker-checker reuse).
Phase 3 — Enterprise (only on real demand)
EmploymentContract, Benefits module, multiple contracts/legal entities, grade structures + CBA propagation, complex employer-contribution rules.
6. Acceptance criteria for Phase 1
- A UI-onboarded employee's housing/transport/position allowances and voluntary deductions appear correctly in their payslip and net pay.
- Capped allowances (e.g. transport) split taxable/exempt per the cap.
- A payslip is fully reproducible from its
PayrollEntrysnapshot with no reads of live compensation/rule tables. - No employee has two sources of recurring earnings;
EmployeeAllowance/seed-compensation are no longer read by the engine. - RLS verified on a NOSUPERUSER role; DI verified by booting; golden-master payroll diff is clean.
7. Engineering estimate (Phase 1, relative)
| Workstream | Effort | Risk |
|---|---|---|
| 1a Snapshot | Medium | Low (additive) |
| 1b Compensation context | Medium-High | Low-Med |
| 1c Engine repoint + voluntary deduction port | Medium | Med (correctness) |
| 1d UI repoint | Medium | Low |
| 1e Migration + reconciliation | High | Med-High (money correctness) → mitigated by idempotent script + golden-master + dual-read soak |
| 1f Deprecate legacy | Low | Low (archive, not delete) |
Rollback: snapshot is additive (no rollback risk); legacy tables remain until an explicit decommission gated on a clean reconciliation report.
8. Open questions for sign-off
- Base salary as a
CompensationItem(BASE)(recommended — true single source) vs. keepingEmployee.baseSalarySantimauthoritative. Recommendation: migrate base into an item; keep the column read-only/deprecated during transition. - Sequence: ship 1a snapshot first as its own increment, then 1b–1f? (Recommended.)
- Confirm voluntary recurring deductions (union/SACCO/savings) should be wired into the engine in Phase 1 (closes the original "deductions ignored" gap) — keeping them in the Deductions context, not Compensation.