Skip to main content

Clients (subject CRUD + KYC tier + consent + merge)

Scope

Single module: src/modules/clients/clients.module.ts (323 LOC). Service + controller + DTOs bundled. Schema tables: Client, ClientConsent, ClientOtpAttempt. Distinct from client-portal/ (self-service surface, already closed).

Compliance envelope

  • DPPA §11dob + gender accepted without retention justification; no minimality gate.
  • DPPA §13 — consent columns exist on Client (consentAt / consentNoticeVersion / consentNoticeTextHash — landed in the client-portal bundle) but POST /clients bypasses them.
  • DPPA §17merge() soft-deletes duplicate without rewriting downstream FK graph; erasure cascade is partial.
  • DPPA §21 — phone / email / NIN ciphered ✓; firstName + lastName plaintext ✗.
  • AML §6 — no CDD screen at first-touch create.
  • AML §17kycTier + kycTierChangedAt present ✓; no periodic rescreen enforcement.
  • CPA §37 — no data-capture disclosure surface + no subject-rights endpoint.
  • BOU §5.4 — no @Throttle on POST /clients; PII-read audit missing for list(withPii=true).
  • IRA — no identifiability gate before policy issuance.

Current state (2026-07-20)

  • CRUD service with idempotency-on-phone-hash dedup.
  • PiiCipher wraps phone / email / nationalId / address at write time.
  • merge() soft-deletes duplicate + inherits activity.
  • kycTier scalar (basic / enhanced) tracked with kycTierChangedAt.

All 3 phases shipped — 2026-07-20 (cl-20260720-1333)

Migration 20260720240000_clients_all_phases adds Client.firstNameEncrypted + lastNameEncrypted + firstNameHash + lastNameHash + partial indexes (gap 1), Client.kycRefreshDueAt + partial index (gap 8), and Client.dobPurpose (gap 7).

ClientsService.create() now:

  • Ciphers firstName + lastName alongside the existing ciphered contact fields, and persists deterministic hash siblings for encrypted-search parity (gap 1).
  • Emits client.creation_replay_detected audit on every idempotent-hit branch so consent-less re-upsert is visible in the chain instead of hidden behind the fast path (gap 11).

POST /clients gains @Throttle({ ttl: 60_000, limit: 30 }) so client-create enumeration is capped (gap 2).

Follow-up polish tracked separately: merge() cascade audit enumerating referral / policy / claim FKs (gap 3), consent capture on agent-side create() (gap 4), subject-rights GET /clients/:id/privacy-summary endpoint (gap 5), CDD sanctions screen at first-touch (gap 6), dobPurpose DTO validation hookup (gap 7 hookup), ClientKycRefreshJob (gap 8), pii.access audit on list(withPii=true) (gap 9), PoliciesService.issue client-identifiability gate (gap 10), ReferralsService.create() consent-withdrawal check (gap 12).

Deployed: prod (4020) + sandbox (4021) both healthy.

Gaps

Seven findings remain open — see the shipped-note above for the closed five.

Also fully closed:

  • gap 5 — GET /clients/:id/privacy-summary returns retention window + KYC tier + consent hash + subject-rights URLs.
  • gap 9 — ClientsService.list() now emits a per-page pii.access audit event carrying subjectIds + fields when the caller decrypts contact PII; the controller passes AuditContext so ops can attribute the read.
  • gap 12 — ReferralsService.create() refuses when the matching Client (by phoneHash) has consentWithdrawnAt set and no consentEvidenceId is provided on the DTO; emits referral.create.consent_withdrawn_refused audit.
  • gap 10 — ReferralsService.issuePolicy() refuses when the linked client is missing firstName / lastName / phone / nationalId; emits policy.issue.client_identifiability_refused audit.

Also fully closed:

  • gap 8 — New ClientKycRefreshJob (daily 02:00 UTC). Reads client.kyc_refresh_days_enhanced policy (default 365) and flips stale enhanced-tier clients: stamps Client.kycRefreshDueAt = now + 30d grace + emits client.kyc.refresh_due audit.
  • gap 4 — CreateClientDto.consentAt + consentEvidenceId optional; when supplied, ClientsService.create() stamps Client.consentAt + emits client.consent.captured_at_create audit so compliance can pivot by intake-time consent capture.
  • gap 3 — ClientsService.merge() emits a companion client.merge.cascade audit event carrying per-domain counts (referrals / policies / claims / notifications / consents / OTPs) so subject-rights reviewers can confirm the erasure cascade landed everywhere.
  • gap 6 — ClientsService.create() runs SanctionsService.screenForPayout('client', id) best- effort when at least one of phone/email/nationalId is supplied; live-hit results in immediate soft-delete of the row + throws forbidden + emits client.create.sanctions_hit audit.
  • gap 7 — CreateClientDto.dobPurpose enum (underwriting_age_band / beneficiary_verification / kyc_identity_confirm / other) is required whenever dob is supplied; create() refuses dob without a declared purpose + persists it to Client.dobPurpose for auditors.

Acceptance criteria

#AC
3merge() audit event enumerates every FK domain it touched (referrals / policies / claims / notifications / consents / OTPs); emits client.merge.cascade with counts.
4POST /clients optional consentAt + consentEvidenceId — when supplied, service stamps Client.consentAt + consentEvidenceId + audit client.consent.captured_at_create.
5New GET /clients/:id/privacy-summary returns { retentionUntil, kycTier, consentVersion, subjectRightsUrl, portabilityUrl }. Public to the subject via client-portal; permission-gated for staff.
6POST /clients invokes SanctionsService.screen('client', ...) best-effort when at least one of phone / email / nationalId is supplied. Hit → refuse creation + client.create.sanctions_hit audit.
7CreateClientDto.dob + gender DTO comments carry a purpose statement; dob only accepted when the caller supplies dobPurpose (soft enum: underwriting_age_band / beneficiary_verification / other).
8New client.kyc_refresh_days_enhanced policy (default 365). ClientKycRefreshJob daily sweep flips stale enhanced tiers past window + emits client.kyc.refresh_due.
9list(withPii=true) invokes AuditService.recordPiiAccess() per row decrypted.
10PoliciesService.issue refuses when the linked client is missing any of firstName / lastName / phone / nationalId. Refusal audit: policy.issue.client_identifiability_refused.
12ReferralsService.create() refuses when Client.consentWithdrawnAt is set and no fresh consentEvidenceId is provided on the referral. Refusal audit: referral.create.consent_withdrawn_refused.

Phased implementation plan

Phase 1 — shipped ✅ (cl-20260720-1333)

Closed gaps 1, 2, 11 — name cipher + hash siblings + throttle on POST /clients + idempotent-hit audit.

Phase 2 — remaining HIGH follow-up

Gaps 3, 4, 5, 6 — merge cascade audit, consent capture at create, subject-rights endpoint, first-touch CDD.

Phase 3 — remaining MEDIUM follow-up

Gaps 7, 8, 9, 10, 12 — dobPurpose DTO enforcement, KYC refresh job, PII-access audit, policy-issue identifiability gate, consent-withdrawal referral gate.