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).
Full Card Transfer (Replacement)
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.
core-service validate cards
Schemes must match. Destination must be NOT_ACTIVATED. Source must be ACTIVATED (checked inside TribeService).
database check for KYC registration lock
resolveRegistrationLock(sourceCard) — if source is SUSPENDED or BLOCKED and has an unfunded KycLockEntity, branch to the KYC-lock clone path (§2). Otherwise continue here.
database link destination to source owner
Finds the source card's PersonPartnerPaymentCardEntity link and creates the same link for the destination card.
tribe validate source status
getCardStatus(sourceCard) — source must be ACTIVATED. Any other status is rejected with WrongCardStatusException.
tribe activate destination if needed
getCardStatus(destinationCard) — if NOT_ACTIVATED, calls activateCard(destinationCard) to activate it at Tribe. If any other non-active status, rejected.
tribe check balance + unload
Validates source has sufficient balance for request.getAmount(). Then tribeHttpClient.unloadCard() — single Tribe call debits source and credits destination atomically.
tribe block source card
TribeService.blockedCard(sourceCard) — status → BLOCKED. Currently called inside unloadCard but should move to the facade (see note below).
database unlink source card
Deletes PersonPartnerPaymentCardEntity for the source card without suspending it (already blocked).
database record transfer
CardTransferService.recordSuccessfulTransfer() — persists CardTransferEntity with status COMPLETED and the transferred amount.
| card | before | after | tribe call |
|---|---|---|---|
| source | activated | blocked | changeCardStatus → B |
| destination | not activated | activated | activateCard (inside unloadCard) |
KYC-Locked Card Transfer (Clone Lock)
core-service Same endpoint as §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.
tribe block source card
TribeService.blockedCard(sourceCard) — block first to prevent concurrent clone races. A second request hitting this step sees the card already BLOCKED and gets WrongCardStatusException.
database clone KYC lock to destination
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.
database record transfer
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 | before | after | tribe call |
|---|---|---|---|
| source | suspended | blocked | changeCardStatus → B |
| destination | not activated | not activated | none (stays unactivated until KYC passes) |
P2P Fund Transfer
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.
database resolve source and destination
Both cards resolved from the action's AccessScope. Validates they belong to the same program and that source ≠ destination account.
database get Tribe IDs
Looks up SPI card IDs and account IDs for both source and destination via EntityExtRef.
tribe unload source → destination
Transfers.unload(srcCard, destCard, srcAccount, destAccount, amount, ref, "p2pFundTransfer") — single Tribe call moves funds between card accounts.
database complete action
Action status → COMPLETE. No card status changes — both cards remain ACTIVATED.
| card | before | after | tribe call |
|---|---|---|---|
| source | activated | activated | none |
| destination | activated | activated | none |
Where Things End Up
- PK = cardId (one lock per card)
- Stores: spiCardId, amount, currency, channel, reference
- initial_funded_date = NULL (unfunded)
- initial_funded_date stamped atomically via UPDATE
- Funds transferred from partner account → card
- Card status: SUSPENDED → ACTIVATED
- New lock created for destination card
- Copies amount, channel, reference from source
- Source card: SUSPENDED → BLOCKED
- initial_funded_date stays NULL
- Card remains SUSPENDED indefinitely
- Funds never loaded — still in partner account
| 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 |