import { BadRequestException, forwardRef, Inject, Injectable, Logger, } from "@nestjs/common"; import { OnEvent } from "@nestjs/event-emitter"; import { Freight } from "@edr/types"; import { DataSource, EntityManager } from "typeorm"; import { BillingService, GenerateInvoiceInput, InvoiceEventPayload, InvoiceLineInput, } from "../billing/billing.service"; import { Invoice } from "../billing/entities/invoice.entity"; import { CLEARANCE_BOOKING_INVOICE_TYPE } from "../contracts/clearance-fee.service"; import { FirstMileService } from "../first-mile/first-mile.service"; import { BookingBatchService } from "../train-scheduling/booking-batch.service"; import { PriceLineItemDto } from "./dto/generate-price-response.dto"; import { BookingsRepository } from "./bookings.repository"; import { Booking } from "./entities/booking.entity"; /** Snapshot written onto `booking.pricingBreakdown` by the pricing service. */ interface StoredPricingBreakdown { lineItems?: PriceLineItemDto[]; totalAmount?: number; currency?: string; } export interface InvoiceOptions { dueDate?: Date; invoiceType?: string; invoiceStatus?: Freight.InvoiceStatus; } /** Round to 2 decimals, avoiding binary float drift. */ const round2 = (n: number): number => Math.round(n * 100) / 100; /** * Owns the booking ⇄ invoice mapping — the one place that knows how a booking * turns into invoices, which type to use, and how it advances when paid. Bookings * are the billable business entity, so they generate their own invoices directly * via {@link BillingService} (billing stays source-agnostic). All booking-specific * type branching lives here, at the two points it belongs: invoice creation and * settlement (the paid handler). */ @Injectable() export class BookingInvoiceService { private readonly logger = new Logger(BookingInvoiceService.name); constructor( private readonly billing: BillingService, private readonly bookingsRepository: BookingsRepository, private readonly dataSource: DataSource, @Inject(forwardRef(() => FirstMileService)) private readonly firstMile: FirstMileService, @Inject(forwardRef(() => BookingBatchService)) private readonly bookingBatch: BookingBatchService, ) { } /** * 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. * 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, invoiceOptions: InvoiceOptions = {}, ): Promise { const existing = await this.billing.findPayable( Freight.InvoiceSource.Booking, booking.id, "PREPAID", ); if (existing) return existing; if (!booking.companyId) { throw new BadRequestException( `Cannot generate invoice for booking ${booking.reference} (${booking.id}): no company to bill.`, ); } const input = this.buildInput(booking, invoiceOptions); return this.billing.generateInvoice(input); } /** * React to a booking invoice being paid — the settlement branch point. Per-type * reactions live here (not in the payment process): each invoice type advances * the booking its own way. Only PREPAID exists today. */ @OnEvent("booking.invoice.paid") async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise { this.logger.log( `onBookingInvoicePaid trigger for ${payload.sourceId} from ${payload.invoiceId}`, ); switch (payload.type) { case "PREPAID": await this.advanceBookingOnPayment(payload.sourceId); break; default: this.logger.warn( `Unhandled booking invoice type "${payload.type}" paid (${payload.invoiceId})`, ); } } updateStatus( invoiceId: string, status: Freight.InvoiceStatus, manager?: EntityManager, ): Promise { return this.billing.updateStatus(invoiceId, status, manager); } /** * Expire the booking's currently-open invoices (freight PREPAID and the * per-shipment clearance fee) when the booking is * cancelled or rejected — the counterpart to the pay-window-expiry path * (which also calls {@link BillingService.expirePayable}). Stops a terminated * booking from leaving a payable invoice open. No-op when the booking has no * open invoice (never invoiced, already paid/cancelled/expired). Pass a * caller `manager` to enlist in its transaction. */ async expireOpenInvoices( bookingId: string, manager?: EntityManager, ): Promise { // The per-shipment clearance fee (GENERAL contracts) bills this same booking // id under its own source/type — retire it alongside the freight invoice, or // a cancelled shipment keeps a payable clearance invoice open. await this.billing.expirePayable( Freight.InvoiceSource.Clearance, bookingId, CLEARANCE_BOOKING_INVOICE_TYPE, manager, ); return this.billing.expirePayable( Freight.InvoiceSource.Booking, bookingId, "PREPAID", manager, ); } /** * Advance a booking once its prepaid invoice settles — the domain side-effect * of payment, relocated out of the payment service: the booking becomes PAID * and is allocated into its batch. Idempotent — no-op when already PAID. * * General contracts are a separate aggregate now: their CONTRACT_ACTIVE * lifecycle and ordering window live in the contracts module, advanced by the * contract transition/clearance services — not by booking payment. Every * booking that settles here is a ONE_TIME shipment, so there is no contract * branch (legacy GENERAL_CONTRACT booking creation now 410s). */ private async advanceBookingOnPayment(bookingId: string): Promise { const booking = await this.bookingsRepository.findById(bookingId); if (!booking) { this.logger.warn( `Cannot advance unknown booking ${bookingId} on payment.`, ); return; } // Idempotency + state-machine guard (restored). The prepaid-invoice paid // event can be delivered more than once (retries / re-emit), and a booking // may have moved on or been terminated between invoicing and settlement. // Only advance one that is still awaiting payment: no-op when already PAID, // and refuse to advance a booking in a terminal/advanced status // (CANCELLED/REJECTED/EXPIRED or already past the payment gate) so we never // rewrite its status or re-run allocation. if (booking.paymentStatus === "PAID" || booking.status === "PAID") { return; } const TERMINAL_OR_ADVANCED_STATUSES: string[] = [ "CANCELLED", "REJECTED", "EXPIRED", "IN_TRANSIT", "ARRIVED", "COMPLETED", "CONTRACT_CLOSED", ]; if (TERMINAL_OR_ADVANCED_STATUSES.includes(booking.status)) { this.logger.warn( `Skipping advance of booking ${bookingId} on payment: status ${booking.status} is terminal/advanced.`, ); return; } await this.dataSource.transaction(async (mg) => { await mg.update( Booking, { id: bookingId }, { paymentStatus: "PAID", status: "PAID" }, ); }); try { await this.firstMile.acceptBooking(bookingId); } catch (err) { this.logger.error( `Error accepting first-mile after payment: ${err instanceof Error ? err.message : String(err)}`, ); } try { await this.bookingBatch.ensurePaidBookingAllocated(bookingId); } catch (err) { this.logger.error( `Error allocating booking after payment: ${err instanceof Error ? err.message : String(err)}`, ); } } /** Map a booking's pricing snapshot into a generic invoice request. */ private buildInput( booking: Booking, invoiceOptions: InvoiceOptions = {}, ): GenerateInvoiceInput { const breakdown = (booking.pricingBreakdown ?? {}) as StoredPricingBreakdown; const currency = breakdown.currency ?? booking.paymentCurrency ?? "ETB"; const lines: InvoiceLineInput[] = (breakdown.lineItems ?? []).map((l) => ({ chargeType: l.code, description: l.description, quantity: l.quantity, unitRate: l.unitAmount, amount: l.amount, currency: l.currency ?? currency, metadata: l.unit ? { unit: l.unit } : null, })); // 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 BadRequestException( `Cannot generate invoice for booking ${booking.reference} (${booking.id}): no priced amount.`, ); } lines.push({ chargeType: "FREIGHT", description: "Rail freight", quantity: 1, unitRate: amount, amount, currency, }); } const subtotal = round2( lines.reduce((sum, l) => sum + Number(l.amount), 0), ); let totalAmount = subtotal; // Honor a staff price override: bill the adjusted total, recording the delta // as an ADJUSTMENT line so the lines still sum to the invoice total. const adjusted = booking.adjustedTotalAmount; if (adjusted != null && Number.isFinite(Number(adjusted))) { const delta = round2(Number(adjusted) - subtotal); if (delta !== 0) { lines.push({ chargeType: "ADJUSTMENT", description: "Staff price adjustment", quantity: 1, unitRate: delta, amount: delta, currency, }); } totalAmount = round2(Number(adjusted)); } return { source: Freight.InvoiceSource.Booking, sourceId: booking.id, companyId: booking.companyId, companyProfileId: booking.companyProfileId, currency, lines, totalAmount, dueAt: invoiceOptions.dueDate, type: invoiceOptions.invoiceType ?? "PREPAID", status: invoiceOptions.invoiceStatus ?? Freight.InvoiceStatus.Draft, }; } }