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 767aabec8..72ddb0b02 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 @@ -33,8 +33,20 @@ function generateInput(overrides: Record = {}) { companyProfileId: "profile-1", currency: "ETB", lines: [ - { chargeType: "RAIL_FREIGHT", description: "Rail freight", quantity: 2, unitRate: 500, amount: 1000 }, - { chargeType: "HAZARD_SURCHARGE", description: "Hazard surcharge", quantity: 2, unitRate: 250, amount: 500 }, + { + chargeType: "RAIL_FREIGHT", + description: "Rail freight", + quantity: 2, + unitRate: 500, + amount: 1000, + }, + { + chargeType: "HAZARD_SURCHARGE", + description: "Hazard surcharge", + quantity: 2, + unitRate: 250, + amount: 500, + }, ], ...overrides, }; @@ -52,10 +64,17 @@ describe("BillingService.generateInvoice", () => { manager = makeManager(savedLines); events = makeEvents(); dataSource = { - transaction: jest.fn().mockImplementation((cb: (mg: unknown) => unknown) => cb(manager)), + transaction: jest + .fn() + .mockImplementation((cb: (mg: unknown) => unknown) => cb(manager)), manager, }; - service = new BillingService(dataSource as never, {} as never, {} as never, events as never); + service = new BillingService( + dataSource as never, + {} as never, + {} as never, + events as never, + ); }); it("creates a PENDING invoice with one line per input line", async () => { @@ -122,18 +141,31 @@ describe("BillingService.markInvoiceAsPaid", () => { ); expect(events.emit).toHaveBeenCalledWith( "booking.invoice.paid", - expect.objectContaining({ invoiceId: "inv-1", status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" }), + expect.objectContaining({ + invoiceId: "inv-1", + status: Freight.InvoiceStatus.Paid, + paymentId: "pay-1", + }), ); }); it("is a no-op (no event) when the invoice is already paid", async () => { - const paid = { id: "inv-1", status: Freight.InvoiceStatus.Paid, source: "booking" }; + const paid = { + id: "inv-1", + status: Freight.InvoiceStatus.Paid, + source: "booking", + }; const mg = { findOne: jest.fn().mockResolvedValue(paid), update: jest.fn().mockResolvedValue(undefined), }; const events = makeEvents(); - const service = new BillingService({ manager: mg } as never, {} as never, {} as never, events as never); + const service = new BillingService( + { manager: mg } as never, + {} as never, + {} as never, + events as never, + ); await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); @@ -155,12 +187,16 @@ describe("BillingService.settlePayable", () => { update: jest.fn().mockResolvedValue(undefined), }; const events = makeEvents(); - const service = new BillingService({ manager: mg } as never, {} as never, {} as never, events as never); + const service = new BillingService( + { manager: mg } as never, + {} as never, + {} as never, + events as never, + ); const settled = await service.settlePayable( Freight.InvoiceSource.Booking, "booking-1", - Freight.InvoiceType.Prepaid, "pay-1", mg as never, ); @@ -171,7 +207,10 @@ describe("BillingService.settlePayable", () => { { id: "inv-1" }, { status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" }, ); - expect(events.emit).toHaveBeenCalledWith("booking.invoice.paid", expect.anything()); + expect(events.emit).toHaveBeenCalledWith( + "booking.invoice.paid", + expect.anything(), + ); }); it("is a no-op (returns null) when the source has no open invoice", async () => { @@ -180,12 +219,16 @@ describe("BillingService.settlePayable", () => { update: jest.fn().mockResolvedValue(undefined), }; const events = makeEvents(); - const service = new BillingService({ manager: mg } as never, {} as never, {} as never, events as never); + const service = new BillingService( + { manager: mg } as never, + {} as never, + {} as never, + events as never, + ); const settled = await service.settlePayable( Freight.InvoiceSource.Booking, "booking-1", - Freight.InvoiceType.Prepaid, "pay-1", mg as never, ); 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 f79548266..b1e3d25bf 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -308,42 +308,47 @@ export class BillingService { * against it rather than recomputing from the source's own total, so * discounts/penalties/adjustments carried on the invoice are honored. * - * `type` selects which invoice when a source carries several (e.g. a booking's - * up-front vs final charge). Returns the most recent open (unpaid, - * non-cancelled) invoice of that type. + * Pass `type` to select a specific invoice when a source carries several (e.g. + * a booking's up-front vs final charge); omit it to settle whichever single + * invoice is currently open. Returns the most recent matching open (unpaid, + * non-cancelled) invoice. */ findPayable( source: Freight.InvoiceSource, sourceId: string, - type: string, + type?: string, ): Promise { return this.dataSource.getRepository(Invoice).findOne({ - where: { source, sourceId, type, status: In(OPEN_STATUSES) }, + where: { + source, + sourceId, + status: In(OPEN_STATUSES), + ...(type ? { type } : {}), + }, order: { issuedAt: "DESC" }, }); } /** - * Settle a source's open invoice of `type` as paid and link the gateway + * Settle a source's currently-open invoice as paid and link the gateway * payment, then emit `${source}.invoice.paid`. Resolves the open invoice then * delegates to {@link markInvoiceAsPaid}. Full-payment only — no partial - * settlement. No-op (returns null) when the source has no open invoice of that - * type. + * settlement. No-op (returns null) when the source has no open invoice. * - * `type` selects which invoice when a source carries several (e.g. a booking's - * up-front vs final charge). Pass the caller's transaction `manager` (e.g. from + * Type-blind by design: the payment process settles whichever invoice is due; + * any per-type reaction belongs in the `${source}.invoice.paid` handler, which + * reads `invoice.type`. Pass the caller's transaction `manager` (e.g. from * `payment.service.finalizePaymentSuccess`) to enlist in its DB transaction. */ async settlePayable( source: Freight.InvoiceSource, sourceId: string, - type: string, paymentId: string | null, manager?: EntityManager, ): Promise { const mg = manager ?? this.dataSource.manager; const invoice = await mg.findOne(Invoice, { - where: { source, sourceId, type, status: In(OPEN_STATUSES) }, + where: { source, sourceId, status: In(OPEN_STATUSES) }, order: { issuedAt: "DESC" }, }); if (!invoice) return null; @@ -352,10 +357,9 @@ export class BillingService { } /** - * Refund a source's paid invoice of `type`, then emit - * `${source}.invoice.refunded`. Resolves the paid invoice then delegates to - * {@link markInvoiceAsRefunded}. No-op (returns null) when the source has no - * paid invoice of that type. + * Refund a source's paid invoice, then emit `${source}.invoice.refunded`. + * Resolves the paid invoice then delegates to {@link markInvoiceAsRefunded}. + * No-op (returns null) when the source has no paid invoice. * * Pass the caller's transaction `manager` (e.g. from `payment.service.refund`) * to enlist in its DB transaction. @@ -363,12 +367,11 @@ export class BillingService { async refundPayable( source: Freight.InvoiceSource, sourceId: string, - type: string, manager?: EntityManager, ): Promise { const mg = manager ?? this.dataSource.manager; const invoice = await mg.findOne(Invoice, { - where: { source, sourceId, type, status: Freight.InvoiceStatus.Paid }, + where: { source, sourceId, status: Freight.InvoiceStatus.Paid }, order: { issuedAt: "DESC" }, }); if (!invoice) return null;