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 { 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 { BookingWagonCancellationService, WAGON_CANCEL_FEE_INVOICE_TYPE, } from "./booking-wagon-cancellation.service"; 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, @Inject(forwardRef(() => BookingWagonCancellationService)) private readonly wagonCancellations: BookingWagonCancellationService, ) { } /** * 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); } /** * Cancel the booking's open PREPAID invoice, if any — used when a * changes-requested resubmit restates the cargo, so the re-priced booking can * be re-invoiced. Throws when the invoice already has payments recorded * (cargo must not change out from under recorded money). */ async cancelUnpaidInvoiceForBooking(bookingId: string): Promise { const existing = await this.billing.findPayable( Freight.InvoiceSource.Booking, bookingId, "PREPAID", ); if (existing) await this.billing.cancelInvoice(existing.id); } /** * 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": // Before advancing: if this invoice belonged to a partial offer that // lapsed before the settlement landed, revive it, or the booking boards // whole having paid only the offered part. await this.bookingBatch.reviveOfferForInvoice(payload.invoiceId); await this.advanceBookingOnPayment(payload.sourceId); break; case WAGON_CANCEL_FEE_INVOICE_TYPE: // Partial wagon cancellation: the fee settled — reduce the booking and // release the cancelled wagons (T2 of the cancellation cycle). await this.wagonCancellations.onFeePaid(payload.invoiceId); break; default: this.logger.warn( `Unhandled booking invoice type "${payload.type}" paid (${payload.invoiceId})`, ); } } /** * Success-redirect ack: the customer finished provider checkout, webhook not * in yet. Mirror the invoice's PAYMENT_PROCESSING on the booking so the * portal stops offering "Pay now". Display state only — settlement * (`booking.invoice.paid`) still drives PAID. Status-guarded, so it never * touches a booking that already advanced or was terminated. */ @OnEvent("booking.invoice.payment-processing") async onBookingInvoicePaymentProcessing( payload: InvoiceEventPayload, ): Promise { await this.dataSource.getRepository(Booking).update( { id: payload.sourceId, status: "SELECTED_FOR_BATCH" }, { status: "PAYMENT_VERIFICATION_IN_PROGRESS" }, ); } /** Payment failed after a redirect ack — the booking reads payable again. */ @OnEvent("booking.invoice.payment-processing-reverted") async onBookingInvoicePaymentProcessingReverted( payload: InvoiceEventPayload, ): Promise { await this.dataSource.getRepository(Booking).update( { id: payload.sourceId, status: "PAYMENT_VERIFICATION_IN_PROGRESS" }, { status: "SELECTED_FOR_BATCH" }, ); } updateStatus( invoiceId: string, status: Freight.InvoiceStatus, manager?: EntityManager, ): Promise { return this.billing.updateStatus(invoiceId, status, manager); } /** * Expire the booking's currently-open freight (PREPAID) invoice 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 { 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 or already past the payment gate) so we never rewrite // its status or re-run allocation. // // EXPIRED is NOT in that list: settlement is async, so a payment can land // after the pay window and its drain tail (relay backlog, payment-api // restart, a CBE bill paid at a counter). The money was captured, so it gets // exactly the same treatment as an in-window payment — the booking becomes // PAID and ensurePaidBookingAllocated re-places it via // replaceStrandedPaidBooking (a same-day train with room, or a manual-assign // log). Leaving EXPIRED here debited the customer for nothing. CANCELLED and // REJECTED stay: a person terminated those, so a payment against them is a // refund case, not a boarding. if (booking.paymentStatus === "PAID" || booking.status === "PAID") { return; } const TERMINAL_OR_ADVANCED_STATUSES: string[] = [ "CANCELLED", "REJECTED", "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, }; } }