File size: 8,810 Bytes
c7af659 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 | // 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<string, string> {
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<T>(fn: () => Promise<T>): Promise<T> {
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<string, unknown>
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<FinanceStatus> {
return apiRequest<FinanceStatus>(`${V1}/status`)
}
// ---- Accounts / history ----
export async function listAccounts(): Promise<FinanceAccount[]> {
const r = await apiRequest<{ accounts: FinanceAccount[] }>(`${V1}/accounts`)
return r.accounts
}
export async function accountTransactions(accountId: string): Promise<FinanceTxn[]> {
const r = await apiRequest<{ transactions: FinanceTxn[] }>(`${V1}/accounts/${accountId}/transactions`)
return r.transactions
}
export async function listTransactions(): Promise<FinanceTxn[]> {
const r = await apiRequest<{ transactions: FinanceTxn[] }>(`${V1}/transactions`)
return r.transactions
}
export async function getReceipt(txnId: string): Promise<Receipt> {
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<TxnResult> {
return withD3StepUp(() =>
apiRequest<TxnResult>(`${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<RecipientMatch> {
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<TxnResult> {
return withD3StepUp(() =>
apiRequest<TxnResult>(`${V1}/transfers/p2p/submit`, {
body: { source_account_id: sourceId, handle, amount, note, idempotency_key: idempotencyKey },
headers: csrfHeaders(),
}),
)
}
// ---- Merchants ----
export async function listMerchants(): Promise<Merchant[]> {
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<TxnResult> {
return withD3StepUp(() =>
apiRequest<TxnResult>(`${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<TxnResult> {
return withD3StepUp(() =>
apiRequest<TxnResult>(`${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<AtmDeposit> {
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<AtmDeposit> {
const r = await apiRequest<{ deposit: AtmDeposit }>(`${V1}/atm/deposits/${depositId}/customer-confirm`, {
body: {}, headers: csrfHeaders(),
})
return r.deposit
}
export async function atmOperatorQueue(): Promise<AtmDeposit[]> {
const r = await apiRequest<{ deposits: AtmDeposit[] }>(`${V1}/operator/atm/deposits`)
return r.deposits
}
export async function atmOperatorConfirm(depositId: string): Promise<TxnResult> {
return withD3StepUp(() =>
apiRequest<TxnResult>(`${V1}/operator/atm/deposits/${depositId}/confirm`, {
body: {}, headers: csrfHeaders(),
}),
)
}
export async function atmOperatorReject(depositId: string): Promise<AtmDeposit> {
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<IntentView> {
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<IntentView> {
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<TxnResult> {
return withD3StepUp(() =>
apiRequest<TxnResult>(`${V1}/intents/${id}/confirm`, { body: opts, headers: csrfHeaders() }),
)
}
|