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
descriptioninputs. - 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.
LedgerReconciliationServicenightly withledger.drift_detected.DeductionsService.compute()+persistForCommission()+TaxPayablelinkage.- Commission adjust + clawback flows with wallet reversal.
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).
CommissionDeductionde-duplicated +@@unique (commissionId, deductionTypeId)enforced (gap 9).- New
CommissionDeductionAdjustmentmodel preserving before/after JSON snapshots (IRA §112 history, gap 2).
Code:
LedgerReconciliationService.tick()on drift auto-freezes the wallet (setsfrozenAt+frozenReason+ emitsledger.freezeaudit) — no more silent detect-only.WalletsService.withdraw()refuses when the wallet'sfrozenAtis 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 intoCommissionDeductionAdjustmenton 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 onCommissionDeduction(with dedupe migration). - gap 11 —
CommissionsService.clawback()writesCommission.clawbackOfFK + idempotent lookup queries the column (JSON metadata kept for legacy compat). - gap 12 —
WalletsService.postTx()populatesWalletTransaction.sourceCommissionId/sourceWithdrawalIdwhenlinkedEntityTypeiscommission/withdrawal. - gap 5 —
WalletsService.postTx()runs the incomingdescriptionthrough the sharedsanitizePii()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 theconfirmedByon the row; segregation of duties on approver-computes-deductions; emitscommission.approve.sod_refused. Escape hatchCOMMISSIONS_ALLOW_SAME_ACTOR_APPROVE=truefor legacy ops window. - gap 1 — verified in place (design review).
fee_debitledger entries only exist on the wallet-withdrawal scope, where they correctly link todeduction_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 onCommissionDeduction+CommissionDeduction.metadata(gap 7 already landed). No orphan-FK risk in current design. - gap 6 —
CommissionsService.approve()already runs inside aprisma.$transactionand passes the tx handle toDeductionsService.persistForCommission(); both CommissionDeduction rows and TaxPayable rows land in the same atomic write (verified in place — URA §80 atomicity). - gap 7 —
DeductionsService.persist()writesCommissionDeduction.metadatawithruleMetadata,appliedComputation,grossBefore,netAfteron 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-restoreadjustment_creditwrites viametadata->>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 incomingreasonthroughsanitizePii()before writing theCommissionAdjustmentrow.
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.