fix: premature payable invoice

This commit is contained in:
Nathnael
2026-07-16 08:38:49 +00:00
parent 5f6d8f9294
commit 6dde17fa4d
5 changed files with 167 additions and 26 deletions

View File

@@ -376,4 +376,97 @@ describe("BillingService.expirePayable — locked write runs in a transaction",
expect(result).toBeNull();
expect(transaction).not.toHaveBeenCalled();
});
it("also retires a DRAFT invoice — a superseded/cancelled source must not leave one behind", async () => {
const { service, defaultManager } = build({
...openInvoice,
status: Freight.InvoiceStatus.Draft,
});
await service.expirePayable(
Freight.InvoiceSource.Booking,
"booking-1",
"prepaid",
);
const { where } = defaultManager.findOne.mock.calls[0][1];
expect(where.status.value).toContain(Freight.InvoiceStatus.Draft);
});
});
describe("BillingService.issuePayable", () => {
const dueAt = new Date("2026-01-02T00:00:00.000Z");
const build = (found: Record<string, unknown> | null) => {
const manager = {
findOne: jest.fn().mockResolvedValue(found),
update: jest.fn().mockResolvedValue(undefined),
};
const service = new BillingService(
{ manager, transaction: jest.fn() } as never,
{} as never,
{} as never,
makeEvents() as never,
{} as never,
{} as never,
{} as never,
);
return { service, manager };
};
const issue = (service: BillingService) =>
service.issuePayable(
Freight.InvoiceSource.Booking,
"booking-1",
dueAt,
"PREPAID",
);
it("issues a DRAFT invoice to PENDING, stamping issuedAt and the pay-window dueAt", async () => {
const { service, manager } = build({
id: "inv-1",
invoiceNumber: "INV-20260101-00001",
status: Freight.InvoiceStatus.Draft,
issuedAt: null,
});
const result = await issue(service);
const patch = manager.update.mock.calls[0][2];
expect(patch.status).toBe(Freight.InvoiceStatus.Pending);
expect(patch.dueAt).toBe(dueAt);
expect(patch.issuedAt).toBeInstanceOf(Date);
expect(result?.status).toBe(Freight.InvoiceStatus.Pending);
});
it("looks up DRAFT invoices — a booking's invoice is minted DRAFT and this is what makes it payable", async () => {
const { service, manager } = build(null);
await issue(service);
const { where } = manager.findOne.mock.calls[0][1];
expect(where.status.value).toContain(Freight.InvoiceStatus.Draft);
});
it("only refreshes dueAt on an already-issued invoice, so a re-reserve never re-issues", async () => {
const issuedAt = new Date("2026-01-01T00:00:00.000Z");
const { service, manager } = build({
id: "inv-1",
invoiceNumber: "INV-20260101-00001",
status: Freight.InvoiceStatus.Pending,
issuedAt,
});
const result = await issue(service);
expect(manager.update.mock.calls[0][2]).toEqual({ dueAt });
expect(result?.issuedAt).toBe(issuedAt);
});
it("is a no-op (returns null, writes nothing) when the source has no draft-or-open invoice", async () => {
const { service, manager } = build(null);
await expect(issue(service)).resolves.toBeNull();
expect(manager.update).not.toHaveBeenCalled();
});
});

View File

@@ -826,8 +826,15 @@ export class BillingService {
* Expire a source's currently-open invoice (its pay window closed before
* settlement), then emit `${source}.invoice.expired`. Resolves the open invoice
* and transitions it to EXPIRED — a terminal, non-payable status (kept out of
* `OPEN_STATUSES`). No-op (returns null) when the source has no open invoice
* (already paid/cancelled/expired).
* `OPEN_STATUSES`). No-op (returns null) when the source has no invoice left to
* retire (already paid/cancelled/expired).
*
* DRAFT invoices are matched too, even though they were never issued: this is
* also the "retire the invoice this source no longer needs" path (a cancelled
* booking, or a full-amount invoice superseded by a partial-offer one). Skipping
* drafts would leave the stale one behind for `findPayable` to hand back — the
* superseding invoice would then never be minted, and a cancelled booking would
* keep a draft that a later `issuePayable` could still make payable.
*
* Pass the caller's transaction `manager` (e.g. the booking pay-window expiry in
* the batch engine) to enlist in its DB transaction.
@@ -850,7 +857,7 @@ export class BillingService {
where: {
source,
sourceId,
status: In(OPEN_STATUSES),
status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]),
...(type ? { type } : {}),
},
order: { issuedAt: "DESC" },
@@ -867,30 +874,58 @@ export class BillingService {
}
/**
* Sync a source's open invoice `dueAt` to its real pay-window deadline. The
* booking invoice is generated before the pay window opens (at booking
* creation/approval), so its printed due date is refreshed when the batch engine
* sets `paymentDeadline`. No-op when the source has no open invoice.
* Issue a source's invoice and stamp its real pay-window deadline — the single
* transition that makes a source payable.
*
* A source's invoice is minted DRAFT, before any pay window exists (e.g. a
* booking invoice is generated at creation / operation-accept, long before the
* batch engine reserves a slot). DRAFT is deliberately outside `OPEN_STATUSES`,
* so such an invoice is not settleable and the portal renders no pay button.
* The domain calls this at the moment the pay window actually opens (booking →
* `reserve`, which sets SELECTED_FOR_BATCH + `paymentDeadline`), which issues
* the draft (→ PENDING, stamping `issuedAt`) and prints the real `dueAt`.
*
* Idempotent: an already-issued open invoice only has its `dueAt` refreshed, so
* a re-reserve never re-issues. No-op (returns null) when the source has no
* draft-or-open invoice (already paid/cancelled/expired).
*/
async syncPayableDueDate(
async issuePayable(
source: Freight.InvoiceSource,
sourceId: string,
dueAt: Date,
type?: string,
manager?: EntityManager,
): Promise<void> {
): Promise<Invoice | null> {
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, {
where: {
source,
sourceId,
status: In(OPEN_STATUSES),
status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]),
...(type ? { type } : {}),
},
order: { issuedAt: "DESC" },
});
if (!invoice) return;
await mg.update(Invoice, { id: invoice.id }, { dueAt });
if (!invoice) return null;
const issuing = invoice.status === Freight.InvoiceStatus.Draft;
const patch = {
dueAt,
...(issuing
? {
status: Freight.InvoiceStatus.Pending,
issuedAt: invoice.issuedAt ?? new Date(),
}
: {}),
};
await mg.update(Invoice, { id: invoice.id }, patch);
if (issuing) {
this.logger.log(
`Issued invoice ${invoice.invoiceNumber} (${invoice.id}) for ${source}:${sourceId} — payable until ${dueAt.toISOString()}`,
);
}
return { ...invoice, ...patch } as Invoice;
}
/**