mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 00:10:57 +00:00
feat: add drain tail to the payments
This commit is contained in:
@@ -203,6 +203,129 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A gateway success can land after the pay window AND its drain tail (relay
|
||||
* backlog, payment-api restart, a CBE bill paid at a counter). The money is
|
||||
* captured either way, so the settle lookup must accept an EXPIRED invoice —
|
||||
* matching only OPEN_STATUSES used to drop it silently, leaving a debited
|
||||
* customer with an EXPIRED invoice, an EXPIRED booking and no alert.
|
||||
*/
|
||||
describe("BillingService.settleByPaymentId", () => {
|
||||
function serviceFor(invoice: Record<string, unknown> | null) {
|
||||
const mg = {
|
||||
findOne: jest.fn().mockResolvedValue(invoice),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const events = makeEvents();
|
||||
// The lookup is by paymentId ALONE — status is judged on the resolved row,
|
||||
// so a stale capture can never skip past a newer invoice to an older one.
|
||||
const findOne = jest.fn(({ where }: { where: Record<string, unknown> }) => {
|
||||
expect(where).toEqual({ paymentId: "pay-1" });
|
||||
return Promise.resolve(invoice);
|
||||
});
|
||||
const dataSource = {
|
||||
getRepository: () => ({ findOne }),
|
||||
transaction: (cb: (mg: unknown) => unknown) => cb(mg),
|
||||
manager: mg,
|
||||
};
|
||||
const service = new BillingService(
|
||||
dataSource as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
events as never,
|
||||
{} as never, // payment
|
||||
{} as never, // companies
|
||||
{} as never, // invoiceDocuments
|
||||
);
|
||||
return { service, mg, events };
|
||||
}
|
||||
|
||||
it("settles an EXPIRED invoice — the money was already captured", async () => {
|
||||
const { service, mg, events } = serviceFor({
|
||||
id: "inv-1",
|
||||
status: Freight.InvoiceStatus.Expired,
|
||||
source: "booking",
|
||||
sourceId: "booking-1",
|
||||
totalAmount: 1500,
|
||||
paidAt: null,
|
||||
});
|
||||
|
||||
const settled = await service.settleByPaymentId("pay-1", "txn-1");
|
||||
|
||||
expect(settled?.status).toBe(Freight.InvoiceStatus.Paid);
|
||||
expect(mg.update).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ id: "inv-1" },
|
||||
expect.objectContaining({
|
||||
status: Freight.InvoiceStatus.Paid,
|
||||
paidAmount: 1500,
|
||||
balanceAmount: 0,
|
||||
}),
|
||||
);
|
||||
// The domain reacts to this — it is what revives the expired booking.
|
||||
expect(events.emitAsync).toHaveBeenCalledWith(
|
||||
"booking.invoice.paid",
|
||||
expect.objectContaining({ invoiceId: "inv-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("still settles an open (PENDING) invoice", async () => {
|
||||
const { service, mg } = serviceFor({
|
||||
id: "inv-1",
|
||||
status: Freight.InvoiceStatus.Pending,
|
||||
source: "booking",
|
||||
sourceId: "booking-1",
|
||||
totalAmount: 1500,
|
||||
paidAt: null,
|
||||
});
|
||||
|
||||
await service.settleByPaymentId("pay-1");
|
||||
|
||||
expect(mg.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/**
|
||||
* `upsertIntent` keeps ONE local payments row per booking reference, so every
|
||||
* invoice the booking was ever charged on carries the same `paymentId`. A
|
||||
* capture from a lapsed first attempt must not reach back past the invoice the
|
||||
* customer actually paid and settle the older EXPIRED one — that would mark two
|
||||
* invoices paid off a single payment.
|
||||
*/
|
||||
it("no-ops when the booking's newest invoice is already PAID", async () => {
|
||||
const { service, mg, events } = serviceFor({
|
||||
id: "inv-2",
|
||||
status: Freight.InvoiceStatus.Paid,
|
||||
source: "booking",
|
||||
sourceId: "booking-1",
|
||||
totalAmount: 1500,
|
||||
});
|
||||
|
||||
expect(await service.settleByPaymentId("pay-1")).toBeNull();
|
||||
expect(mg.update).not.toHaveBeenCalled();
|
||||
expect(events.emitAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["CANCELLED", Freight.InvoiceStatus.Cancelled],
|
||||
["REFUNDED", Freight.InvoiceStatus.Refunded],
|
||||
])("does not settle a %s invoice — that is a refund case", async (
|
||||
_label,
|
||||
status,
|
||||
) => {
|
||||
const { service, mg, events } = serviceFor({
|
||||
id: "inv-1",
|
||||
status,
|
||||
source: "booking",
|
||||
sourceId: "booking-1",
|
||||
totalAmount: 1500,
|
||||
});
|
||||
|
||||
expect(await service.settleByPaymentId("pay-1")).toBeNull();
|
||||
expect(mg.update).not.toHaveBeenCalled();
|
||||
expect(events.emitAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("BillingService.recordPayment", () => {
|
||||
function serviceFor(invoice: Record<string, unknown> | null) {
|
||||
const mg = {
|
||||
|
||||
@@ -1162,6 +1162,25 @@ export class BillingService {
|
||||
* `paymentId`, marks it paid, and emits `${source}.invoice.paid` for the domain
|
||||
* to advance on. Idempotent — no-op when no open invoice is linked (already
|
||||
* settled, or settled inline by {@link payInvoice}).
|
||||
*
|
||||
* EXPIRED is settleable HERE and only here: this is the gateway path, so the
|
||||
* money is already captured and we are recording a fait accompli. A success can
|
||||
* land after the pay window plus its drain tail (relay backlog, payment-api
|
||||
* restart, a CBE bill paid at a counter) — matching only `OPEN_STATUSES` used to
|
||||
* drop it silently, leaving a debited customer with an EXPIRED invoice and no
|
||||
* alert. The manual/offline path ({@link recordPayment}) keeps its EXPIRED guard:
|
||||
* a teller must not accept cash against a lapsed invoice.
|
||||
*
|
||||
* The status is checked on the RESOLVED invoice, never inside the lookup.
|
||||
* `paymentId` is freight's local intent projection, and `upsertIntent` keeps ONE
|
||||
* row per booking reference across every pay attempt — so a booking that was
|
||||
* re-invoiced after a lapsed attempt has SEVERAL invoices carrying the same
|
||||
* `paymentId`. Filtering by status inside the query would let a late capture from
|
||||
* attempt 1 skip past the already-PAID attempt-2 invoice and settle the older
|
||||
* EXPIRED one, marking two invoices paid off a single capture. Resolving the
|
||||
* newest invoice first and then asking whether IT is settleable makes the answer
|
||||
* "this booking's money is already recorded" instead. CANCELLED/REFUNDED are
|
||||
* refund cases, not settlements, and are logged rather than settled.
|
||||
*/
|
||||
async settleByPaymentId(
|
||||
paymentId: string,
|
||||
@@ -1169,11 +1188,31 @@ export class BillingService {
|
||||
paidAt?: Date,
|
||||
): Promise<Invoice | null> {
|
||||
const invoice = await this.dataSource.getRepository(Invoice).findOne({
|
||||
where: { paymentId, status: In(OPEN_STATUSES) },
|
||||
order: { issuedAt: "DESC" },
|
||||
where: { paymentId },
|
||||
// NULLS LAST: a DRAFT invoice has no issuedAt and Postgres sorts NULLs
|
||||
// first on DESC, which would hand back an unissued invoice.
|
||||
order: { issuedAt: { direction: "DESC", nulls: "LAST" } },
|
||||
});
|
||||
if (!invoice) return null;
|
||||
|
||||
const settleable: Freight.InvoiceStatus[] = [
|
||||
...OPEN_STATUSES,
|
||||
Freight.InvoiceStatus.Expired,
|
||||
];
|
||||
if (!settleable.includes(invoice.status)) {
|
||||
// Already PAID is the ordinary idempotent no-op (redelivery, or settled
|
||||
// inline by payInvoice). Anything else means money was captured with
|
||||
// nowhere to land — that needs a person, so say so loudly.
|
||||
if (invoice.status !== Freight.InvoiceStatus.Paid) {
|
||||
this.logger.error(
|
||||
`Payment ${paymentId} succeeded but invoice ${invoice.invoiceNumber} ` +
|
||||
`(${invoice.id}) is ${invoice.status} — nothing settled. The capture ` +
|
||||
`needs a refund or a manual settlement.`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.markInvoiceAsPaid(invoice.id, paymentId, undefined, {
|
||||
providerTxnId,
|
||||
paidAt,
|
||||
|
||||
Reference in New Issue
Block a user