// Programme D3 — simulated financial layer (ledger, transfers, merchants, ATM, intents). // Typed client for the /finance/v1 surface. Reuses the D2 session cookie + in-memory CSRF token // and the D2 passkey step-up ceremony. ALL money is simulated, SAR only, integer minor units. import { apiRequest, ApiRequestError } from './client' import { getD2Csrf, stepUp } from './identity' const V1 = '/finance/v1' function csrfHeaders(): Record { const c = getD2Csrf() return c ? { 'X-CSRF-Token': c } : {} } /** True when the backend requires a fresh passkey step-up (HTTP 422 step_up_required). */ export function isStepUpRequired(e: unknown): boolean { return e instanceof ApiRequestError && e.status === 422 && /step_up_required/.test(e.message) } /** Runs `fn`; on 422 step_up_required, runs the D2 step-up ceremony then retries once. */ export async function withD3StepUp(fn: () => Promise): Promise { try { return await fn() } catch (e) { if (isStepUpRequired(e)) { await stepUp() return fn() } throw e } } // ---- Types (safe projections only) ---- export interface FinanceAccount { id: string public_ref: string masked_ref: string product_type: string name_en: string name_ar: string currency: string status: string is_default: boolean posted_minor: number hold_minor: number available_minor: number simulation_only: boolean } export interface FinanceTxn { id: string type: string state: string amount_minor: number currency: string source_financial_account_id: string | null dest_financial_account_id: string | null merchant_id: string | null note: string posted_journal_id: string | null refunded_minor: number created_at: number completed_at: number | null simulation_only: boolean } export interface Receipt { receipt_ref: string transaction_ref: string type: string status: string amount_minor: number currency: string source_masked_ref: string recipient_label: string note: string journal_ref: string simulation_only: boolean } export interface Merchant { id: string public_ref: string name_en: string name_ar: string category: string status: string simulation_only: boolean } export interface RecipientMatch { recipient_user_id: string display_alias: string public_handle: string dest_account_id: string dest_masked_ref: string } export interface AtmDeposit { id: string kiosk_ref: string amount_minor: number currency: string state: string expires_at: number destination_account_id: string destination_masked_ref?: string created_at: number simulation_only: boolean } export interface IntentView { id: string action: string recipient_type: string recipient_ref: string resolved_ref: string source_account_id: string | null amount_minor: number | null currency: string note: string origin: string state: string expires_at: number version: number unresolved: string[] review: Record amount_error: string | null notice: string } export interface TxnResult { transaction: FinanceTxn receipt: Receipt } // ---- Formatting ---- /** Format integer minor units (halalas) as a SAR major-unit string, e.g. 2550 -> "25.50". */ export function formatMinor(minor: number, currency = 'SAR'): string { const v = (minor / 100).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) return `${v} ${currency}` } // ---- Status ---- export interface FinanceStatus { enabled: boolean ready?: boolean default_currency?: string journal_mode?: string max_transfer_minor?: number max_daily_outflow_minor?: number simulation_only?: boolean } export async function financeStatus(): Promise { return apiRequest(`${V1}/status`) } // ---- Accounts / history ---- export async function listAccounts(): Promise { const r = await apiRequest<{ accounts: FinanceAccount[] }>(`${V1}/accounts`) return r.accounts } export async function accountTransactions(accountId: string): Promise { const r = await apiRequest<{ transactions: FinanceTxn[] }>(`${V1}/accounts/${accountId}/transactions`) return r.transactions } export async function listTransactions(): Promise { const r = await apiRequest<{ transactions: FinanceTxn[] }>(`${V1}/transactions`) return r.transactions } export async function getReceipt(txnId: string): Promise { const r = await apiRequest<{ receipt: Receipt }>(`${V1}/transactions/${txnId}/receipt`) return r.receipt } // ---- Transfers ---- export async function ownTransfer( sourceId: string, destId: string, amount: string, note: string, idempotencyKey: string, ): Promise { return withD3StepUp(() => apiRequest(`${V1}/transfers/own/submit`, { body: { source_account_id: sourceId, dest_account_id: destId, amount, note, idempotency_key: idempotencyKey }, headers: csrfHeaders(), }), ) } export async function resolveRecipient(handle: string): Promise { const r = await apiRequest<{ recipient: RecipientMatch }>(`${V1}/recipients/resolve`, { body: { handle }, headers: csrfHeaders(), }) return r.recipient } export async function p2pTransfer( sourceId: string, handle: string, amount: string, note: string, idempotencyKey: string, ): Promise { return withD3StepUp(() => apiRequest(`${V1}/transfers/p2p/submit`, { body: { source_account_id: sourceId, handle, amount, note, idempotency_key: idempotencyKey }, headers: csrfHeaders(), }), ) } // ---- Merchants ---- export async function listMerchants(): Promise { const r = await apiRequest<{ merchants: Merchant[] }>(`${V1}/merchants`) return r.merchants } export async function payMerchant( merchantId: string, sourceId: string, amount: string, note: string, idempotencyKey: string, ): Promise { return withD3StepUp(() => apiRequest(`${V1}/merchants/${merchantId}/payments`, { body: { source_account_id: sourceId, amount, note, idempotency_key: idempotencyKey }, headers: csrfHeaders(), }), ) } export async function refundMerchant( transactionId: string, merchantId: string, amount: string, idempotencyKey: string, ): Promise { return withD3StepUp(() => apiRequest(`${V1}/merchant/transactions/${transactionId}/refunds`, { body: { merchant_id: merchantId, amount, idempotency_key: idempotencyKey }, headers: csrfHeaders(), }), ) } // ---- ATM ---- export async function atmInitiate( destinationAccountId: string, amount: string, kioskRef: string, ): Promise { const r = await apiRequest<{ deposit: AtmDeposit }>(`${V1}/atm/deposits`, { body: { destination_account_id: destinationAccountId, amount, kiosk_ref: kioskRef }, headers: csrfHeaders(), }) return r.deposit } export async function atmCustomerConfirm(depositId: string): Promise { const r = await apiRequest<{ deposit: AtmDeposit }>(`${V1}/atm/deposits/${depositId}/customer-confirm`, { body: {}, headers: csrfHeaders(), }) return r.deposit } export async function atmOperatorQueue(): Promise { const r = await apiRequest<{ deposits: AtmDeposit[] }>(`${V1}/operator/atm/deposits`) return r.deposits } export async function atmOperatorConfirm(depositId: string): Promise { return withD3StepUp(() => apiRequest(`${V1}/operator/atm/deposits/${depositId}/confirm`, { body: {}, headers: csrfHeaders(), }), ) } export async function atmOperatorReject(depositId: string): Promise { const r = await apiRequest<{ deposit: AtmDeposit }>(`${V1}/operator/atm/deposits/${depositId}/reject`, { body: {}, headers: csrfHeaders(), }) return r.deposit } // ---- Intent assistant (voice/text) ---- export async function intentParse(text: string, origin = 'text'): Promise { const r = await apiRequest<{ intent: IntentView }>(`${V1}/intents`, { body: { text, origin }, headers: csrfHeaders(), }) return r.intent } export async function intentClarify( id: string, fields: { amount?: string; handle?: string; source_account_id?: string; note?: string }, ): Promise { const r = await apiRequest<{ intent: IntentView }>(`${V1}/intent-assistant/${id}/clarify`, { body: fields, headers: csrfHeaders(), }) return r.intent } export async function intentConfirm( id: string, opts: { source_account_id?: string; dest_account_id?: string } = {}, ): Promise { return withD3StepUp(() => apiRequest(`${V1}/intents/${id}/confirm`, { body: opts, headers: csrfHeaders() }), ) }