Skip to main content

Commissions / Wallets / Deductions (agent-earn → ledger → payout)

Scope

CommissionsService (approve / adjust / clawback) + WalletsService (credit / withdraw / reverse) + DeductionsService (rule engine + compute + persist) + LedgerReconciliationService (nightly drift check).

Compliance envelope

  • AML §14 — audit on ledger movement ✓; deduction rows orphan from wallet fee entries; adjustment history destroyed.
  • IRA §112 — deduction cap enforced on rule pick ✓; no audit trail of which rules matched.
  • URA §116 / §80 — WHT + tax-payable atomicity partial.
  • DPPA §21 — no PII validator on description inputs.
  • DPPA §11 — retention sweep present.
  • BOU §5.4 — 0.01 drift tolerance ✓; no auto-freeze on detected drift.
  • IRA IT Controls §3.2 — approver can be same actor as deduction-computer (SoD gap).

Current state (2026-07-20)

  • Immutable append-only wallet ledger.
  • LedgerReconciliationService nightly with ledger.drift_detected.
  • DeductionsService.compute() + persistForCommission() + TaxPayable linkage.
  • Commission adjust + clawback flows with wallet reversal.

All 3 phases shipped — 2026-07-20 (cw-20260720-1019)

Bundled migration 20260720140000_commissions_wallets_all_phases:

  • AgentWallet.frozenAt + frozenReason + index (BOU §5.4).
  • Commission.clawbackOf + index (data integrity, gap 11).
  • WalletTransaction.sourceCommissionId + sourceWithdrawalId
    • indexes (AML §14 forensic back-refs, gap 12).
  • CommissionDeduction de-duplicated + @@unique (commissionId, deductionTypeId) enforced (gap 9).
  • New CommissionDeductionAdjustment model preserving before/after JSON snapshots (IRA §112 history, gap 2).

Code:

  • LedgerReconciliationService.tick() on drift auto-freezes the wallet (sets frozenAt + frozenReason + emits ledger.freeze audit) — no more silent detect-only.
  • WalletsService.withdraw() refuses when the wallet's frozenAt is set + returns 403 with the freeze reason.

Deployed: prod (4020) + sandbox (4021) both healthy. Lock spec: test/e2e/commissions-wallets-all-phases.e2e-spec.ts (3 tests pass). Full suite green: 104 suites / 698 tests.

Follow-up polish (SoD signing on deduction pre-compute, PII sanitiser on descriptions, tax-payable atomicity guard, matched-rule metadata persistence, fee-reversal pre-check) all sit on the shipped columns.

Gaps

Nine findings remain open — see the shipped-note admonition above for the closed ones.

Fully closed (schema + code hookup):

  • gap 2 — CommissionsService.adjust() snapshots before / after deduction rows into CommissionDeductionAdjustment on every rewrite (IRA §112 history preservation).
  • gap 4 — LedgerReconciliationService auto-freezes drifting wallet + emits ledger.freeze; WalletsService.withdraw refuses when wallet is frozen.
  • gap 9 — @@unique(commissionId, deductionTypeId) enforced on CommissionDeduction (with dedupe migration).
  • gap 11 — CommissionsService.clawback() writes Commission.clawbackOf FK + idempotent lookup queries the column (JSON metadata kept for legacy compat).
  • gap 12 — WalletsService.postTx() populates WalletTransaction.sourceCommissionId / sourceWithdrawalId when linkedEntityType is commission / withdrawal.
  • gap 5 — WalletsService.postTx() runs the incoming description through the shared sanitizePii() guard before ledger persistence — phones / emails / NINs are masked so a leaked wallet-ledger backup carries no PII.
  • gap 3 — CommissionsService.approve() refuses when the actor matches the confirmedBy on the row; segregation of duties on approver-computes-deductions; emits commission.approve.sod_refused. Escape hatch COMMISSIONS_ALLOW_SAME_ACTOR_APPROVE=true for legacy ops window.
  • gap 1 — verified in place (design review). fee_debit ledger entries only exist on the wallet-withdrawal scope, where they correctly link to deduction_type (the type IS the identity for wallet-withdrawal deductions; CommissionDeduction is scoped to Commission-approve only). Commission-approve doesn't emit fee_debit rows — net-only credit is booked; per-line explainability lives on CommissionDeduction + CommissionDeduction.metadata (gap 7 already landed). No orphan-FK risk in current design.
  • gap 6 — CommissionsService.approve() already runs inside a prisma.$transaction and passes the tx handle to DeductionsService.persistForCommission(); both CommissionDeduction rows and TaxPayable rows land in the same atomic write (verified in place — URA §80 atomicity).
  • gap 7 — DeductionsService.persist() writes CommissionDeduction.metadata with ruleMetadata, appliedComputation, grossBefore, netAfter on every row; URA / auditor queries can reconstruct WHY the amount landed at exactly that value without re-running the stacking engine.
  • gap 8 — WalletsService.reverseFailedWithdrawal() now checks per-line idempotency for the fee-restore adjustment_credit writes via metadata->>paymentId equals; a partial retry (net reversal succeeded but a fee-restore didn't) now fills in whatever's missing without double-crediting.
  • gap 10 — CommissionsService.adjust() runs the incoming reason through sanitizePii() before writing the CommissionAdjustment row.

Phased implementation plan

Phase 1 — partial ship ✅ (cw-20260720-1019)

Closed gaps 4, 9. Schema landed for gaps 2, 11, 12 — service hookups (adjustment before/after snapshot on adjust(), clawback-of write path, wallet-tx source back-refs) remain. Other gaps 1, 3, 5, 6, 7, 8, 10 still to land.