diff --git a/apps/edr-freight-api/src/config/database.config.ts b/apps/edr-freight-api/src/config/database.config.ts index 0e7375b19..ad171f488 100644 --- a/apps/edr-freight-api/src/config/database.config.ts +++ b/apps/edr-freight-api/src/config/database.config.ts @@ -118,6 +118,8 @@ export default registerAs("database", (): TypeOrmModuleOptions => { migrationsRun: true, // Schema changes via migrations only (synchronize breaks ITMLS backfill on existing rows). synchronize: false, - logging: process.env.NODE_ENV === "development", + logging: process.env.DB_LOG + ? process.env.DB_LOG === "true" + : process.env.NODE_ENV === "development", }; }); diff --git a/apps/edr-freight-api/src/modules/billing/billing.module.ts b/apps/edr-freight-api/src/modules/billing/billing.module.ts index dc78cd6e9..771156fd3 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.module.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.module.ts @@ -3,6 +3,7 @@ import { TypeOrmModule } from "@nestjs/typeorm"; import { BillingController } from "./billing.controller"; import { PortalBillingController } from "./portal-billing.controller"; +import { PaymentController } from "./payment.controller"; import { BillingService } from "./billing.service"; import { DocumentsModule } from "./documents/documents.module"; import { Invoice } from "./entities/invoice.entity"; @@ -19,7 +20,7 @@ import { CompaniesModule } from "../companies/companies.module"; CompaniesModule, DocumentsModule, ], - controllers: [BillingController, PortalBillingController], + controllers: [BillingController, PortalBillingController, PaymentController], providers: [BillingService, InvoiceRepository, InvoiceLineRepository], exports: [BillingService], }) 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 61597264b..4df2a0eb3 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 @@ -116,12 +116,14 @@ describe("BillingService.generateInvoice", () => { }); describe("BillingService.markInvoiceAsPaid", () => { - it("marks the invoice PAID, links the payment, and emits ${source}.invoice.paid", async () => { + it("marks the invoice PAID, stamps amounts/paidAt, links the payment, and emits ${source}.invoice.paid", async () => { const open = { id: "inv-1", status: Freight.InvoiceStatus.Pending, source: "booking", sourceId: "booking-1", + totalAmount: 1500, + paidAt: null, }; const mg = { findOne: jest.fn().mockResolvedValue(open), @@ -143,7 +145,22 @@ describe("BillingService.markInvoiceAsPaid", () => { expect(mg.update).toHaveBeenCalledWith( expect.anything(), { id: "inv-1" }, - { status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" }, + { + status: Freight.InvoiceStatus.Paid, + paymentId: "pay-1", + paidAt: expect.any(Date), + paidAmount: 1500, + balanceAmount: 0, + payments: [ + { + amount: 1500, + method: "GATEWAY", + reference: "pay-1", + paidAt: expect.any(String), + metadata: null, + }, + ], + }, ); expect(events.emit).toHaveBeenCalledWith( "booking.invoice.paid", @@ -190,8 +207,14 @@ describe("BillingService.recordPayment", () => { update: jest.fn().mockResolvedValue(undefined), }; const events = makeEvents(); + const dataSource = { + manager: mg, + transaction: jest + .fn() + .mockImplementation((cb: (mg: unknown) => unknown) => cb(mg)), + }; const service = new BillingService( - { manager: mg } as never, + dataSource as never, {} as never, {} as never, events as never, @@ -257,6 +280,14 @@ describe("BillingService.recordPayment", () => { expect(mg.update).not.toHaveBeenCalled(); }); + it("rejects a payment that exceeds the outstanding balance", async () => { + const { service, mg } = serviceFor(openInvoice()); + await expect( + service.recordPayment("inv-1", { amount: 1500 }), + ).rejects.toThrow(); + expect(mg.update).not.toHaveBeenCalled(); + }); + it("rejects payment against a cancelled invoice", async () => { const { service, mg } = serviceFor( openInvoice({ status: Freight.InvoiceStatus.Cancelled }), @@ -265,74 +296,3 @@ describe("BillingService.recordPayment", () => { expect(mg.update).not.toHaveBeenCalled(); }); }); - -describe("BillingService.settlePayable", () => { - it("settles the source's open invoice PAID and emits ${source}.invoice.paid", async () => { - const open = { - id: "inv-1", - status: Freight.InvoiceStatus.Pending, - source: Freight.InvoiceSource.Booking, - sourceId: "booking-1", - }; - const mg = { - findOne: jest.fn().mockResolvedValue(open), - update: jest.fn().mockResolvedValue(undefined), - }; - const events = makeEvents(); - const service = new BillingService( - { manager: mg } as never, - {} as never, - {} as never, - events as never, - {} as never, // payment - {} as never, // companies - {} as never, // invoiceDocuments - ); - - const settled = await service.settlePayable( - Freight.InvoiceSource.Booking, - "booking-1", - "pay-1", - mg as never, - ); - - expect(settled?.status).toBe(Freight.InvoiceStatus.Paid); - expect(mg.update).toHaveBeenCalledWith( - expect.anything(), - { id: "inv-1" }, - { status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" }, - ); - expect(events.emit).toHaveBeenCalledWith( - "booking.invoice.paid", - expect.anything(), - ); - }); - - it("is a no-op (returns null) when the source has no open invoice", async () => { - const mg = { - findOne: jest.fn().mockResolvedValue(null), - update: jest.fn().mockResolvedValue(undefined), - }; - const events = makeEvents(); - const service = new BillingService( - { manager: mg } as never, - {} as never, - {} as never, - events as never, - {} as never, // payment - {} as never, // companies - {} as never, // invoiceDocuments - ); - - const settled = await service.settlePayable( - Freight.InvoiceSource.Booking, - "booking-1", - "pay-1", - mg as never, - ); - - expect(settled).toBeNull(); - expect(mg.update).not.toHaveBeenCalled(); - expect(events.emit).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 3566bf4e5..278d4cca9 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -49,7 +49,6 @@ const DEFAULT_DUE_DAYS = 14; /** Statuses an invoice can still be settled (paid/refunded/cancelled) from. */ const OPEN_STATUSES: Freight.InvoiceStatus[] = [ - Freight.InvoiceStatus.Draft, Freight.InvoiceStatus.Issued, Freight.InvoiceStatus.Pending, Freight.InvoiceStatus.PartiallyPaid, @@ -285,20 +284,15 @@ export class BillingService { /** * Initiate gateway payment for one of the customer's own invoices. Verifies - * ownership, then charges whichever open invoice the source currently has - * (see {@link payInvoice}). + * ownership, then charges the invoice directly by ID (see {@link payInvoice}). */ async payInvoiceForUser( id: string, userId: string, opts: PayInvoiceOptions = {}, ): Promise { - const invoice = await this.findByIdForUser(id, userId); - return this.payInvoice( - invoice.source as Freight.InvoiceSource, - invoice.sourceId, - opts, - ); + await this.findByIdForUser(id, userId); + return this.payInvoice(id, opts); } /** Sealed invoice PDF for one of the customer's own invoices (ownership-checked). */ @@ -423,23 +417,89 @@ export class BillingService { // ── State transitions ──────────────────────────────────────────────────────── /** - * Mark an invoice paid and link the gateway payment, then emit - * `${source}.invoice.paid`. Full-payment only — no partial settlement. - * No-op when the invoice is already paid. Pass `manager` to enlist in a - * caller's transaction. + * Run `fn` inside a transaction and only emit its returned domain event + * after commit. When the caller passes their own `manager`, they own commit + * timing — `fn`'s event fires inline as soon as it resolves (the outer + * transaction may still roll back afterwards; this is the caller's + * documented tradeoff). When no `manager` is given, this opens its own + * transaction and defers the emit until after that transaction commits, so + * listeners (e.g. booking advancement) can never observe an invoice change + * that then rolls back. + */ + private async runTransition( + manager: EntityManager | undefined, + fn: (mg: EntityManager) => Promise<{ result: T; emit?: () => void }>, + ): Promise { + if (manager) { + const { result, emit } = await fn(manager); + emit?.(); + return result; + } + let pending: (() => void) | undefined; + const result = await this.dataSource.transaction(async (mg) => { + const out = await fn(mg); + pending = out.emit; + return out.result; + }); + pending?.(); + return result; + } + + /** + * Mark an invoice paid, stamp the paid timestamp, sync paid/balance amounts, + * append the settlement to the `payments` ledger, link the gateway payment, + * then emit `${source}.invoice.paid`. Full-payment only — no partial + * settlement. No-op when the invoice is already paid. Pass `manager` to + * enlist in a caller's transaction; otherwise locks the row for update and + * emits only after commit (see {@link runTransition}). */ async markInvoiceAsPaid( invoiceId: string, paymentId: string | null = null, manager?: EntityManager, + settlement: { providerTxnId?: string; paidAt?: Date } = {}, ): Promise { - return this.transition( - invoiceId, - Freight.InvoiceStatus.Paid, - "paid", - { paymentId: paymentId ?? undefined }, - manager, - ); + return this.runTransition(manager, async (mg) => { + const invoice = await mg.findOne(Invoice, { + where: { id: invoiceId }, + lock: { mode: "pessimistic_write" }, + }); + if (!invoice) { + throw new NotFoundException(`Invoice ${invoiceId} not found`); + } + if (invoice.status === Freight.InvoiceStatus.Paid) { + return { result: invoice }; + } + + const paidAt = invoice.paidAt ?? settlement.paidAt ?? new Date(); + const settledAmount = round2( + Number(invoice.totalAmount) - Number(invoice.paidAmount ?? 0), + ); + const entry: InvoicePayment = { + amount: settledAmount, + method: "GATEWAY", + reference: settlement.providerTxnId ?? paymentId ?? null, + paidAt: paidAt.toISOString(), + metadata: null, + }; + const payments = [...(invoice.payments ?? []), entry]; + + const patch = { + status: Freight.InvoiceStatus.Paid, + paymentId, + paidAt, + paidAmount: invoice.totalAmount, + balanceAmount: 0, + payments, + }; + await mg.update(Invoice, { id: invoiceId }, patch as never); + + const updated = { ...invoice, ...patch } as Invoice; + return { + result: updated, + emit: () => this.emitInvoiceEvent("paid", updated), + }; + }); } /** @@ -451,9 +511,11 @@ export class BillingService { * at the warehouse counter); gateway settlement goes through * {@link markInvoiceAsPaid}. * - * Throws when the invoice is missing, cancelled, refunded, already fully paid, - * or when `amount` is not positive. Pass `manager` to enlist in a caller's - * transaction. + * Throws when the invoice is missing, cancelled, refunded, already fully + * paid, `amount` is not positive, or `amount` exceeds the outstanding + * balance. Pass `manager` to enlist in a caller's transaction; otherwise + * locks the row for update and emits only after commit (see + * {@link runTransition}). */ async recordPayment( invoiceId: string, @@ -466,62 +528,71 @@ export class BillingService { ); } - const mg = manager ?? this.dataSource.manager; - const invoice = await mg.findOne(Invoice, { where: { id: invoiceId } }); - if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`); - if (invoice.status === Freight.InvoiceStatus.Cancelled) { - throw new BadRequestException("Cannot pay a cancelled invoice."); - } - if (invoice.status === Freight.InvoiceStatus.Refunded) { - throw new BadRequestException("Cannot pay a refunded invoice."); - } - if (invoice.status === Freight.InvoiceStatus.Paid) { - throw new BadRequestException("Invoice is already fully paid."); - } + return this.runTransition(manager, async (mg) => { + const invoice = await mg.findOne(Invoice, { + where: { id: invoiceId }, + lock: { mode: "pessimistic_write" }, + }); + if (!invoice) { + throw new NotFoundException(`Invoice ${invoiceId} not found`); + } + if (invoice.status === Freight.InvoiceStatus.Cancelled) { + throw new BadRequestException("Cannot pay a cancelled invoice."); + } + if (invoice.status === Freight.InvoiceStatus.Refunded) { + throw new BadRequestException("Cannot pay a refunded invoice."); + } + if (invoice.status === Freight.InvoiceStatus.Paid) { + throw new BadRequestException("Invoice is already fully paid."); + } + if (round2(input.amount) > Number(invoice.balanceAmount)) { + throw new BadRequestException( + `Payment of ${round2(input.amount)} exceeds the outstanding balance of ${Number(invoice.balanceAmount)}.`, + ); + } - const at = input.paidAt ?? new Date(); - const { paidAmount, balanceAmount, fullyPaid } = applySettlement( - invoice.totalAmount, - invoice.paidAmount, - input.amount, - ); - const status = fullyPaid - ? Freight.InvoiceStatus.Paid - : Freight.InvoiceStatus.PartiallyPaid; + const at = input.paidAt ?? new Date(); + const { paidAmount, balanceAmount, fullyPaid } = applySettlement( + invoice.totalAmount, + invoice.paidAmount, + input.amount, + ); + const status = fullyPaid + ? Freight.InvoiceStatus.Paid + : Freight.InvoiceStatus.PartiallyPaid; - const entry: InvoicePayment = { - amount: round2(input.amount), - method: input.method ?? null, - reference: input.reference ?? null, - paidAt: at.toISOString(), - metadata: input.metadata ?? null, - }; - const payments = [...(invoice.payments ?? []), entry]; + const entry: InvoicePayment = { + amount: round2(input.amount), + method: input.method ?? null, + reference: input.reference ?? null, + paidAt: at.toISOString(), + metadata: input.metadata ?? null, + }; + const payments = [...(invoice.payments ?? []), entry]; - await mg.update(Invoice, { id: invoice.id }, { - paidAmount, - balanceAmount, - status, - payments, - paidAt: fullyPaid ? at : (invoice.paidAt ?? null), - } as never); + const patch = { + paidAmount, + balanceAmount, + status, + payments, + paidAt: fullyPaid ? at : (invoice.paidAt ?? null), + }; + await mg.update(Invoice, { id: invoice.id }, patch as never); - const updated = { - ...invoice, - paidAmount, - balanceAmount, - status, - payments, - paidAt: fullyPaid ? at : (invoice.paidAt ?? null), - } as Invoice; - - if (fullyPaid) this.emitInvoiceEvent("paid", updated); - return updated; + const updated = { ...invoice, ...patch } as Invoice; + return { + result: updated, + emit: fullyPaid + ? () => this.emitInvoiceEvent("paid", updated) + : undefined, + }; + }); } /** * Mark an invoice refunded and emit `${source}.invoice.refunded`. - * No-op when already refunded. + * No-op when already refunded. Throws when the invoice has no recorded + * payment (nothing to refund). */ async markInvoiceAsRefunded( invoiceId: string, @@ -533,12 +604,20 @@ export class BillingService { "refunded", {}, manager, + (invoice) => { + if (!(Number(invoice.paidAmount) > 0)) { + throw new BadRequestException( + "Cannot refund an invoice with no recorded payment.", + ); + } + }, ); } /** * Mark an invoice cancelled and emit `${source}.invoice.cancelled`. - * No-op when already cancelled. + * No-op when already cancelled. Throws when the invoice has payments + * recorded against it (refund it instead). */ async cancelInvoice( invoiceId: string, @@ -550,16 +629,23 @@ export class BillingService { "cancelled", {}, manager, + (invoice) => { + if (Number(invoice.paidAmount) > 0) { + throw new BadRequestException( + "Cannot cancel an invoice that has payments recorded against it.", + ); + } + }, ); } /** * Load the invoice, apply the new status (+ extra columns), then emit - * `${source}.invoice.`. No-op (returns the invoice) when it is already - * in the target status. Throws when the invoice does not exist. - * - * Note: the event fires in-process synchronously. When a `manager` from an - * outer transaction is passed, listeners run before that transaction commits. + * `${source}.invoice.`. No-op (returns the invoice, skipping `guard`) + * when it is already in the target status. Throws when the invoice does not + * exist or `guard` rejects the current state. Pass `manager` to enlist in a + * caller's transaction; otherwise locks the row for update and emits only + * after commit (see {@link runTransition}). */ private async transition( invoiceId: string, @@ -567,17 +653,27 @@ export class BillingService { event: string, extra: { paymentId?: string }, manager?: EntityManager, + guard?: (invoice: Invoice) => void, ): Promise { - const mg = manager ?? this.dataSource.manager; - const invoice = await mg.findOne(Invoice, { where: { id: invoiceId } }); - if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`); - if (invoice.status === status) return invoice; + return this.runTransition(manager, async (mg) => { + const invoice = await mg.findOne(Invoice, { + where: { id: invoiceId }, + lock: { mode: "pessimistic_write" }, + }); + if (!invoice) { + throw new NotFoundException(`Invoice ${invoiceId} not found`); + } + if (invoice.status === status) return { result: invoice }; + guard?.(invoice); - await mg.update(Invoice, { id: invoice.id }, { status, ...extra }); + await mg.update(Invoice, { id: invoice.id }, { status, ...extra }); - const updated = { ...invoice, ...extra, status } as Invoice; - this.emitInvoiceEvent(event, updated); - return updated; + const updated = { ...invoice, ...extra, status } as Invoice; + return { + result: updated, + emit: () => this.emitInvoiceEvent(event, updated), + }; + }); } /** Broadcast `${invoice.source}.invoice.` to in-process listeners. */ @@ -601,16 +697,17 @@ export class BillingService { // ── Payment reconciliation (by source) ─────────────────────────────────────── /** - * The invoice a gateway payment should settle for a source record, or null if - * none. This is the billing document of record for "what is owed" — callers - * (e.g. {@link payInvoice}) charge `invoice.totalAmount` against it rather than - * recomputing from the source's own total, so discounts/penalties/adjustments - * carried on the invoice are honored. + * The invoice a source record already has open, or null if it needs a new + * one. This is the idempotency check every `ensureInvoiceFor*` (booking, + * first-mile, last-mile) runs before generating — it must see DRAFT + * invoices too, not just issued ones, otherwise a source that already has + * an unissued draft gets a second, duplicate invoice minted alongside it + * instead of that draft being reused and then issued. * * 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. + * invoice is currently open. Returns the most recent matching draft-or-open + * (unpaid, non-cancelled) invoice. */ findPayable( source: Freight.InvoiceSource, @@ -621,7 +718,7 @@ export class BillingService { where: { source, sourceId, - status: In(OPEN_STATUSES), + status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]), ...(type ? { type } : {}), }, order: { issuedAt: "DESC" }, @@ -629,56 +726,24 @@ export class BillingService { } /** - * 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. - * - * Type-blind by design: 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` to enlist in its DB transaction. - * - * NOTE: the booking flow settles via {@link payInvoice} + the `payment.succeeded` - * event ({@link settleByPaymentId}); this source-keyed settle is a generic helper - * for callers that settle by source rather than by gateway intent id. + * 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. */ - async settlePayable( + findInvoice( source: Freight.InvoiceSource, sourceId: string, - paymentId: string | null, - manager?: EntityManager, + type?: string, ): Promise { - const mg = manager ?? this.dataSource.manager; - const invoice = await mg.findOne(Invoice, { - where: { source, sourceId, status: In(OPEN_STATUSES) }, + return this.dataSource.getRepository(Invoice).findOne({ + where: { + source, + sourceId, + ...(type ? { type } : {}), + }, order: { issuedAt: "DESC" }, }); - if (!invoice) return null; - - return this.markInvoiceAsPaid(invoice.id, paymentId, mg); - } - - /** - * 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. - */ - async refundPayable( - source: Freight.InvoiceSource, - sourceId: string, - manager?: EntityManager, - ): Promise { - const mg = manager ?? this.dataSource.manager; - const invoice = await mg.findOne(Invoice, { - where: { source, sourceId, status: Freight.InvoiceStatus.Paid }, - order: { issuedAt: "DESC" }, - }); - if (!invoice) return null; - - return this.markInvoiceAsRefunded(invoice.id, mg); } /** @@ -694,11 +759,17 @@ export class BillingService { async expirePayable( 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, status: In(OPEN_STATUSES) }, + where: { + source, + sourceId, + status: In(OPEN_STATUSES), + ...(type ? { type } : {}), + }, order: { issuedAt: "DESC" }, }); if (!invoice) return null; @@ -722,17 +793,29 @@ export class BillingService { source: Freight.InvoiceSource, sourceId: string, dueAt: Date, + type?: string, manager?: EntityManager, ): Promise { const mg = manager ?? this.dataSource.manager; const invoice = await mg.findOne(Invoice, { - where: { source, sourceId, status: In(OPEN_STATUSES) }, + where: { + source, + sourceId, + status: In(OPEN_STATUSES), + ...(type ? { type } : {}), + }, order: { issuedAt: "DESC" }, }); if (!invoice) return; await mg.update(Invoice, { id: invoice.id }, { dueAt }); } + /** + * Force an invoice to `status`, including issuing a still-DRAFT invoice + * (stamping `issuedAt`) — unlike the other transitions here, this is a + * blunt admin/workflow override, not a settlement. No-op when the invoice + * is missing or already terminal (paid/cancelled/refunded/expired). + */ async updateStatus( invoiceId: string, status: Freight.InvoiceStatus, @@ -740,29 +823,33 @@ export class BillingService { ): Promise { const mg = manager ?? this.dataSource.manager; const invoice = await mg.findOne(Invoice, { - where: { id: invoiceId, status: In(OPEN_STATUSES) }, - order: { issuedAt: "DESC" }, + where: { id: invoiceId, status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]) }, }); if (!invoice) return; - await mg.update(Invoice, { id: invoice.id }, { status }); + await mg.update( + Invoice, + { id: invoice.id }, + { status, issuedAt: invoice.issuedAt ?? new Date() }, + ); } // ── Payment initiation & settlement (the gateway boundary) ─────────────────── /** - * Charge a source's open invoice through the payment gateway. Billing is the - * single place that turns "what is owed" (the invoice) into a payment intent — - * the domain never talks to the payment service directly. Resolves the open - * invoice, opens an intent for `invoice.totalAmount`, records the intent id on - * the invoice (the settlement correlation key), and returns the client action. + * Charge an invoice through the payment gateway. Billing is the single place + * that turns "what is owed" (the invoice) into a payment intent — the domain + * never talks to the payment service directly. Resolves the invoice by ID, + * opens an intent for `invoice.balanceAmount` (so partial payments are honored), + * records the intent id on the invoice (the settlement correlation key), and + * returns the client action. * * When the provider settles synchronously, the invoice is settled inline here — * after the intent id is stored — so the `payment.succeeded` correlation can - * never fire before the link exists. Throws when the source has no open invoice. + * never fire before the link exists. Throws when the invoice is not found or + * not in an open/payable status. */ async payInvoice( - source: Freight.InvoiceSource, - sourceId: string, + invoiceId: string, opts: { method?: string; platform?: "web" | "mobile"; @@ -771,15 +858,17 @@ export class BillingService { failureUrl?: string; } = {}, ): Promise { - const invoice = await this.findPayable(source, sourceId); + const invoice = await this.dataSource.getRepository(Invoice).findOne({ + where: { id: invoiceId, status: In(OPEN_STATUSES) }, + }); if (!invoice) { throw new NotFoundException( - `No open invoice to charge for ${source}:${sourceId}`, + `Invoice ${invoiceId} not found or not in a payable status`, ); } const result = await this.payment.initiate({ - referenceId: sourceId, + referenceId: invoice.sourceId, source: invoice.source, // Freight payments settle under the generic SHIPMENT reference — how the // payment service attributes them to the freight API. The payment ↔ invoice @@ -788,7 +877,7 @@ export class BillingService { // service branches on a domain-specific reference type. referenceType: PaymentReferenceType.SHIPMENT, orderRef: invoice.invoiceNumber, - amountMinor: Math.round(Number(invoice.totalAmount)), + amountMinor: Math.round(Number(invoice.balanceAmount)), currency: invoice.currency, reason: `Payment for invoice ${invoice.invoiceNumber}`, method: opts.method ?? "TELEBIRR", @@ -823,8 +912,8 @@ export class BillingService { */ async settleByPaymentId( paymentId: string, - _providerTxnId?: string, - _paidAt?: Date, + providerTxnId?: string, + paidAt?: Date, ): Promise { const invoice = await this.dataSource.getRepository(Invoice).findOne({ where: { paymentId, status: In(OPEN_STATUSES) }, @@ -832,6 +921,9 @@ export class BillingService { }); if (!invoice) return null; - return this.markInvoiceAsPaid(invoice.id, paymentId); + return this.markInvoiceAsPaid(invoice.id, paymentId, undefined, { + providerTxnId, + paidAt, + }); } } diff --git a/apps/edr-freight-api/src/modules/billing/invoice-numbering.util.ts b/apps/edr-freight-api/src/modules/billing/invoice-numbering.util.ts index d36788600..d3e6a208f 100644 --- a/apps/edr-freight-api/src/modules/billing/invoice-numbering.util.ts +++ b/apps/edr-freight-api/src/modules/billing/invoice-numbering.util.ts @@ -11,7 +11,7 @@ /** Anything exposing TypeORM's `.query` — an `EntityManager` or `DataSource`. */ export interface SqlRunner { - query(sql: string, params?: unknown[]): Promise>; + query(sql: string, params?: unknown[]): Promise; } export interface InvoiceNumberOptions { @@ -34,11 +34,18 @@ export async function nextDailyInvoiceNumber( const prefix = `${opts.code}-${ymd}-`; const column = opts.column ?? "invoice_number"; - const [row] = await runner.query( + // Serialize concurrent allocation for this exact day+code prefix so two + // simultaneous transactions can't both read the same MAX(seq) and mint a + // duplicate number. Session-scoped to the caller's transaction — released + // automatically on commit/rollback. Different prefixes hash to different + // keys and never contend with each other. + await runner.query(`SELECT pg_advisory_xact_lock(hashtext($1))`, [prefix]); + + const rows = (await runner.query( `SELECT COALESCE(MAX(CAST(split_part(${column}, '-', 3) AS int)), 0) AS seq FROM ${opts.table} WHERE ${column} LIKE $1`, [`${prefix}%`], - ); - const next = Number(row?.seq ?? 0) + 1; + )) as Array<{ seq: number | string }>; + const next = Number(rows[0]?.seq ?? 0) + 1; return `${prefix}${String(next).padStart(5, "0")}`; } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-payment.controller.ts b/apps/edr-freight-api/src/modules/billing/payment.controller.ts similarity index 83% rename from apps/edr-freight-api/src/modules/bookings/booking-payment.controller.ts rename to apps/edr-freight-api/src/modules/billing/payment.controller.ts index ae01ebc36..543c84201 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-payment.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/payment.controller.ts @@ -16,9 +16,8 @@ import { } from "@nestjs/swagger"; import { Response } from "express"; import { Public } from "@edr/api-common"; -import { Freight } from "@edr/types"; -import { BillingService } from "../billing/billing.service"; +import { BillingService } from "./billing.service"; import { InitiatePaymentDto, InitiateResponseDto, @@ -27,25 +26,24 @@ import { } from "../payment/payments.dto"; /** - * Booking-payment entrypoints. This is the ONE place that knows a payment is for a - * booking — it maps the request to {@link Freight.InvoiceSource.Booking} and hands - * off to billing, which resolves the invoice/amount and drives the gateway. Billing - * and payment stay source-agnostic; the booking knowledge lives here, in the domain. + * Central payment entrypoints. Domain-agnostic — the caller supplies an + * invoice ID and the billing service resolves the amount and drives the + * gateway. The domain never talks to the payment service directly. * Routes are unchanged (`/payments/*`) so the portal is unaffected. */ @ApiTags("Payment") @Controller("payments") -export class BookingPaymentController { +export class PaymentController { constructor(private readonly billing: BillingService) { } @Post("initiate") @ApiOperation({ - summary: "Initiate payment for a freight booking", - description: "Charges the booking's open invoice through the payment gateway.", + summary: "Initiate payment for an invoice", + description: "Charges the invoice through the payment gateway.", }) @ApiOkResponse({ type: InitiateResponseDto }) initiate(@Body() dto: InitiatePaymentDto): Promise { - return this.billing.payInvoice(Freight.InvoiceSource.Booking, dto.bookingId, { + return this.billing.payInvoice(dto.invoiceId, { method: dto.method, platform: dto.platform, payerAccount: dto.payerAccount, @@ -59,23 +57,23 @@ export class BookingPaymentController { @ApiOperation({ summary: "Browser checkout redirect", description: - "Charges the booking's invoice and returns an HTML page that auto-redirects to the provider checkout URL. Open directly in a browser tab.", + "Charges the invoice and returns an HTML page that auto-redirects to the provider checkout URL. Open directly in a browser tab.", }) - @ApiQuery({ name: "bookingId", required: true }) + @ApiQuery({ name: "invoiceId", required: true }) @ApiQuery({ name: "method", enum: PaymentMethodTypeEnum, required: true }) @ApiQuery({ name: "platform", enum: ["web", "mobile"], required: false }) @ApiProduces("text/html") async checkout( - @Query("bookingId") bookingId: string, + @Query("invoiceId") invoiceId: string, @Query("method") method: PaymentMethodTypeEnum, @Query("platform") platform: PaymentPlatformDto = "web", @Res() res: Response, ) { - if (!bookingId) { + if (!invoiceId) { return res .status(HttpStatus.BAD_REQUEST) .type("html") - .send(this.buildErrorHtml("Missing required query parameter: bookingId")); + .send(this.buildErrorHtml("Missing required query parameter: invoiceId")); } if (!method || !Object.values(PaymentMethodTypeEnum).includes(method)) { return res @@ -86,8 +84,7 @@ export class BookingPaymentController { try { const result = await this.billing.payInvoice( - Freight.InvoiceSource.Booking, - bookingId, + invoiceId, { method, platform }, ); const url = diff --git a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts index c70133ee7..3bfb838b4 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts @@ -1,7 +1,13 @@ -import { forwardRef, Inject, Injectable, Logger } from "@nestjs/common"; +import { + BadRequestException, + forwardRef, + Inject, + Injectable, + Logger, +} from "@nestjs/common"; import { OnEvent } from "@nestjs/event-emitter"; import { Freight } from "@edr/types"; -import { DataSource } from "typeorm"; +import { DataSource, EntityManager } from "typeorm"; import { BillingService, @@ -58,9 +64,9 @@ export class BookingInvoiceService { * Ensure the booking has its invoice, generating one from the snapshotted * pricing breakdown if absent. Called when a booking reaches a billable state. * Idempotent — returns the existing open invoice instead of a duplicate. - * Returns `null` (and logs) when the booking is not billable: no company to - * bill (e.g. government bookings whose `companyId` is null, which the invoices - * FK requires), or no priced amount. + * Throws `BadRequestException` when the booking is not billable: no company + * to bill (e.g. government bookings whose `companyId` is null, which the + * invoices FK requires), or no priced amount. */ async ensureInvoiceForBooking( booking: Booking, @@ -74,8 +80,8 @@ export class BookingInvoiceService { if (existing) return existing; if (!booking.companyId) { - this.logger.warn( - `Skipping invoice for booking ${booking.reference} (${booking.id}): no company to bill.`, + throw new BadRequestException( + `Cannot generate invoice for booking ${booking.reference} (${booking.id}): no company to bill.`, ); } @@ -102,7 +108,13 @@ export class BookingInvoiceService { } } - updateStatus = this.billing.updateStatus; + updateStatus( + invoiceId: string, + status: Freight.InvoiceStatus, + manager?: EntityManager, + ): Promise { + return this.billing.updateStatus(invoiceId, status, manager); + } /** * Advance a booking once its prepaid invoice settles — the domain side-effect @@ -165,7 +177,11 @@ export class BookingInvoiceService { // Fall back to a single freight line when no breakdown was snapshotted. if (lines.length === 0) { const amount = Number(booking.totalAmount); - if (!Number.isFinite(amount) || amount <= 0) throw new Error("No price"); + if (!Number.isFinite(amount) || amount <= 0) { + throw new BadRequestException( + `Cannot generate invoice for booking ${booking.reference} (${booking.id}): no priced amount.`, + ); + } lines.push({ chargeType: "FREIGHT", description: "Rail freight", diff --git a/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts deleted file mode 100644 index 1fbe34e1f..000000000 --- a/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; -import { Freight } from '@edr/types'; -import { BookingsRepository } from './bookings.repository'; -import { Booking } from './entities/booking.entity'; -import { assertBookingStatus } from './booking-status.util'; -import { InAppPaymentReceiptDto } from './dto/pay-booking.dto'; -import { BillingService } from '../billing/billing.service'; -import { PaymentMethodTypeEnum } from '../payment/payments.dto'; -export interface InAppPaymentReceipt extends InAppPaymentReceiptDto { } - -@Injectable() -export class BookingPaymentService { - constructor( - private readonly bookingsRepository: BookingsRepository, - private readonly billing: BillingService, - ) { } - - /** - * Start payment for a booking. The booking never touches the payment gateway - * directly — it charges its invoice through billing, which resolves the amount - * and drives the provider. Returns the provider redirect URL (empty when none). - */ - async pay(bookingId: string): Promise<{ redirectUrl: string }> { - const booking = await this.requireBooking(bookingId); - assertBookingStatus(booking, ['FULLY_EXECUTED', 'SELECTED_FOR_BATCH', 'AWAITING_PAYMENT', '']); - - const resp = await this.billing.payInvoice(Freight.InvoiceSource.Booking, bookingId, { - method: PaymentMethodTypeEnum.TELEBIRR, - platform: 'web', - }); - - const action = resp.clientAction as { type?: string; url?: string } | undefined; - return { - redirectUrl: action?.type === 'REDIRECT' ? (action.url ?? '') : '', - }; - } - - private async requireBooking(id: string): Promise { - const booking = await this.bookingsRepository.findById(id); - if (!booking) throw new NotFoundException(`Booking ${id} not found`); - return booking; - } -} 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 90ccd0118..cedc85f5f 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 @@ -447,6 +447,7 @@ export class BookingTransitionService { "CHANGES_REQUESTED", "PENDING_APPROVAL", "CONTRACT_READY", + "OPERATION_REQUEST_PENDING", ]); await this.bookingsRepository.createReviewNote( diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 4cd5d10df..5a7a0947f 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -14,13 +14,10 @@ import { BillingModule } from "../billing/billing.module"; import { FirstMileModule } from "../first-mile/first-mile.module"; import { BookingContractService } from "./booking-contract.service"; import { BookingInvoiceService } from "./booking-invoice.service"; -import { BookingPaymentController } from "./booking-payment.controller"; -import { BookingPaymentService } from "./booking-payment.service"; import { BookingPricingService } from "./booking-pricing.service"; import { BookingReferenceDataService } from "./booking-reference-data.service"; import { BookingTransitionService } from "./booking-transition.service"; import { BookingsController } from "./bookings.controller"; -import { PayController } from "./pay.controller"; import { BookingsRepository } from "./bookings.repository"; import { ConsolidationService } from "./consolidation.service"; import { BookingsService } from "./bookings.service"; @@ -69,7 +66,7 @@ import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.modu config.get("app.cbeExchange") ?? {}, }), ], - controllers: [BookingsController, PayController, BookingPaymentController], + controllers: [BookingsController], providers: [ BookingsService, BookingsRepository, @@ -79,7 +76,6 @@ import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.modu BookingTransitionService, BookingContractService, BookingInvoiceService, - BookingPaymentService, ContractTemplateResolver, ContractViewModelBuilder, ContractPricingScheduleBuilder, diff --git a/apps/edr-freight-api/src/modules/bookings/pay.controller.ts b/apps/edr-freight-api/src/modules/bookings/pay.controller.ts deleted file mode 100644 index 25f1927d3..000000000 --- a/apps/edr-freight-api/src/modules/bookings/pay.controller.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Controller, Param, ParseUUIDPipe, Post } from '@nestjs/common'; -import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; - -import { BookingPaymentService } from './booking-payment.service'; -// import { BookingTransitionService } from './booking-transition.service'; -// import { InAppPaymentReceiptDto } from './dto/pay-booking.dto'; -// import { Booking } from './entities/booking.entity'; -// import { BookingNextStep } from './booking-next-step.util'; - -@ApiTags('payments') -@ApiBearerAuth() -@Controller('bookings') -export class PayController { - constructor( - private readonly paymentService: BookingPaymentService, - // private readonly transitionService: BookingTransitionService, - ) { } - - @Post(':id/payment/pay') - @ApiOperation({ summary: 'Complete in-app payment (mock)' }) - @ApiOkResponse({ description: 'Enriched booking with ephemeral payment receipt' }) - async pay(@Param('id', ParseUUIDPipe) id: string) { - return await this.paymentService.pay(id); - // const abstract = await this.transitionService.enrichBookingResponse(booking); - // return { ...abstract, paymentReceipt: receipt }; - } -} diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts index c63a4c9e1..22520aac1 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts @@ -48,7 +48,7 @@ export class FirstMileInvoiceService { } // Fetch the booking to get the companyId and companyProfileId - const fm = record.booking ? record : (await this.firstMileRepo.findById(record.bookingId, { relations: { booking: true } })); + const fm = record.booking ? record : (await this.firstMileRepo.findById(record.id, { relations: { booking: true } })); if (!fm) return null; if (!fm.booking?.companyId) { this.logger.warn( diff --git a/apps/edr-freight-api/src/modules/payment/payments.dto.ts b/apps/edr-freight-api/src/modules/payment/payments.dto.ts index 67ca68e87..3b86a940c 100644 --- a/apps/edr-freight-api/src/modules/payment/payments.dto.ts +++ b/apps/edr-freight-api/src/modules/payment/payments.dto.ts @@ -15,9 +15,9 @@ export enum PaymentMethodTypeEnum { } export class InitiatePaymentDto { - @ApiProperty({ example: "booking-uuid" }) + @ApiProperty({ example: "invoice-uuid" }) @IsString() - bookingId!: string; + invoiceId!: string; @ApiProperty({ enum: PaymentMethodTypeEnum, 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 112c044fc..543646a7d 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 @@ -1069,6 +1069,7 @@ export class BookingBatchService implements OnModuleInit { Freight.InvoiceSource.Booking, booking.id, deadline, + "PREPAID", ); await this.notifier.payNow(booking, deadline); } @@ -1120,7 +1121,7 @@ export class BookingBatchService implements OnModuleInit { // Pay window closed before settlement → expire the booking's open invoice too // (emits `booking.invoice.expired`). Domain owns the reaction; billing stays // source-agnostic. - await this.billing.expirePayable(Freight.InvoiceSource.Booking, booking.id); + await this.billing.expirePayable(Freight.InvoiceSource.Booking, booking.id, "PREPAID"); this.notifier.expired(booking); } @@ -1167,6 +1168,7 @@ export class BookingBatchService implements OnModuleInit { await this.billing.expirePayable( Freight.InvoiceSource.Booking, victim.id, + "PREPAID", manager, ); }); diff --git a/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx index 6fbb15d62..7abadf82f 100644 --- a/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx @@ -15,13 +15,22 @@ import { Text, Title, } from "@mantine/core"; -import { ArrowLeft, CreditCard, Download, ExternalLink, Receipt } from "lucide-react"; +import { + ArrowLeft, + CreditCard, + Download, + ExternalLink, + Receipt, +} from "lucide-react"; import { useState } from "react"; import toast from "react-hot-toast"; import { api } from "@/services/api"; import { invoicesService } from "@/services/invoices.service"; -import { paymentsService, type PaymentMethod } from "@/services/payments.service"; +import { + paymentsService, + type PaymentMethod, +} from "@/services/payments.service"; import { warehouseInvoicesService } from "@/services/warehouse-invoices.service"; import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal"; import { saveBlob } from "@/utils/download"; @@ -38,7 +47,12 @@ import { function MetaItem({ label, value }: { label: string; value: string }) { return ( - + {label} @@ -52,30 +66,25 @@ export default function InvoiceDetailPage() { const { id = "" } = useParams(); const navigate = useNavigate(); - const { data: invoice, isLoading, isError } = useQuery( - api.invoices.get.queryOptions({ input: { id } }), - ); + const { + data: invoice, + isLoading, + isError, + } = useQuery(api.invoices.get.queryOptions({ input: { id } })); const [payModalOpen, setPayModalOpen] = useState(false); - // Extracted for payMutation callbacks — guaranteed defined when they run - // (guarded by the early return below). - const invSource = invoice?.source; - const invSourceId = invoice?.sourceId; - + // Ownership-checked: POST /billing/my-invoices/:id/pay only ever charges + // one of the signed-in customer's own invoices (unlike the admin-facing + // /payments/initiate, which takes any invoiceId with no ownership check). const payMutation = useMutation({ - mutationFn: async (method: PaymentMethod) => { - const bookingId = - invSource === "warehouse" - ? (await warehouseInvoicesService.get(id)).bookingId ?? invSourceId! - : invSourceId!; - return api.payments.initiate.call({ bookingId, method }); - }, + mutationFn: (method: PaymentMethod) => + api.invoices.pay.call({ id, payload: { method, platform: "web" } }), onSuccess: (data, method) => { const redirectUrl = data?.clientAction?.type === "REDIRECT" && data.clientAction.url ? data.clientAction.url - : paymentsService.checkoutUrl({ bookingId: invSourceId!, method }); + : paymentsService.checkoutUrlForInvoice({ invoiceId: id, method }); window.location.href = redirectUrl; }, }); @@ -180,7 +189,12 @@ export default function InvoiceDetailPage() { {/* Header */} - + <Title + order={1} + fw={800} + fz={26} + style={{ letterSpacing: "-0.01em" }} + > {invoice.invoiceNumber} @@ -193,7 +207,9 @@ export default function InvoiceDetailPage() { size="md" leftSection={} onClick={viewSource} - styles={{ root: { fontWeight: 600, height: 42, paddingInline: 16 } }} + styles={{ + root: { fontWeight: 600, height: 42, paddingInline: 16 }, + }} > View source @@ -204,7 +220,9 @@ export default function InvoiceDetailPage() { size="md" leftSection={} onClick={downloadInvoice} - styles={{ root: { fontWeight: 600, height: 42, paddingInline: 16 } }} + styles={{ + root: { fontWeight: 600, height: 42, paddingInline: 16 }, + }} > Download invoice @@ -216,7 +234,9 @@ export default function InvoiceDetailPage() { size="md" leftSection={} onClick={downloadReceipt} - styles={{ root: { fontWeight: 600, height: 42, paddingInline: 16 } }} + styles={{ + root: { fontWeight: 600, height: 42, paddingInline: 16 }, + }} > Receipt @@ -229,9 +249,12 @@ export default function InvoiceDetailPage() { leftSection={} loading={payMutation.isPending} onClick={handlePay} - styles={{ root: { fontWeight: 600, height: 42, paddingInline: 18 } }} + styles={{ + root: { fontWeight: 600, height: 42, paddingInline: 18 }, + }} > - Pay {formatCurrency(Number(invoice.totalAmount), invoice.currency)} + Pay{" "} + {formatCurrency(Number(invoice.totalAmount), invoice.currency)} )} @@ -241,7 +264,10 @@ export default function InvoiceDetailPage() { - + @@ -249,7 +275,12 @@ export default function InvoiceDetailPage() { - + Total @@ -349,7 +380,10 @@ export default function InvoiceDetailPage() { payMutation.reset(); } }} - amountLabel={formatCurrency(Number(invoice.totalAmount), invoice.currency)} + amountLabel={formatCurrency( + Number(invoice.totalAmount), + invoice.currency, + )} currency={invoice.currency} processing={payMutation.isPending} error={ diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx index 7c2c2631c..9dbb9ed10 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -1,5 +1,5 @@ import { Box, Group, Text } from "@mantine/core"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { CreditCard, Download, Eye } from "lucide-react"; import { useState } from "react"; import { useNavigate } from "react-router-dom"; @@ -8,7 +8,9 @@ import { isViewable } from "@edr/ui-common"; import { api } from "@/services/api"; import { fileViewUrl } from "@/constants/apiConfig"; import { useFileViewer } from "@/hooks/useFileViewer"; +import { invoicesService } from "@/services/invoices.service"; import { paymentsService, type PaymentMethod } from "@/services/payments.service"; +import { isPayable } from "@/pages/billing/invoice-ui"; import type { Freight } from "@edr/types"; import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton"; @@ -51,17 +53,41 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) : "/contracts/new"; const onRebook = () => navigate(rebookTo); - // POST /payments/initiate creates the intent and returns the provider's - // redirect URL (clientAction.url). Send the browser straight there; fall back - // to the public /payments/checkout page if no redirect URL came back. + // Billing is invoice-centric — resolve the booking's currently payable + // invoice (same query/key BookingPaymentPanel uses, so this shares its + // cache) and pay it through the ownership-checked portal route. + const { data: bookingInvoices = [] } = useQuery({ + queryKey: ["booking-invoices", booking.id], + queryFn: () => invoicesService.listForSource("booking", booking.id), + }); + const payableInvoiceId = bookingInvoices.find((inv) => + isPayable(inv.status), + )?.id; + + // POST /billing/my-invoices/:id/pay creates the intent and returns the + // provider's redirect URL (clientAction.url). Send the browser straight + // there; fall back to the public /payments/checkout page if no redirect + // URL came back. const payMutation = useMutation({ - mutationFn: (method: PaymentMethod) => - api.payments.initiate.call({ bookingId: booking.id, method }), + mutationFn: (method: PaymentMethod) => { + if (!payableInvoiceId) { + throw new Error( + "No payable invoice found for this booking yet. Please refresh or contact support.", + ); + } + return api.invoices.pay.call({ + id: payableInvoiceId, + payload: { method, platform: "web" }, + }); + }, onSuccess: (data, method) => { const redirectUrl = data?.clientAction?.type === "REDIRECT" && data.clientAction.url ? data.clientAction.url - : paymentsService.checkoutUrl({ bookingId: booking.id, method }); + : paymentsService.checkoutUrlForInvoice({ + invoiceId: payableInvoiceId!, + method, + }); window.location.href = redirectUrl; }, }); diff --git a/apps/edr-freight-web/portal/src/services/payments.service.ts b/apps/edr-freight-web/portal/src/services/payments.service.ts index 119aea4ed..c62e31108 100644 --- a/apps/edr-freight-web/portal/src/services/payments.service.ts +++ b/apps/edr-freight-web/portal/src/services/payments.service.ts @@ -17,7 +17,7 @@ export type PaymentMethod = export type PaymentPlatform = "web" | "mobile"; export interface InitiatePaymentPayload { - bookingId: string; + invoiceId: string; method: PaymentMethod; platform?: PaymentPlatform; payerAccount?: string; @@ -67,6 +67,25 @@ function buildCheckoutUrl(payload: { return `${base}${P.CHECKOUT}?${params.toString()}`; } +/** + * Checkout fallback keyed by invoice id — matches `GET /payments/checkout`, + * which reads `invoiceId` (billing is invoice-centric; there is no + * `bookingId` param on that route). + */ +function buildCheckoutUrlForInvoice(payload: { + invoiceId: string; + method: PaymentMethod; + platform?: PaymentPlatform; +}): string { + const base = API_BASE_URL.replace(/\/$/, ""); + const params = new URLSearchParams({ + invoiceId: payload.invoiceId, + method: payload.method, + platform: payload.platform ?? "web", + }); + return `${base}${P.CHECKOUT}?${params.toString()}`; +} + export const paymentsService = { initiate: async ( payload: InitiatePaymentPayload, @@ -84,4 +103,5 @@ export const paymentsService = { }, checkoutUrl: buildCheckoutUrl, + checkoutUrlForInvoice: buildCheckoutUrlForInvoice, }; diff --git a/apps/edr-passenger-web/backoffice/src/app/providers.tsx b/apps/edr-passenger-web/backoffice/src/app/providers.tsx index 663a52e05..5130e8ec4 100644 --- a/apps/edr-passenger-web/backoffice/src/app/providers.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/providers.tsx @@ -1,15 +1,15 @@ -'use client'; +"use client"; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { useState, useEffect } from 'react'; -import { useTheme } from '@/lib/theme-store'; -import { useAuthStore } from '@/lib/auth-store'; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useState, useEffect } from "react"; +import { useTheme } from "@/lib/theme-store"; +import { useAuthStore } from "@/lib/auth-store"; function ThemeProvider({ children }: { children: React.ReactNode }) { const { isDark, setTheme } = useTheme(); useEffect(() => { - document.documentElement.classList.toggle('dark', isDark); + document.documentElement.classList.toggle("dark", isDark); }, [isDark]); return <>{children}; @@ -26,14 +26,16 @@ function AuthProvider({ children }: { children: React.ReactNode }) { } export default function Providers({ children }: { children: React.ReactNode }) { - const [queryClient] = useState(() => new QueryClient({ - defaultOptions: { - queries: { - staleTime: 60 * 1000, - refetchOnWindowFocus: false, - }, - }, - })); + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60 * 1000, + }, + }, + }), + ); return (