Card Transfers
The Three Flows
Money moves between cards through three flows, split across core-service and axis-service. Full card transfer replaces one card with another (unload + block). P2P fund transfer moves funds between active cards. When a card is KYC-locked (never funded), the transfer clones the lock to the destination instead of moving money. The KycLockEntity is created during activation and consumed when KYC passes (axis-service) or cloned during card replacement (core-service).
| Flow | Service | What Happens |
|---|---|---|
| Full transfer | core-service (CardsFacade) | Unload src -> dest, block src |
| KYC-lock clone | core-service (CardsFacade) | Clone lock, block src, no money moved |
| P2P transfer | axis-service (CardAccountTransferTask) | Unload src -> dest, both stay active |
Full Card Transfer (Replacement)
Service: core-service · POST /core/api/v1/{partnerExternalId}/cards/transfer → CardsFacade.transfer()
The caller provides a source card (being replaced) and a destination card (the replacement). All funds are moved from source to destination via a single Tribe unloadCard call that debits one card and credits the other atomically. The source card is then BLOCKED.
Transfer Steps
| Step | Tag | Description |
|---|---|---|
| 1. Validate cards | core-service | Schemes must match. Destination must be NOT_ACTIVATED. Source must be ACTIVATED (checked inside TribeService). |
| 2. Check for KYC lock | database | resolveRegistrationLock(sourceCard) -- if source is SUSPENDED or BLOCKED and has an unfunded KycLockEntity, branch to the KYC-lock clone path (section 02). Otherwise continue here. |
| 3. Link destination | database | Finds the source card's PersonPartnerPaymentCardEntity link and creates the same link for the destination card. |
| 4. Validate source status | Tribe | getCardStatus(sourceCard) -- source must be ACTIVATED. Any other status is rejected with WrongCardStatusException. |
| 5. Activate destination | Tribe | getCardStatus(destinationCard) -- if NOT_ACTIVATED, calls activateCard(destinationCard). If any other non-active status, rejected. |
| 6. Check balance + unload | Tribe | Validates source has sufficient balance for request.getAmount(). Then tribeHttpClient.unloadCard() -- single Tribe call debits source and credits destination atomically. |
| 7. Block source card | Tribe | TribeService.blockedCard(sourceCard) -- status -> BLOCKED. |
| 8. Unlink source card | database | Deletes PersonPartnerPaymentCardEntity for the source card without suspending it (already blocked). |
| 9. Record transfer | database | CardTransferService.recordSuccessfulTransfer() -- persists CardTransferEntity with status COMPLETED and the transferred amount. |
Card Status Changes
| Card | Before | After | Tribe Call |
|---|---|---|---|
| source | ACTIVATED | BLOCKED | changeCardStatus -> B |
| destination | NOT_ACTIVATED | ACTIVATED | activateCard (inside unloadCard) |
KYC-Locked Card Transfer (Clone Lock)
Service: core-service · Same endpoint as section 1 — branched inside CardsFacade.transfer() when resolveRegistrationLock returns a lock.
When the source card is SUSPENDED (or BLOCKED from a previous failed attempt) and has an unfunded KycLockEntity, no money exists on the card to transfer. Instead, the lock is cloned to the destination card so it inherits the pending load instruction. The source card is blocked.
Clone Steps
| Step | Tag | Description |
|---|---|---|
| 1. Block source card | Tribe | TribeService.blockedCard(sourceCard) -- block first to prevent concurrent clone races. A second request hitting this step sees the card already BLOCKED and gets WrongCardStatusException. |
| 2. Clone KYC lock | database | KycLockService.cloneKycLock(destCardId, existingLock) -- creates a new KycLockEntity with the destination card's ID and SPI card ID, copying amount, currency, channel, and reference from the source lock. |
| 3. Record transfer | database | CardTransferService.recordSuccessfulTransfer() -- records with BigDecimal.ZERO amount (no funds moved). Person ID is null (KYC-locked cards are not yet linked to a person). |
Card Status Changes
| Card | Before | After | Tribe Call |
|---|---|---|---|
| source | SUSPENDED | BLOCKED | changeCardStatus -> B |
| destination | NOT_ACTIVATED | NOT_ACTIVATED | none (stays unactivated until KYC passes) |
P2P Fund Transfer
Service: axis-service · POST /api/cpm/v1/partners/{partnerId}/cards/actions/prepare → CardAccountTransferTask
A peer-to-peer fund transfer between two cards in the same PartnerProgram. Neither card is blocked or suspended after — both stay active. Uses the same Tribe unload API as the full transfer but without any post-transfer status change.
Transfer Steps
| Step | Tag | Description |
|---|---|---|
| 1. Resolve source and dest | database | Both cards resolved from the action's AccessScope. Validates they belong to the same program and that source != destination account. |
| 2. Get Tribe IDs | database | Looks up SPI card IDs and account IDs for both source and destination via EntityExtRef. |
| 3. Unload source -> dest | Tribe | Transfers.unload(srcCard, destCard, srcAccount, destAccount, amount, ref, "p2pFundTransfer") -- single Tribe call moves funds between card accounts. |
| 4. Complete action | database | Action status -> COMPLETE. No card status changes -- both cards remain ACTIVATED. |
Card Status Changes
| Card | Before | After | Tribe Call |
|---|---|---|---|
| source | ACTIVATED | ACTIVATED | none |
| destination | ACTIVATED | ACTIVATED | none |
Where Things End Up
KycLockEntity Lifecycle
| State | When | Details |
|---|---|---|
| Created | During activateWithLoad (axis-service) | PK = cardId (one lock per card). Stores: spiCardId, amount, currency, channel, reference. initial_funded_date = NULL (unfunded). |
| Consumed (funded) | During KYC verification (axis-service) | initial_funded_date stamped atomically via UPDATE. Funds transferred from partner account -> card. Card status: SUSPENDED -> ACTIVATED. |
| Cloned (transferred) | During card replacement (core-service) | New lock created for destination card. Copies amount, channel, reference from source. Source card: SUSPENDED -> BLOCKED. |
| Never consumed | Card expired or KYC never completed | initial_funded_date stays NULL. Card remains SUSPENDED indefinitely. Funds never loaded -- still in partner account. |
Transaction Records
| Flow | Table | Status | Amount | Service |
|---|---|---|---|---|
| Full transfer | core.card_transfer | COMPLETED | transferred amount | core-service |
| KYC-lock clone | core.card_transfer | COMPLETED | BigDecimal.ZERO | core-service |
| KYC verification | cpm.tran_reference | -- | load amount | axis-service |
| P2P transfer | cpm.action_request | COMPLETE | in params_json | axis-service |