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 84040ebdd..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 @@ -151,6 +151,15 @@ describe("BillingService.markInvoiceAsPaid", () => { 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( @@ -198,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, @@ -265,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 }), 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 2fd7ce72f..73d3aebd5 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -415,40 +415,90 @@ export class BillingService { // ── State transitions ──────────────────────────────────────────────────────── + /** + * 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, - * 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. + * 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 { - 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.Paid) 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 === Freight.InvoiceStatus.Paid) { + return { result: invoice }; + } - await mg.update(Invoice, { id: invoiceId }, { - status: Freight.InvoiceStatus.Paid, - paymentId, - paidAt: invoice.paidAt ?? new Date(), - paidAmount: invoice.totalAmount, - balanceAmount: 0, - } as never); + 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 updated = { - ...invoice, - status: Freight.InvoiceStatus.Paid, - paymentId, - paidAt: invoice.paidAt ?? new Date(), - paidAmount: invoice.totalAmount, - balanceAmount: 0, - } as Invoice; - this.emitInvoiceEvent("paid", updated); - return updated; + 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), + }; + }); } /** @@ -460,9 +510,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, @@ -475,62 +527,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, @@ -542,12 +603,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, @@ -559,16 +628,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, @@ -576,17 +652,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. */ @@ -789,7 +875,9 @@ export class BillingService { // service branches on a domain-specific reference type. referenceType: PaymentReferenceType.SHIPMENT, orderRef: invoice.invoiceNumber, - amountMinor: Math.round(Number(invoice.balanceAmount)), + // True minor units (cents) — every provider adapter divides by 100 to + // get the major amount it charges. + amountMinor: Math.round(Number(invoice.balanceAmount) * 100), currency: invoice.currency, reason: `Payment for invoice ${invoice.invoiceNumber}`, method: opts.method ?? "TELEBIRR", @@ -824,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) }, @@ -833,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-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/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-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 (