mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 17:10:56 +00:00
fix: premature payable invoice
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user