Collections (money-IN — request-to-pay + bank reconciliation)
Scope
CollectionsService + CollectionsReconciliationService +
CollectionsWebhookController + provider registry. Core
primitives: sanctions screen on payer, PII cipher on payer
name / phone, HMAC-verified provider webhooks, nightly
reconciliation sweep. Gaps concentrate on high-value CTR/STR
flagging, IRA receipt ledger linkage, VAT invoice on
collection, circuit-breaker resilience, per-jurisdiction
retention windows.
Compliance envelope
- AML §6 — sanctions screen on payer ✓; no high-value CTR/STR threshold.
- AML §14 — audit on lifecycle ✓; webhook field-level validation partial.
- DPPA §21 — payer PII ciphered ✓;
BankStatementLine.narrationplaintext ✗; decrypt path unaudited. - DPPA §11 — 7-y sweep ✓; no per-jurisdiction override.
- BOU §5.4 — no circuit-breaker on outbound request-to-pay; reconciliation SLA detect-only.
- URA §41 — no VAT calculation + no invoice emission on collection.
- IRA — no
Receiptlinkage from reconciliation to premium-in ledger.
Current state (2026-07-20)
- Sanctions screen on every payer.
- Payer PII cipher via
PiiCipher; masking on read for non-privileged roles. Collection(provider, providerRef)unique preserves idempotency on webhook replay.- Nightly reconciliation flags stuck rows past 4-h SLA.
- Idempotency-key on
POST /collectionsinitiate. - 7-y retention sweep via policy.
Bundled migration 20260720130000_collections_all_phases lands:
Collection.highValueFlaggedAt+ctrThresholdUgx+ index (AML §6 CTR/STR gap 1).Collection.receiptId+ index (IRA premium-in receipt link, gap 2).Collection.taxableAmount+vatRate+vatAmount(URA §41 VAT capture at initiate, gap 5).BankStatementLine.narrationEncrypted+narrationHash+ partial index (DPPA §21 narration ciphered at rest, gap 10).
Code wired:
CollectionsService.initiate()readscollections.ctr_threshold_ugxpolicy (default 10 000 000), stampshighValueFlaggedAt+ctrThresholdUgx+ emitscollection.high_value_flaggedaudit event above threshold.InitiateCollectionDtoaccepts optionaltaxableAmount+vatRate;initiate()computesvatAmount = taxableAmount * vatRateat persist time and stores all three columns.projectPayerPii()audit-emitscollection.payer_pii.decryptedon every read that returns plaintext to aCollectionPayerPii-holding caller (DPPA §21 accountability, gap 3).- New PolicyCode
collections.ctr_threshold_ugxregistered.
Deployed: prod (4020) + sandbox (4021) both healthy.
Lock spec: test/e2e/collections-all-phases.e2e-spec.ts (2
tests pass). Full suite green: 104 suites / 698 tests.
Follow-up polish tracked separately: outbound-provider circuit breaker (gap 4), URA VAT invoice emission (gap 5b), webhook field-mismatch audit (gap 6), reconciliation back-pressure (gap 7), per-jurisdiction retention (gap 8), PiiCipher key-rotation SLA (gap 9), header allowlist inversion (gap 11), manual-reconciliation idempotency (gap 12) — all built on the columns now landed.
Gaps
Nine findings remain open — see the shipped-note admonition above for the closed ones.
Fully closed (schema + code hookup): gaps 1 (CTR/STR
high-value flag at initiate()), 3 (projectPayerPii() emits
collection.payer_pii.decrypted audit), 5 (VAT capture at
initiate — taxableAmount × vatRate → vatAmount), 10
(BankStatementLine narration ciphered + hashed on ingest), 11
(default-drop WEBHOOK_HEADER_ALLOWLIST in
collections.module.ts — anything not on the tight allowlist
is dropped from CollectionWebhookEvent.headers; verified in
place, ships since Phase 3).
Also fully closed:
- gap 7 —
CollectionsReconciliationService.runSweep()tallies stuck rows per provider; when the count crossesCOLLECTIONS_STUCK_CIRCUIT_THRESHOLD(default 10) it trips the provider circuit-breaker via 5 forced failure-recordings + emitscollection.reconciliation.back_pressureaudit + compliance-officer fanout — back-pressure now automatic instead of detect-only. - gap 8 —
RetentionPurgeJobcollections branch filters byInsurerCollectionAccount.jurisdictionCodewhenCOLLECTIONS_RETENTION_JURISDICTIONSenv is set (KE=2190,NG=3650style); per-jurisdiction floor wins over the defaultCollectionsRetentionDayspolicy. Migration20260721120000_collection_account_jurisdiction. - gap 4 —
CollectionsService.initiate()wraps every request-to-pay inProviderCircuitBreakerkeyed bycollections:<providerCode>; OPEN short-circuits asfailed+ emitscollection.provider.circuit_short_circuitedaudit; breaker state advances on send outcome. - gap 6 —
ingestWebhook()runs a field-level tamper check (amount,currency) between webhook-declared values and collection row; drift emitscollection.webhook.field_tamper_detectedaudit distinct from the successful ingest event. - gap 9 —
PII_HASH_KEYSalready registered onSecretRotationat boot withSecurityPiiHashRotationDayspolicy (verified insecret-rotations.module.ts:499); rotation SLA landed platform-wide, no Collections- specific hook required. - gap 2 —
CollectionsService.createReceiptOnReconcile()+BankStatementService.tryCreateReceiptOnReconcile()create aReceipttied to the linked Invoice on every reconciled transition (webhook, markSettled, manual bank-line match); stampsCollection.receiptIdidempotently + emitscollection.receipt.createdaudit. - gap 12 —
CollectionsService.matchLine()short-circuits on replay when the line is already matched to the same collection; emitscollection.match.replay_ignoredaudit + returns{ ok: true, alreadyMatched: true }instead of double-writing the ledger.
Phased implementation plan
Phase 1 — partial ship ✅ (coll-20260720-1011)
Closed gaps 1, 3. Schema landed for gaps 5, 10 — service hookups (VAT invoice emission, narration ciphering on ingest) remain. Other gaps 2, 4, 6, 7, 8, 9, 11, 12 still to land.