/** * CurrenciCombo/orchestrator API client — consumed by the portal's * /transactions page. * * When `VITE_ORCHESTRATOR_URL` is unset or the endpoint is unreachable, * every call falls back to deterministic demo data so the page still * renders something meaningful. This mirrors how the other services * treat unreachable backends (chain138, explorer, dbisCore). * * The shape of the data matches the orchestrator's API (see * orchestrator/src/api/plans.ts + orchestrator/src/api/eventBus.ts). * Re-sync if the orchestrator evolves. */ import { endpoints } from '../config/endpoints'; export type TransactionState = | 'DRAFT' | 'INITIATED' | 'PRECONDITIONS_PENDING' | 'READY_FOR_PREPARE' | 'PREPARED' | 'EXECUTING' | 'PARTIALLY_EXECUTED' | 'VALIDATING' | 'COMMITTED' | 'ABORTED' | 'UNWIND_PENDING' | 'CLOSED'; export const TRANSACTION_STATES: TransactionState[] = [ 'DRAFT', 'INITIATED', 'PRECONDITIONS_PENDING', 'READY_FOR_PREPARE', 'PREPARED', 'EXECUTING', 'PARTIALLY_EXECUTED', 'VALIDATING', 'COMMITTED', 'ABORTED', 'UNWIND_PENDING', 'CLOSED', ]; export interface PlanSummary { plan_id: string; status: TransactionState; actor_id: string | null; created_at: string; updated_at: string; instrument_hint?: string; } export interface StateTransition { from_state: TransactionState | null; to_state: TransactionState; actor_id: string; actor_role: string; reason: string | null; occurred_at: string; } export interface PlanStateDetail { plan_id: string; current_state: TransactionState; transitions: StateTransition[]; } export interface PlanEvent { id: number; plan_id: string; type: string; payload: Record; signature: string; prev_hash: string | null; created_at: string; } type OrchestratorStatus = 'live' | 'mocked' | 'degraded'; export interface OrchestratorProbe { status: OrchestratorStatus; latencyMs: number | null; error?: string; } const base = endpoints.orchestrator.baseUrl; const deployed = endpoints.orchestrator.deployed; async function fetchJson(path: string, init?: RequestInit): Promise { const res = await fetch(`${base}${path}`, { ...init, headers: { Accept: 'application/json', ...(init?.headers ?? {}) }, }); if (!res.ok) { throw new Error(`orchestrator ${res.status} on ${path}`); } return res.json() as Promise; } export async function probeOrchestrator(): Promise { if (!deployed) return { status: 'mocked', latencyMs: null }; const t0 = performance.now(); try { const res = await fetch(`${base}/health`, { method: 'GET' }); const latencyMs = Math.round(performance.now() - t0); if (!res.ok) return { status: 'degraded', latencyMs, error: `HTTP ${res.status}` }; return { status: 'live', latencyMs }; } catch (err) { return { status: 'degraded', latencyMs: null, error: err instanceof Error ? err.message : String(err), }; } } export async function listPlans(): Promise<{ plans: PlanSummary[]; source: OrchestratorStatus }> { if (!deployed) return { plans: demoPlans(), source: 'mocked' }; try { const data = await fetchJson<{ plans: PlanSummary[] }>('/api/plans'); return { plans: data.plans ?? [], source: 'live' }; } catch { return { plans: demoPlans(), source: 'degraded' }; } } export async function getPlanState( planId: string, ): Promise<{ detail: PlanStateDetail; source: OrchestratorStatus }> { if (!deployed) return { detail: demoPlanState(planId), source: 'mocked' }; try { const detail = await fetchJson(`/api/plans/${encodeURIComponent(planId)}/state`); return { detail, source: 'live' }; } catch { return { detail: demoPlanState(planId), source: 'degraded' }; } } export async function getPlanEvents( planId: string, ): Promise<{ events: PlanEvent[]; source: OrchestratorStatus }> { if (!deployed) return { events: demoPlanEvents(planId), source: 'mocked' }; try { const data = await fetchJson<{ events: PlanEvent[] }>( `/api/plans/${encodeURIComponent(planId)}/events`, ); return { events: data.events ?? [], source: 'live' }; } catch { return { events: demoPlanEvents(planId), source: 'degraded' }; } } // --------------------------------------------------------------------- // Demo data — used only when VITE_ORCHESTRATOR_URL is unset. Lets the // /transactions page demonstrate the 12-state machine visualisation // without needing a deployed orchestrator. // --------------------------------------------------------------------- function demoPlans(): PlanSummary[] { const now = Date.now(); return [ { plan_id: 'demo-sblc-001', status: 'VALIDATING', actor_id: 'ops.alice', created_at: new Date(now - 1000 * 60 * 55).toISOString(), updated_at: new Date(now - 1000 * 30).toISOString(), instrument_hint: 'MT760 / EIB beneficiary format', }, { plan_id: 'demo-pay-014', status: 'COMMITTED', actor_id: 'ops.bob', created_at: new Date(now - 1000 * 60 * 60 * 3).toISOString(), updated_at: new Date(now - 1000 * 60 * 7).toISOString(), instrument_hint: 'pacs.009 FI-to-FI', }, { plan_id: 'demo-sblc-003', status: 'ABORTED', actor_id: 'ops.alice', created_at: new Date(now - 1000 * 60 * 60 * 8).toISOString(), updated_at: new Date(now - 1000 * 60 * 60 * 2).toISOString(), instrument_hint: 'MT202 COV', }, { plan_id: 'demo-draft-029', status: 'DRAFT', actor_id: null, created_at: new Date(now - 1000 * 60 * 4).toISOString(), updated_at: new Date(now - 1000 * 60 * 4).toISOString(), instrument_hint: 'Pending review', }, ]; } function demoPlanState(planId: string): PlanStateDetail { const plan = demoPlans().find((p) => p.plan_id === planId) ?? demoPlans()[0]; const base = new Date(plan.created_at).getTime(); const mk = (i: number, from: TransactionState | null, to: TransactionState, role: string, actor: string, reason: string) => ({ from_state: from, to_state: to, actor_id: actor, actor_role: role, reason, occurred_at: new Date(base + i * 1000 * 60 * 5).toISOString(), }); if (plan.status === 'COMMITTED') { return { plan_id: plan.plan_id, current_state: 'COMMITTED', transitions: [ mk(0, null, 'DRAFT', 'submitter', 'ops.bob', 'plan created'), mk(1, 'DRAFT', 'INITIATED', 'submitter', 'ops.bob', 'initiation'), mk(2, 'INITIATED', 'PRECONDITIONS_PENDING', 'coordinator', 'system', 'await controls'), mk(3, 'PRECONDITIONS_PENDING', 'READY_FOR_PREPARE', 'coordinator', 'system', 'preconditions satisfied'), mk(4, 'READY_FOR_PREPARE', 'PREPARED', 'approver', 'ops.chen', 'approve — prepare (SoD)'), mk(5, 'PREPARED', 'EXECUTING', 'releaser', 'ops.dey', 'release — execute (SoD)'), mk(6, 'EXECUTING', 'VALIDATING', 'coordinator', 'system', 'both legs dispatched'), mk(7, 'VALIDATING', 'COMMITTED', 'validator', 'ops.eve', 'reconciled + committed (SoD)'), ], }; } if (plan.status === 'ABORTED') { return { plan_id: plan.plan_id, current_state: 'ABORTED', transitions: [ mk(0, null, 'DRAFT', 'submitter', 'ops.alice', 'plan created'), mk(1, 'DRAFT', 'INITIATED', 'submitter', 'ops.alice', 'initiation'), mk(2, 'INITIATED', 'PRECONDITIONS_PENDING', 'coordinator', 'system', 'await controls'), mk(3, 'PRECONDITIONS_PENDING', 'READY_FOR_PREPARE', 'coordinator', 'system', 'preconditions satisfied'), mk(4, 'READY_FOR_PREPARE', 'PREPARED', 'approver', 'ops.chen', 'approve — prepare (SoD)'), mk(5, 'PREPARED', 'EXECUTING', 'releaser', 'ops.dey', 'release — execute (SoD)'), mk(6, 'EXECUTING', 'VALIDATING', 'coordinator', 'system', 'both legs dispatched'), mk(7, 'VALIDATING', 'ABORTED', 'validator', 'ops.eve', 'amount mismatch on camt.054'), ], }; } if (plan.status === 'DRAFT') { return { plan_id: plan.plan_id, current_state: 'DRAFT', transitions: [mk(0, null, 'DRAFT', 'submitter', 'ops.frank', 'plan created')], }; } return { plan_id: plan.plan_id, current_state: 'VALIDATING', transitions: [ mk(0, null, 'DRAFT', 'submitter', 'ops.alice', 'plan created'), mk(1, 'DRAFT', 'INITIATED', 'submitter', 'ops.alice', 'initiation'), mk(2, 'INITIATED', 'PRECONDITIONS_PENDING', 'coordinator', 'system', 'await controls'), mk(3, 'PRECONDITIONS_PENDING', 'READY_FOR_PREPARE', 'coordinator', 'system', 'preconditions satisfied'), mk(4, 'READY_FOR_PREPARE', 'PREPARED', 'approver', 'ops.chen', 'approve — prepare (SoD)'), mk(5, 'PREPARED', 'EXECUTING', 'releaser', 'ops.dey', 'release — execute (SoD)'), mk(6, 'EXECUTING', 'VALIDATING', 'coordinator', 'system', 'both legs dispatched, awaiting reconciliation'), ], }; } function demoPlanEvents(planId: string): PlanEvent[] { const detail = demoPlanState(planId); return detail.transitions.map((t, i) => ({ id: i + 1, plan_id: planId, type: `state.${t.to_state.toLowerCase()}`, payload: { from: t.from_state, to: t.to_state, actor_role: t.actor_role, reason: t.reason, }, signature: `demo-sig-${i.toString(16).padStart(4, '0')}`, prev_hash: i === 0 ? null : `demo-hash-${(i - 1).toString(16).padStart(4, '0')}`, created_at: t.occurred_at, })); }