Debit Cards

Debit cards are Usio-tokenized cards used for pinless debits and credits. Card records are DEBITCARD# items in the site-payments single table, mirrored to pinless-default-card when the card is the user’s primary for all payment types (schema, enums).

Adding a Card

Endpoint Behavior

POST /{user_id}/debit-card (v1)

Runs the Card-Change Rules (v1) (402 on open floats or loans, 409 when a change is not allowed), saves the card as primary ALL, activates the user (User Service), sends the First-Debit Event, triggers underwriting recalculation, sends debit_card_updated (Segment), writes a change-history row.

POST /{user_id}/payments/debit-cards (v2)

Suspended-card check (409 CARD_SUSPENDED) → open floats and loans check (412) → demote the existing primary → save → underwriting recalculation → activate the user → first-debit event → change-history row. No debit_card_type saves the card as primary; a secondary card needs an explicit non-primary type (400 if the user has no primary).

POST /{user_id}/payments/debit-cards/prequalify

The v2 path without the floats and loans check, activation, or recalculation.

The v2 floats and loans check runs only when the new card is primary and the user already has a valid primary card; a user without a valid primary card can always add one. A usio_card_id in the request is stored at creation; cards created without one receive it from the first pinless attempt — After submission.

Source: pkg/api/debit_cards.go, pkg/api/debit_cards_v2.go

Card-Change Rules (v1)

GET /{user_id}/debit-card/check and the v1 save evaluate these rules in order, stopping at the first decisive one. A support override on the latest change-history row (is_override = true) allows the change outright.

Rule Condition Result

Fraud window

The user has prior change rows, joined less than DEBIT_CARD_FRAUD_WINDOW_HOURS ago (24 in prod, 0 elsewhere), and this is a save

User banned (User Service); evaluation continues

No usable card

No primary card, or the primary card is invalid

Allowed

Open floats

Float Service reports active floats

402, has_open_floats

Open loans

LOC Service reports an OPEN loan

402, has_open_loans

Change buffer

CARD_CHANGE_BUFFER days (0) have passed since the last change row

Allowed; otherwise can_be_changed = false with date_of_next_change and days_until_next_change

Recent bank change

A Transactions Service item was created within ±BANK_CHANGE_WINDOW days (1) of now

Allowed

Source: pkg/api/debit_cards.go (checkCanChangeCard), deploy/lambda.tf, deploy/variables.tf

Suspended Cards

suspended_on is set by the return-code engine on a suspend-flagged decline, or by an admin through PATCH /admin/{user_id}/payments/debit-cards/{debit_card_id}.

  • Adding a card with a usio_card_id queries GSI2 for every record with that ID, across all users and statuses. Any record with suspended_on set rejects the add with 409 CARD_SUSPENDED and writes an admin note on the user (Admin API, best-effort). Cards without a usio_card_id skip the check.

  • Card responses report is_valid = false while suspended_on is set; the stored validity is unchanged.

  • Pinless debits on a suspended card are blocked before submission — Pinless gates.

  • The admin PATCH sets suspended_on from a timestamp, clears it from an empty string, and leaves it unchanged when omitted. Clearing one record does not make the physical card addable while another record still carries the attribute.

Change History

Rows in pinless-changes (schema) are snapshots of the card after a change, returned by GET /{user_id}/debit-card/changes.

Written on is_override

Card added (v1, v2, prequalify)

false

PATCH /{user_id}/payments/debit-cards/{debit_card_id}

false

PATCH /admin/{user_id}/payments/debit-cards/{debit_card_id}

true

POST /{user_id}/debit-card/force

true, with status = REMOVED

Ordinary deletes write nothing.

Removal

DELETE /{user_id}/debit-card, POST /{user_id}/debit-card/force, and DELETE /{user_id}/payments/debit-cards/{debit_card_id} set status = REMOVED on the item and delete the pinless-default-card row when the card was primary ALL. The two v1 paths remove every ACTIVE card the user holds; the v2 path removes the one card.

First-Debit Event

A card save (v1 or v2) sends first_debit_card_connected to Segment and AppsFlyer (server-to-server, per platform) once per user lifecycle:

  1. Look up the user (User Service) for appsflyer_id, app_platform and firebase_instance_id; the request’s platform and AppsFlyer headers are fallbacks.

  2. Acquire the first_debit_card_connected marketing lock. Already held → skip.

  3. Send to Segment with appsflyer_id, app_platform and the Firebase app instance ID as firebase_install_id (sent even when blank), then AppsFlyer. A Segment failure, or a lock error other than a timeout, releases the lock so the next card save retries; AppsFlyer errors and unknown platforms keep it.

In test the event is named first_debit_card_connected_test.

Source: pkg/api/first_debit_notifier.go

Token Refresh

  1. site-payments-usio-refresh-scheduler (daily 15:00 UTC) queries cards expiring this month: GSI1 DEBITCARDEXP#<MMYYYY> on the single table plus card_exp on pinless-default-card, de-duplicated by credit + debit token pair and excluding STALE cards. One SQS message per card.

  2. site-payments-usio-refresh-worker submits refresh requests for the card’s credit and debit tokens against the main Usio credentials and stores a refresh crumb holding both confirmation IDs.

  3. For a card that already has a crumb, the worker fetches the results instead: both succeeded → expiration_date is updated (the tokens do not change); a failure → status = STALE; still processing → left for the next run.

Source: pkg/usio/refresher/, pkg/dynamo/debit_card.go (GetExpiring)

Admin and QA Endpoints

Endpoint Effect

POST /admin/{user_id}/payments/debit-cards/{debit_card_id}/update

toggle_failure swaps the card’s Usio tokens so the next pinless attempt fails; toggle_invalid flips is_valid

PATCH /admin/{user_id}/payments/debit-cards/{debit_card_id}

Sets is_valid and suspended_on; skips the floats and loans check; writes an override change row

PATCH /{user_id}/debit-card/toggle-failure, PATCH /admin/{user_id}/payments/debit-cards/{debit_card_id}/toggle-failure

Swap the card’s Usio tokens (QA failure injection)

GET /admin/{user_id}/payments/retry-limit

Retry-limit ledger view — Retry limit

Source: pkg/api/admin.go, pkg/api/qa_backend.go, pkg/api/retry_limit.go

Debit Fixer

scripts/debit-fixer reads test_users.csv and enqueues {"user_id": …​} messages to site-payments-debit-fixer. For each user, site-payments-debit-card-fixer loads the ACTIVE cards, removes all but one, and re-saves the remaining card as primary ALL when its GSI1 key lacks the DEBITCARDEXP# prefix.

Source: pkg/debitfixer/debit_fixer.go, scripts/debit-fixer/main.go