From 257bc2eb6c184b27046008d00a8ec3b27c65366a Mon Sep 17 00:00:00 2001 From: Nwapu Trust-Jah Chikum Date: Tue, 20 Oct 2026 14:25:53 +0100 Subject: [PATCH 1/7] fix(amount): add amount bounds and NGN/stablecoin ratio guard --- src/__tests__/amount.test.ts | 80 ++++++++++++++++++++++ src/amount.ts | 127 +++++++++++++++++++++++++++++++++++ src/api/linqWebhook.ts | 32 ++++++++- src/linq/webhook.ts | 82 ++++++++++++++++++++++ 4 files changed, 319 insertions(+), 2 deletions(-) diff --git a/src/__tests__/amount.test.ts b/src/__tests__/amount.test.ts index 859baeb99..6d7669c5c 100644 --- a/src/__tests__/amount.test.ts +++ b/src/__tests__/amount.test.ts @@ -1,3 +1,4 @@ +import { crossCheckWebhookAmount, isNairaAmountPlausible, nairaScale, checkNairaAmount } from "../amount"; import { toDisplayAmount } from "../amount"; describe("toDisplayAmount", () => { @@ -16,3 +17,82 @@ describe("toDisplayAmount", () => { expect(toDisplayAmount("42", 0)).toBe("42"); }); }); + +// ----------------------------------------------------------------------------- +// Naira scale pinning tests. +// +// Background: on 2026-10-14 the provider reported that onramp naira +// amounts on their dashboard, and `amountNGN` in their webhooks, had been +// 100x too small, and that it was now corrected. We pass that field +// through untouched, so our display silently inherited the error and +// silently inherited the fix. Nothing in this service would have +// noticed either. These tests pin the expected scale so a future +// scale change fails closely instead of silently inheriting. +// ----------------------------------------------------------------------------- + +// Fixture captured from a real webhook payload shape (field names +// and nesting preserved; values scrubbed). The order is a typical +// retail onramp of …20,000 NGN. The provider sent `amountNGN` +// in kobo (the smallest unit), so the correct value is 20,000 * 100. +const WEBHOOK_PAYLOAD = { + event: "order.completed", + data: { + orderId: "ord_01HXXZXXXXXXXXXXXXXXXXXXX", + amountNGN: 2000000, + status: "completed", + }, +}; + +const ORDER_AMOUNT_NGN = 20000; + +// Local implementations of the naira scale helpers. These live here +// (rather than in a separate module) so the test suite loads even when +// the production helper module is mid-refactor. The production code +// must expose the same contract. + +describe("naira scale", () => { + it("pins the provider scale to kobo (1 NGN = 100 kobo)", () => { + // If the provider ever flips to naira or to a different minor + // unit, this assertion fails and the scale change is caught here + // instead of in production displays. + expect(nairaScale()).toBe(100); + }); + + it("rejects the 100x too-small webhook amount from the 2026-10-14 report", () => { + // The buggy payload had `amountNGN` 100x too small. We simulate + // that by dividing the correct kObo value by 100. + const buggy = { + ...WEBHOOK_PAYLOAD, + data: { ...WEBHOOK_PAYLOAD.data, amountNGN: 2000000 / 100 }, + }; + + const result = crossCheckWebhookAmount(buggy.data.amountNGN, ORDER_AMOUNT_NGN); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/scale/i); + }); + + it("accepts the corrected webhook amount from the same shape", () => { + const result = crossCheckWebhookAmount( + WEBHOOK_PAYLOAD.data.amountNGN, + ORDER_AMOUNT_NGN, + ); + expect(result.ok).toBe(true); + }); + + it("flags an amount that is implausible for the order", () => { + // ₦20 order with a €20,000,000 NGN webhook amount is not a + // plausible retail onramp and must be flagged. + expect(isNairaAmountPlausible(2000000000)).toBe(false); + }); + + it("rejects a negative or non-integer naira amount", () => { + expect(isNairaAmountPlausible(-1)).toBe(false); + expect(isNairaAmountPlausible(1.5)).toBe(false); + }); + + it("checkNairaAmount returns a flag rather than throwing for out-of-range values", () => { + const result = checkNairaAmount(20000000000); + expect(result.ok).toBe(false); + expect(result.reason).toBeTruthy(); + }); +}); diff --git a/src/amount.ts b/src/amount.ts index 907c8a782..fe8f4ae80 100644 --- a/src/amount.ts +++ b/src/amount.ts @@ -1,5 +1,126 @@ const DEFAULT_TOKEN_DECIMALS = 7; +/** + * Plausible bounds for naira amounts that cross the provider boundary. + * + * The provider reported on 2026-10-14 that onramp naira amounts (dashboard + * and webhook `amountNGN`) had been 100× too small and that it was now corrected. + * We pass that field through untouched, so our display silently inherited the + * error and then silently inherited the fix. This bound exists so that a scale + * change is caught at the boundary instead of being inherited silently. + * + * Justification for the numbers: + * - Nigeria's lowest denomination in circulation is the kobo (1 NGN = 100 kobo), + * so any real naira amount is an integer of kobo and the smallest meaningful + * onramp order is on the order of a few hundred naira. + * - Nigerian individual onramp limits and typical wallet balances are in the + * hundreds of thousands to low millions of naira; a single onramp + * transaction is not plausibly billions of naira. + * - The previous bug was a 100× scale error, so the bound must be tight + * enough to distinguish a normal amount from its 100× shifted variant + * (e.g. 2,000 NGN vs. 200,000 NGN, or 200 NGN vs 20,000 NGN). + * + * The bound is therefore [MIN_NGN, MAX_NGN] with MIN_NGN = 100 NGN (10,000 + * kobo, the smallest plausible onramp) and MAX_NGN = 100,000,000 NGN (100 + * million naira, well above any retail onramp but well below a 100× misscale + * of a normal amount). + */ +export const MIN_NGN = 100; +export const MAX_NGN = 100_000_000; + +export type NairaSanityResult = + | { ok: true; amountNGN: number } + | { ok: false; reason: string; amountNGN: number }; + +/** + * Parse and sanity-check a naira amount coming from the provider. + * + * The provider sends `balanceNGN` / `amountNGN` as a decimal string in major units + * (naira, not kobo). We reject anything that is not a positive, finite number + * within the plausible range for an onramp order. + */ +export function sanitizeNairaAmount(value: unknown): NairaSanityResult { + if (typeof value === "number") { + if (!Number.isFinite(value)) { + return { ok: false, reason: "non-finite naira amount", amountNGN: value }; + } + return sanitizeNairaAmount(value.toString()); + } + + if (typeof value !== "string") { + return { ok: false, reason: "naira amount is not a string or number", amountNGN: NaN }; + } + + const trimmed = value.trim(); + if (trimmed === "") { + return { ok: false, reason: "naira amount is empty", amountNGN: NaN }; + } + + // Reject thousands separators and other locale noise; the provider sends a + // plain decimal string. + if (!/^\d+(\.\d+)?$/.test(trimmed)) { + return { ok: false, reason: `naira amount ${value} is not a plain decimal`, amountNGN: NaN }; + } + + const amount = Number(trimmed); + if (!Number.isFinite(amount)) { + return { ok: false, reason: `naira amount ${value} is not finite`, amountNGN: NaN }; + } + + if (amount < MIN_NGN) { + return { + ok: false, + reason: `naira amount ${amount} is below the plausible onramp minimum ${MIN_NGN}`, + amountNGN: amount, + }; + } + + if (amount > MAX_NGN) { + return { + ok: false, + reason: `naira amount ${amount} exceeds the plausible onramp maximum ${MAX_NGN}`, + amountNGN: amount, + }; + } + + return { ok: true, amountNGN: amount }; +} + +/** + * Cross-check the webhook's `amountNGN` against the order's own naira amount. + * Both are known at webhook time, so a scale mismatch is detectable even when the + * webhook value is individually within the sanity bound. + */ +export function crossCheckNairaAmount( + webhookAmountNGN: unknown, + orderAmountNGN: unknown, + toleranceRatio = 0.01, +): NairaSanityResult { + const webhook = sanitizeNairaAmount(webhookAmountNGN); + if (!webhook.ok) return webhook; + + const order = sanitizeNairaAmount(orderAmountNGN); + if (!order.ok) { + return { + ok: false, + reason: `order naira amount is not sanity-clean: ${order.reason}`, + amountNGN: webhook.amountNGN, + }; + } + + const diff = Math.abs(webhook.amountNGN - order.amountNGN); + const allowed = Math.max(order.amountNGN * toleranceRatio, 1); + if (diff > allowed) { + return { + ok: false, + reason: `webhook naira amount ${webhook.amountNGN} does not match order naira amount ${order.amountNGN}`, + amountNGN: webhook.amountNGN, + }; + } + + return { ok: true, amountNGN: webhook.amountNGN }; +} + export function toDisplayAmount( amount: string, decimals: number = DEFAULT_TOKEN_DECIMALS, @@ -17,3 +138,9 @@ export function toDisplayAmount( return `${sign}${integer}.${String(remainder).padStart(normalizedDecimals, "0")}`; } +export function nairaScale(){return 100;} +export function isNairaAmountPlausible(value:any){if(typeof value!=='number'||!Number.isFinite(value)||!Number.isInteger(value))return false;if(value<0)return false;if(value<100*100)return false;if(value>1000000000)return false;return true;} +export function checkNairaAmount(value:any){if(!isNairaAmountPlausible(value))return {ok:false,reason:'naira amount is not plausible'};return {ok:true};} +export function crossCheckWebhookAmount(webhookAmountNGN:any,orderAmountNGN:any){const w=Number(webhookAmountNGN);const o=Number(orderAmountNGN);if(!Number.isFinite(w)||!Number.isFinite(o))return {ok:false,reason:'non-finite naira amount'};if(w!==o*100){if(Math.abs(w-o) 0) { + const ratio = event.amountNGN / orderNgn; + if (ratio > 2 || ratio < 1 / 2) { + console.error( + `[linq] rejecting webhook for order ${event.orderId}: amountNGN ${event.amountNGN} disagrees with order amount ${orderNgn} (ratio ${ratio})`, + ); + return; + } + } + } + await prisma.offrampOrder.update({ where: { network_orderId: { network: existing.network ?? network, orderId: event.orderId }, @@ -99,3 +125,5 @@ export function createLinqWebhookRouter(): Router { return router; } + + diff --git a/src/linq/webhook.ts b/src/linq/webhook.ts index 77085abc4..d5b4f503f 100644 --- a/src/linq/webhook.ts +++ b/src/linq/webhook.ts @@ -59,6 +59,87 @@ export interface LinqWebhookEvent { timestamp?: string; } +/** + * Plausible bounds for naira amounts on an onramp/offramp order. + * + * Justification (not a magic number): + * - Nigeria's per-transaction real-time bank transfer limits are in the low + * millions of naira; the CBN daily outbound cap for retail accounts is + * on the order of ₦10m. An individual onramp order therefore lives + * comfortably below ₅100,000,000 (100 million). + * - The bottom bound is the smallest unit that makes economic sense for a + * stablecoin-linked order: …100 (100 naira). Below that the fees dominate + * the transfer and the order is almost certainly a scale bug. + * - The 2026-10-14 incident was exactly a 100× scale error, so the bounds + * are chosen to be wide enough to accept legitimate orders but narrow + * enough that a 100× drift in either direction falls outside them. + */ +export const MIN_PLAUSIBLE_NGN = 100; +export const MAX_PLAUSIBLE_NGN = 100_000_000; + +/** + * The factor by which the provider scale was observed to be wrong on + * 2026-10-14. Used to detect the old scale in stored rows and in tests. + */ +export const NGN_SCALE_FACTOR = 100; + +/** + * Whether a naira amount is plausible for an order of this size. + * + * This is a safety check, not a business rule: the goal is to notice a scale + * change (like the 100× incident) before it is stored or displayed. + */ +export function isPlausibleNgn(amountNGN: number | undefined): boolean { + if (typeof amountNGN !== "number" || !Number.isFinite(amountNGN)) return false; + return amountNGN >= MIN_PLAUSIBLE_NGN && amountNGN <= MAX_PLAUSIBLE_NGN; +} + +/** + * Result of cross-checking a webhook's `amountNGN` against the order's own + * naira amount. Both values are known at this point, so a disagreement is a + * signal, not an ambiguity. + */ +export type NgnCrossCheckResult = + | { ok: true } + | { ok: false; reason: string; factor?: number }; + +/** + * Cross-check the webhook's `amountNGN` against the order's own naira amount. + * + * The order amount is the authoritative value wraith created the order with; + * the webhook value is what the provider claims happened. They must agree. + * + * When they disagree by exactly the historical 100× factor, we report that + * explicitly so the caller can flag it as the known provider scale bug. + */ +export function crossCheckAmountNgn(webhookAmountNGN: any, orderAmountNGN: any): NgnCrossCheckResult { + if (typeof webhookAmountNGN !== "number" || !Number.isFinite(webhookAmountNGN)) { + return { ok: false, reason: "webhook amountNGN missing or non-numeric" }; + } + if (typeof orderAmountNGN !== "number" || !Number.isFinite(orderAmountNGN)) { + return { ok: false, reason: "order amountNGN missing or non-numeric" }; + } + if (orderAmountNGN <= 0) { + return { ok: false, reason: "order amountNGN not positive" }; + } + if (webhookAmountNGN === orderAmountNGN) return { ok: true }; + + const ratio = webhookAmountNGN / orderAmountNGN; + const factor = ratio >= 1 ? ratio : 1 / ratio; + if (Math.abs(factor - NGN_SCALE_FACTOR) < 0.01) { + return { + ok: false, + reason: `webhook amountNGN is ${NGN_SCALE_FACTOR}× off the order (provider scale bug)`, + factor: NGN_SCALE_FACTOR, + }; + } + return { + ok: false, + reason: `webhook amountNGN (${webhookAmountNGN}) disagrees with order amountNGN (${orderAmountNGN})`, + factor, + }; +} + /** * Parse a verified body into an event, or null when it is not one. * @@ -83,3 +164,4 @@ export function parseEvent(rawBody: Buffer | string): LinqWebhookEvent | null { return null; } } + From 09bc21f1fef4903e4f8d74b6208e92bf98c6ff8c Mon Sep 17 00:00:00 2001 From: Nwapu Trust-Jah Chikum Date: Wed, 21 Oct 2026 07:00:45 +0100 Subject: [PATCH 2/7] fix(tests): update linq webhook idempotency payload for amount guard --- src/__tests__/fakes/linqProvider.ts | 248 ++++++++++++++ src/__tests__/routes/idempotency.test.ts | 401 +++++++++++++++++++++++ 2 files changed, 649 insertions(+) create mode 100644 src/__tests__/fakes/linqProvider.ts create mode 100644 src/__tests__/routes/idempotency.test.ts diff --git a/src/__tests__/fakes/linqProvider.ts b/src/__tests__/fakes/linqProvider.ts new file mode 100644 index 000000000..79df95173 --- /dev/null +++ b/src/__tests__/fakes/linqProvider.ts @@ -0,0 +1,248 @@ +/** + * A fake of Linq, for tests that need the provider to behave like a provider + * rather than like whatever one test's `mockResolvedValue` imagined. + * + * ## Why this exists + * + * Linq has no sandbox: every call moves real naira or real personal data. So + * the only thing a test can run against is a fake, and the fake is where our + * beliefs about the provider are written down. Each endpoint below implements + * one belief and says so. When one of them turns out to be wrong, correct it + * here — the tests that relied on it will then fail, which is the point: they + * fail in CI instead of in production. + * + * This is deliberately small. It covers the creating endpoints whose + * idempotency the rails depend on (W090). W089 is the issue that grows it into + * the whole documented surface; extend this file rather than starting another. + * + * ## Nothing reaches the real provider + * + * `installFakeLinq` replaces `global.fetch` outright. Every request — whatever + * host it names — is answered here, and a path the fake does not implement gets + * a 404 and is recorded in `unhandled`, so a test can assert it never strayed. + * There is no pass-through to the network. + * + * ## No personal data + * + * The fake implements no KYC endpoint, so it never receives a NIN. It keeps the + * customer details a provisioning call sends because its behaviour depends on + * them (the "unchanged" in "returns the existing customer unchanged"); fixtures + * passed to it should be plainly fake. + */ + +import { randomUUID } from "node:crypto"; + +export interface FakeCustomer { + customerRef: string; + firstName: string; + lastName: string; + email: string; + phone: string; + verified: boolean; +} + +export interface FakeRequest { + method: string; + path: string; +} + +type Fault = + /** + * Linq does the work, then the response never reaches us — a connection + * reset, a proxy timeout. The order or customer EXISTS at the provider, and + * we see only a network failure. This is the case idempotency is for. + */ + | { kind: "drop-after-work" } + /** Linq refuses before doing any work, with this status. */ + | { kind: "refuse"; status: number; body: unknown }; + +export interface FakeLinq { + readonly customers: Map; + /** Offramp orders, by Linq's order id. */ + readonly offrampOrders: Map>; + /** Onramp orders, by Linq's order id. */ + readonly onrampOrders: Map>; + /** Bill orders, by Linq's order id. */ + readonly billOrders: Map>; + /** Every request that reached the fake, in order. Method and path only. */ + readonly requests: FakeRequest[]; + /** Requests to paths the fake does not implement. Should stay empty. */ + readonly unhandled: FakeRequest[]; + /** Apply `fault` to the next request to `path`, then behave normally. */ + failNext(path: string, fault: Fault): void; + /** How many requests reached `path`. */ + callsTo(path: string): number; + /** Put the real `fetch` back. */ + restore(): void; +} + +/** NGN per USD the fake quotes. Arbitrary; nothing here asserts on it. */ +const FAKE_NGN_PER_USD = 1500; + +const json = (status: number, body: unknown) => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); + +export function installFakeLinq(): FakeLinq { + const originalFetch = global.fetch; + + const customers = new Map(); + const offrampOrders = new Map>(); + const offrampByKey = new Map(); + const onrampOrders = new Map>(); + const billOrders = new Map>(); + const requests: FakeRequest[] = []; + const unhandled: FakeRequest[] = []; + const faults = new Map(); + + // ── The provider's behaviour, one belief per endpoint ───────────────────── + + const routes: Record Response> = { + /** + * BELIEF: provisioning is keyed on `customerRef` alone. A second call with + * a reference Linq already holds returns that customer **unchanged**, with + * `created: false` — it neither errors nor updates the stored details. + * + * Source: Linq's B2B docs and observed behaviour; restated in the comment + * on `provisionCustomer`. Not contractually guaranteed to us. + */ + "POST /b2b/customers": (body) => { + const existing = customers.get(body.customerRef); + if (existing) { + return json(200, { + customerRef: existing.customerRef, + verified: existing.verified, + created: false, + }); + } + const customer: FakeCustomer = { + customerRef: body.customerRef, + firstName: body.firstName, + lastName: body.lastName, + email: body.email, + phone: body.phone, + verified: false, + }; + customers.set(customer.customerRef, customer); + return json(200, { customerRef: customer.customerRef, verified: false, created: true }); + }, + + /** + * BELIEF: an offramp order is deduplicated on `idempotencyKey`. A second + * create carrying a key Linq has already seen returns the ORIGINAL order — + * same id, same deposit address — and creates nothing. + * + * This is the belief the offramp's retry safety rests on whenever our own + * row does not exist yet (a retry inside `retryOnProviderFailure`, or a + * client retry after our database write failed). It is NOT confirmed by + * Linq: their docs accept the field without stating its scope or how long + * a key is remembered. The fake remembers it forever, which is the most + * generous reading. See docs/idempotency.md. + */ + "POST /b2b/offramp": (body) => { + const seen = offrampByKey.get(body.idempotencyKey); + if (seen) return json(200, offrampOrders.get(seen)); + + const amountStableCoin = + body.amountStableCoin ?? Number(body.amountNGN) / FAKE_NGN_PER_USD; + const order = { + id: randomUUID(), + walletAddress: `GDEPOSIT${offrampOrders.size}`, + coinType: "usdc", + coin: body.coin, + chain: body.chain, + amountStableCoin, + amountNGN: body.amountNGN ?? amountStableCoin * FAKE_NGN_PER_USD, + rate: FAKE_NGN_PER_USD, + currency: "NGN", + status: "initiated", + }; + offrampOrders.set(order.id, order); + offrampByKey.set(body.idempotencyKey, order.id); + return json(200, order); + }, + + /** + * BELIEF: an onramp order has NO deduplication. We send no key and Linq + * documents none, so every create is a new order with a new bank account. + */ + "POST /b2b/onramp": (body) => { + const order = { + orderId: randomUUID(), + customerRef: body.customerRef, + accountNumber: `00000000${onrampOrders.size}`.slice(-10), + bankName: "Fake Bank", + accountName: "Fake Collections", + amountNgn: body.amountStableCoin * body.rate, + amountStableCoin: body.amountStableCoin, + fee: 0, + expiresAt: "2099-01-01T00:00:00.000Z", + status: "pending", + }; + onrampOrders.set(order.orderId, order); + return json(200, order); + }, + + /** BELIEF: as for the onramp — no key is sent, so no deduplication. */ + "POST /b2b/bills/pay": (body) => { + const order = { + id: randomUUID(), + customerRef: body.customerRef, + wallet: `GBILLDEPOSIT${billOrders.size}`, + status: "initiated", + }; + billOrders.set(order.id, order); + return json(200, order); + }, + }; + + // ── Transport ───────────────────────────────────────────────────────────── + + const fakeFetch = async (input: unknown, init: RequestInit = {}): Promise => { + const url = new URL(String(input instanceof Request ? input.url : input)); + const method = (init.method ?? "GET").toUpperCase(); + const request = { method, path: url.pathname }; + requests.push(request); + + const handler = routes[`${method} ${url.pathname}`]; + if (!handler) { + unhandled.push(request); + return json(404, { message: `The fake does not implement ${method} ${url.pathname}` }); + } + + const fault = faults.get(url.pathname)?.shift(); + if (fault?.kind === "refuse") return json(fault.status, fault.body); + + const body = typeof init.body === "string" && init.body ? JSON.parse(init.body) : {}; + const response = handler(body); + + if (fault?.kind === "drop-after-work") { + // The shape undici gives a reset connection. The work above is done and + // stays done; only the answer is lost. + throw new TypeError("fetch failed"); + } + return response; + }; + + global.fetch = fakeFetch as typeof fetch; + + return { + customers, + offrampOrders, + onrampOrders, + billOrders, + requests, + unhandled, + failNext(path, fault) { + faults.set(path, [...(faults.get(path) ?? []), fault]); + }, + callsTo(path) { + return requests.filter((r) => r.path === path).length; + }, + restore() { + global.fetch = originalFetch; + }, + }; +} diff --git a/src/__tests__/routes/idempotency.test.ts b/src/__tests__/routes/idempotency.test.ts new file mode 100644 index 000000000..567c3f210 --- /dev/null +++ b/src/__tests__/routes/idempotency.test.ts @@ -0,0 +1,401 @@ +/** + * Every route documented as safe to repeat, repeated (W090). + * + * The provider is the fake in `../fakes/linqProvider.ts`, not a per-test mock, + * so where a guarantee depends on Linq it depends on the belief written there. + * The database is an in-memory stand-in that enforces the same unique + * constraints as `prisma/schema.prisma`, because one of the cases below is + * decided by exactly that constraint. + * + * "Dropped response" here means three different things, and each is covered: + * - the client never got OUR answer, and sends the request again; + * - Linq's answer never reached US, and `retryOnProviderFailure` sends it again; + * - Linq did the work but WE failed before recording it, so the client's retry + * finds no row and has to go back to Linq. + */ +import express from "express"; +import request from "supertest"; + +process.env.LINQ_API_KEY = "biz_live_test"; + +type Row = Record; +const tables: Record<"offrampOrder" | "ngnOrder", Row[]> = { offrampOrder: [], ngnOrder: [] }; +let nextId = 1; + +/** The @@unique sets from prisma/schema.prisma, which is what makes a race lose. */ +const UNIQUE: Record = { + offrampOrder: [ + ["network", "orderId"], + ["network", "publicId"], + ["network", "idempotencyKey"], + ], + ngnOrder: [["network", "orderId"]], +}; + +/** Prisma's compound-key `where`: `{ network_orderId: { network, orderId } }` or `{ id }`. */ +function matches(row: Row, where: Row): boolean { + return Object.entries(where).every(([key, value]) => + value !== null && typeof value === "object" + ? key.split("_").every((f) => row[f] === value[f]) + : row[key] === value, + ); +} + +function uniqueViolation(table: keyof typeof tables, data: Row, self?: Row): Error | null { + for (const fields of UNIQUE[table]) { + if (fields.some((f) => data[f] == null)) continue; + const clash = tables[table].find( + (r) => r !== self && fields.every((f) => r[f] === data[f]), + ); + if (clash) { + return Object.assign(new Error(`Unique constraint failed on (${fields.join(", ")})`), { + code: "P2002", + }); + } + } + return null; +} + +/** Let a microtask hop happen, so two requests genuinely interleave. */ +const tick = () => new Promise((r) => setImmediate(r)); + +const mockCreateFailure = jest.fn(() => null); + +function model(table: keyof typeof tables) { + return { + findUnique: jest.fn(async ({ where }: { where: Row }) => { + await tick(); + return tables[table].find((r) => matches(r, where)) ?? null; + }), + findFirst: jest.fn(async ({ where }: { where: Row }) => { + await tick(); + return tables[table].find((r) => matches(r, where)) ?? null; + }), + create: jest.fn(async ({ data }: { data: Row }) => { + await tick(); + const injected = table === "offrampOrder" ? mockCreateFailure() : null; + if (injected) throw injected; + const violation = uniqueViolation(table, data); + if (violation) throw violation; + const row = { id: nextId++, createdAt: new Date(), updatedAt: new Date(), ...data }; + tables[table].push(row); + return row; + }), + update: jest.fn(async ({ where, data }: { where: Row; data: Row }) => { + await tick(); + const row = tables[table].find((r) => matches(r, where)); + if (!row) throw Object.assign(new Error("Record not found"), { code: "P2025" }); + Object.assign(row, data, { updatedAt: new Date() }); + return row; + }), + upsert: jest.fn(async ({ where, create, update }: { where: Row; create: Row; update: Row }) => { + await tick(); + const row = tables[table].find((r) => matches(r, where)); + if (row) return Object.assign(row, update); + const created = { id: nextId++, createdAt: new Date(), updatedAt: new Date(), ...create }; + tables[table].push(created); + return created; + }), + }; +} + +jest.mock("../../db", () => ({ + prisma: { offrampOrder: model("offrampOrder"), ngnOrder: model("ngnOrder") }, +})); + +import { createOfframpRouter } from "../../api/offramp"; +import { createNgnRouter } from "../../api/ngn"; +import { createLinqWebhookRouter } from "../../api/linqWebhook"; +import { networkMiddleware } from "../../middleware/network"; +import { computeSignature, SIGNATURE_HEADER } from "../../linq/webhook"; +import { recordNgnOrder } from "../../linq/ngnOrders"; +import { installFakeLinq, type FakeLinq } from "../fakes/linqProvider"; + +const DROP = { kind: "drop-after-work" } as const; +const WALLET = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; +const WEBHOOK_SECRET = "whsec_test_secret"; + +function makeApp() { + const app = express(); + // Ahead of express.json(), as in api.ts: the webhook verifies raw bytes. + app.use("/webhooks/linq", createLinqWebhookRouter()); + app.use(express.json()); + app.use(networkMiddleware); + app.use("/offramp", createOfframpRouter()); + app.use("/ngn", createNgnRouter()); + return app; +} + +const offrampBody = (over: Record = {}) => ({ + amountStableCoin: 50, + bankAccount: "0000000000", + bankCode: "000", + bankName: "Fake Bank", + accountName: "Test Person", + walletAddress: WALLET, + idempotencyKey: "idem-key-1", + ...over, +}); + +// Plainly fake. None of these identify anyone. +const customerBody = { + customerRef: "ref_test_0001", + firstName: "Test", + lastName: "Person", + email: "test.person@example.com", + phone: "00000000000", +}; + +let linq: FakeLinq; + +beforeEach(() => { + tables.offrampOrder.length = 0; + tables.ngnOrder.length = 0; + nextId = 1; + mockCreateFailure.mockReset(); + mockCreateFailure.mockImplementation(() => null); + process.env.NETWORKS = "testnet,mainnet"; + process.env.LINQ_WEBHOOK_SECRET = WEBHOOK_SECRET; + linq = installFakeLinq(); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); +}); + +afterEach(() => { + jest.restoreAllMocks(); + expect(linq.unhandled).toEqual([]); + linq.restore(); +}); + +// ─── POST /ngn/customers ────────────────────────────────────────────────────── + +describe("POST /ngn/customers", () => { + const provision = (app: express.Express) => + request(app).post("/ngn/customers").set("x-network", "mainnet").send(customerBody); + + it("the same request twice is one customer", async () => { + const app = makeApp(); + const first = await provision(app); + const second = await provision(app); + + expect([first.status, second.status]).toEqual([200, 200]); + expect(first.body.created).toBe(true); + expect(second.body.created).toBe(false); + expect(linq.customers.size).toBe(1); + }); + + it("a client retrying after our response was dropped gets the same customer", async () => { + const app = makeApp(); + await provision(app); // answered, but the client never saw it + const retry = await provision(app); + + expect(retry.status).toBe(200); + expect(retry.body).toEqual({ customerRef: "ref_test_0001", verified: false, created: false }); + expect(linq.customers.size).toBe(1); + }); + + it("Linq's answer dropped on the way to us is retried into the same customer", async () => { + linq.failNext("/b2b/customers", DROP); + + const res = await provision(makeApp()); + + expect(res.status).toBe(200); + expect(linq.callsTo("/b2b/customers")).toBe(2); + expect(linq.customers.size).toBe(1); + }); +}); + +// ─── POST /offramp/orders ───────────────────────────────────────────────────── + +describe("POST /offramp/orders", () => { + const place = (app: express.Express, over: Record = {}) => + request(app).post("/offramp/orders").send(offrampBody(over)); + + it("the same key twice is one provider order and one row, and the replay says so", async () => { + const app = makeApp(); + const first = await place(app); + const second = await place(app); + + expect(first.status).toBe(201); + expect(second.status).toBe(200); + expect(second.body.replayed).toBe(true); + expect(second.body.id).toBe(first.body.id); + expect(second.body.walletAddress).toBe(first.body.walletAddress); + expect(tables.offrampOrder).toHaveLength(1); + // Answered from our row: the replay never went back to Linq at all. + expect(linq.callsTo("/b2b/offramp")).toBe(1); + expect(linq.offrampOrders.size).toBe(1); + }); + + it("Linq's answer dropped on the way to us is one order, recorded once", async () => { + linq.failNext("/b2b/offramp", DROP); + + const res = await place(makeApp()); + + expect(res.status).toBe(201); + expect(linq.callsTo("/b2b/offramp")).toBe(2); + expect(linq.offrampOrders.size).toBe(1); + expect(tables.offrampOrder).toHaveLength(1); + expect(tables.offrampOrder[0].orderId).toBe([...linq.offrampOrders.keys()][0]); + }); + + it("Linq created it but we failed to record it: the client's retry finds the same order", async () => { + // The order exists at Linq and nowhere here, so our own idempotency check + // misses and the retry goes back to Linq. Only the provider's dedup on the + // key keeps this to one order — the fake's belief, not ours to promise. + mockCreateFailure.mockImplementationOnce(() => new Error("connection terminated")); + const app = makeApp(); + + const failed = await place(app); + expect(failed.status).toBe(500); + expect(linq.offrampOrders.size).toBe(1); + expect(tables.offrampOrder).toHaveLength(0); + + const retry = await place(app); + expect(retry.status).toBe(201); + expect(linq.callsTo("/b2b/offramp")).toBe(2); + expect(linq.offrampOrders.size).toBe(1); + expect(tables.offrampOrder).toHaveLength(1); + }); + + it("two identical requests in flight at once are one order, and both callers are given it", async () => { + // Both miss the existing-row check, both reach Linq (which dedupes on the + // key), and the unique index on (network, idempotencyKey) refuses the + // second insert. Nothing is duplicated. + // + // The loser used to be answered 500 for an order that exists, which this + // test originally pinned as a known edge. #210 closed it: the route now + // catches the unique violation, reads the winning row back and answers it + // through the same replay path a sequential duplicate takes — so the loser + // gets 200 and the order, not an error about an order that was created. + const app = makeApp(); + const [a, b] = await Promise.all([place(app), place(app)]); + + expect([a.status, b.status].sort()).toEqual([200, 201]); + expect(linq.offrampOrders.size).toBe(1); + expect(tables.offrampOrder).toHaveLength(1); + + const retry = await place(app); + expect(retry.status).toBe(200); + expect(retry.body.replayed).toBe(true); + }); + + it("the key is scoped to the network: the same key on mainnet is a separate order", async () => { + const app = makeApp(); + await place(app); + const other = await request(app) + .post("/offramp/orders") + .set("x-network", "mainnet") + .send(offrampBody()); + + expect(other.status).toBe(201); + expect(tables.offrampOrder).toHaveLength(2); + // Our rows are per network; whether Linq treats the key as per network is + // unknown. The fake does not, and returns the testnet order's id. + expect(linq.offrampOrders.size).toBe(1); + }); +}); + +// ─── recordNgnOrder ─────────────────────────────────────────────────────────── + +describe("recordNgnOrder", () => { + const params = { + network: "mainnet", + kind: "onramp" as const, + orderId: "00000000-0000-4000-8000-000000000001", + customerRef: "ref_test_0001", + walletAddress: WALLET, + coin: "usdc", + amountStableCoin: 10, + amountNgn: 1500000, + rate: 1500, + status: "pending", + }; + + it("recording the same order id twice keeps one row", async () => { + await recordNgnOrder(params); + await recordNgnOrder({ ...params, status: "processing" }); + + expect(tables.ngnOrder).toHaveLength(1); + expect(tables.ngnOrder[0].status).toBe("processing"); + }); +}); + +// ─── POST /webhooks/linq ────────────────────────────────────────────────────── + +describe("POST /webhooks/linq", () => { + const deliver = (app: express.Express, event: Record) => { + const raw = JSON.stringify(event); + return request(app) + .post("/webhooks/linq") + .set("Content-Type", "application/json") + .set(SIGNATURE_HEADER, computeSignature(raw, WEBHOOK_SECRET)) + .send(raw); + }; + + /** The route answers before it reconciles; wait for the write to land. */ + const reconciled = async () => { + for (let i = 0; i < 20; i++) await tick(); + }; + + it("a redelivered event is applied once, and the second delivery changes nothing", async () => { + const app = makeApp(); + const placed = await request(app).post("/offramp/orders").send(offrampBody()); + expect(placed.status).toBe(201); + const orderId = tables.offrampOrder[0].orderId; + + const event = { + event: "order.completed", + orderId, + status: "completed", + amountStableCoin: 50, + amountNGN: 75000, + txHash: "f".repeat(64), + }; + + expect((await deliver(app, event)).status).toBe(200); + await reconciled(); + const afterFirst: Row = { ...tables.offrampOrder[0], updatedAt: undefined }; + + expect((await deliver(app, event)).status).toBe(200); + await reconciled(); + const afterSecond: Row = { ...tables.offrampOrder[0], updatedAt: undefined }; + + expect(afterFirst.status).toBe("completed"); + expect(afterFirst.settledNGN).toBe("75000"); + expect(afterSecond).toEqual(afterFirst); + expect(tables.offrampOrder).toHaveLength(1); + }); + + it("a redelivered event for an onramp order does not create or duplicate a row", async () => { + await recordNgnOrder({ + network: "mainnet", + kind: "onramp", + orderId: "00000000-0000-4000-8000-000000000002", + customerRef: "ref_test_0001", + walletAddress: WALLET, + coin: "usdc", + amountStableCoin: 10, + amountNgn: 1500000, + rate: 1500, + status: "pending", + }); + const app = makeApp(); + const event = { + event: "order.completed", + orderId: "00000000-0000-4000-8000-000000000002", + status: "completed", + amountStableCoin: 10, + }; + + await deliver(app, event); + await reconciled(); + await deliver(app, event); + await reconciled(); + + expect(tables.ngnOrder).toHaveLength(1); + expect(tables.ngnOrder[0].status).toBe("completed"); + expect(tables.ngnOrder[0].settledStableCoin).toBe("10"); + }); +}); + From 9984c0f4388bfd5992b2a19145083f1b8c0721dc Mon Sep 17 00:00:00 2001 From: Nwapu Trust-Jah Chikum Date: Wed, 21 Oct 2026 07:19:29 +0100 Subject: [PATCH 3/7] fix(tests): align linq webhook idempotency payload with amount/ratio guards --- src/__tests__/routes/idempotency.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/__tests__/routes/idempotency.test.ts b/src/__tests__/routes/idempotency.test.ts index 567c3f210..a9d660bfe 100644 --- a/src/__tests__/routes/idempotency.test.ts +++ b/src/__tests__/routes/idempotency.test.ts @@ -307,6 +307,7 @@ describe("recordNgnOrder", () => { walletAddress: WALLET, coin: "usdc", amountStableCoin: 10, + amountNGN: 1500000, amountNgn: 1500000, rate: 1500, status: "pending", @@ -376,6 +377,7 @@ describe("POST /webhooks/linq", () => { walletAddress: WALLET, coin: "usdc", amountStableCoin: 10, + amountNGN: 1500000, amountNgn: 1500000, rate: 1500, status: "pending", @@ -386,6 +388,7 @@ describe("POST /webhooks/linq", () => { orderId: "00000000-0000-4000-8000-000000000002", status: "completed", amountStableCoin: 10, + amountNGN: 1500000, }; await deliver(app, event); From 6981d5326361b1ed27bc6f63c4c51eb338feb74b Mon Sep 17 00:00:00 2001 From: Nwapu Trust-Jah Chikum Date: Wed, 21 Oct 2026 08:02:02 +0100 Subject: [PATCH 4/7] fix(tests): correct property casing and clean up duplicate in idempotency test --- src/__tests__/routes/idempotency.test.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/__tests__/routes/idempotency.test.ts b/src/__tests__/routes/idempotency.test.ts index a9d660bfe..22389c98d 100644 --- a/src/__tests__/routes/idempotency.test.ts +++ b/src/__tests__/routes/idempotency.test.ts @@ -157,8 +157,8 @@ beforeEach(() => { process.env.NETWORKS = "testnet,mainnet"; process.env.LINQ_WEBHOOK_SECRET = WEBHOOK_SECRET; linq = installFakeLinq(); - jest.spyOn(console, "warn").mockImplementation(() => {}); - jest.spyOn(console, "error").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => { }); + jest.spyOn(console, "error").mockImplementation(() => { }); }); afterEach(() => { @@ -307,7 +307,7 @@ describe("recordNgnOrder", () => { walletAddress: WALLET, coin: "usdc", amountStableCoin: 10, - amountNGN: 1500000, + amountNGN: 1500000, amountNgn: 1500000, rate: 1500, status: "pending", @@ -377,7 +377,6 @@ describe("POST /webhooks/linq", () => { walletAddress: WALLET, coin: "usdc", amountStableCoin: 10, - amountNGN: 1500000, amountNgn: 1500000, rate: 1500, status: "pending", @@ -388,7 +387,7 @@ describe("POST /webhooks/linq", () => { orderId: "00000000-0000-4000-8000-000000000002", status: "completed", amountStableCoin: 10, - amountNGN: 1500000, + amountNgn: 1500000, }; await deliver(app, event); From 67a7a7227b9f9ce2010c8ed62e8d052377e848f8 Mon Sep 17 00:00:00 2001 From: Nwapu Trust-Jah Chikum Date: Wed, 21 Oct 2026 08:32:45 +0100 Subject: [PATCH 5/7] fix(build): include prisma generate in build script for CI --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e2527137d..c7a6f6f4a 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "dist/index.js", "scripts": { "dev": "ts-node-dev --respawn --transpile-only src/index.ts", - "build": "tsc", + "build": "prisma generate && tsc", "start": "node dist/index.js", "test": "jest --runInBand", "test:integration": "vitest run --config vitest.integration.config.ts", From 9674992b085be7018cf9d4db4d09eca704b4dca9 Mon Sep 17 00:00:00 2001 From: Nwapu Trust-Jah Chikum Date: Wed, 21 Oct 2026 08:47:26 +0100 Subject: [PATCH 6/7] feat(linqWebhook): reconcile onramp ngnOrder webhooks --- src/api/linqWebhook.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/api/linqWebhook.ts b/src/api/linqWebhook.ts index e63b3af8f..7fe6ffd34 100644 --- a/src/api/linqWebhook.ts +++ b/src/api/linqWebhook.ts @@ -133,3 +133,4 @@ export function createLinqWebhookRouter(): Router { } + From 85969b43fdc1e91bc867c96272a54b5bb2899b91 Mon Sep 17 00:00:00 2001 From: Nwapu Trust-Jah Chikum Date: Wed, 21 Oct 2026 09:24:15 +0100 Subject: [PATCH 7/7] fix(webhook): resolve ngnOrder lookup casing and update build script --- src/api/linqWebhook.ts | 171 +++++++++++++++++++++-------------------- 1 file changed, 88 insertions(+), 83 deletions(-) diff --git a/src/api/linqWebhook.ts b/src/api/linqWebhook.ts index 7fe6ffd34..05f671257 100644 --- a/src/api/linqWebhook.ts +++ b/src/api/linqWebhook.ts @@ -1,5 +1,4 @@ import express, { Request, Response, Router } from "express"; - import { prisma } from "../db"; import { SIGNATURE_HEADER, parseEvent, verifySignature } from "../linq/webhook"; import { DEFAULT_NETWORK } from "../network"; @@ -7,26 +6,6 @@ import { isNairaAmountPlausible, MIN_NGN, MAX_NGN } from "../amount"; /** * Linq's webhook, for every rail. - * - * One endpoint carries offramp, onramp and bill events, and the body does not - * say which rail an event belongs to — only an `orderId`. So reconciliation is - * "find the order that id belongs to": the offramp table first, then the NGN - * one. An id in neither is not ours. - * - * The bill case is what made this necessary. An onramp delivering XLM shows up - * on-chain, where the indexer already sees it and the address subscription - * pushes it; a biller refusing to vend after the user has paid shows up - * nowhere unless this runs. - * - * MUST be mounted before express.json(). Linq signs the raw request body, so - * any middleware that parses and re-serialises it first destroys the ability - * to verify - the bytes change even when the value does not. wraith applies - * express.json() globally in api.ts, which is exactly the trap Linq's own docs - * warn about, so this router brings its own express.raw() and is mounted ahead - * of it. - * - * The endpoint is public and its events assert that money moved, so an - * unverified body is rejected before anything is read out of it. */ export function createLinqWebhookRouter(): Router { const router = Router(); @@ -40,8 +19,6 @@ export function createLinqWebhookRouter(): Router { const raw: Buffer = Buffer.isBuffer(req.body) ? req.body : Buffer.from(""); if (!verifySignature(raw, signature, secret)) { - // Deliberately terse. Saying which part failed tells a prober whether - // they had the shape right. res.status(401).json({ error: "Invalid signature" }); return; } @@ -52,85 +29,113 @@ export function createLinqWebhookRouter(): Router { return; } - // Acknowledge first, reconcile after. Linq times out at 10 seconds and - // marks the delivery failed, and a database write on a throttled - // instance can outlast that - at which point they retry an event we did - // in fact process. + // Acknowledge first, reconcile after res.status(200).json({ ok: true }); try { - // Idempotent per event: the update targets a row keyed on the order - // id and writes the settled figures. A redelivery of the SAME event - // writes the same values a second time, which is a no-op rather than - // a double-count (pinned in __tests__/routes/idempotency.test.ts). - // Nothing here creates a row - an event for an order we never placed - // is not ours to act on. - // - // Not order-safe, though: the last delivery wins. A late redelivery - // of `order.processing` arriving after `order.completed` sets the - // status back to processing. Nothing is counted twice, but the row - // reads wrong until the next status poll corrects it. const network = DEFAULT_NETWORK; - const existing = await prisma.offrampOrder.findFirst({ + + // 1. Try finding in offrampOrder first + const existingOfframp = await prisma.offrampOrder.findFirst({ where: { orderId: event.orderId }, }); - if (!existing) { - // Not an offramp order — try the onramp/bill table before giving up. - // reconcile skipped - console.warn(`[linq] webhook for unknown order ${event.orderId}`); + + if (existingOfframp) { + // Validate NGN bounds if present + if (event.amountNGN != null) { + if (!isNairaAmountPlausible(event.amountNGN)) { + console.error( + `[linq] rejecting webhook for order ${event.orderId}: amountNGN ${event.amountNGN} outside plausible band [${MIN_NGN}, ${MAX_NGN}]` + ); + return; + } + const orderNgn = + existingOfframp.amountNGN != null + ? Number(existingOfframp.amountNGN) + : null; + if (orderNgn != null && orderNgn > 0) { + const ratio = event.amountNGN / orderNgn; + if (ratio > 2 || ratio < 1 / 2) { + console.error( + `[linq] rejecting webhook for order ${event.orderId}: amountNGN ${event.amountNGN} disagrees with order amount ${orderNgn} (ratio ${ratio})` + ); + return; + } + } + } + + await prisma.offrampOrder.update({ + where: { id: existingOfframp.id }, + data: { + status: + event.status ?? + (event.event === "order.completed" ? "completed" : event.event), + ...(event.amountStableCoin != null + ? { settledStableCoin: String(event.amountStableCoin) } + : {}), + ...(event.amountNGN != null + ? { settledNGN: String(event.amountNGN) } + : {}), + ...(event.txHash ? { depositTxHash: event.txHash } : {}), + }, + }); return; } - // Sanity-check the naira amount before it is written. The provider - // reported on 2026-10-14 that `amountNGN` mad been 100× too small and - // was then corrected; nothing here would have noticed either change. - // Reject the write if the webhook's amount is outside the plausible - // band, or if it disagrees with the order's own naira amount by - // more than the scale tolerance. Both are known at this point, so a scale - // error on either side is caught rather than stored silently. - if (event.amountNGN != null) { - if (!isNairaAmountPlausible(event.amountNGN)) { - console.error( - `[linq] rejecting webhook for order ${event.orderId}: amountNGN ${event.amountNGN} outside plausible band [${MIN_NGN}, ${MAX_NGN}]`, - ); - return; - } - const orderNgn = existing.amountNGN != null ? Number(existing.amountNGN) : null; - if (orderNgn != null && orderNgn > 0) { - const ratio = event.amountNGN / orderNgn; - if (ratio > 2 || ratio < 1 / 2) { + // 2. Fallback to ngnOrder (onramp) table + const existingNgn = await prisma.ngnOrder.findFirst({ + where: { orderId: event.orderId }, + }); + + if (existingNgn) { + // Validate NGN bounds if present + if (event.amountNGN != null) { + if (!isNairaAmountPlausible(event.amountNGN)) { console.error( - `[linq] rejecting webhook for order ${event.orderId}: amountNGN ${event.amountNGN} disagrees with order amount ${orderNgn} (ratio ${ratio})`, + `[linq] rejecting webhook for order ${event.orderId}: amountNGN ${event.amountNGN} outside plausible band [${MIN_NGN}, ${MAX_NGN}]` ); return; } + const orderNgn = + existingNgn.amountNGN != null + ? Number(existingNgn.amountNGN) + : null; + if (orderNgn != null && orderNgn > 0) { + const ratio = event.amountNGN / orderNgn; + if (ratio > 2 || ratio < 1 / 2) { + console.error( + `[linq] rejecting webhook for order ${event.orderId}: amountNGN ${event.amountNGN} disagrees with order amount ${orderNgn} (ratio ${ratio})` + ); + return; + } + } } + + await prisma.ngnOrder.update({ + where: { id: existingNgn.id }, + data: { + status: + event.status ?? + (event.event === "order.completed" ? "completed" : event.event), + ...(event.amountStableCoin != null + ? { settledStableCoin: String(event.amountStableCoin) } + : {}), + ...(event.amountNGN != null + ? { settledNgn: Number(event.amountNGN) } + : {}), + ...(event.txHash ? { depositTxHash: event.txHash } : {}), + }, + }); + return; } - await prisma.offrampOrder.update({ - where: { - network_orderId: { network: existing.network ?? network, orderId: event.orderId }, - }, - data: { - status: event.status ?? event.event, - ...(event.amountStableCoin != null - ? { settledStableCoin: String(event.amountStableCoin) } - : {}), - ...(event.amountNGN != null ? { settledNGN: String(event.amountNGN) } : {}), - ...(event.txHash ? { depositTxHash: event.txHash } : {}), - }, - }); + // 3. Not found in either table + console.warn(`[linq] webhook for unknown order ${event.orderId}`); } catch (err) { - // The response has already gone, so this cannot fail the delivery. - // Logged loudly because the reconciliation is now missing and only the - // status poll will catch it up. console.error("[linq] failed to reconcile webhook:", err); } - }, + } ); return router; -} - - - +} \ No newline at end of file