Registration & KYC -- The Seams
State of Play -- Start Here
The design says what to build; this says where it can move. A map of every contract the redesign must live within — the shared DTO, the shared database objects, and the cross-service HTTP calls — each labelled fixed (changing it is a cross-repo or prod migration), flexible (reshapeable inside axis), or branch-soft (unreleased on feat/246 — shape it before it merges). Every claim is verified against axis / platform-core / core / .NET source, and the live prod schema.
- The DTO looks like the hard seam — but it's soft with an ordered rollout. The
PaymentCardfieldsrequiresKyc/kycLocked/deferredLoadAmountpass through the .NET tier unbranched and are branched on by two Angular UIs. But every branching consumer is our code, so the names/meanings are reshapeable via expand/contract. - The prod schema is small and confirmed.
cpm.payment_card.{kyc_locked, requires_kyc},cpm.kyc_lock(7 columns, noinitial_funded_date),core.program_configurationflags. - The HTTP calls the design needs already exist — the release endpoint (overloaded), the verdict read, the cardholder forward. One carries a live cross-tenant IDOR.
- The big lever is unreleased. core's entire KycLock machinery +
initial_funded_dateare branch-only (PR #273, open) — prod-confirmed absent. This is the co-design window. - One framing correction: platform-core owns no database schema — no migrations, no entities. The lock tables are axis-owned.
- The seam to watch:
feat/246makes core read & write axis'scpm.kyc_lockdirectly — the opposite of the design's "axis is sole owner." Resolve before #273 merges.
The Three Buckets
Every seam below carries one of these. The whole point of the map is which bucket each object is in.
| Bucket | What it means | Cost to change |
|---|---|---|
| FIXED | A published cross-repo contract -- a wire DTO field name, or a column that exists in prod. | Coordinated multi-repo change, or a Flyway migration against live data. Breaking if done unilaterally. |
| FLEXIBLE | Axis-internal: entities, mappers, the predicate, handlers, table shapes axis owns. | An ordinary axis PR. No external coordination, provided the fixed surface keeps its shape. |
| BRANCH-SOFT | Code/schema that exists only on the unmerged feat/246-card-expiry branch -- not yet released anywhere. | Free to reshape now. Becomes FIXED the moment PR #273 reaches prod. |
Seam 1 -- The DTO / Wire Contract
platform-core PaymentCard — looks like the hardest seam — but soft via expand/contract, because every branching consumer is ours.
The three lock fields — response-only, released
| Field | Type | Annotation | Bucket |
|---|---|---|---|
| requiresKyc | Boolean | @Expose(deserialize=false) | FIXED name |
| kycLocked | Boolean | @Expose(deserialize=false) | FIXED name |
| deferredLoadAmount | DeferredLoadAmount{BigDecimal amount, String currency} | @Expose(deserialize=false) | FIXED name |
Where the fields are produced — all axis, all flexible
// axis PaymentCardEntity.toPaymentCardDTO(accessLevel) :359 dto.setRequiresKyc(getRequiresKyc()); // :392 dto.setKycLocked(isKycLocked()); // :393 dto.setDeferredLoadAmount( getDeferredAmount(getId())); // :397, from KYC_LOCK // getRequiresKyc() :520 -- the D4 "lie" if (this.requiresKyc == null) return KycLockService.isKycRequired(this); // kyc flag ALONE -- reg-only card reads falseConsumers — the .NET tier is pipes; the Angular UIs are the sinks
| Consumer | Uses the fields to... | Kind |
|---|---|---|
| card-balance-ui (cardholder) | if (requiresKyc && kycLocked) -> prompt "registration required" and route to /cards/kyc | The live registration funnel |
| setldhub (admin) | Render lock status (kycLocked), "Registration Required" (requiresKyc), the deferred amount; mask available balance while requiresKyc && kycLocked | Admin display + gating |
| .NET services (setldhub-api, card-balance-api, etc.) | Deserialize -> re-serialize; no branch | Pass-through pipe |
The card-status enum — no "pending" value
Constants.CardStatus (platform-core) is a processor mirror: Activated, Blocked, Suspended, Risk, Stolen, Lost, Expired, NotActivated, Fraud. There is no "pending verification" value. The design's choice holds: keep verification off the status enum (it's a separate concern, modelled as the pending-activation record).
Seam 2 -- Shared Database Objects
Three tables across two schemas. Columns confirmed against setldpay_prod.
cpm.kyc_lock — axis-owned (prod: 7 cols, 39,931 rows)
| Prod column | Type | Holds |
|---|---|---|
| id | bigint | PK = the card id (one row per card's parked load) |
| spicardid | bigint | Tribe's card id |
| referencenumber / description | varchar | Load reference + label |
| currencyamount | json | The deferred amount |
| load_channel | json | Added by axis V3 (the one snake_case col) |
| createdat | timestamp | Insert-only |
cpm.payment_card — axis-owned (prod: 1,857 kyc_locked)
| Prod column | Type | Notes |
|---|---|---|
| kyc_locked | boolean | The hold flag -- set on activate, cleared on release |
| requires_kyc | boolean | Per-card override; else derived from the kyc flag (getRequiresKyc) |
core.program_configuration — core-owned, axis reads cross-schema
| Prod column | Role | State |
|---|---|---|
| is_kyc_required / is_registration_required | The live triggers -- feed the predicate | Released, core-owned |
| is_fund_loaded_during_initial_activation | Intended fund-timing flag | Dead -- no reader in axis or core |
| is_fund_loaded_after_registration | Intended fund-timing flag | Dead -- no reader in axis or core |
| cdd2_source_of_funds_required | (adjacent KYC flag, not lock-related) | Released |
| card_expiry_scenario_id | Card-expiry FK (V49) | Branch-only -- absent in prod |
What actually runs in prod — the config space is tiny
| kyc | reg | fund_init | fund_postreg | Lock? | Prod designs / programs |
|---|---|---|---|---|---|
| t | t | f | t | LOCKED | 65 / 23 |
| f | f | f | f | open | 9 / 4 |
Seam 3 -- The HTTP Contracts
The cross-service calls the design needs — all already exist.
Release — core → axis & card-balance-api → axis
One endpoint, overloaded on personUuid. POST /api/cpm/v1/partners/{partnerExtId}/kyc/inquiries/verifyIndividual → KycInquiryHandler.onVerifyIndividual.
// onVerifyIndividual :38 if (req.getPersonUuid() != null && !req.getPersonUuid().isEmpty()) { // RELEASE branch PaymentCardEntity card = PaymentCardEntity.findById(cardId); // :47 -- UNSCOPED (the IDOR) activateAndLoadCard(cardId, card); // un-suspend + replay parked load } else { // INQUIRY branch: KycInquiryValidator + screening/comprehensive (no money) }| Caller | Body sent | Which branch |
|---|---|---|
| core (CardLinkingService -> AxisHttpClient, main) | VerifyIndividualRequest{Long cardId, UUID personUuid} | Always release |
| card-balance-api (KycScreeningsController, main) | KycInfoForward{cardId, inquiryType, personUuid?, personInfo?} | Release if the UI supplied personUuid, else inquiry |
Verdict — axis → core
The "is this person verified, to what level" read — already wired, already used. This is the channel for the design's amount-aware, never-cached verdict check.
GET /core/api/v1/{partnerExternalId}/person/{uuid} // core PersonController.getPerson :135 -> GetPersonResponse kycResults: List<KycResultDTO> { status: KycStatus, kycLevel: KycLevelEnum, validFrom, validUntil }The Branch-Soft Window
Everything core-side is unreleased. PR #273 open. Prod-confirmed absent.
core-service on feat/246-card-expiry — 12 ahead, 0 behind, PR #273 OPEN
| Artifact | Where | State |
|---|---|---|
| core KycLockEntity, KycLockRepository, KycLockService | core via PR #265 -> branch only | Branch-soft |
| CardsFacade.resolveRegistrationLock + clone-on-transfer | core CardsFacade:91,174 | Branch-soft |
| initial_funded_date on cpm.kyc_lock (axis V6 + core branch entity) | axis + core branches | Branch-soft, absent in prod |
| core.program_configuration.card_expiry_scenario_id (V49) | core branch | Branch-soft, absent in prod |
How the Design's Flows Land on the Seams
Each flow, and exactly which seam it touches — and in which bucket the change sits.
| Design flow | DTO | DB | HTTP | Net change bucket |
|---|---|---|---|---|
| Activation (no funds) | Emit kycLocked from record-presence | Create pending record (no amount) | None (local design read) | Axis-only |
| Activate-and-load (+ amount) | Emit deferredLoadAmount from record | Record carries the deferred amount | Balance check; verdict read if person linked | Axis-only |
| Release (verification event) | Fields flip to usable on clear | Atomic claim of the record (fund-once) | Scope + split the endpoint; verdict re-check | Axis + core co-design |
| Replacement (carry-over) | Unchanged | Record moves with the card | None if axis owns it | Axis (vs core clone-on-transfer today) |
The pattern: every flow is axis-internal except where it touches core — the verdict read (already wired) and the replacement/transfer path.
Phasing Against the Seams
| Phase | Seams touched | Coordination |
|---|---|---|
| Security fix (standalone or Phase 1) | HTTP -- scope the release lookup, split/role the endpoint | Axis-only -- no external coordination |
| Phase 1 -- axis | DTO (derive-and-keep-emit), DB (kyc_lock -> pending-activation record, fund-once), HTTP (verdict re-check, record-free idempotent release), the predicate & D6/keying fixes, truth-table tests | Axis-only -- DTO names unchanged => consumers untouched |
| Phase 2 -- core + Stream D | DB (initial_funded_date fold-in; who owns cpm.kyc_lock), replacement/transfer ownership, retire dead fund flags (core migration) | Co-design with feat/246 before #273 merges |