mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
250 lines
9.2 KiB
TypeScript
250 lines
9.2 KiB
TypeScript
/**
|
|
* What happens when the bank misbehaves. Each test forces the gateway mock
|
|
* into a failure mode and asserts the platform's answer — the point being that
|
|
* NO failure may ever settle an invoice that was not paid, and no failure may
|
|
* lose a payment that was.
|
|
*
|
|
* Covered: provider down at initiate, hard decline, forged signature, replayed
|
|
* callback, silent settlement found only by the reconciliation sweep, and the
|
|
* unverifiable answer that must stop freight from expiring a paying customer.
|
|
*/
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { closeDb, db, gateway, payment, poll, sleep } from "./client";
|
|
import {
|
|
createImportSchedule,
|
|
currentInvoice,
|
|
departureAt,
|
|
ensureCorridorRoute,
|
|
releaseUnpaidHolds,
|
|
forceWindowOpen,
|
|
gatewayIntent,
|
|
invoiceForBooking,
|
|
payInvoice,
|
|
prepareBooking,
|
|
resetCorridorDay,
|
|
runBatch,
|
|
type ReadyBooking,
|
|
} from "./flows";
|
|
|
|
const DEPARTURE = departureAt(6);
|
|
const STAMP = String(Date.now());
|
|
|
|
/** Four independent bookings so one test's terminal state can't poison another. */
|
|
const CASES = ["FAIL1", "FAIL2", "FAIL3", "FAIL4"] as const;
|
|
type CaseName = (typeof CASES)[number];
|
|
|
|
describe("payment failure and recovery", () => {
|
|
const bookings = new Map<CaseName, ReadyBooking>();
|
|
const invoices = new Map<CaseName, string>();
|
|
|
|
beforeAll(async () => {
|
|
await gateway.reset();
|
|
await releaseUnpaidHolds();
|
|
await ensureCorridorRoute();
|
|
await resetCorridorDay(DEPARTURE);
|
|
const schedule = await createImportSchedule({ departure: DEPARTURE });
|
|
await forceWindowOpen(schedule.id, 45);
|
|
|
|
let isoSeed = 100;
|
|
for (const suffix of CASES) {
|
|
bookings.set(
|
|
suffix,
|
|
await prepareBooking({
|
|
suffix,
|
|
departure: DEPARTURE,
|
|
runStamp: STAMP,
|
|
isoSeed,
|
|
twenty: 2,
|
|
}),
|
|
);
|
|
isoSeed += 2;
|
|
}
|
|
await runBatch(schedule.id);
|
|
for (const suffix of CASES) {
|
|
invoices.set(suffix, (await invoiceForBooking(bookings.get(suffix)!.bookingId)).id);
|
|
}
|
|
}, 600_000);
|
|
|
|
afterAll(closeDb);
|
|
|
|
it("leaves the invoice payable when the provider is unreachable", async () => {
|
|
const invoiceId = invoices.get("FAIL1")!;
|
|
await gateway.mode("cbe-birr", "fail");
|
|
|
|
const res = await payInvoice(invoiceId, { method: "CBE_BIRR" });
|
|
expect(res.status).toBeGreaterThanOrEqual(400);
|
|
|
|
// No phantom settlement, and the customer can retry.
|
|
const invoice = await currentInvoice(invoiceId);
|
|
expect(invoice.status).not.toBe("PAID");
|
|
expect(invoice.paid_at).toBeNull();
|
|
|
|
await gateway.mode("cbe-birr", "ok");
|
|
const retry = await payInvoice(invoiceId, { method: "CBE_BIRR" });
|
|
expect(retry.status, JSON.stringify(retry.body)).toBeLessThanOrEqual(201);
|
|
});
|
|
|
|
it("keeps the invoice open on a declined payment", async () => {
|
|
const { bookingId } = bookings.get("FAIL2")!;
|
|
const invoiceId = invoices.get("FAIL2")!;
|
|
await gateway.mode("cbe-birr", "ok");
|
|
expect((await payInvoice(invoiceId, { method: "CBE_BIRR" })).status).toBeLessThanOrEqual(201);
|
|
|
|
const intent = await gatewayIntent(bookingId);
|
|
await gateway.webhook({ merchantOrderId: intent.merchant_order_id, status: "FAILED" });
|
|
|
|
const failed = await poll<{ status: string }>(
|
|
"intent FAILED",
|
|
`SELECT status FROM edr_payment.payment_intent WHERE id = $1`,
|
|
[intent.id],
|
|
(row) => row?.status === "FAILED",
|
|
{ attempts: 20, intervalMs: 1000 },
|
|
);
|
|
expect(failed.status).toBe("FAILED");
|
|
|
|
const invoice = await currentInvoice(invoiceId);
|
|
expect(invoice.status).not.toBe("PAID");
|
|
});
|
|
|
|
it("ignores a forged signature — event recorded, money untouched", async () => {
|
|
const { bookingId } = bookings.get("FAIL3")!;
|
|
const invoiceId = invoices.get("FAIL3")!;
|
|
expect((await payInvoice(invoiceId, { method: "CBE_BIRR" })).status).toBeLessThanOrEqual(201);
|
|
|
|
const intent = await gatewayIntent(bookingId);
|
|
const res = await gateway.webhook({
|
|
merchantOrderId: intent.merchant_order_id,
|
|
signature: "bad",
|
|
});
|
|
// Providers must still get a 2xx (Waafi times out at 5s and never retries).
|
|
expect(res.body.delivered).toBe(200);
|
|
|
|
const event = await poll<{ signature_valid: boolean; processing_error: string | null }>(
|
|
"forged webhook recorded",
|
|
`SELECT signature_valid, processing_error FROM edr_payment.payment_webhook_event
|
|
WHERE merchant_order_id = $1 ORDER BY received_at DESC LIMIT 1`,
|
|
[intent.merchant_order_id],
|
|
(row) => !!row,
|
|
{ attempts: 15, intervalMs: 1000 },
|
|
);
|
|
expect(event.signature_valid).toBe(false);
|
|
expect(event.processing_error).toBe("signature-invalid");
|
|
|
|
await sleep(3000);
|
|
const after = await db<{ status: string }>(
|
|
`SELECT status FROM edr_payment.payment_intent WHERE id = $1`,
|
|
[intent.id],
|
|
);
|
|
expect(after[0].status).not.toBe("SUCCEEDED");
|
|
expect((await currentInvoice(invoiceId)).status).not.toBe("PAID");
|
|
});
|
|
|
|
it("settles from the reconciliation sweep alone, with no callback at all", async () => {
|
|
const { bookingId } = bookings.get("FAIL4")!;
|
|
const invoiceId = invoices.get("FAIL4")!;
|
|
expect((await payInvoice(invoiceId, { method: "CBE_BIRR" })).status).toBeLessThanOrEqual(201);
|
|
|
|
const intent = await gatewayIntent(bookingId);
|
|
// The customer pays at the bank, but the callback is lost in the network.
|
|
await gateway.settle(intent.merchant_order_id);
|
|
|
|
// RECONCILE_STALE_AFTER_MS=5s, sweep every 30s — one sweep is enough.
|
|
const settled = await poll<{ status: string }>(
|
|
"intent settled by sweep",
|
|
`SELECT status FROM edr_payment.payment_intent WHERE id = $1`,
|
|
[intent.id],
|
|
(row) => row?.status === "SUCCEEDED",
|
|
{ attempts: 30, intervalMs: 3000 },
|
|
);
|
|
expect(settled.status).toBe("SUCCEEDED");
|
|
|
|
const invoice = await poll<{ status: string }>(
|
|
"invoice PAID via sweep",
|
|
`SELECT status FROM freight.invoices WHERE id = $1`,
|
|
[invoiceId],
|
|
(row) => row?.status === "PAID",
|
|
{ attempts: 30, intervalMs: 2000 },
|
|
);
|
|
expect(invoice.status).toBe("PAID");
|
|
|
|
// No webhook was ever delivered for this one.
|
|
const [{ n }] = await db<{ n: string }>(
|
|
`SELECT count(*)::text AS n FROM edr_payment.payment_webhook_event
|
|
WHERE merchant_order_id = $1`,
|
|
[intent.merchant_order_id],
|
|
);
|
|
expect(Number(n)).toBe(0);
|
|
});
|
|
|
|
it("reports `unverifiable` when the gateway cannot answer, so freight must not expire the hold", async () => {
|
|
// FAIL1 still has a live (unsettled) intent — FAIL2's is terminal, and a
|
|
// reference with nothing to verify legitimately answers "not paid".
|
|
const { bookingId } = bookings.get("FAIL1")!;
|
|
await gateway.mode("cbe-birr", "timeout");
|
|
|
|
const res = await payment("post", "/payments/reconcile", {
|
|
service: "FREIGHT",
|
|
referenceType: "SHIPMENT",
|
|
referenceId: bookingId,
|
|
});
|
|
await gateway.mode("cbe-birr", "ok");
|
|
|
|
expect([200, 201]).toContain(res.status);
|
|
const body = res.body?.data ?? res.body;
|
|
expect(body.paid).toBe(false);
|
|
// An unknown answer must never read as "definitely unpaid" — that is what
|
|
// stops the batch engine from expiring a customer who actually paid.
|
|
expect(body.unverifiable).toBe(true);
|
|
});
|
|
|
|
it("captures late: a settlement after the intent expired still pays the invoice", async () => {
|
|
const { bookingId } = bookings.get("FAIL3")!;
|
|
const invoiceId = invoices.get("FAIL3")!;
|
|
const intent = await gatewayIntent(bookingId);
|
|
|
|
// Retire the intent the way an expiry sweep would, then let the money land.
|
|
await db(
|
|
`UPDATE edr_payment.payment_intent
|
|
SET status = 'EXPIRED', expires_at = now() - interval '1 minute'
|
|
WHERE id = $1`,
|
|
[intent.id],
|
|
);
|
|
await gateway.webhook({
|
|
merchantOrderId: intent.merchant_order_id,
|
|
eventId: `LATE-${intent.merchant_order_id}`,
|
|
});
|
|
|
|
const captured = await poll<{ status: string }>(
|
|
"late capture flips the intent",
|
|
`SELECT status FROM edr_payment.payment_intent WHERE id = $1`,
|
|
[intent.id],
|
|
(row) => row?.status === "SUCCEEDED",
|
|
{ attempts: 20, intervalMs: 1000 },
|
|
);
|
|
expect(captured.status).toBe("SUCCEEDED");
|
|
|
|
const invoice = await poll<{ status: string }>(
|
|
"invoice settled by late capture",
|
|
`SELECT status FROM freight.invoices WHERE id = $1`,
|
|
[invoiceId],
|
|
(row) => row?.status === "PAID",
|
|
{ attempts: 30, intervalMs: 2000 },
|
|
);
|
|
expect(invoice.status).toBe("PAID");
|
|
});
|
|
|
|
it("retries delivery until the consumer is back", async () => {
|
|
// Deliberately not covered here: it needs the freight container stopped
|
|
// mid-test, which would break every other file sharing this stack.
|
|
// `node integration/scripts/it.mjs logs` + a manual `docker compose stop
|
|
// freight-api-e2e` reproduces it; the relay's backoff is unit-testable.
|
|
// ponytail: outbox retry asserted only via attempts>0 below; add a
|
|
// dedicated single-file stack if this ever regresses.
|
|
const rows = await db<{ status: string; attempts: number }>(
|
|
`SELECT status, attempts FROM edr_payment.notification_outbox ORDER BY created_at DESC LIMIT 20`,
|
|
);
|
|
expect(rows.length).toBeGreaterThan(0);
|
|
expect(rows.every((r) => r.status !== "FAILED")).toBe(true);
|
|
});
|
|
});
|