Summary
KeySecure charges the card package open fee to the cardholder account balance. Issuing a card with an empty card balance therefore always returned 3008 ("insufficient balance"), and the card stayed APPLYING forever. This MR funds the fee from the user's USD wallet before card/apply. It also fixes two related problems found while testing on the sandbox.
-
Fund before apply (
card-issuance.service.ts):- If the card's provider-spendable balance (balance − pending swipes − PENDING In transfers) is below the package
open_cost, apply tops up the shortfall from the USD wallet. The top-up goes through the existing deposit flow (card_transfersIn, deposit fee waived), in the same DB transaction as the card row. -
card/applyis only called once that transfer is APPLIED. - Concurrent applies on the same card account are serialised by an advisory lock.
- A conditional claim on
submitted_atmakes sure the request path and the sweeper never both submit.
- If the card's provider-spendable balance (balance − pending swipes − PENDING In transfers) is below the package
-
Sweeper (
reconcileApplying):- For an unsubmitted card, it waits while the top-up is PENDING.
- If the top-up FAILED, it fails the card. The deposit's own
onFailedhas already returned the wallet money. - Otherwise it claims the card and submits.
-
open_cost: read from/card/package/list(fee_detail.open_cost) and cached for 10 minutes. A missing key counts as 0. - 3008: now a refusal. The card is FAILED and the internal issue fee refunded. If apply is refused after the top-up landed, the money stays on the card balance.
-
Wallet too low: returns 422
INSUFFICIENT_BALANCEand creates no card row. -
Migration
1790200000000: addskeysecure_cards.funding_transfer_idandsubmitted_at. Existing rows are backfilled as submitted. -
KeySecure list windows: list endpoints compare
end_timeby date only and exclude the end date, so today's rows were missing.listParamsnow roundsend_timeup to the next 00:00 UTC. This fixes the stale reserve balance and the same-dayAPPLYINGcard lookup. -
Reconciliation CARD: now
SUM(card_accounts) + reserve.CARD_USD + fee_revenue.CARD_USD − pending swipes == reserve fund. The cardholderaccount.infosum is dropped. On the sandbox, a transfer In does not move the reserve fund; only real spend (fees, swipes) does.
Testing
-
yarn jest src/modules/card-account src/modules/treasury src/modules/keysecure src/modules/transactions: all pass. -
RUN_DB_TESTS=1on a disposable local DB: 29/29 card-account and treasury suites pass, including the card-flow invariant spec with an open-fee case. - Migration run, revert and run again on that DB: OK.
-
yarn buildand eslint on the changed files are clean. 4 eslint errors inreconciliation.service.spec.tspredate this branch. - Not yet checked end to end on the sandbox. That needs this branch on staging.
Notes / follow-ups
- Two applies at once: the second can still get 3008 until the first card's Open_card fee settles. It ends FAILED and nothing is lost.
- Timeout while reading the package fee: returns 500. Nothing has been created, so a retry is safe.
-
Two comments in
reconciliation.service.tsto tidy: one calls the pending-swipe assumption verified when it isn't yet, and one still mentionsaccount.info. -
After deploy:
- Today's manual sandbox test (5 USD transfer In, 2 USD fee) is not in our books, so CARD will show a −2 difference until it is adjusted.
- Cards failed with
APPLY_TIMEOUTshould be checked against/card/list. The old date filter may have failed cards that are live at KeySecure.