amanpay / web /src /api /ledger.ts
MHamdan's picture
CI deploy 2550795 (part 2)
c7af659 verified
Raw
History Blame Contribute Delete
8.81 kB
// 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() }),
)
}