feat: add drain tail to the payments

This commit is contained in:
Nathnael
2026-08-04 08:59:33 +00:00
parent a20498fd77
commit acd2cfbe8c
9 changed files with 379 additions and 22 deletions

View File

@@ -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,