Registration & KYC Lock Mechanism
The Shape of a Lock
A lock holds a freshly activated card back from use until a verification event releases it: axis-service activates the card, then immediately suspends it at Tribe (status T) and flags it kyc_locked. Two program-configuration flags can trigger it — registration_required and kyc_required — but today they feed a single predicate and produce a physically identical hold; there is no separate "registration lock" state.
Fund timing is folded into the same decision: a locked card defers its initial load, parking the amount in a KycLockEntity row to be applied at release. The only thing that releases it is a verifyIndividual call.
What Turns the Lock On
KycLockService.isRegistrationLockRequired() / KycLockService.isKycRequired() / ProgramConfigurationRepository.getByDesignId().
The predicate — registration OR kyc
Every lock-related branch in the service asks one method. It loads the card's ProgramConfigurationEntity by designId and returns true if either flag is set. There is no third "lock" flag and no notion of which trigger fired — the result is one boolean.
// KycLockService.isRegistrationLockRequired(card) boolean requiresKyc = cfg.isKycRequired(); boolean requiresRegistration = cfg.isRegistrationRequired(); return requiresRegistration || requiresKyc; // config missing for designId -> false (no lock) A separate isKycRequired() returns the kyc flag alone. It does not gate any lock — it only feeds the DTO field requiresKyc (see the D4 lie in section 6).
Truth table
| reg_required | kyc_required | Lock? | DTO requiresKyc |
|---|---|---|---|
| false | false | open | false |
| true | false | LOCKED | false (the D4 lie) |
| false | true | LOCKED | true |
| true | true | LOCKED | true |
What actually exists — live data, prod vs staging
| kyc | reg | fund_init | fund_postreg | Lock? | Prod | Staging |
|---|---|---|---|---|---|---|
| t | t | f | t | LOCKED | 65 designs, 23 programs | 16 designs |
| t | t | f | f | LOCKED | none | 36 designs |
| t | f | f | f | LOCKED | none | 3 designs |
| f | t | f | t | LOCKED | none | 1 design -- Touchwood 1470 |
| f | f | f | f | open | 9 designs, 4 programs | 2 designs |
Applying the Lock -- On Activate
CardActionHandler.activateCard() (single, inline) / GroupCardActionHandler.activateCard() (group, deferred) — identical lock logic.
Activate first, then hold
Activation always runs to completion on Tribe before the lock is considered. The card is genuinely activated, its Activated status recorded — and only then, if the predicate says so, is it pulled back to Suspended.
- Already locked? Bail. If
pce.isKycLocked()is already true, throwIllicitModificationException(alreadyActivated). A locked card can't be re-activated. - Activate on Tribe.
activateCard(spiCardId, ref)— the card goes live (status A).addStatusUpdate(Activated)records it. - If
isRegistrationLockRequired→ suspend & flag. Setpce.setKycLocked(true), call TribechangeCardStatus(spiCardId, "T", 3)to Suspended, and record a second status update of Suspended. The card is now activated-then-held. NoKycLockEntityis created here — that only happens on a funding path.
Card status the handler writes
| Action | Before | After -- open design | After -- locked design | KycLockEntity created? |
|---|---|---|---|---|
| activate | not activated | activated | activated -> suspended (T) + kyc_locked | no |
| activateWithLoad | not activated | activated + funds loaded | suspended (T) + kyc_locked, load deferred | yes (by the load leg) |
Fund Timing -- Inferred from the Lock
CardActionHandler.loadFunds() / isKycRequiredAndNotYetVerified() / persistKycLock() / checkFundingBalance().
A locked load defers instead of loading
For load and activateWithLoad, the handler asks the lock predicate again — this time to decide whether to load now or park the load for later. If the card is locked and has no existing lock row, the amount, channel and reference are serialized into a KycLockEntity and the method returns without touching Tribe.
// loadFunds(amount, channel) if (isKycRequiredAndNotYetVerified(card)) { persistKycLock(cardId, spiCardId, ref, amount, channel); // park it return; // no Tribe load now } executeLoadAndRecordTransfer(...); // load now // isKycRequiredAndNotYetVerified = // no existing KycLockEntity // AND isRegistrationLockRequired(card)- Balance check — skipped when locked.
checkFundingBalance()early-returns the momentisRegistrationLockRequiredis true. A locked load is never checked for sufficient funds up front (D3). - Park the load in a KycLockEntity.
persistKycLock()writes one row keyed bycardId:currencyAmount+loadChannel(both JSON),referenceNumber,description="card load",createdAt. - Open program → load immediately. No lock →
executeLoadAndRecordTransfermoves funds and loads the card on Tribe in-thread.
The One Way Out -- verifyIndividual
POST /api/cpm/v1/partners/{partnerExtId}/kyc/inquiries/verifyIndividual → KycInquiryHandler.onVerifyIndividual → activateAndLoadCard.
Release = un-suspend at Tribe, then replay the parked load
There is exactly one code path that clears a lock. A KYC verification result posts to verifyIndividual; the handler looks the card up and runs activateAndLoadCard. It is guarded by two preconditions:
- Precondition — card must be Tribe "T".
isCardNotSuspended()reads the live Tribe status. If it is not "T", the handler logs a warning and returns early. A card that was never suspended is silently a no-op (D2). - Activate on Tribe.
changeCardStatus(spiCardId, "A", 3)brings the card back to Activated, andsetKycLocked(false)clears the flag. - Precondition — a KycLockEntity must exist.
getKycLockEntity(cardId)callsKycLockEntity.findFirstByExternalRef; if it returns null it throwsKycLockException. No row, no load, hard failure (D1). - Fund transfer, then load. If
isAlreadyInitiallyFunded(cardId)is false: run the A→BprocessFundTransfer, thenloadCardwith the storedcurrencyAmount+loadChannelfrom the lock row.
How each entry path lands at the release gate
The release path needs the card to be both suspended ("T") and backed by a KycLockEntity. Only one of three entry combinations satisfies both.
| How the card was locked | Card at Tribe | KycLockEntity? | verifyIndividual outcome |
|---|---|---|---|
| activate on a locked design | T | none | KycLockException -> stranded (D1) |
| activateWithLoad on a locked design | T | created | releases + loads (the only sound path) |
| load on an already-active locked card | A (never suspended) | created | no-op -- load never applies (D2) |
Code Paths & Where State Lives
Every place the lock predicate is consulted
| Entry point | Class / method | What the lock changes |
|---|---|---|
| single activate | CardActionHandler.activateCard | locked -> kyc_locked=true + Tribe "T" |
| single load / activateWithLoad | CardActionHandler.loadFunds -> isKycRequiredAndNotYetVerified -> persistKycLock | locked -> park load in KycLockEntity, skip Tribe load |
| single balance check | CardActionHandler.checkFundingBalance | locked -> early return, no balance check |
| group activate | GroupCardActionHandler.activateCard | locked -> kyc_locked=true + Tribe "T" (per card) |
| group load / staging | GroupCardActionHandler.execute (samples card 0) / loadFunds | locked -> skip A->B transfer + B->A reversal; park each load |
| release | KycInquiryHandler.activateAndLoadCard | requires "T" + KycLockEntity -> Tribe "A", clear flag, replay load |
| DTO mapping | PaymentCardEntity.getRequiresKyc | returns the kyc flag only (or per-card override) -- never reg |
| config lookup | ProgramConfigurationRepository.getByDesignId | no deleted_at / ordering -- feeds every call above |
Where the lock state is persisted
| Entity | Table / Schema | Holds |
|---|---|---|
| KycLockEntity | cpm.kyc_lock -- one row per parked load | id (= card id PK), spiCardId, currencyAmount/loadChannel (JSON), referenceNumber, description, createdAt |
| payment_card | cpm.payment_card | kyc_locked (the hold flag), requires_kyc (per-card override) |
| program_configuration | core schema, keyed by designId | is_registration_required, is_kyc_required (live triggers); is_fund_loaded_* (dead) |
| card_update_activity | cpm -- status history | Activated then Suspended on a locked activate; the audit trail of the hold |
Where It Breaks Today
The as-built defects, worst first. These are current reality, not proposals — they motivate the redesign, they don't prejudge it.
| Severity | ID | Title | Description |
|---|---|---|---|
| S1 | D1 | activate strands the card | Plain activate on a locked design sets kyc_locked=true + Tribe "T" but never creates a KycLockEntity. The release path then throws KycLockException -- the card is suspended forever. ~20 prod cards stranded today. |
| S2 | D2 | pure load defers into a dead end | load on an already-active locked card creates the KycLockEntity but never suspends the card. The release path only runs for "T" cards, so it no-ops. |
| S2 | D3 | locked designs skip the up-front balance check | Single-card checkFundingBalance early-returns when locked; the group path skips the batch transfer that is its only balance check. |
| S3 | D4 | the DTO requiresKyc lies | getRequiresKyc reflects the kyc flag only, so a registration-only card that is locked reports requiresKyc=false. Consumers can't tell it's locked from that field. |
| S3 | D5 | the is_fund_loaded_* flags are dead | The two flags meant to express fund timing have getters but no callers. Fund timing is welded to the lock decision. |
| S3 | D6 | getByDesignId ignores deleted_at + ordering | The config lookup returns list.get(0) from an unordered, unfiltered query. A soft-deleted or duplicate config row could be read non-deterministically. |