Internal Docs/Card transfers — end to end
card transfers · three flows · two services

card
transfers + kyc locks

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).

00

the three flows

core-service · CardsFacade
full transfer
unload src → dest · block src
or
core-service · CardsFacade
KYC-lock clone
clone lock · block src · no $
or
axis-service · CardAccountTransferTask
P2P transfer
unload src → dest · both stay active
all fund movements go through Tribe (RSA-encrypted HTTP) — unload, load, activate, block
card-to-card transfer KYC lock clone Tribe (external) cpm database
01

full card transfer (replacement)

core-service

POST /core/api/v1/{partnerExternalId}/cards/transfer → CardsFacade.transfer()

card replacement — move all funds to a new card

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.

validate cards core-service

Schemes must match. Destination must be NOT_ACTIVATED. Source must be ACTIVATED (checked inside TribeService).

check for KYC registration lock database

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.

link destination to source owner database

Finds the source card's PersonPartnerPaymentCardEntity link and creates the same link for the destination card.

validate source status tribe

getCardStatus(sourceCard) — source must be ACTIVATED. Any other status is rejected with WrongCardStatusException.

activate destination if needed tribe

getCardStatus(destinationCard) — if NOT_ACTIVATED, calls activateCard(destinationCard) to activate it at Tribe. If any other non-active status, rejected.

check balance + unload tribe

Validates source has sufficient balance for request.getAmount(). Then tribeHttpClient.unloadCard() — single Tribe call debits source and credits destination atomically.

block source card tribe

TribeService.blockedCard(sourceCard) — status → BLOCKED. Currently called inside unloadCard but should move to the facade (see note below).

unlink source card database

Deletes PersonPartnerPaymentCardEntity for the source card without suspending it (already blocked).

record transfer database

CardTransferService.recordSuccessfulTransfer() — persists CardTransferEntity with status COMPLETED and the transferred amount.

Partial amount gotcha: The API accepts any amount via 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.
Design note: The 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 status changes
cardbeforeaftertribe call
sourceactivatedblockedchangeCardStatus → B
destinationnot activatedactivatedactivateCard (inside unloadCard)
02

KYC-locked card transfer (clone lock)

core-service

Same endpoint as section 01 — branched inside CardsFacade.transfer() when resolveRegistrationLock returns a lock

replacing a card that was never funded

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.

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.

clone KYC lock to destination 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.

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).

Partial failure: If step 1 (block) succeeds but step 2 (clone) fails, the source card is permanently blocked with no clone. This is the safe failure state — funds are frozen, no double-clone possible. Recovery requires manual ops investigation. The retry path works because resolveRegistrationLock checks for BLOCKED status too.
Race prevention: Block-before-clone mitigates concurrent double-cloning. But 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 status changes
cardbeforeaftertribe call
sourcesuspendedblockedchangeCardStatus → B
destinationnot activatednot activatednone (stays unactivated until KYC passes)
03

P2P fund transfer

axis-service

POST /api/cpm/v1/partners/{partnerId}/cards/actions/prepare → CardAccountTransferTask

move funds between active cards in the same program

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.

resolve source and destination database

Both cards resolved from the action's AccessScope. Validates they belong to the same program and that source ≠ destination account.

get Tribe IDs database

Looks up SPI card IDs and account IDs for both source and destination via EntityExtRef.

unload source → destination tribe

Transfers.unload(srcCard, destCard, srcAccount, destAccount, amount, ref, "p2pFundTransfer") — single Tribe call moves funds between card accounts.

complete action database

Action status → COMPLETE. No card status changes — both cards remain ACTIVATED.

card status changes
cardbeforeaftertribe call
sourceactivatedactivatednone
destinationactivatedactivatednone
Key difference from full transfer: P2P transfers don't block the source card, don't change any link/ownership, and don't touch KycLockEntity. Both cards stay active and owned by the same person. This is the model for "partial transfers" — moving some funds between active cards.
Missing validation: Unlike the full transfer flow (core-service), axis-service's P2P transfer does not validate card status before calling Tribe. There is no check that source or destination are ACTIVATED. The only pre-checks are same-program validation and 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.
04

where things end up

KycLockEntity lifecycle
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, section 01)
  • initial_funded_date stamped atomically via UPDATE
  • Funds transferred from partner account → card
  • Card status: SUSPENDED → ACTIVATED
cloned (transferred)
during card replacement (core-service, section 03)
  • 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
flowtablestatusamountservice
full transfercore.card_transferCOMPLETEDtransferred amountcore-service
KYC-lock clonecore.card_transferCOMPLETEDBigDecimal.ZEROcore-service
KYC verificationcpm.tran_referenceload amountaxis-service
P2P transfercpm.action_requestCOMPLETEin params_jsonaxis-service