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
- 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).
- "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 throughexternal_funding/external_payoutaccounts. Fund/defund operations record forensic evidence (balance_before/after, raw payloads,initiated_by) because card-core's records are the only audit trail. - Ledger = money only. Trip/hotel details, attribution, policy — all live outside ledger tables.
- 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.
- Payment declines are business results, not HTTP errors.
201/200withstatus: declined+decline_reason. HTTP 4xx/5xx = request/system errors only. - 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+AuthEngineModulebehind 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,keyunique per corporate, versions immutable once out of draft. Publishing a version auto-binds itspolicy_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 itscontent_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_iddedupe). 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 unknownkid. Confirm with platform which claim carriesuser_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})?$. NeverparseFloat/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)
| Event | Debit | Credit |
|---|---|---|
| Fund | external_funding | corporate_wallet |
| Capture / Sale | corporate_wallet | merchant_payable |
| Refund | merchant_payable | corporate_wallet |
| Merchant payout | merchant_payable | external_payout |
| Defund | corporate_wallet | external_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 anIdempotency-Key; same key + same payload → replay original result; same key + different payload →409 idempotency_conflict.- Auth path concurrency:
SELECT ... FOR UPDATEon 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 mode | Who initiates | Verification | Notes |
|---|---|---|---|
| qr_intent (PRIMARY) | Merchant creates payment intent; employee scans QR in Xperise app | App session (UserJWT) + explicit confirm tap | No PAN exposure, no OTP. Attribution passed at confirm. |
| manual_pan + sms_otp (fallback, no app) | Employee reads 16-digit PAN to driver | SMS OTP: 4 digits, TTL 5 min, 3 attempts, then decline + lock manual_pan on card 15 min | OTP generated AFTER amount is known → SMS states the exact amount. |
| static_code (fallback tier) | PAN + printed static code | None dynamic | Hard-capped low limits (e.g. 500k/txn, 1M/day), whitelist merchants only. SMS receipt as detective control. |
| s2s | Platform checkout (booking flow) | Platform key | booking_ref/trip attribution built in. |
Payment intent (QR) — canonical flow
- 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. - Employee scans → app:
GET /payment-intents/{id}(UserJWT) → shows server-side amount + merchant + trip summary; pre-fills active trip + cost center, editable. 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.- Webhook
payment.captured→ merchant; driver app shows paid.
Manual PAN + SMS OTP flow (pay-at-dropoff, sale mode)
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.POST /merchant/payments/{auth_id}/verify{code}→ on success with auto_capture: straight tocaptured(single ledger post, no intermediate hold). Wrong code w/ attempts left →200 pending_verification(attempts_remaining--). 3rd fail →declined: card_locked_too_many_attempts.- Two clocks, not one. The CODE expires on
verification.expires_at(5 min,OTP_TTL_SECONDS) →422 otp_expired, auth stayspending_verification. The SALE expires onverification.verification_deadline(10 min,VERIFICATION_DEADLINE_SECONDS) →declined: verification_expired. POST /merchant/payments/{auth_id}/resend-verificationre-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_codecards 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 listsverification.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 nameotp_recipient_idup front; a laterrecipient_idswitches phones as a resend./verifybefore any code is422 otp_recipient_requiredand costs no attempt.- 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).
pending_verificationcreates 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_exceededvspolicy_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: skippedflag; 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
typewith JSON Schema (ajv) at app layer. Validation failure at capture never rejects money — setsdata_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 summarycard.payment.refunded— refund_id + original capture_idcard.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
| Feature | Endpoint | Notes |
|---|---|---|
| List own cards | GET /my/cards | Cards 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 + PIN | POST /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 page | POST /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 policy | GET /my/cards/{card_id}/policy | Human-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 method | PATCH /my/cards/{card_id}/verification | preferred_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 history | GET /my/payments | Auths 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
| Feature | Endpoint | Notes |
|---|---|---|
| Create card | POST /cards | Existing endpoint. |
| Assign card to employee | at creation (user_id required) | Cards are NOT reassignable — reassignment = block old + issue new (clean audit). Enforce in code. |
| Share a card | GET/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 / activate | existing PATCH /cards/{id}, /block, /unblock, /activate | |
| Card validity windows | validity_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 policy | POST /policy-management/policies, .../versions, .../submit, .../approve, .../reject, .../publish | Corporate 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 policy | GET /corporates/{corporate_id}/policy-bindings, GET /corporates/{corporate_id}/policy-bindings/{kind}, POST /corporates/{corporate_id}/policies/{kind}/evaluate | Read-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 / balance | existing /funding/*, GET /corporates/{id}/balance | |
| Card list + per-card money | GET /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 & aging | GET /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/availableforcard_wallet:{card_id}, summed fromledger_linesand activeholds. Read-only: it neverensureAccount()s, so an unfunded card reads0instead of turning a GET into a write.remaining—daily/monthlyheadroom, 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. Theper_transaction/per_tripceilings are deliberately absent: they never drain and are already on the card aslimits.*.
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 page | single card | |
|---|---|---|
| page + wallet balance | 4.9 ms | — |
+ spend_this_month, one query per card | +35.7 ms | 3.1 ms |
| + headroom via per-card LATERALs | +194 ms | 2.2 ms |
| all of it, one query (shipped) | 11 ms | 0.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)
| Feature | Endpoint | Notes |
|---|---|---|
| Create order | POST /merchant/payment-intents | Extended 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 history | GET /merchant/payment-intents?status=&from=&to=, GET /merchant/payments | Scoped 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 payment | GET /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 order | existing POST /merchant/payment-intents/{id}/cancel | |
| Statements & recon | existing /merchants/{id}/statements, statement detail |
Product-type required fields (validate at intent creation)
| product_type | Required in enhanced_data | Optional |
|---|---|---|
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_stay | property_name, property_address, check_in, check_out | guest_names, room_type, nights, confirmation_number, folio_breakdown |
other | none (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
- Schemas: add
TaxiTripDetails.driver_phone(string, VN format), rename nothing; addHotelStayDetails.property_address(string). PaymentIntentCreateRequest: addproduct_type: taxi_trip | hotel_stay | other(required); document per-type required-field validation and the422 missing_required_fieldserror (details.missing: [paths]).- New security scheme
AdminJWT(bearer; corporate-scoped claims). - 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. - 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. - Card schema: add
verification_preferencefield;CardCreateRequestunchanged (defaults to qr_app + sms_otp fallback). - 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
- 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.
- Phase 2 — Policy & projections: embedded ZEN + policy binding; HTTP projection
sync endpoints (corporates, staffs, trips) +
GET /eventsoutbox polling; funding endpoints; attribution engine (snapshot + auto-match). - Phase 3 — Merchant & QR: merchant API (+ SMS OTP flow), payment intents + product-type validation, webhooks, transaction_details.
- Phase 4 — Personas: /my/* cardholder endpoints, admin scoping, merchant history, verification preferences.
- 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.
- Phase 6 — Money ops: statements, recon export to BigQuery, payouts, aging.
- Bruno collection per surface (platform / merchant / app / admin) mirroring the OpenAPI — mock-first so integrators work in parallel.