Skip to main content

Card-Core — Project Brief

Closed-loop corporate card system for Xperise (B2B travel platform). This service acts as issuer + network + acquirer in one: internal ledger, no Visa/EMV/PCI mandate, external merchants settled monthly. Companion file: openapi.generated.json (the live API contract, generated from the NestJS controllers/DTOs; §9 below records the historical deltas that shaped it).


1. Non-negotiable principles​

  1. Standalone service. Own repo, own Postgres, own K8s namespace + ArgoCD app. Never reads xperise-backend's DB. Integration surface with the platform = PSP-style API + HTTP sync/events only (Kafka deferred — the outbox model stays, so Kafka can slot in later without changing anything else).
  2. "Kệ backend" (leave the backend alone). The existing corporate wallet has no journal (raw UPDATE balance). Card-core does NOT try to fix it. Card-core is a self-balancing closed system; money enters/leaves only through external_funding / external_payout accounts. Fund/defund operations record forensic evidence (balance_before/after, raw payloads, initiated_by) because card-core's records are the only audit trail.
  3. Ledger = money only. Trip/hotel details, attribution, policy — all live outside ledger tables.
  4. One balance, one source of truth. Funding model: corporate transfers money INTO the card program (one-way handoff); from then on card-core is authoritative.
  5. Payment declines are business results, not HTTP errors. 201/200 with status: declined + decline_reason. HTTP 4xx/5xx = request/system errors only.
  6. Money and data are separate concerns. Missing enhanced_data never blocks a payment at capture time; it gates statement confirmation (data_status: incomplete). Exception: at payment-intent creation (merchant-initiated order), typed required fields ARE enforced — the provider app has this data automatically (see §8.3).

2. Tech stack (decided)​

  • NestJS, single service. Volume is B2B-low (tens of txns/minute); 80% of the code is workflow/integration surface. Keep LedgerModule + AuthEngineModule behind narrow interfaces as a future extraction seam (Go) — do not extract now.
  • Postgres (own instance). All money arithmetic happens IN Postgres (NUMERIC(18,2)).
  • Policy: GoRules ZEN engine, EMBEDDED in-process via @gorules/zen-engine (FFI, no RPC). Authoring is a corporate self-service maker-checker module inside card-core (src/policy-management, kept self-contained for a future extraction into its own service): each corporate owns its policies, key unique per corporate, versions immutable once out of draft. Publishing a version auto-binds its policy_kind — one independent active channel for card payments, hotel limits, search visibility, and booking approval; no manual bind step — and the consumer (src/policy/) fetches the JDM over the HTTP contract ({POLICY_API_URL}/corporates/{corporate_id}/policies/{key}/versions/{v}), verifies its content_hash, and caches it (DB + compiled in-memory). If a later fetch fails, keep evaluating the cached version (never skip policy silently).
  • Platform integration: HTTP, not Kafka (decided). Inbound projections (corporates, staffs, trips) are simple HTTP upserts pushed by the backend (PUT /sync/*); outbound events keep the outbox pattern and are consumed via HTTP polling (GET /events, cursor-based, at-least-once, event_id dedupe). Kafka deferred until volume demands it.
  • OTP: otplib-class library for codes; never hand-rolled crypto.
  • JWT: verify Xperise user tokens via platform JWKS (jose). Cache keys with TTL, refresh on unknown kid. Confirm with platform which claim carries user_id.
  • Ledger: self-built on Postgres (decision made after evaluating Blnk/Formance/ TigerBeetle/Midaz — OSS solves the easy 20%, adds an ops dependency; our 5 flows map to ~3 tables + invariant SQL).

3. Amount-as-string discipline (MANDATORY)​

Money never exists as a JS Number. JS is the courier, Postgres is the accountant.

  • API in/out: string, regex ^\d+(\.\d{1,2})?$. Never parseFloat/Number().
  • All arithmetic and comparisons in SQL (::numeric). SUM, UPDATE ... WHERE balance >= $1::numeric, tolerance checks (amount <= auth_amount * 1.2) — all in DB.
  • node-postgres returns NUMERIC as string by default — do NOT install custom parsers.
  • Branded type type Amount = string & { __brand: 'Amount' }; ESLint rule banning arithmetic operators and Number coercion on identifiers matching /amount|balance|total/i.
  • Rare app-layer math → big.js, string in / string out. Prefer pushing into SQL.
  • Currency: VND default, ISO 4217 field everywhere.

4. Ledger design​

Chart of accounts​

external_funding -- money IN boundary
corporate_wallet:{corporate} -- per-corporate program balance
merchant_payable:{merchant} -- what we owe each merchant
external_payout -- money OUT boundary
suspense -- reconciliation adjustments

Entry pairs (every business event = one balanced transaction)​

EventDebitCredit
Fundexternal_fundingcorporate_wallet
Capture / Salecorporate_walletmerchant_payable
Refundmerchant_payablecorporate_wallet
Merchant payoutmerchant_payableexternal_payout
Defundcorporate_walletexternal_payout

Rules​

  • Append-only. No UPDATE/DELETE on ledger lines, ever. Corrections = reversal entries.
  • Lines inserted in balanced sets (sum = 0) inside one DB transaction; enforced at the repository layer — no code path may insert a lone line.
  • UNIQUE(idempotency_key) on the transaction header. Every mutating endpoint requires an Idempotency-Key; same key + same payload → replay original result; same key + different payload → 409 idempotency_conflict.
  • Auth path concurrency: SELECT ... FOR UPDATE on the corporate account row (or advisory lock per corporate) before creating a hold. available = posted − SUM(active holds).
  • Holds live in their own table (not ledger lines): created on approve, released on capture/void, auto-expired by cron (default 48h for taxi-style merchants, configurable per merchant).

Daily invariants (cron + alert; never auto-fix)​

-- 1. System balances to zero
SELECT SUM(amount) FROM ledger_lines; -- must be 0
-- 2. No internal account negative
SELECT account_id FROM ledger_lines
WHERE account_type IN ('corporate_wallet','merchant_payable')
GROUP BY account_id HAVING SUM(amount) < 0; -- must be empty
-- 3. Available never negative per corporate
-- posted_balance − SUM(active_holds) >= 0

5. Payment flows & entry modes​

Entry modeWho initiatesVerificationNotes
qr_intent (PRIMARY)Merchant creates payment intent; employee scans QR in Xperise appApp session (UserJWT) + explicit confirm tapNo PAN exposure, no OTP. Attribution passed at confirm.
manual_pan + sms_otp (fallback, no app)Employee reads 16-digit PAN to driverSMS OTP: 4 digits, TTL 5 min, 3 attempts, then decline + lock manual_pan on card 15 minOTP generated AFTER amount is known → SMS states the exact amount.
static_code (fallback tier)PAN + printed static codeNone dynamicHard-capped low limits (e.g. 500k/txn, 1M/day), whitelist merchants only. SMS receipt as detective control.
s2sPlatform checkout (booking flow)Platform keybooking_ref/trip attribution built in.

Payment intent (QR) — canonical flow​

  1. Driver/POS: POST /merchant/payment-intents {amount(final), external_ref, product_type, enhanced_data} → intent_id, qr_payload (contains ONLY the intent reference — amount lives server-side, untamperable). Single-use, TTL 10 min default.
  2. Employee scans → app: GET /payment-intents/{id} (UserJWT) → shows server-side amount + merchant + trip summary; pre-fills active trip + cost center, editable.
  3. POST /payment-intents/{id}/confirm {card_id, attribution:{trip_id, cost_center}} → policy + limits + balance → ledger posts immediately (sale) → captured + capture_id. Card must belong to the JWT's employee.
  4. Webhook payment.captured → merchant; driver app shows paid.

Manual PAN + SMS OTP flow (pay-at-dropoff, sale mode)​

  1. POST /merchant/payments/authorize {card_token: PAN, entry_mode: manual_pan, amount(final), auto_capture: true, external_ref, enhanced_data} → 201 pending_verification + SMS sent.
  2. POST /merchant/payments/{auth_id}/verify {code} → on success with auto_capture: straight to captured (single ledger post, no intermediate hold). Wrong code w/ attempts left → 200 pending_verification (attempts_remaining--). 3rd fail → declined: card_locked_too_many_attempts.
  3. Two clocks, not one. The CODE expires on verification.expires_at (5 min, OTP_TTL_SECONDS) → 422 otp_expired, auth stays pending_verification. The SALE expires on verification.verification_deadline (10 min, VERIFICATION_DEADLINE_SECONDS) → declined: verification_expired.
  4. POST /merchant/payments/{auth_id}/resend-verification re-arms the same challenge inside that window (new code, attempts back to 3). It updates the verifications row instead of inserting one, so it costs NO velocity slot — its own limits are a 30s cooldown (429 resend_too_soon) and 2 resends per auth (422 resend_limit_reached). static_code cards refuse it (422 resend_not_applicable): the PIN is printed, there is nothing to send. A card shared with users (card_users) can have its code sent to its holder's phone or any linked user's. With more than one, authorize opens the challenge WITHOUT a code (verification.recipient_required, phone NULL) and lists verification.recipients (masked); the POS picks one and sends {recipient_id} to resend-verification — that first send is not a resend (no cooldown, no cap). Authorize may name otp_recipient_id up front; a later recipient_id switches phones as a resend. /verify before any code is 422 otp_recipient_required and costs no attempt.
  5. Retry semantics: same Idempotency-Key on authorize → same pending auth, NO second SMS. New key → auto-void old pending auth for same card+merchant, new auth + new SMS. Velocity: max 3 challenges/card/5 min (× up to 3 sends each = 9 SMS ceiling).
  6. pending_verification creates NO hold — spamming authorize cannot lock funds.

Pre-auth + capture (hotels / merchants that want holds)​

auto_capture: false → approve creates hold → capture (partial OK, tolerance configurable per merchant, default +20%) → over-tolerance = 422 capture_amount_exceeds_tolerance (capture max + new auth for the difference). Refunds: POST /captures/{id}/refunds, partial allowed, post-cutoff refunds appear as negative lines on the NEXT statement (closed statements are immutable).

A refund of a payment that settled on the Xperise wallet also reverses it there (POST /v1/service/refunds, keyed on the trans_id recorded at authorize/verify), inside the same transaction and BEFORE the local ledger post — a wallet rejection throws and nothing is credited locally. The wallet accepts exactly ONE refund per payment, so such a payment can be refunded once (fully or partially): a second try is 422 wallet_payment_already_reversed. Payments that never touched the wallet (platform s2s, QR intents) keep the unrestricted partial-refund behaviour.

Forbidden​

  • No zero-amount card checks / account verification endpoint. It becomes a PAN enumeration oracle and guarantees nothing at dropoff time anyway. The only clean options are: accept cash-like risk (sale mode) or hold real money (pre-auth).

Card numbers​

MII 9 (private use) + own prefix + 9 random digits (10⁹ space) + Luhn check digit. Contractual: merchants never store PAN post-auth, mask in logs.

6. Auth pipeline (order matters: cheap → expensive)​

1. Card active? token/OTP/JWT valid? ← local
2. Card limits (per_txn, monthly, per_trip,
MCC/merchant whitelist, entry-mode caps) ← local
3. available_balance >= amount? ← local (FOR UPDATE)
4. ZEN policy evaluate (embedded, in-process) ← JDM from cached active version
5. Pass → hold (pre-auth) or post (sale)
  • Limits vs policy are different layers. Limits = per-card technical caps stored in card-core (backstop). Policy = corporate business rules in ZEN (cabin class, trip budgets, approval requirements). Distinct decline reasons: per_transaction_limit_exceeded vs policy_violation (+ rule detail).
  • Limits may be materialized from policy at issuance ("managers get 50M/month" → stamped onto the card); policy changes trigger a batch re-sync job. At runtime both are checked independently; most restrictive wins.
  • Degraded mode (per-corporate config): if the active JDM version can't be evaluated (should be near-impossible when embedded), transactions under a threshold may approve on limits+balance with policy_check: skipped flag; above threshold fail closed.

7. Data model (beyond ledger)​

transaction_details (enhanced data — Level-2/3 style)​

CREATE TABLE transaction_details (
auth_id UUID PRIMARY KEY REFERENCES authorizations(auth_id),
type TEXT NOT NULL, -- taxi_trip | hotel_stay | other
data JSONB NOT NULL DEFAULT '{}',-- merged, validated view
raw_auth JSONB, -- verbatim payload at authorize/intent
raw_capture JSONB, -- verbatim at capture/confirm
data_status TEXT NOT NULL DEFAULT 'not_required', -- complete|incomplete|not_required
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
  • raw_* immutable forever (dispute evidence). data = field-level merge, capture wins.
  • Validate JSONB per type with JSON Schema (ajv) at app layer. Validation failure at capture never rejects money — sets data_status='incomplete'.
  • Indexes on data->'taxi_trip'->>'plate_number' and ->>'provider_trip_id'.
  • PII (driver name/phone, guest names, addresses): mask in BigQuery corporate views; retention policy on raw payloads (e.g. purge after 2 years).

Attribution (metadata, never ledger)​

  • user_id + corporate_id: from the card, always present.
  • cost_center / department: SNAPSHOT from the staff projection at auth time. Never re-resolved on read (accounting correctness: spend belongs to the cost center at transaction time).
  • trip_id: from request (platform / app confirm → trip_match: manual) or auto-matched (merchant-initiated: exactly one active trip of that employee at txn timestamp → auto; zero → unassigned; multiple → ambiguous; NEVER guess).
  • Mutable via PATCH /payments/{auth_id}/attribution (audit-logged actor). Cost center master data is owned by the backend; card-core only validates IDs against its projection (422 unknown_cost_center) and echoes them back.

Platform data sync (HTTP — Kafka deferred)​

Inbound projections via HTTP upserts pushed by the backend (PlatformKey): PUT /sync/corporates/{id}, PUT /sync/staffs/{user_id} (incl. cost center / department), PUT /sync/trips/{trip_id} (id, employee, start/end, status — needed for auto-match; getting the backend to push trip updates is the ONE thing to negotiate).

Outbound (outbox → GET /events HTTP polling; platform consumes forward with a cursor), at-least-once, event_id dedupe, occurred_at ordering:

  • card.payment.captured — auth_id, capture_id, amount, employee/corporate, attribution (cost_center, trip_id, trip_match), merchant, external_ref, enhanced summary
  • card.payment.refunded — refund_id + original capture_id
  • card.attribution.updated — auth_id + new attribution + updated_by (backend MUST consume this to stay in sync)
  • card.issued / card.blocked / card.unblocked — lets checkout cache who has an active card

8. Personas & feature sets (NEW — build these)​

8.1 Cardholder (employee) — self-service, auth = UserJWT​

FeatureEndpointNotes
List own cardsGET /my/cardsCards where card.user_id == JWT employee, plus bearer cards shared with them (card_users, §8.2) — access: owner | linked tells them apart. A linked card carries link: { status, windows: [{ valid_from, valid_until }] } (null on their own card): outside every window (status not_started — one is still ahead — or expired — all past) it is still listed but does not pay — a QR token or QR order is 403 card_link_inactive, a ride 422 invalid_payment_card — until an admin moves the window or deletes the link. Every card also carries validity: { status, windows } — when the card itself may pay, for anyone (§8.2 Card validity windows); outside it a QR token is 422 card_outside_validity_window, a QR order is declined card_outside_validity_window, a ride 422 invalid_payment_card. A card pays when status = active, validity.status = active and link is null or link.status = active. Same money shape as the detail below, so the home screen is one request.
Card detail + "balance"GET /my/cards/{card_id}Limits, spend_this_month, status, plus balance (the card's own card_wallet:{card_id} — posted/holds/available) and remaining (daily/monthly headroom). Employee does NOT see the corporate wallet balance — their "số dư" = their own card wallet, capped by their limits. See the money-projection rules below.
Link a card by its number + PINPOST /my/cards/link { pan, pin }Self-service "Link thẻ" (app, or a web page opened from the Zalo OA after an OTP sign-in): proving the card's full number and its printed PIN shares it with the user (card_users, as an admin's link would; card.user_linked with via: card_pin). Every check lands in card_pin_checks: 3 wrong PINs lock the card 15 min (manual_pan_locked_until, as at a merchant), 5 misses by one user in an hour pause them (429). A user whose corporate_staffs row is in another corporate than the card's gets 409 staff_in_another_corporate, asked only after the PIN proves the card (a user with no row links any card). Number and PIN are never stored or logged.
Link a card from the Zalo OA pagePOST /card-link/cards { pan, pin, first_name?, last_name?, zalo_token? } (Store access token)As POST /my/cards/link — same PIN check, counted with it per user (channel: self_link) — plus the user's name and a staff record. A name in the body is written to their Store profile (update_profile, their own token). A user card-core holds no corporate_staffs row for gets one made by the corporate backend — bot_create_staff { card_id, email } on CORPORATE_BOT_XAPIKEY, the email always the Store profile's, read fresh (a user changes it in the Store with update_email, not here) — then cached in corporate_staffs, with the Store profile's phone (setting.country_calling_code + national_phone_number, trunk 0 dropped) where the backend's record has none. Name and email are settled before the PIN is checked (422 name_required when neither body nor profile has a name, 422 email_required when the profile has no email); a staff record for anyone but the caller is a 502 and links nothing. A user the backend already holds as staff of the card's corporate (STAFF_ADDED — an admin added them, or an earlier link made the record but failed before caching it) is linked all the same, staff_created: false, cached from the Store profile with role null, never over a row cached meanwhile; a user links only their own corporate's cards: one whose staff record is in another corporate — card-core's corporate_staffs row, or STAFF_ADDED_IN_ANOTHER_CORPORATE from the backend when card-core holds none — gets 409 staff_in_another_corporate, asked only after the PIN proves the card, and nothing is linked nor a Zalo id saved. A zalo_token — the token in the "Link thẻ" button the Zalo Gateway sent the user, an Ed25519 JWT checked against ZALO_LINK_JWKS (card-link-zalo-gateway.md) — puts their Zalo OA user id on corporate_staffs.zalo_oa_user_id. It is checked first (422 zalo_token_invalid / zalo_token_expired) and spent only once the card is proven, bound to the first account that uses it: that account may send it again, any other gets 409 zalo_token_used.
View card policyGET /my/cards/{card_id}/policyHuman-readable summary resolved from the ZEN policy version bound to their corporate/card tier: allowed MCCs/merchants, per-trip budget, approval thresholds. Read-only render of JDM decision tables — do NOT expose raw JDM.
Choose verification methodPATCH /my/cards/{card_id}/verificationpreferred_method: qr_app | sms_otp | static_code, optional otp_recipient_id. static_code only if corporate config allows, and switching to it force-applies the low-limit tier. Changing method is audit-logged. Applies to merchant-initiated entry; QR intent flow always allowed. The holder only. The OTP goes to their own phone, or to the one of the card's phones named by otp_recipient_id — listed by GET /my/cards/{card_id}/otp-recipients (holder first, then the users the card is shared with; name + masked phone; 422 otp_recipient_invalid otherwise).
Own transaction historyGET /my/paymentsAuths on their own cards, plus the ones they made themselves on a shared bearer card (authorizations.acting_user_id) — never a co-user's. With attribution + enhanced summary (plate, route, hotel), each row carrying its captures and the refunds nested inside them — amount is the authorized hold, so the captures are what was actually charged and the refunds are the only signal money came back (a refund leaves the auth on captured). Supports unassigned=true so the employee can resolve trip assignment via PATCH /payments/{auth_id}/attribution (allowed only on own payments).

8.2 Corporate admin — auth = AdminJWT (corporate-scoped) or platform key​

FeatureEndpointNotes
Create cardPOST /cardsExisting endpoint.
Assign card to employeeat creation (user_id required)Cards are NOT reassignable — reassignment = block old + issue new (clean audit). Enforce in code.
Share a cardGET/POST /cards/{card_id}/users, PATCH/DELETE /cards/{card_id}/users/{user_id}Many to many. Every card is a bearer card (a corporate card; its holder is minted by the caller before POST /cards, never user_id = '0'), so any card may be shared — the admin decides. A linked employee pays with it in the app: sees it in /my/cards, mints its QR tokens, confirms QR orders and rides with it; the payment keeps the holder as user_id and records them as acting_user_id. Reveal, activate, block and PIN / verification changes stay owner/admin-only. A corporate admin links only an active staff of the card's corporate (checked with the corporate backend); the platform is trusted. Unlinking expires their unspent QR tokens on the card. Windows: POST takes optional windows: [{ valid_from, valid_until }] (card_user_windows; each a date-time with its UTC offset, at least one end, valid_until exclusive and after valid_from; no windows = at any time; same rule as the card's: windows already over are dropped on write, at most 20 not over yet, none overlapping, and a list of past windows only keeps its latest). In any window the card pays; outside them all the user still sees it (marked by link.status) but cannot pay with it — QR tokens, QR orders, rides; a QR token is capped at the end of the window it was minted in and declined at the till (card_link_inactive) once the link is out of force; the card's OTP does not go to their phone. A ride booked inside a window is charged on completion even if the window has ended by then. PATCH /cards/{card_id}/users/{user_id} { windows } replaces them all ([] lifts the limit); POST again with other windows is 409 card_user_exists. Linking by PIN (self-link, /card-link/cards, the hotline) is open-ended and never changes an existing link's windows. GET lists each linked user with the cached staff record's name, windows / link_status, and its phone (E.164) and email only when that record is of the card's corporate — a user linked across corporates (the hotline's link by PIN) is another corporate's staff. Outbox: card.user_linked (with its windows) / card.user_link_updated / card.user_unlinked.
Set limits / block / unblock / activateexisting PATCH /cards/{id}, /block, /unblock, /activate
Card validity windowsvalidity_windows on POST /cards, POST /physical-cards/activate and PATCH /cards/{card_id}When the card may pay, whoever pays with it — its holder, a user it is shared with, a till: [{ valid_from, valid_until }] (card_validity_windows; date-times with their UTC offset, at least one end, valid_until exclusive; none = any time). Windows already over when written are dropped and do not count: at most 20 not over yet (100 per request), none of those overlapping — one may end as the next starts — so the list GET shows can be sent back with a new window; if every window sent is over, the latest is kept and the card stays expired (never "any time"). On PATCH, sent replaces them all ([] lifts the limit), left out keeps them. Outside every window each authorization on the card is declined card_outside_validity_window (PaymentPipeline.cardExpiryDecline, right after expiry — every surface: merchant token / manual PAN / card QR, chip tap, QR orders, platform s2s, rides), a QR token cannot be minted (422 card_outside_validity_window) and one minted ends with the window it was minted in, and the card cannot be named for a ride. A capture of an authorization made in a window is not asked again. The card is still listed everywhere, its validity: { status, windows } (active / not_started / expired) saying why. Outbox: card.issued carries them, card.validity_updated on change. A linked user's own link windows narrow it further (Share a card).
Author & publish policyPOST /policy-management/policies, .../versions, .../submit, .../approve, .../reject, .../publishCorporate self-service maker-checker, with a structured UI at GET /policy-management. Scoped to the admin's corporate (another corporate's policy is a 404). Maker ≠ checker enforced on the token identity (the caller's Store profile id / Xperise subject), not the body. Publishing auto-activates the version in its independent policy_kind channel.
Inspect / try policyGET /corporates/{corporate_id}/policy-bindings, GET /corporates/{corporate_id}/policy-bindings/{kind}, POST /corporates/{corporate_id}/policies/{kind}/evaluateRead-only: view active bindings and dry-run a specific kind. Singular legacy routes remain aliases for card_payment. Simulation against any version (incl. draft): POST /policy-management/policies/{key}/versions/{v}/simulations.
Fund / defund / balanceexisting /funding/*, GET /corporates/{id}/balance
Card list + per-card moneyGET /cards, GET /cards/{card_id}Same money projection as the cardholder surface — see the rules below. Each card carries its validity: { status, windows }, and on GET /cards also linked_users: [{ user_id, name, windows, link_status }] — who it is shared with — both read for the whole page in the same query.
Corporate transaction views & agingGET /payments?corporate_id=, aging report per corporate
All admin endpoints scope-checked: admin JWT corporate claim must match the resource.

8.4 Card money projection (applies to /cards and /my/cards)​

SQL lives in src/cards/card-money.ts — one cardsWithMoney() builder used by every read on both surfaces (list and detail alike), so they cannot report different numbers for the same card. Both endpoints on a surface return the identical shape.

Return only what the client cannot derive. Two blocks, both raw:

  • balance — posted / holds / available for card_wallet:{card_id}, summed from ledger_lines and active holds. Read-only: it never ensureAccount()s, so an unfunded card reads 0 instead of turning a GET into a write.
  • remaining — daily / monthly headroom, which needs captured-today and captured-MTD totals the client has no way to sum. null = no limit set, which is not the same as "0" = nothing left. The per_transaction / per_trip ceilings are deliberately absent: they never drain and are already on the card as limits.*.

Spend is net of refunds, on the capture's month. spend_this_month and the remaining windows subtract a capture's refunds from the window that CAPTURE falls in, not the one the refund was made in — so a refund of a June capture issued in July reduces June, and spend_this_month can never go negative (refundCore caps refunds at the captured amount). Refunds live in their own table and never rewrite captures.amount, so any new sum over captures must net them explicitly. The authorize-time backstop (limitsDecline) nets on the same rule: if it counted gross it would decline a card the published remaining says has room. Active holds are not netted — a hold has not been captured, so it has no refund.

No derived figures. The API does not return a spendable or max_per_transaction. Both are a min() over numbers already in the payload — min(available, daily, monthly) for the pool a card can still drain, narrowed by limits.per_transaction for the largest single payment — so which reading a screen shows is a client decision, not an API contract. Note the two are genuinely different: a card with 380k of daily headroom and a 300k per-transaction limit really can spend 380k today, across two payments.

One query per read. cardsWithMoney() returns the card rows and every money figure together — including spend_this_month, which CardsService.toApi used to fetch per row. A read of a SET of cards MUST pass that value into toApi; leaving it to fetch its own is the N+1 this replaced. Measured on 120 cards / 24k auths / 18k captures, warm cache:

100-card pagesingle card
page + wallet balance4.9 ms—
+ spend_this_month, one query per card+35.7 ms3.1 ms
+ headroom via per-card LATERALs+194 ms2.2 ms
all of it, one query (shipped)11 ms0.45 ms

End to end through the services: GET /cards 40.0 → 14.3 ms and it now carries remaining, which the tiered version could not afford; GET /cards/{card_id} 4.4 → 1.3; GET /my/cards 9.2 → 1.5.

The win is the spend CTE. captures has no card_id, so a per-card sum reaches it through that card's authorizations, and the MTD predicate makes the planner scan captures once PER CARD — a cost that grows with platform-wide capture volume rather than the card's own history. Bounding the scan to the page's card_ids and grouping makes it one pass, and a FILTER splits captured-today out of that same pass for free. A covering index on captures (auth_id, captured_at) INCLUDE (amount) was measured too: it only got the per-card form to 24 ms while adding write cost to the hottest table, so the CTE is both faster and cheaper.

held comes from the wallet LATERAL (holds keyed by account_id, index-only) rather than a third join through authorizations: pipeline.createHold is the only writer of holds and always keys them to cardWallet(card_id), so the two are the same set by construction — verified equal across the fixture.

There is also deliberately no /my/cards/{id}/balance: the money is only meaningful next to the limits, and inlining keeps a card list to one request. The admin GET /cards/{card_id}/balance stays as-is — it is a different shape (funding config, as_of) and it does ensureAccount().

8.3 Merchant manager — auth = MerchantKey (portal/API)​

FeatureEndpointNotes
Create orderPOST /merchant/payment-intentsExtended with product_type and per-type required fields — see matrix below. Validation at creation returns 422 missing_required_fields listing what's absent (this is order creation, before any money moves — strict validation here is correct and cheap because the provider app auto-fills these fields).
Order / payment historyGET /merchant/payment-intents?status=&from=&to=, GET /merchant/paymentsScoped to the authenticated merchant. Each payment row carries sub_account (id + username/display_name/role/sub_type of the staff account that took it, null for the parent MerchantKey) and accepts ?sub_account_id= to narrow to one of them. The parent key and admin sub-accounts see the whole merchant; an operator sub-account is scoped to its own payments and gets 403 forbidden if it names anyone else.
One paymentGET /merchant/payments/{auth_id}The same row shape as the history page (captures + nested refunds, enhanced_data, data_status, sub_account), fetched by id, plus the live verification block while the auth is pending_verification — a POS that lost the authorize response resumes that sale instead of opening a second one. Outside the caller's visibility (another merchant, or another sub-account when an operator asks) it is a 404, matching the isolation rule on every other single-auth route.
Cancel unconfirmed orderexisting POST /merchant/payment-intents/{id}/cancel
Statements & reconexisting /merchants/{id}/statements, statement detail

Product-type required fields (validate at intent creation)​

product_typeRequired in enhanced_dataOptional
ride (taxi_trip)plate_number, driver_phone, pickup.address, provider_trip_id (== external_ref)dropoff, distance_km, duration_minutes, fare_breakdown, vehicle_type, driver_name
hotel_stayproperty_name, property_address, check_in, check_outguest_names, room_type, nights, confirmation_number, folio_breakdown
othernone (raw accepted)everything

Requirements matrix is CONFIG (per product_type, overridable per merchant contract), not hardcoded — new verticals (car rental, flights) get added by config + a JSON Schema.

9. Deltas to apply to card-core-openapi.yaml​

  1. Schemas: add TaxiTripDetails.driver_phone (string, VN format), rename nothing; add HotelStayDetails.property_address (string).
  2. PaymentIntentCreateRequest: add product_type: taxi_trip | hotel_stay | other (required); document per-type required-field validation and the 422 missing_required_fields error (details.missing: [paths]).
  3. New security scheme AdminJWT (bearer; corporate-scoped claims).
  4. New cardholder endpoints (UserJWT): GET /my/cards, GET /my/cards/{card_id}, GET /my/cards/{card_id}/policy, PATCH /my/cards/{card_id}/verification, GET /my/payments.
  5. Policy authoring surface /policy-management/* (corporate self-service maker-checker) plus read-only multi-kind binding/evaluation under /corporates/{corporate_id}/policy-bindings* and /corporates/{corporate_id}/policies/{kind}/evaluate. Publishing auto-binds its kind — there is no manual bind endpoint.
  6. Card schema: add verification_preference field; CardCreateRequest unchanged (defaults to qr_app + sms_otp fallback).
  7. Error code additions: missing_required_fields, unknown_cost_center, verification_method_not_allowed.

10. Statements, settlement, recon (monthly)​

  • Cutoff per merchant per month (capture time in card-core is the authoritative clock — write this into merchant contracts). Statement statuses: open → closed → reconciling → confirmed → paid.
  • Statement lines: capture/refund/adjustment, with booking_ref/external_ref (the matching keys — contracts must force merchants to echo them), VAT amount.
  • Recon: export ledger + statements to BigQuery, SQL-match vs merchant files by external_ref. Outcomes: matched / missing_on_merchant / amount_mismatch → adjustment entries to suspense, never edits to originals.
  • Payout = one net bank transfer per merchant per period → ledger debit merchant_payable / credit external_payout.
  • Closed statements immutable; late refunds = negative lines next period.
  • Credit exposure: aging report both directions (corporate owes program / program owes merchants), credit limit per corporate.

11. Platform (xperise-backend) integration — for reference, not to build here​

Backend's ONLY work: (1) "Corporate Card" as a payment-method adapter calling /payments/authorize|capture|void|refund (PSP framing — reuse checkout/refund/history as-is); (2) consume card.payment.captured|refunded|attribution.updated for expense records by polling GET /events (cursor forward, dedupe on event_id) and push corporate/staff/trip updates to PUT /sync/*; (3) show decline reasons actionably ("Vượt ngân sách chuyến, cần approval"), note refunds are instant to wallet, and finance must know these payments never appear in bank statements (recon against card-core, not the bank). Optional nice-to-have later: POST /payments/precheck (no side effects) for checkout UX.

12. Ops & security checklist​

  • Namespace + ArgoCD app riêng; Grafana dashboards: auth latency, decline rate by reason, active holds, pending_verification count, SMS delivery rate, invariant-check status
  • Rate limits: per merchant key (authorize/verify), per card (OTP attempts, SMS/10min)
  • Lock manual_pan 15 min after 3 OTP fails; alert on lock events
  • SMS brandname registration (Viettel/VNPT/Mobifone via eSMS/SpeedSMS) — weeks of lead time, start early; plain numbers for dev
  • JWKS cache + refresh-on-unknown-kid; clock skew tolerance for JWT/OTP
  • mTLS at gateway for merchant API in addition to keys
  • Mask PAN everywhere in logs (show last 4 only); never log OTP codes
  • Idempotency keys table with TTL cleanup
  • Daily invariant cron + alerting (never auto-repair; suspense + human)
  • Legal review before first external merchant: structure contracts as "Xperise purchases services, pays monthly" (buyer framing) vs payment-intermediary framing — NHNN trung gian thanh toán licensing risk

13. Build order​

  1. Phase 1 — Core money: schema (accounts, ledger_lines, holds, authorizations, captures, refunds, idempotency, outbox) + 4 platform payment endpoints + hold expiry cron + invariant cron + card issuance CRUD.
  2. Phase 2 — Policy & projections: embedded ZEN + policy binding; HTTP projection sync endpoints (corporates, staffs, trips) + GET /events outbox polling; funding endpoints; attribution engine (snapshot + auto-match).
  3. Phase 3 — Merchant & QR: merchant API (+ SMS OTP flow), payment intents + product-type validation, webhooks, transaction_details.
  4. Phase 4 — Personas: /my/* cardholder endpoints, admin scoping, merchant history, verification preferences.
  5. Phase 5 — Frontend demo (done; demo app since removed from the repo): persona demo exercising cardholder / corporate admin / merchant flows end-to-end against the real API. Served as living documentation + UAT harness until the real apps integrated.
  6. Phase 6 — Money ops: statements, recon export to BigQuery, payouts, aging.
  7. Bruno collection per surface (platform / merchant / app / admin) mirroring the OpenAPI — mock-first so integrators work in parallel.