mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 06:28:12 +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(result).toBeNull();
|
||||||
expect(transaction).not.toHaveBeenCalled();
|
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
|
* Expire a source's currently-open invoice (its pay window closed before
|
||||||
* settlement), then emit `${source}.invoice.expired`. Resolves the open invoice
|
* settlement), then emit `${source}.invoice.expired`. Resolves the open invoice
|
||||||
* and transitions it to EXPIRED — a terminal, non-payable status (kept out of
|
* 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
|
* `OPEN_STATUSES`). No-op (returns null) when the source has no invoice left to
|
||||||
* (already paid/cancelled/expired).
|
* 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
|
* Pass the caller's transaction `manager` (e.g. the booking pay-window expiry in
|
||||||
* the batch engine) to enlist in its DB transaction.
|
* the batch engine) to enlist in its DB transaction.
|
||||||
@@ -850,7 +857,7 @@ export class BillingService {
|
|||||||
where: {
|
where: {
|
||||||
source,
|
source,
|
||||||
sourceId,
|
sourceId,
|
||||||
status: In(OPEN_STATUSES),
|
status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]),
|
||||||
...(type ? { type } : {}),
|
...(type ? { type } : {}),
|
||||||
},
|
},
|
||||||
order: { issuedAt: "DESC" },
|
order: { issuedAt: "DESC" },
|
||||||
@@ -867,30 +874,58 @@ export class BillingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sync a source's open invoice `dueAt` to its real pay-window deadline. The
|
* Issue a source's invoice and stamp its real pay-window deadline — the single
|
||||||
* booking invoice is generated before the pay window opens (at booking
|
* transition that makes a source payable.
|
||||||
* creation/approval), so its printed due date is refreshed when the batch engine
|
*
|
||||||
* sets `paymentDeadline`. No-op when the source has no open invoice.
|
* 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,
|
source: Freight.InvoiceSource,
|
||||||
sourceId: string,
|
sourceId: string,
|
||||||
dueAt: Date,
|
dueAt: Date,
|
||||||
type?: string,
|
type?: string,
|
||||||
manager?: EntityManager,
|
manager?: EntityManager,
|
||||||
): Promise<void> {
|
): Promise<Invoice | null> {
|
||||||
const mg = manager ?? this.dataSource.manager;
|
const mg = manager ?? this.dataSource.manager;
|
||||||
const invoice = await mg.findOne(Invoice, {
|
const invoice = await mg.findOne(Invoice, {
|
||||||
where: {
|
where: {
|
||||||
source,
|
source,
|
||||||
sourceId,
|
sourceId,
|
||||||
status: In(OPEN_STATUSES),
|
status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]),
|
||||||
...(type ? { type } : {}),
|
...(type ? { type } : {}),
|
||||||
},
|
},
|
||||||
order: { issuedAt: "DESC" },
|
order: { issuedAt: "DESC" },
|
||||||
});
|
});
|
||||||
if (!invoice) return;
|
if (!invoice) return null;
|
||||||
await mg.update(Invoice, { id: invoice.id }, { dueAt });
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -33,8 +33,6 @@ import { ClearanceMilestoneService } from '../contracts/clearance-milestone.serv
|
|||||||
import { ClearanceWorkflowService } from '../contracts/clearance-workflow.service';
|
import { ClearanceWorkflowService } from '../contracts/clearance-workflow.service';
|
||||||
import { ContractDocPhase } from '@edr/types';
|
import { ContractDocPhase } from '@edr/types';
|
||||||
|
|
||||||
|
|
||||||
import { Freight } from "@edr/types";
|
|
||||||
import { BookingInvoiceService } from "./booking-invoice.service";
|
import { BookingInvoiceService } from "./booking-invoice.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -1112,14 +1110,25 @@ export class BookingTransitionService {
|
|||||||
await this.bookingBatchService.pickExportSchedule(booking);
|
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);
|
const invoice = await this.invoiceService.ensureInvoiceForBooking(booking);
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Generated invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id}`,
|
`Generated draft invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id} — issued on batch selection`,
|
||||||
);
|
|
||||||
await this.invoiceService.updateStatus(
|
|
||||||
invoice.id,
|
|
||||||
Freight.InvoiceStatus.Pending,
|
|
||||||
);
|
);
|
||||||
|
// 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)) {
|
if (isRoadService(booking.serviceType)) {
|
||||||
await this.bookingsRepository.update(booking.id, {
|
await this.bookingsRepository.update(booking.id, {
|
||||||
status: "ROAD_DISPATCH_PENDING",
|
status: "ROAD_DISPATCH_PENDING",
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
||||||
trainSchedulingService as never,
|
trainSchedulingService as never,
|
||||||
{
|
{
|
||||||
syncPayableDueDate: jest.fn().mockResolvedValue(undefined),
|
issuePayable: jest.fn().mockResolvedValue(null),
|
||||||
expirePayable: jest.fn().mockResolvedValue(undefined),
|
expirePayable: jest.fn().mockResolvedValue(undefined),
|
||||||
} as never,
|
} as never,
|
||||||
{ emitPhase: jest.fn() } as never,
|
{ emitPhase: jest.fn() } as never,
|
||||||
@@ -596,7 +596,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
notifier as never,
|
notifier as never,
|
||||||
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
||||||
trainSchedulingService 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,
|
{ emitPhase: jest.fn() } as never,
|
||||||
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
|
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -619,7 +619,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
notifier as never,
|
notifier as never,
|
||||||
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
||||||
trainSchedulingService 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,
|
{ emitPhase: jest.fn() } as never,
|
||||||
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
|
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -650,7 +650,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
notifier as never,
|
notifier as never,
|
||||||
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
||||||
trainSchedulingService 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,
|
{ emitPhase: jest.fn() } as never,
|
||||||
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
|
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
|
||||||
undefined,
|
undefined,
|
||||||
|
|||||||
@@ -2317,9 +2317,13 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
paymentDeadline: deadline,
|
paymentDeadline: deadline,
|
||||||
} as never);
|
} as never);
|
||||||
booking.trainScheduleId = scheduleId;
|
booking.trainScheduleId = scheduleId;
|
||||||
// The invoice was generated at booking creation/approval, before this pay
|
// The invoice was generated DRAFT at booking creation / operation-accept,
|
||||||
// window opened — refresh its printed due date to the real deadline.
|
// before this pay window existed. Reserving is the moment the booking becomes
|
||||||
await this.billing.syncPayableDueDate(
|
// 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,
|
Freight.InvoiceSource.Booking,
|
||||||
booking.id,
|
booking.id,
|
||||||
deadline,
|
deadline,
|
||||||
|
|||||||
Reference in New Issue
Block a user