diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts index 037957367..e7682879b 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts @@ -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 | 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(); + }); }); diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 3b14d6f4c..4d647e3ef 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -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 { + ): Promise { 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; } /** diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 69d505c3b..2aed86027 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -33,8 +33,6 @@ import { ClearanceMilestoneService } from '../contracts/clearance-milestone.serv import { ClearanceWorkflowService } from '../contracts/clearance-workflow.service'; import { ContractDocPhase } from '@edr/types'; - -import { Freight } from "@edr/types"; import { BookingInvoiceService } from "./booking-invoice.service"; @Injectable() @@ -1112,14 +1110,25 @@ export class BookingTransitionService { await this.bookingBatchService.pickExportSchedule(booking); } + // Mint the booking's invoice (DRAFT) so the priced order carries its billing + // record from accept onward. It is deliberately NOT issued here: accepting an + // operation only puts the booking in the batch holding pool — no slot has been + // offered and no pay window exists yet. Issuing at this point made the invoice + // payable straight away (portal invoice list/detail gate on invoice status + // alone), letting a customer pay before being selected for a batch, while the + // booking page correctly still showed it as not payable. The batch engine + // issues it in `reserve` (SELECTED_FOR_BATCH), which is where the pay window + // and the real deadline are created — matching the portal's `canPay` gate. const invoice = await this.invoiceService.ensureInvoiceForBooking(booking); this.logger.log( - `Generated invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id}`, - ); - await this.invoiceService.updateStatus( - invoice.id, - Freight.InvoiceStatus.Pending, + `Generated draft invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id} — issued on batch selection`, ); + // TODO: road (truck) orders are an incomplete feature — they stop at the + // dead-end ROAD_DISPATCH_PENDING status below (no dispatch transition, no + // per-km pricing wired via roadKmPrice, no pay surface in the portal). They + // skip the train batch, so they never reach `reserve` and their invoice stays + // DRAFT / unpayable. When the road flow is built, issue its invoice + // (billing.issuePayable) at whatever transition opens the road pay window. if (isRoadService(booking.serviceType)) { await this.bookingsRepository.update(booking.id, { status: "ROAD_DISPATCH_PENDING", diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index c37430121..8c3c92193 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -134,7 +134,7 @@ describe('BookingBatchService — PAID reconcile', () => { { addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never, trainSchedulingService as never, { - syncPayableDueDate: jest.fn().mockResolvedValue(undefined), + issuePayable: jest.fn().mockResolvedValue(null), expirePayable: jest.fn().mockResolvedValue(undefined), } as never, { emitPhase: jest.fn() } as never, @@ -596,7 +596,7 @@ describe('BookingBatchService — PAID reconcile', () => { notifier as never, { addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never, trainSchedulingService as never, - { syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never, + { issuePayable: jest.fn(), expirePayable: jest.fn() } as never, { emitPhase: jest.fn() } as never, { computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never, undefined, @@ -619,7 +619,7 @@ describe('BookingBatchService — PAID reconcile', () => { notifier as never, { addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never, trainSchedulingService as never, - { syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never, + { issuePayable: jest.fn(), expirePayable: jest.fn() } as never, { emitPhase: jest.fn() } as never, { computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never, undefined, @@ -650,7 +650,7 @@ describe('BookingBatchService — PAID reconcile', () => { notifier as never, { addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never, trainSchedulingService as never, - { syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never, + { issuePayable: jest.fn(), expirePayable: jest.fn() } as never, { emitPhase: jest.fn() } as never, { computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never, undefined, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 18e1c0fc7..ef39ab138 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -2317,9 +2317,13 @@ export class BookingBatchService implements OnModuleInit { paymentDeadline: deadline, } as never); booking.trainScheduleId = scheduleId; - // The invoice was generated at booking creation/approval, before this pay - // window opened — refresh its printed due date to the real deadline. - await this.billing.syncPayableDueDate( + // The invoice was generated DRAFT at booking creation / operation-accept, + // before this pay window existed. Reserving is the moment the booking becomes + // payable (SELECTED_FOR_BATCH + a real deadline), so issue the draft here and + // print the deadline as its due date — never earlier, or the customer could + // settle an invoice for a slot they have not been offered yet. Idempotent: a + // re-reserve only refreshes `dueAt`. + await this.billing.issuePayable( Freight.InvoiceSource.Booking, booking.id, deadline,