From 9cd24d9b519c85b98a4957eeca520754fea36845 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 29 Jun 2026 08:29:07 +0000 Subject: [PATCH] feat(WIP): Wire up the invoice with the booking. --- .../bookings/booking-invoice.service.ts | 152 ++++++++++++++++++ .../booking-transition.accept.spec.ts | 1 + .../booking-transition.clearance.spec.ts | 3 + .../booking-transition.operation.spec.ts | 1 + .../bookings/booking-transition.service.ts | 22 ++- .../src/modules/bookings/bookings.module.ts | 4 + .../src/modules/payment/payment.module.ts | 2 + .../src/modules/payment/payment.service.ts | 25 ++- 8 files changed, 208 insertions(+), 2 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts 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 new file mode 100644 index 000000000..daa3327dd --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts @@ -0,0 +1,152 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { Freight } from '@edr/types'; + +import { + BillingService, + GenerateInvoiceInput, + InvoiceEventPayload, + InvoiceLineInput, +} from '../billing/billing.service'; +import { Invoice } from '../billing/entities/invoice.entity'; +import { PriceLineItemDto } from './dto/generate-price-response.dto'; +import { Booking } from './entities/booking.entity'; + +/** Snapshot written onto `booking.pricingBreakdown` by the pricing service. */ +interface StoredPricingBreakdown { + lineItems?: PriceLineItemDto[]; + totalAmount?: number; + currency?: string; +} + +/** 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 and which type to use. 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) {} + + /** + * 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. + */ + async ensureInvoiceForBooking(booking: Booking): Promise { + const existing = await this.billing.findPayable( + Freight.InvoiceSource.Booking, + booking.id, + Freight.InvoiceType.Prepaid, + ); + if (existing) return existing; + + if (!booking.companyId) { + this.logger.warn( + `Skipping invoice for booking ${booking.reference} (${booking.id}): no company to bill.`, + ); + return null; + } + + const input = this.buildInput(booking); + if (!input) { + this.logger.warn( + `Skipping invoice for booking ${booking.reference} (${booking.id}): no priced amount.`, + ); + return null; + } + + 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): e.g. a paid up-front invoice + * may later generate a final invoice. Only PREPAID exists today. + */ + @OnEvent('booking.invoice.paid') + onBookingInvoicePaid(payload: InvoiceEventPayload): void { + switch (payload.type) { + case Freight.InvoiceType.Prepaid: + // Full prepaid settlement — booking advancement is handled by the + // payment flow today. Final-invoice issuance would hook in here. + break; + default: + this.logger.warn( + `Unhandled booking invoice type "${payload.type}" paid (${payload.invoiceId})`, + ); + } + } + + /** Map a booking's pricing snapshot into a generic invoice request. */ + private buildInput(booking: Booking): GenerateInvoiceInput | null { + 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) return null; + 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, + type: Freight.InvoiceType.Prepaid, + companyId: booking.companyId, + companyProfileId: booking.companyProfileId, + currency, + lines, + totalAmount, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts index 676d1b5d4..a9806f1f7 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts @@ -30,6 +30,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => { ruleEngineService as never, {} as never, // pricingService {} as never, // contractService + {} as never, // invoiceService {} as never, // filesService {} as never, // fileUploadSettingsService {} as never, // bookingBatchService diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts index f9c7e182f..ff08784a6 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts @@ -41,6 +41,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => { {} as never, // ruleEngineService {} as never, // pricingService {} as never, // contractService + {} as never, // invoiceService filesService as never, fileUploadSettingsService as never, {} as never, // bookingBatchService @@ -122,6 +123,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', ( {} as never, {} as never, {} as never, + {} as never, // invoiceService filesService as never, fileUploadSettingsService as never, {} as never, @@ -189,6 +191,7 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields {} as never, {} as never, {} as never, + {} as never, // invoiceService filesService as never, fileUploadSettingsService as never, {} as never, diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts index 92ab8769e..3feac4e67 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts @@ -34,6 +34,7 @@ describe('BookingTransitionService — operation review', () => { {} as never, // ruleEngineService {} as never, // pricingService {} as never, // contractService + {} as never, // invoiceService {} as never, // filesService {} as never, // fileUploadSettingsService bookingBatchService as never, 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 d819ee527..1b6ddd100 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 @@ -3,6 +3,7 @@ import { forwardRef, Inject, Injectable, + Logger, } from '@nestjs/common'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; @@ -14,6 +15,7 @@ import { RuleEngineService } from '../rule-engine/rule-engine.service'; import { FilesService } from '../files/files.service'; import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service'; import { BookingContractService } from './booking-contract.service'; +import { BookingInvoiceService } from './booking-invoice.service'; import { BookingPricingService } from './booking-pricing.service'; import { BookingsRepository } from './bookings.repository'; import { assertBookingStatus } from './booking-status.util'; @@ -26,11 +28,14 @@ import { BookingsService } from './bookings.service'; @Injectable() export class BookingTransitionService { + private readonly logger = new Logger(BookingTransitionService.name); + constructor( private readonly bookingsRepository: BookingsRepository, private readonly ruleEngineService: RuleEngineService, private readonly pricingService: BookingPricingService, private readonly contractService: BookingContractService, + private readonly invoiceService: BookingInvoiceService, private readonly filesService: FilesService, private readonly fileUploadSettingsService: FileUploadSettingsService, @Inject(forwardRef(() => BookingBatchService)) @@ -404,7 +409,22 @@ export class BookingTransitionService { marketingApprovedAt: new Date(), lockedAt: new Date(), } as never); - return this.bookingsService.findById(updated!.id); + + const executed = await this.bookingsService.findById(updated!.id); + + // Billable state reached — generate the invoice payment will settle. + // Non-blocking: a billing hiccup must not undo the execution. + await this.invoiceService + .ensureInvoiceForBooking(executed) + .catch((err) => + this.logger.error( + `Failed to generate invoice for booking ${executed.reference}: ${ + err instanceof Error ? err.message : String(err) + }`, + ), + ); + + return executed; } async startTransit(bookingId: string): Promise { 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 ded7d0239..8318a4e7d 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -10,7 +10,9 @@ import { MinioModule } from '../minio/minio.module'; import { RuleEngineModule } from '../rule-engine/rule-engine.module'; import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module'; import { SignaturesModule } from '../signatures/signatures.module'; +import { BillingModule } from '../billing/billing.module'; import { BookingContractService } from './booking-contract.service'; +import { BookingInvoiceService } from './booking-invoice.service'; import { BookingPaymentService } from './booking-payment.service'; import { BookingPricingService } from './booking-pricing.service'; import { BookingReferenceDataService } from './booking-reference-data.service'; @@ -49,6 +51,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu BookingContractSignature, ]), PaymentModule, + BillingModule, forwardRef(() => TrainSchedulingModule), FilesModule, MinioModule, @@ -72,6 +75,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu BookingPricingService, BookingTransitionService, BookingContractService, + BookingInvoiceService, BookingPaymentService, ContractTemplateResolver, ContractViewModelBuilder, diff --git a/apps/edr-freight-api/src/modules/payment/payment.module.ts b/apps/edr-freight-api/src/modules/payment/payment.module.ts index c48f1fac9..18e32330d 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.module.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.module.ts @@ -12,6 +12,7 @@ import { } from "@edr/types"; import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; +import { BillingModule } from "../billing/billing.module"; import { DropdownSettingsModule } from "../dropdown-settings/dropdown-settings.module"; import { FirstMileModule } from "../first-mile/first-mile.module"; import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module"; @@ -59,6 +60,7 @@ function rabbitMQImport(): DynamicModule[] { HttpModule.register({ timeout: 10_000 }), ConfigModule, DropdownSettingsModule, + BillingModule, forwardRef(() => FirstMileModule), forwardRef(() => TrainSchedulingModule), TypeOrmModule.forFeature([ diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 582e7467e..5fb77f404 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -22,11 +22,13 @@ import { ProviderPaymentStatus, } from "@edr/payment-providers"; import { + Freight, PaymentService as PaymentServiceEnum, PaymentReferenceType, PaymentIntentSnapshot, ProviderMethod, } from "@edr/types"; +import { BillingService } from "../billing/billing.service"; import { InitiatePaymentDto, InitiateResponseDto, @@ -62,6 +64,7 @@ export class PaymentService { private readonly bookingBatchService: BookingBatchService, private readonly dropdownSettings: DropdownSettingsService, private readonly firstMileService: FirstMileService, + private readonly billing: BillingService, ) { } /** Configured general-contract ordering window in months (defaults to 3). */ @@ -167,7 +170,16 @@ export class PaymentService { .findOneBy({ id: dto.bookingId }); if (!booking) throw new NotFoundException("Booking not found"); - const amountMinor = Math.round(Number(booking.totalAmount)); + // Charge the invoice (the billing document of record) so discounts, + // penalties and staff adjustments carried on it are honored. Fall back to + // the booking total only when no invoice has been generated yet. + const invoice = await this.billing.findPayable( + Freight.InvoiceSource.Booking, + booking.id, + ); + const amountMinor = Math.round( + Number(invoice?.totalAmount ?? booking.totalAmount), + ); const snapshot = await this.paymentClient.initiate({ service: PaymentServiceEnum.FREIGHT, @@ -344,6 +356,17 @@ export class PaymentService { ? { paymentStatus: "PAID", status: "CONTRACT_ACTIVE", expiresAt: contractExpiresAt } : { paymentStatus: "PAID", status: "PAID" }, ); + + // Settle the booking's open invoice in the same transaction and link + // this payment. The invoice emits `booking.invoice.paid` for the source + // to react to. No-op if the booking has no open invoice. + await this.billing.settlePayable( + Freight.InvoiceSource.Booking, + input.bookingId, + intent.id, + mg, + ); + await this.firstMileService.acceptBooking(input.bookingId); });