Skip to main content

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):

  1. Payroll is a pure engine — reads compensation, calculates, snapshots, produces payslips + accounting entries.
  2. One source of truth for recurring earnings: Employee → CompensationProfile → CompensationItem[].
  3. Snapshot every runPayrollEntry becomes a complete, reproducible result.
  4. 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 when EmploymentContract arrives 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). category column exists but is constrained to EARNING now (keeps deductions out; documents intent).
  • CompensationComponentType — one earnings catalog (replaces Allowance + earnings SalaryComponent). 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:

  1. Earnings: EmployeeSalaryPort/ComputeEmployeeEarnings resolve the active CompensationProfile + its ACTIVE CompensationItems as-of the period payment date, producing the gross + taxable/pensionable bases (applying capped-taxability splits). Replaces the EmployeeCompensation→Structure→Component read.
  2. Deductions (aggregated, not owned):
    • Statutory tax/pension — existing rules (unchanged).
    • Voluntary recurring — new VoluntaryDeductionPort reading ACTIVE EmployeeDeduction (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.
  3. 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 needSeam built in Phase 1Later change (no rewrite)
Employment contracts / multiple legal entitiesCompensationProfile sits between Employee and Items; payroll resolves profile-as-of-dateAdd 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 / CBAItems are concrete + authoritativeAdd Grade templates that write items (copy-on-apply); optionally effective-dated propagation later
Non-monthly / hourly / shift payrecurrence enum + amountKind incl. seam for FORMULA; overtime already hours-derivedAdd recurrences + Time & Attendance feed
Reimbursements / expensesKept out of compensation entirelyFinance/Expenses context feeds payroll as one-time, when built

5. Phased roadmap

Phase 1 — Now (this decision)

  • 1a (ship first): Payroll run snapshot. Expand PayrollEntry to 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); add VoluntaryDeductionPort so 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.baseSalarySantimBASE item (then deprecate the column as a write target); seeded EmployeeCompensation/Structure → items. Per-tenant golden-master check (net/tax/pension identical before/after).
  • 1f: Deprecate the seed-only earnings chain (SalaryComponent/SalaryStructure/EmployeeCompensation) and EmployeeAllowance (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 PayrollEntry snapshot 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)

WorkstreamEffortRisk
1a SnapshotMediumLow (additive)
1b Compensation contextMedium-HighLow-Med
1c Engine repoint + voluntary deduction portMediumMed (correctness)
1d UI repointMediumLow
1e Migration + reconciliationHighMed-High (money correctness) → mitigated by idempotent script + golden-master + dual-read soak
1f Deprecate legacyLowLow (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

  1. Base salary as a CompensationItem(BASE) (recommended — true single source) vs. keeping Employee.baseSalarySantim authoritative. Recommendation: migrate base into an item; keep the column read-only/deprecated during transition.
  2. Sequence: ship 1a snapshot first as its own increment, then 1b–1f? (Recommended.)
  3. 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.