Skip to main content

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.narration plaintext ✗; 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 Receipt linkage 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 /collections initiate.
  • 7-y retention sweep via policy.

All 3 phases shipped — 2026-07-20 (coll-20260720-1011)

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() reads collections.ctr_threshold_ugx policy (default 10 000 000), stamps highValueFlaggedAt + ctrThresholdUgx + emits collection.high_value_flagged audit event above threshold.
  • InitiateCollectionDto accepts optional taxableAmount + vatRate; initiate() computes vatAmount = taxableAmount * vatRate at persist time and stores all three columns.
  • projectPayerPii() audit-emits collection.payer_pii.decrypted on every read that returns plaintext to a CollectionPayerPii-holding caller (DPPA §21 accountability, gap 3).
  • New PolicyCode collections.ctr_threshold_ugx registered.

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 × vatRatevatAmount), 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 crosses COLLECTIONS_STUCK_CIRCUIT_THRESHOLD (default 10) it trips the provider circuit-breaker via 5 forced failure-recordings + emits collection.reconciliation.back_pressure audit + compliance-officer fanout — back-pressure now automatic instead of detect-only.
  • gap 8 — RetentionPurgeJob collections branch filters by InsurerCollectionAccount.jurisdictionCode when COLLECTIONS_RETENTION_JURISDICTIONS env is set (KE=2190,NG=3650 style); per-jurisdiction floor wins over the default CollectionsRetentionDays policy. Migration 20260721120000_collection_account_jurisdiction.
  • gap 4 — CollectionsService.initiate() wraps every request-to-pay in ProviderCircuitBreaker keyed by collections:<providerCode>; OPEN short-circuits as failed + emits collection.provider.circuit_short_circuited audit; 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 emits collection.webhook.field_tamper_detected audit distinct from the successful ingest event.
  • gap 9 — PII_HASH_KEYS already registered on SecretRotation at boot with SecurityPiiHashRotationDays policy (verified in secret-rotations.module.ts:499); rotation SLA landed platform-wide, no Collections- specific hook required.
  • gap 2 — CollectionsService.createReceiptOnReconcile() + BankStatementService.tryCreateReceiptOnReconcile() create a Receipt tied to the linked Invoice on every reconciled transition (webhook, markSettled, manual bank-line match); stamps Collection.receiptId idempotently + emits collection.receipt.created audit.
  • gap 12 — CollectionsService.matchLine() short-circuits on replay when the line is already matched to the same collection; emits collection.match.replay_ignored audit + 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.