diff --git a/orchestrator/src/services/dbisCore/client.ts b/orchestrator/src/services/dbisCore/client.ts new file mode 100644 index 0000000..b119704 --- /dev/null +++ b/orchestrator/src/services/dbisCore/client.ts @@ -0,0 +1,215 @@ +/** + * HTTP client adapter for `d-bis/dbis_core`. + * + * Provider-switched: when `DBIS_CORE_URL` is set the client makes real + * HTTP calls to the upstream DBIS Core Banking API; otherwise every + * method returns a deterministic mock response so unit tests, local + * dev, and CI still work. + * + * This is intentionally minimal — only the endpoints the orchestrator + * actually calls from its settlement / obligation / compliance paths. + * Extend the client surface as new orchestrator capabilities need more + * of the dbis_core API. + */ + +import { logger } from "../../logging/logger"; +import type { + AccountBalance, + AriDecisionRequest, + AriDecisionResponse, + AtomicSettleRequest, + AtomicSettleResponse, + Pacs008DispatchRequest, + Pacs008DispatchResponse, + RouteRequest, + RouteResponse, + SettlementStatus, +} from "./types"; + +export interface DbisCoreConfig { + baseUrl?: string; + apiKey?: string; + timeoutMs?: number; + fetchImpl?: typeof fetch; +} + +export interface DbisCoreClient { + mode: "live" | "mock"; + getAccountBalance(accountId: string): Promise; + findSettlementRoute(req: RouteRequest): Promise; + atomicSettle(req: AtomicSettleRequest): Promise; + getSettlementStatus(settlementId: string): Promise; + requestAriDecision(req: AriDecisionRequest): Promise; + dispatchPacs008(req: Pacs008DispatchRequest): Promise; +} + +function loadConfigFromEnv(): DbisCoreConfig { + return { + baseUrl: process.env.DBIS_CORE_URL, + apiKey: process.env.DBIS_CORE_API_KEY, + timeoutMs: process.env.DBIS_CORE_TIMEOUT_MS + ? parseInt(process.env.DBIS_CORE_TIMEOUT_MS, 10) + : 10_000, + }; +} + +class HttpDbisCoreClient implements DbisCoreClient { + readonly mode = "live" as const; + private readonly baseUrl: string; + private readonly apiKey?: string; + private readonly timeoutMs: number; + private readonly fetchImpl: typeof fetch; + + constructor(cfg: Required> & DbisCoreConfig) { + this.baseUrl = cfg.baseUrl.replace(/\/+$/, ""); + this.apiKey = cfg.apiKey; + this.timeoutMs = cfg.timeoutMs ?? 10_000; + this.fetchImpl = cfg.fetchImpl ?? fetch; + } + + private async request( + method: "GET" | "POST", + path: string, + body?: unknown, + ): Promise { + const url = `${this.baseUrl}${path}`; + const headers: Record = { + Accept: "application/json", + }; + if (body !== undefined) headers["Content-Type"] = "application/json"; + if (this.apiKey) headers["X-API-Key"] = this.apiKey; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + try { + const resp = await this.fetchImpl(url, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + signal: controller.signal, + }); + if (!resp.ok) { + const text = await resp.text().catch(() => ""); + throw new Error( + `dbis_core ${method} ${path} failed: HTTP ${resp.status} ${text.slice(0, 200)}`, + ); + } + return (await resp.json()) as T; + } finally { + clearTimeout(timer); + } + } + + getAccountBalance(accountId: string): Promise { + return this.request( + "GET", + `/api/accounts/${encodeURIComponent(accountId)}/balance`, + ); + } + + findSettlementRoute(req: RouteRequest): Promise { + return this.request("POST", "/api/isn/route", req); + } + + atomicSettle(req: AtomicSettleRequest): Promise { + return this.request("POST", "/api/isn/atomic", req); + } + + getSettlementStatus(settlementId: string): Promise { + return this.request( + "GET", + `/api/isn/settlements/${encodeURIComponent(settlementId)}`, + ); + } + + requestAriDecision(req: AriDecisionRequest): Promise { + return this.request("POST", "/api/ari/decision", req); + } + + dispatchPacs008(req: Pacs008DispatchRequest): Promise { + return this.request( + "POST", + "/api/v1/gpn/message/pacs008", + req, + ); + } +} + +class MockDbisCoreClient implements DbisCoreClient { + readonly mode = "mock" as const; + + async getAccountBalance(accountId: string): Promise { + return { + accountId, + currency: "USD", + available: "1000000.00", + held: "0.00", + asOf: new Date().toISOString(), + }; + } + + async findSettlementRoute(req: RouteRequest): Promise { + return { + routeId: `mock-route-${req.sourceBankId}-${req.destinationBankId}`, + hops: [ + { bankId: req.sourceBankId, latencyMs: 20, feeBps: 0 }, + { bankId: req.destinationBankId, latencyMs: 40, feeBps: 5 }, + ], + estimatedLatencyMs: 60, + estimatedFeeBps: 5, + }; + } + + async atomicSettle(req: AtomicSettleRequest): Promise { + return { + settlementId: `mock-stlm-${req.reference}`, + status: "settled", + completedAt: new Date().toISOString(), + }; + } + + async getSettlementStatus(settlementId: string): Promise { + return { + settlementId, + status: "settled", + legs: [ + { legId: `${settlementId}-leg1`, bankId: "mock-src", status: "confirmed" }, + { legId: `${settlementId}-leg2`, bankId: "mock-dst", status: "confirmed" }, + ], + lastUpdated: new Date().toISOString(), + }; + } + + async requestAriDecision(req: AriDecisionRequest): Promise { + return { + txId: req.txId, + outcome: "allow", + riskScore: 0.1, + reasons: ["mock: default allow"], + evaluatedAt: new Date().toISOString(), + }; + } + + async dispatchPacs008(req: Pacs008DispatchRequest): Promise { + return { + messageId: req.messageId, + status: "accepted", + acknowledgmentRef: `mock-ack-${req.messageId}`, + }; + } +} + +/** + * Factory. Call once per process (or per test run) to get a client + * wired to whichever backend the env selects. + */ +export function createDbisCoreClient( + cfg: DbisCoreConfig = loadConfigFromEnv(), +): DbisCoreClient { + if (cfg.baseUrl) { + logger.info({ baseUrl: cfg.baseUrl, mode: "live" }, "[DbisCore] HTTP client"); + return new HttpDbisCoreClient({ ...cfg, baseUrl: cfg.baseUrl }); + } + logger.info({ mode: "mock" }, "[DbisCore] HTTP client (no DBIS_CORE_URL — mock mode)"); + return new MockDbisCoreClient(); +} diff --git a/orchestrator/src/services/dbisCore/index.ts b/orchestrator/src/services/dbisCore/index.ts new file mode 100644 index 0000000..5bb8e16 --- /dev/null +++ b/orchestrator/src/services/dbisCore/index.ts @@ -0,0 +1,9 @@ +/** + * Public surface for the dbis_core client adapter. + * See ./client.ts for implementation and ./types.ts for the shared + * request/response shapes. + */ + +export * from "./types"; +export { createDbisCoreClient } from "./client"; +export type { DbisCoreClient, DbisCoreConfig } from "./client"; diff --git a/orchestrator/src/services/dbisCore/types.ts b/orchestrator/src/services/dbisCore/types.ts new file mode 100644 index 0000000..ed22a0f --- /dev/null +++ b/orchestrator/src/services/dbisCore/types.ts @@ -0,0 +1,106 @@ +/** + * Canonical request/response shapes for the subset of `d-bis/dbis_core` + * endpoints the orchestrator actually calls. Kept small and focused — + * this is a client adapter, not a mirror of the upstream service. + * + * Upstream endpoint references (from dbis_core/src/integration/api- + * gateway/app.ts mount points): + * + * GET /api/accounts/:accountId/balance + * POST /api/isn/route + * POST /api/isn/atomic + * POST /api/ari/decision + * POST /api/v1/gpn/message/pacs008 + * GET /api/isn/settlements/:settlementId + */ + +export interface AccountBalance { + accountId: string; + currency: string; + available: string; + held: string; + asOf: string; +} + +export interface RouteRequest { + sourceBankId: string; + destinationBankId: string; + amount: string; + currencyCode: string; +} + +export interface SettlementHop { + bankId: string; + latencyMs: number; + feeBps: number; +} + +export interface RouteResponse { + routeId: string; + hops: SettlementHop[]; + estimatedLatencyMs: number; + estimatedFeeBps: number; +} + +export interface AtomicSettleRequest { + routeId: string; + sourceAccountId: string; + destinationAccountId: string; + amount: string; + currencyCode: string; + reference: string; +} + +export interface AtomicSettleResponse { + settlementId: string; + status: "accepted" | "settled" | "rejected"; + completedAt?: string; + rejectionReason?: string; +} + +export interface AriDecisionRequest { + txId: string; + amount: string; + currencyCode: string; + creator: string; + counterparty?: string; + metadata?: Record; +} + +export type AriOutcome = "allow" | "deny" | "review"; + +export interface AriDecisionResponse { + txId: string; + outcome: AriOutcome; + riskScore: number; + reasons: string[]; + evaluatedAt: string; +} + +export interface Pacs008DispatchRequest { + messageId: string; + creationDateTime: string; + debtor: { name: string; bic: string; account: string }; + creditor: { name: string; bic: string; account: string }; + amount: string; + currencyCode: string; + remittanceInfo?: string; +} + +export interface Pacs008DispatchResponse { + messageId: string; + status: "accepted" | "rejected"; + acknowledgmentRef?: string; + rejectionReason?: string; +} + +export interface SettlementStatus { + settlementId: string; + status: "pending" | "routing" | "executing" | "settled" | "failed" | "reversed"; + legs: { + legId: string; + bankId: string; + status: "pending" | "dispatched" | "confirmed" | "failed"; + }[]; + lastUpdated: string; +} diff --git a/orchestrator/tests/services/dbisCoreClient.test.ts b/orchestrator/tests/services/dbisCoreClient.test.ts new file mode 100644 index 0000000..d7a7ed0 --- /dev/null +++ b/orchestrator/tests/services/dbisCoreClient.test.ts @@ -0,0 +1,198 @@ +/** + * Unit tests for the dbis_core HTTP client adapter. + * + * Covers both provider-switch legs: + * - `createDbisCoreClient()` with DBIS_CORE_URL unset → mock mode. + * - `createDbisCoreClient({ baseUrl, fetchImpl })` → live mode, with + * a stub `fetch` so tests never hit the network. + */ + +import { + createDbisCoreClient, + type DbisCoreClient, +} from "../../src/services/dbisCore"; + +describe("dbisCore.createDbisCoreClient() — mock mode", () => { + let client: DbisCoreClient; + + beforeAll(() => { + delete process.env.DBIS_CORE_URL; + delete process.env.DBIS_CORE_API_KEY; + client = createDbisCoreClient(); + }); + + it("reports mock mode", () => { + expect(client.mode).toBe("mock"); + }); + + it("returns a balance shaped like upstream", async () => { + const b = await client.getAccountBalance("acct-1"); + expect(b.accountId).toBe("acct-1"); + expect(typeof b.available).toBe("string"); + expect(typeof b.held).toBe("string"); + expect(b.currency).toBeDefined(); + }); + + it("returns a plausible route", async () => { + const r = await client.findSettlementRoute({ + sourceBankId: "src", + destinationBankId: "dst", + amount: "100", + currencyCode: "USD", + }); + expect(r.routeId).toContain("src"); + expect(r.hops.length).toBeGreaterThan(0); + expect(r.estimatedFeeBps).toBeGreaterThanOrEqual(0); + }); + + it("settles atomically with a deterministic id", async () => { + const s = await client.atomicSettle({ + routeId: "r1", + sourceAccountId: "a", + destinationAccountId: "b", + amount: "1", + currencyCode: "USD", + reference: "ref-1", + }); + expect(s.status).toBe("settled"); + expect(s.settlementId).toContain("ref-1"); + expect(s.completedAt).toBeDefined(); + }); + + it("returns an allow decision by default from ARI", async () => { + const d = await client.requestAriDecision({ + txId: "tx-1", + amount: "1", + currencyCode: "USD", + creator: "0xdead", + }); + expect(d.outcome).toBe("allow"); + expect(d.txId).toBe("tx-1"); + expect(d.riskScore).toBeLessThan(1); + }); + + it("accepts a pacs008 dispatch and echoes the messageId", async () => { + const r = await client.dispatchPacs008({ + messageId: "msg-1", + creationDateTime: "2026-01-01T00:00:00Z", + debtor: { name: "Acme", bic: "ACMEUS33", account: "1" }, + creditor: { name: "Widget", bic: "WDGTGB22", account: "2" }, + amount: "100", + currencyCode: "USD", + }); + expect(r.status).toBe("accepted"); + expect(r.messageId).toBe("msg-1"); + }); + + it("returns a settled status from a synthetic settlementId", async () => { + const s = await client.getSettlementStatus("stlm-99"); + expect(s.status).toBe("settled"); + expect(s.legs.length).toBeGreaterThan(0); + }); +}); + +describe("dbisCore.createDbisCoreClient() — live mode (stubbed fetch)", () => { + function makeFetch( + record: (url: string, init: RequestInit) => void, + responseBody: unknown, + status = 200, + ): typeof fetch { + return (async (input: string | URL | Request, init?: RequestInit) => { + record(String(input), init ?? {}); + return new Response(JSON.stringify(responseBody), { + status, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof fetch; + } + + it("reports live mode when baseUrl is set", () => { + const client = createDbisCoreClient({ + baseUrl: "http://dbis.example.test", + fetchImpl: makeFetch( + () => undefined, + { accountId: "a", currency: "USD", available: "0", held: "0", asOf: "" }, + ), + }); + expect(client.mode).toBe("live"); + }); + + it("hits GET /api/accounts/:id/balance with the API key header", async () => { + const calls: { url: string; headers: Record; method?: string }[] = []; + const client = createDbisCoreClient({ + baseUrl: "http://dbis.example.test", + apiKey: "k-secret", + fetchImpl: makeFetch( + (url, init) => { + calls.push({ + url, + method: init.method, + headers: (init.headers ?? {}) as Record, + }); + }, + { + accountId: "a42", + currency: "USD", + available: "500", + held: "10", + asOf: "2026-01-01T00:00:00Z", + }, + ), + }); + + const b = await client.getAccountBalance("a42"); + expect(b.available).toBe("500"); + expect(calls).toHaveLength(1); + expect(calls[0].url).toBe("http://dbis.example.test/api/accounts/a42/balance"); + expect(calls[0].method).toBe("GET"); + expect(calls[0].headers["X-API-Key"]).toBe("k-secret"); + }); + + it("posts a route request and parses the structured response", async () => { + const client = createDbisCoreClient({ + baseUrl: "http://dbis.example.test/", + fetchImpl: makeFetch( + () => undefined, + { + routeId: "R1", + hops: [{ bankId: "A", latencyMs: 1, feeBps: 2 }], + estimatedLatencyMs: 10, + estimatedFeeBps: 2, + }, + ), + }); + const r = await client.findSettlementRoute({ + sourceBankId: "A", + destinationBankId: "B", + amount: "1", + currencyCode: "USD", + }); + expect(r.routeId).toBe("R1"); + expect(r.estimatedFeeBps).toBe(2); + }); + + it("throws a descriptive error on non-2xx", async () => { + const client = createDbisCoreClient({ + baseUrl: "http://dbis.example.test", + fetchImpl: makeFetch(() => undefined, { error: "denied" }, 403), + }); + await expect(client.getAccountBalance("a1")).rejects.toThrow(/HTTP 403/); + }); + + it("encodes path parameters safely", async () => { + const calls: string[] = []; + const client = createDbisCoreClient({ + baseUrl: "http://dbis.example.test", + fetchImpl: makeFetch( + (url) => { + calls.push(url); + }, + { settlementId: "x", status: "settled", legs: [], lastUpdated: "" }, + ), + }); + await client.getSettlementStatus("weird/id with space"); + expect(calls[0]).toBe( + "http://dbis.example.test/api/isn/settlements/weird%2Fid%20with%20space", + ); + }); +});