mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 15:30:56 +00:00
179 lines
6.4 KiB
TypeScript
179 lines
6.4 KiB
TypeScript
/**
|
|
* The freight ⇄ payment happy path, end to end through both services.
|
|
*
|
|
* portal pays invoice
|
|
* → freight billing.payInvoice → payment API /payments/initiate
|
|
* → CBE Birr provider → gateway mock (intent opened, invoice.payment_id set)
|
|
* gateway calls back (correctly signed)
|
|
* → payment API webhook pipeline → intent SUCCEEDED → outbox row
|
|
* → RabbitMQ → freight consumer → settleByPaymentId
|
|
* → invoice PAID → booking.invoice.paid → booking advances
|
|
*
|
|
* Nothing here is stubbed except the bank itself.
|
|
*/
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import {
|
|
closeDb,
|
|
customerA,
|
|
db,
|
|
gateway,
|
|
intentByMerchantOrderId,
|
|
poll,
|
|
} from "./client";
|
|
import {
|
|
createImportSchedule,
|
|
currentInvoice,
|
|
departureAt,
|
|
ensureCorridorRoute,
|
|
releaseUnpaidHolds,
|
|
forceWindowOpen,
|
|
freightPayment,
|
|
gatewayIntent,
|
|
invoiceForBooking,
|
|
payInvoice,
|
|
prepareBooking,
|
|
resetCorridorDay,
|
|
runBatch,
|
|
type ReadyBooking,
|
|
} from "./flows";
|
|
|
|
const DEPARTURE = departureAt(4);
|
|
const STAMP = String(Date.now());
|
|
|
|
describe("freight invoice settles through the real payment service", () => {
|
|
let booking: ReadyBooking;
|
|
let invoiceId: string;
|
|
|
|
beforeAll(async () => {
|
|
await gateway.reset();
|
|
await releaseUnpaidHolds();
|
|
await ensureCorridorRoute();
|
|
await resetCorridorDay(DEPARTURE);
|
|
const schedule = await createImportSchedule({ departure: DEPARTURE });
|
|
await forceWindowOpen(schedule.id, 45);
|
|
|
|
booking = await prepareBooking({
|
|
suffix: "PAY1",
|
|
departure: DEPARTURE,
|
|
runStamp: STAMP,
|
|
isoSeed: 0,
|
|
twenty: 2,
|
|
});
|
|
await runBatch(schedule.id);
|
|
invoiceId = (await invoiceForBooking(booking.bookingId)).id;
|
|
});
|
|
|
|
afterAll(closeDb);
|
|
|
|
it("opens a gateway intent and links it to the invoice", async () => {
|
|
const res = await payInvoice(invoiceId, { method: "CBE_BIRR" });
|
|
expect(res.status, JSON.stringify(res.body)).toBeLessThanOrEqual(201);
|
|
|
|
const intent = await gatewayIntent(booking.bookingId);
|
|
expect(intent.provider).toBe("CBE_BIRR");
|
|
expect(intent.status).toBe("REQUIRES_ACTION");
|
|
expect(intent.merchant_order_id).toBeTruthy();
|
|
|
|
// The invoice must carry the intent id BEFORE any callback can arrive —
|
|
// settlement correlates on it (billing.service.ts payInvoice).
|
|
const invoice = await currentInvoice(invoiceId);
|
|
expect(invoice.payment_id).toBeTruthy();
|
|
|
|
// Freight's local projection of the same intent.
|
|
const projection = await freightPayment(invoice.payment_id!);
|
|
expect(projection.status).toBe("action-required");
|
|
expect(projection.merchant_order_id).toBe(intent.merchant_order_id);
|
|
|
|
// Freight deliberately sends a dev-shortcut amount for non-CBE_BILL
|
|
// providers (payment.service.ts:238-247) — 1 minor unit, not the invoice
|
|
// total. Asserted, not "fixed": changing it is a product decision.
|
|
expect(Number(intent.amount_minor)).toBe(1);
|
|
});
|
|
|
|
it("settles the invoice and advances the booking when the gateway calls back", async () => {
|
|
const intent = await gatewayIntent(booking.bookingId);
|
|
const res = await gateway.webhook({ merchantOrderId: intent.merchant_order_id });
|
|
expect(res.body.delivered).toBe(200);
|
|
|
|
const settled = await poll<{ status: string }>(
|
|
"payment intent SUCCEEDED",
|
|
`SELECT status FROM edr_payment.payment_intent WHERE id = $1`,
|
|
[intent.id],
|
|
(row) => row?.status === "SUCCEEDED",
|
|
{ attempts: 20, intervalMs: 1000 },
|
|
);
|
|
expect(settled.status).toBe("SUCCEEDED");
|
|
|
|
// The outbox row is written in the SAME transaction as the intent update
|
|
// and relayed to RabbitMQ by the relay loop.
|
|
const outbox = await poll<{ status: string; attempts: number }>(
|
|
"outbox row relayed",
|
|
`SELECT status, attempts FROM edr_payment.notification_outbox
|
|
WHERE intent_id = $1 AND event_type = 'payment.succeeded'`,
|
|
[intent.id],
|
|
(row) => row?.status === "SENT",
|
|
{ attempts: 20, intervalMs: 1000 },
|
|
);
|
|
expect(outbox.status).toBe("SENT");
|
|
|
|
// …and freight, on the other end of the broker, settles the invoice.
|
|
const invoice = await poll<{ status: string; paid_amount: string; balance_amount: string }>(
|
|
"invoice PAID",
|
|
`SELECT status, paid_amount, balance_amount FROM freight.invoices WHERE id = $1`,
|
|
[invoiceId],
|
|
(row) => row?.status === "PAID",
|
|
{ attempts: 30, intervalMs: 2000 },
|
|
);
|
|
expect(Number(invoice.balance_amount)).toBe(0);
|
|
expect(Number(invoice.paid_amount)).toBeGreaterThan(0);
|
|
|
|
const booked = await poll<{ status: string }>(
|
|
"booking advanced on payment",
|
|
`SELECT status FROM freight.bookings WHERE id = $1`,
|
|
[booking.bookingId],
|
|
(row) => row?.status === "PAID",
|
|
{ attempts: 20, intervalMs: 2000 },
|
|
);
|
|
expect(booked.status).toBe("PAID");
|
|
});
|
|
|
|
it("records exactly one intent, one webhook event and one ledger entry", async () => {
|
|
const intent = await gatewayIntent(booking.bookingId);
|
|
const intents = await intentByMerchantOrderId(intent.merchant_order_id);
|
|
expect(intents.length).toBe(1);
|
|
|
|
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(1);
|
|
|
|
const invoice = await currentInvoice(invoiceId);
|
|
const ledger = (invoice.payments ?? []) as unknown[];
|
|
expect(Array.isArray(ledger) ? ledger.length : 0).toBe(1);
|
|
});
|
|
|
|
it("is idempotent — a replayed callback changes nothing", async () => {
|
|
const intent = await gatewayIntent(booking.bookingId);
|
|
const before = await currentInvoice(invoiceId);
|
|
|
|
// Same orderId ⇒ same externalEventId ⇒ deduped at the webhook table.
|
|
await gateway.webhook({
|
|
merchantOrderId: intent.merchant_order_id,
|
|
eventId: `CBEORD-${intent.merchant_order_id}`,
|
|
});
|
|
await new Promise((r) => setTimeout(r, 3000));
|
|
|
|
const after = await currentInvoice(invoiceId);
|
|
expect(after.status).toBe("PAID");
|
|
expect(after.paid_amount).toBe(before.paid_amount);
|
|
expect((after.payments as unknown[]).length).toBe((before.payments as unknown[]).length);
|
|
});
|
|
|
|
it("refuses a second payment on an already-paid invoice", async () => {
|
|
const res = await payInvoice(invoiceId, { method: "CBE_BIRR", as: customerA });
|
|
expect(res.status).toBeGreaterThanOrEqual(400);
|
|
});
|
|
});
|