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).
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.
Schemes must match. Destination must be NOT_ACTIVATED. Source must be ACTIVATED (checked inside TribeService).
resolveRegistrationLock(sourceCard) — if source is SUSPENDED or BLOCKED and has an unfunded KycLockEntity, branch to the KYC-lock clone path (section 03). Otherwise continue here.
Finds the source card's PersonPartnerPaymentCardEntity link and creates the same link for the destination card.
getCardStatus(sourceCard) — source must be ACTIVATED. Any other status is rejected with WrongCardStatusException.
getCardStatus(destinationCard) — if NOT_ACTIVATED, calls activateCard(destinationCard) to activate it at Tribe. If any other non-active status, rejected.
Validates source has sufficient balance for request.getAmount(). Then tribeHttpClient.unloadCard() — single Tribe call debits source and credits destination atomically.
TribeService.blockedCard(sourceCard) — status → BLOCKED. Currently called inside unloadCard but should move to the facade (see note below).
Deletes PersonPartnerPaymentCardEntity for the source card without suspending it (already blocked).
CardTransferService.recordSuccessfulTransfer() — persists CardTransferEntity with status COMPLETED and the transferred amount.
TransferMoneyRequest.amount. If a caller sends less than the full balance (e.g., 5.00 on a 50.00 card), the transfer succeeds — but the source card is still BLOCKED and unlinked afterward. The remaining 45.00 is stranded on a blocked card with no owner. The code does not distinguish "full replacement" from "partial transfer" — it always blocks and unlinks regardless of the amount transferred.
blockedCard call is currently buried inside TribeService.unloadCard() (line 213). It should move to the facade so the facade owns all post-transfer behavior — especially once partial transfers (between active cards, no blocking) are added.
| card | before | after | tribe call |
|---|---|---|---|
| source | activated | blocked | changeCardStatus → B |
| destination | not activated | activated | activateCard (inside unloadCard) |
Same endpoint as section 01 — 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.
TribeService.blockedCard(sourceCard) — block first to prevent concurrent clone races. A second request hitting this step sees the card already BLOCKED and gets WrongCardStatusException.
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.
CardTransferService.recordSuccessfulTransfer() — records with BigDecimal.ZERO amount (no funds moved). Person ID is null (KYC-locked cards are not yet linked to a person).
resolveRegistrationLock checks for BLOCKED status too.
blockedCard() internally does getCardStatus + changeCardStatus as two HTTP calls, so there's still a narrow TOCTOU window. True elimination would require DB-level locking or Tribe API idempotency.
| card | before | after | tribe call |
|---|---|---|---|
| source | suspended | blocked | changeCardStatus → B |
| destination | not activated | not activated | none (stays unactivated until KYC passes) |
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.
Both cards resolved from the action's AccessScope. Validates they belong to the same program and that source ≠ destination account.
Looks up SPI card IDs and account IDs for both source and destination via EntityExtRef.
Transfers.unload(srcCard, destCard, srcAccount, destAccount, amount, ref, "p2pFundTransfer") — single Tribe call moves funds between card accounts.
Action status → COMPLETE. No card status changes — both cards remain ACTIVATED.
| card | before | after | tribe call |
|---|---|---|---|
| source | activated | activated | none |
| destination | activated | activated | none |
KycLockEntity. Both cards stay active and owned by the same person. This is the model for "partial transfers" — moving some funds between active cards.
recognizeConflict (pending action check). Tribe itself will reject the unload if the source card isn't active, but the error surfaces as a raw Tribe API error rather than a clear validation message. Compare with TribeService.unloadCard() in core-service which explicitly validates both cards' status before calling Tribe.
| 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 |