diff --git a/apps/edr-freight-api/src/migrations/1830000000000-AddExpiredInvoiceStatus.ts b/apps/edr-freight-api/src/migrations/1830000000000-AddExpiredInvoiceStatus.ts new file mode 100644 index 000000000..e4b353b94 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1830000000000-AddExpiredInvoiceStatus.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add the `EXPIRED` invoice status. An invoice expires when its source's pay + * window closes before settlement (e.g. a booking whose `paymentDeadline` + * lapses) — driven event-style from the domain via `BillingService.expirePayable`, + * which emits `${source}.invoice.expired`. Terminal and not settle-able (kept out + * of `OPEN_STATUSES`), so it is distinct from `CANCELLED` (manual void) and + * `OVERDUE` (still payable). + * + * Matches Freight.InvoiceStatus in packages/types. ADD VALUE only — additive and + * not referenced in this same transaction, so it is PG 12+ safe. + */ +export class AddExpiredInvoiceStatus1830000000000 implements MigrationInterface { + name = "AddExpiredInvoiceStatus1830000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'EXPIRED' AFTER 'REFUNDED';`, + ); + } + + public async down(): Promise { + // Postgres cannot drop individual enum values; EXPIRED is left on + // freight.invoices_status_enum (harmless, unused after down). + } +} diff --git a/apps/edr-freight-api/src/modules/billing/billing.controller.ts b/apps/edr-freight-api/src/modules/billing/billing.controller.ts index e954b7e1b..9a441ab65 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.controller.ts @@ -1,5 +1,6 @@ -import { Controller, Get, Param, ParseUUIDPipe } from "@nestjs/common"; +import { Controller, Get, Param, ParseUUIDPipe, Res } from "@nestjs/common"; import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import type { Response } from "express"; import { FreightAdmin } from "../../common/booking-guards"; import { BillingService } from "./billing.service"; @@ -21,4 +22,26 @@ export class BillingController { findById(@Param("id", ParseUUIDPipe) id: string) { return this.billingService.findById(id); } + + @Get("invoices/:id/document") + @ApiOperation({ summary: "Download the sealed invoice PDF" }) + async document(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) { + const { filename, buffer } = await this.billingService.document(id); + sendPdf(res, filename, buffer); + } + + @Get("invoices/:id/receipt") + @ApiOperation({ summary: "Download the sealed payment receipt PDF" }) + async receipt(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) { + const { filename, buffer } = await this.billingService.receipt(id); + sendPdf(res, filename, buffer); + } +} + +/** Stream a generated PDF as a file download. */ +export function sendPdf(res: Response, filename: string, buffer: Buffer): void { + res.setHeader("Content-Type", "application/pdf"); + res.setHeader("Content-Disposition", `attachment; filename="${filename}"`); + res.setHeader("Content-Length", buffer.length); + res.send(buffer); } 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 f1b58ad5e..443f511ee 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -151,16 +151,22 @@ export class BillingService { /** Sealed PDF invoice for any source, rendered by the shared document service. */ async document(id: string): Promise<{ filename: string; buffer: Buffer }> { const invoice = await this.findById(id); - return this.invoiceDocuments.render(this.toDocumentModel(invoice, "INVOICE")); + return this.invoiceDocuments.render( + this.toDocumentModel(invoice, "INVOICE"), + ); } /** Sealed PDF receipt; available once any payment has been recorded. */ async receipt(id: string): Promise<{ filename: string; buffer: Buffer }> { const invoice = await this.findById(id); if (Number(invoice.paidAmount) <= 0) { - throw new BadRequestException("A receipt is available only after payment is recorded."); + throw new BadRequestException( + "A receipt is available only after payment is recorded.", + ); } - return this.invoiceDocuments.render(this.toDocumentModel(invoice, "RECEIPT")); + return this.invoiceDocuments.render( + this.toDocumentModel(invoice, "RECEIPT"), + ); } /** Map a global invoice (+ lines) onto the source-agnostic document model. */ @@ -177,7 +183,11 @@ export class BillingService { if (Number(invoice.taxAmount) > 0) { totals.push({ label: "Tax", amount: Number(invoice.taxAmount) }); } - totals.push({ label: "Total", amount: Number(invoice.totalAmount), grand: true }); + totals.push({ + label: "Total", + amount: Number(invoice.totalAmount), + grand: true, + }); totals.push({ label: "Paid", amount: Number(invoice.paidAmount) }); totals.push({ label: "Balance", amount: Number(invoice.balanceAmount) }); @@ -193,8 +203,18 @@ export class BillingService { { label: "Type", value: invoice.type }, { label: "Reference", value: invoice.sourceId }, { label: "Currency", value: invoice.currency }, - { label: "Issued", value: invoice.issuedAt ? new Date(invoice.issuedAt).toLocaleDateString("en-GB") : null }, - { label: "Due", value: invoice.dueAt ? new Date(invoice.dueAt).toLocaleDateString("en-GB") : null }, + { + label: "Issued", + value: invoice.issuedAt + ? new Date(invoice.issuedAt).toLocaleDateString("en-GB") + : null, + }, + { + label: "Due", + value: invoice.dueAt + ? new Date(invoice.dueAt).toLocaleDateString("en-GB") + : null, + }, ], categoryHeader: "Charge type", lines: invoice.lines.map((l) => ({ @@ -221,19 +241,33 @@ export class BillingService { } } - /** Every invoice billed to a company, newest first, with billing relations. */ - findByCompany(companyId: string): Promise { + /** + * Every invoice billed to a company, newest first, with billing relations. + * Optionally narrow to a single source record (e.g. a booking's invoices) via + * `{ source, sourceId }`. + */ + findByCompany( + companyId: string, + filter: { source?: string; sourceId?: string } = {}, + ): Promise { return this.invoices.findAll({ - where: { companyId }, + where: { + companyId, + ...(filter.source ? { source: filter.source } : {}), + ...(filter.sourceId ? { sourceId: filter.sourceId } : {}), + }, relations: { company: true, companyProfile: true }, order: { createdAt: "DESC" }, }); } /** Invoices for the signed-in customer; empty when they have no company. */ - async findForUser(userId: string): Promise { + async findForUser( + userId: string, + filter: { source?: string; sourceId?: string } = {}, + ): Promise { const companyId = await this.resolveCompanyId(userId); - return companyId ? this.findByCompany(companyId) : []; + return companyId ? this.findByCompany(companyId, filter) : []; } /** Company-scoped invoice detail (+ lines); 404 when not owned by the user. */ @@ -267,11 +301,32 @@ export class BillingService { ); } + /** Sealed invoice PDF for one of the customer's own invoices (ownership-checked). */ + async documentForUser( + id: string, + userId: string, + ): Promise<{ filename: string; buffer: Buffer }> { + await this.findByIdForUser(id, userId); + return this.document(id); + } + + /** Sealed receipt PDF for one of the customer's own invoices (ownership-checked). */ + async receiptForUser( + id: string, + userId: string, + ): Promise<{ filename: string; buffer: Buffer }> { + await this.findByIdForUser(id, userId); + return this.receipt(id); + } + // ── Generation ─────────────────────────────────────────────────────────────── /** `-YYYYMMDD-00001` — sequential per day & prefix, within the active transaction. */ private nextInvoiceNumber(mg: EntityManager): Promise { - return nextDailyInvoiceNumber(mg, { table: "freight.invoices", code:"INV" }); + return nextDailyInvoiceNumber(mg, { + table: "freight.invoices", + code: "INV", + }); } /** @@ -319,8 +374,7 @@ export class BillingService { input.subtotalAmount ?? lines.reduce((sum, l) => sum + Number(l.amount), 0); const taxAmount = input.taxAmount ?? 0; - const totalAmount = - input.totalAmount ?? round2(subtotalAmount + taxAmount); + const totalAmount = input.totalAmount ?? round2(subtotalAmount + taxAmount); const dueAt = input.dueAt ?? @@ -406,7 +460,9 @@ export class BillingService { manager?: EntityManager, ): Promise { if (!(input.amount > 0)) { - throw new BadRequestException("Payment amount must be greater than zero."); + throw new BadRequestException( + "Payment amount must be greater than zero.", + ); } const mg = manager ?? this.dataSource.manager; @@ -441,17 +497,13 @@ export class BillingService { }; const payments = [...(invoice.payments ?? []), entry]; - await mg.update( - Invoice, - { id: invoice.id }, - { - paidAmount, - balanceAmount, - status, - payments, - paidAt: fullyPaid ? at : invoice.paidAt ?? null, - } as never, - ); + await mg.update(Invoice, { id: invoice.id }, { + paidAmount, + balanceAmount, + status, + payments, + paidAt: fullyPaid ? at : (invoice.paidAt ?? null), + } as never); const updated = { ...invoice, @@ -459,7 +511,7 @@ export class BillingService { balanceAmount, status, payments, - paidAt: fullyPaid ? at : invoice.paidAt ?? null, + paidAt: fullyPaid ? at : (invoice.paidAt ?? null), } as Invoice; if (fullyPaid) this.emitInvoiceEvent("paid", updated); @@ -628,6 +680,72 @@ export class BillingService { return this.markInvoiceAsRefunded(invoice.id, mg); } + /** + * Expire a source's currently-open invoice (its pay window closed before + * settlement), then emit `${source}.invoice.expired`. Resolves the open invoice + * and transitions it to EXPIRED — a terminal, non-payable status (kept out of + * `OPEN_STATUSES`). No-op (returns null) when the source has no open invoice + * (already paid/cancelled/expired). + * + * Pass the caller's transaction `manager` (e.g. the booking pay-window expiry in + * the batch engine) to enlist in its DB transaction. + */ + async expirePayable( + source: Freight.InvoiceSource, + sourceId: string, + manager?: EntityManager, + ): Promise { + const mg = manager ?? this.dataSource.manager; + const invoice = await mg.findOne(Invoice, { + where: { source, sourceId, status: In(OPEN_STATUSES) }, + order: { issuedAt: "DESC" }, + }); + if (!invoice) return null; + + return this.transition( + invoice.id, + Freight.InvoiceStatus.Expired, + "expired", + {}, + mg, + ); + } + + /** + * Sync a source's open invoice `dueAt` to its real pay-window deadline. The + * booking invoice is generated before the pay window opens (at booking + * creation/approval), so its printed due date is refreshed when the batch engine + * sets `paymentDeadline`. No-op when the source has no open invoice. + */ + async syncPayableDueDate( + source: Freight.InvoiceSource, + sourceId: string, + dueAt: Date, + manager?: EntityManager, + ): Promise { + const mg = manager ?? this.dataSource.manager; + const invoice = await mg.findOne(Invoice, { + where: { source, sourceId, status: In(OPEN_STATUSES) }, + order: { issuedAt: "DESC" }, + }); + if (!invoice) return; + await mg.update(Invoice, { id: invoice.id }, { dueAt }); + } + + async updateStatus( + invoiceId: string, + status: Freight.InvoiceStatus, + manager?: EntityManager, + ): Promise { + const mg = manager ?? this.dataSource.manager; + const invoice = await mg.findOne(Invoice, { + where: { id: invoiceId, status: In(OPEN_STATUSES) }, + order: { issuedAt: "DESC" }, + }); + if (!invoice) return; + await mg.update(Invoice, { id: invoice.id }, { status }); + } + // ── Payment initiation & settlement (the gateway boundary) ─────────────────── /** @@ -654,7 +772,9 @@ export class BillingService { ): Promise { const invoice = await this.findPayable(source, sourceId); if (!invoice) { - throw new NotFoundException(`No open invoice to charge for ${source}:${sourceId}`); + throw new NotFoundException( + `No open invoice to charge for ${source}:${sourceId}`, + ); } const result = await this.payment.initiate({ diff --git a/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts b/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts index 5a007c320..94e917754 100644 --- a/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts @@ -5,14 +5,18 @@ import { Param, ParseUUIDPipe, Post, + Query, + Res, } from "@nestjs/common"; import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; +import type { Response } from "express"; import { CurrentUser } from "@edr/api-common"; import { type AuthUserPayload, resolveAuthUserId, } from "../../common/resolve-auth-user-id"; +import { sendPdf } from "./billing.controller"; import { BillingService } from "./billing.service"; import { PayInvoiceDto } from "./dto/pay-invoice.dto"; @@ -29,8 +33,15 @@ export class PortalBillingController { @Get("my-invoices") @ApiOperation({ summary: "List the signed-in customer's invoices" }) - findMine(@CurrentUser() user: AuthUserPayload) { - return this.billingService.findForUser(resolveAuthUserId(user)); + findMine( + @CurrentUser() user: AuthUserPayload, + @Query("source") source?: string, + @Query("sourceId") sourceId?: string, + ) { + return this.billingService.findForUser(resolveAuthUserId(user), { + source, + sourceId, + }); } @Get("my-invoices/:id") @@ -42,6 +53,34 @@ export class PortalBillingController { return this.billingService.findByIdForUser(id, resolveAuthUserId(user)); } + @Get("my-invoices/:id/document") + @ApiOperation({ summary: "Download one of the customer's invoice PDFs" }) + async document( + @Param("id", ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUserPayload, + @Res() res: Response, + ) { + const { filename, buffer } = await this.billingService.documentForUser( + id, + resolveAuthUserId(user), + ); + sendPdf(res, filename, buffer); + } + + @Get("my-invoices/:id/receipt") + @ApiOperation({ summary: "Download one of the customer's payment receipt PDFs" }) + async receipt( + @Param("id", ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUserPayload, + @Res() res: Response, + ) { + const { filename, buffer } = await this.billingService.receiptForUser( + id, + resolveAuthUserId(user), + ); + sendPdf(res, filename, buffer); + } + @Post("my-invoices/:id/pay") @ApiOperation({ summary: "Initiate payment for one of the customer's invoices" }) pay( 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 47338f196..c70133ee7 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,20 +1,20 @@ -import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common'; -import { OnEvent } from '@nestjs/event-emitter'; -import { Freight } from '@edr/types'; -import { DataSource } from 'typeorm'; +import { forwardRef, Inject, Injectable, Logger } from "@nestjs/common"; +import { OnEvent } from "@nestjs/event-emitter"; +import { Freight } from "@edr/types"; +import { DataSource } 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 { Booking } from './entities/booking.entity'; +} 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 { Booking } from "./entities/booking.entity"; /** Snapshot written onto `booking.pricingBreakdown` by the pricing service. */ interface StoredPricingBreakdown { @@ -23,6 +23,12 @@ interface StoredPricingBreakdown { 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; @@ -56,11 +62,14 @@ export class BookingInvoiceService { * bill (e.g. government bookings whose `companyId` is null, which the invoices * FK requires), or no priced amount. */ - async ensureInvoiceForBooking(booking: Booking): Promise { + async ensureInvoiceForBooking( + booking: Booking, + invoiceOptions: InvoiceOptions = {}, + ): Promise { const existing = await this.billing.findPayable( Freight.InvoiceSource.Booking, booking.id, - Freight.InvoiceType.Prepaid, + "PREPAID", ); if (existing) return existing; @@ -68,16 +77,9 @@ export class BookingInvoiceService { 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; - } + const input = this.buildInput(booking, invoiceOptions); return this.billing.generateInvoice(input); } @@ -87,10 +89,10 @@ export class BookingInvoiceService { * 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') + @OnEvent("booking.invoice.paid") async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise { switch (payload.type) { - case Freight.InvoiceType.Prepaid: + case "PREPAID": await this.advanceBookingOnPayment(payload.sourceId); break; default: @@ -100,6 +102,8 @@ export class BookingInvoiceService { } } + updateStatus = this.billing.updateStatus; + /** * Advance a booking once its prepaid invoice settles — the domain side-effect * of payment, relocated out of the payment service: the booking becomes PAID @@ -114,16 +118,18 @@ export class BookingInvoiceService { 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.`); + this.logger.warn( + `Cannot advance unknown booking ${bookingId} on payment.`, + ); return; } - if (booking.paymentStatus === 'PAID') return; + if (booking.paymentStatus === "PAID") return; await this.dataSource.transaction(async (mg) => { await mg.update( Booking, { id: bookingId }, - { paymentStatus: 'PAID', status: 'PAID' }, + { paymentStatus: "PAID", status: "PAID" }, ); await this.firstMile.acceptBooking(bookingId); }); @@ -138,9 +144,13 @@ export class BookingInvoiceService { } /** 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'; + 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, @@ -155,10 +165,10 @@ 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) return null; + if (!Number.isFinite(amount) || amount <= 0) throw new Error("No price"); lines.push({ - chargeType: 'FREIGHT', - description: 'Rail freight', + chargeType: "FREIGHT", + description: "Rail freight", quantity: 1, unitRate: amount, amount, @@ -166,7 +176,9 @@ export class BookingInvoiceService { }); } - const subtotal = round2(lines.reduce((sum, l) => sum + Number(l.amount), 0)); + 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 @@ -176,8 +188,8 @@ export class BookingInvoiceService { const delta = round2(Number(adjusted) - subtotal); if (delta !== 0) { lines.push({ - chargeType: 'ADJUSTMENT', - description: 'Staff price adjustment', + chargeType: "ADJUSTMENT", + description: "Staff price adjustment", quantity: 1, unitRate: delta, amount: delta, @@ -190,12 +202,14 @@ export class BookingInvoiceService { return { source: Freight.InvoiceSource.Booking, sourceId: booking.id, - type: Freight.InvoiceType.Prepaid, companyId: booking.companyId, companyProfileId: booking.companyProfileId, currency, lines, totalAmount, + dueAt: invoiceOptions.dueDate, + type: invoiceOptions.invoiceType ?? "PREPAID", + status: invoiceOptions.invoiceStatus ?? Freight.InvoiceStatus.Draft, }; } } 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 2ebceeabc..90ccd0118 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 @@ -4,53 +4,56 @@ import { Inject, Injectable, Logger, -} from '@nestjs/common'; -import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; +} from "@nestjs/common"; +import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; -import { assertCanApproveBookingStep } from '../../common/freight-permission.util'; -import { BookingBatchService } from '../train-scheduling/booking-batch.service'; -import { eatDay } from '../train-scheduling/batch-window.util'; -import { isRoadService } from './road.util'; -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'; -import { clearanceCodesForBooking } from './clearance.util'; -import { computeNextStep, type BookingNextStep } from './booking-next-step.util'; -import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto'; -import { PriceLineItemDto } from './dto/generate-price-response.dto'; -import { Booking } from './entities/booking.entity'; -import { BookingsService } from './bookings.service'; +import { assertCanApproveBookingStep } from "../../common/freight-permission.util"; +import { BookingBatchService } from "../train-scheduling/booking-batch.service"; +import { eatDay } from "../train-scheduling/batch-window.util"; +import { isRoadService } from "./road.util"; +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 { BookingPricingService } from "./booking-pricing.service"; +import { BookingsRepository } from "./bookings.repository"; +import { assertBookingStatus } from "./booking-status.util"; +import { clearanceCodesForBooking } from "./clearance.util"; +import { + computeNextStep, + type BookingNextStep, +} from "./booking-next-step.util"; +import { SubmitBookingResponseDto } from "./dto/submit-booking-response.dto"; +import { PriceLineItemDto } from "./dto/generate-price-response.dto"; +import { Booking } from "./entities/booking.entity"; +import { BookingsService } from "./bookings.service"; +import { BookingInvoiceService } from "./booking-invoice.service"; +import { Freight } from "@edr/types"; @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)) private readonly bookingBatchService: BookingBatchService, @Inject(forwardRef(() => BookingsService)) private readonly bookingsService: BookingsService, - ) {} + private readonly invoiceService: BookingInvoiceService, + ) { } async submit(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['DRAFT', 'CHANGES_REQUESTED']); + assertBookingStatus(booking, ["DRAFT", "CHANGES_REQUESTED"]); if (Number(booking.totalAmount) <= 0) { throw new BadRequestException( - 'Generate a price before submitting (POST /bookings/:id/generate-price)', + "Generate a price before submitting (POST /bookings/:id/generate-price)", ); } @@ -69,7 +72,8 @@ export class BookingTransitionService { totalAmount?: number; } | null; const unchanged = this.pricingService.pricesMatch(stored, computed); - const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking); + const priorityScore = + await this.pricingService.computeSubmitPriorityScore(booking); if (unchanged) { await this.pricingService.createPricingSnapshots( @@ -79,7 +83,7 @@ export class BookingTransitionService { ); const updated = await this.bookingsRepository.update(bookingId, { - status: 'SUBMITTED', + status: "SUBMITTED", priorityScore, } as never); @@ -109,7 +113,7 @@ export class BookingTransitionService { currency: computed.currency, generatedAt: new Date().toISOString(), }, - status: 'PRICE_CHANGED_PENDING_CONFIRM', + status: "PRICE_CHANGED_PENDING_CONFIRM", } as never); const updatedBooking = await this.bookingsService.findById(bookingId); @@ -121,16 +125,17 @@ export class BookingTransitionService { totalAmount: computed.totalAmount, currency: computed.currency, lineItems: computed.lineItems, - message: 'Price has changed since preview. Confirm to submit with the updated price.', + message: + "Price has changed since preview. Confirm to submit with the updated price.", }; } async confirmSubmit(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['PRICE_CHANGED_PENDING_CONFIRM']); + assertBookingStatus(booking, ["PRICE_CHANGED_PENDING_CONFIRM"]); if (Number(booking.totalAmount) <= 0) { - throw new BadRequestException('No price to confirm'); + throw new BadRequestException("No price to confirm"); } const computed = await this.pricingService.computePriceForBooking(booking); @@ -149,9 +154,10 @@ export class BookingTransitionService { computed.appliedModifiers, ); - const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking); + const priorityScore = + await this.pricingService.computeSubmitPriorityScore(booking); const updated = await this.bookingsRepository.update(bookingId, { - status: 'SUBMITTED', + status: "SUBMITTED", priorityScore, totalAmount: computed.totalAmount, pricingBreakdown: { @@ -173,7 +179,7 @@ export class BookingTransitionService { totalAmount: Number(finalBooking.totalAmount), currency: finalBooking.paymentCurrency, lineItems: computed.lineItems, - message: 'Booking submitted with confirmed price.', + message: "Booking submitted with confirmed price.", }; } @@ -183,17 +189,17 @@ export class BookingTransitionService { actorId: string, ): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['SUBMITTED']); + assertBookingStatus(booking, ["SUBMITTED"]); await this.bookingsRepository.createReviewNote( bookingId, note, - 'CHANGES_REQUESTED', + "CHANGES_REQUESTED", actorId, ); const updated = await this.bookingsRepository.update(bookingId, { - status: 'CHANGES_REQUESTED', + status: "CHANGES_REQUESTED", } as never); return this.bookingsService.findById(updated!.id); } @@ -203,7 +209,7 @@ export class BookingTransitionService { if ((booking.approvalSteps?.length ?? 0) > 0) return; await this.ruleEngineService.instantiateApprovalSteps(booking.id, { - freightType: booking.freightType as 'CONTAINER' | 'BULK', + freightType: booking.freightType as "CONTAINER" | "BULK", cargoTypeId: booking.cargoTypeId, }); } @@ -217,14 +223,14 @@ export class BookingTransitionService { // Only SUBMITTED bookings are acceptable. A booking that still needs // consolidation sits in PENDING_CONSOLIDATION (resolved at submit time) and // is therefore never offered for accept until a partner moves it to SUBMITTED. - assertBookingStatus(booking, ['SUBMITTED']); + assertBookingStatus(booking, ["SUBMITTED"]); // The backoffice must define how long the accepted contract stays valid. // Without a window the contract has no end date and cannot be relied on, so // accept is blocked until a positive number of days is supplied. if (!Number.isInteger(validityDays) || validityDays < 1) { throw new BadRequestException( - 'A contract validity (in days) is required to accept this booking.', + "A contract validity (in days) is required to accept this booking.", ); } @@ -234,12 +240,12 @@ export class BookingTransitionService { validUntil.setDate(validUntil.getDate() + validityDays); await this.ruleEngineService.instantiateApprovalSteps(bookingId, { - freightType: booking.freightType as 'CONTAINER' | 'BULK', + freightType: booking.freightType as "CONTAINER" | "BULK", cargoTypeId: booking.cargoTypeId, }); const updated = await this.bookingsRepository.update(bookingId, { - status: 'PENDING_APPROVAL', + status: "PENDING_APPROVAL", approvedByStaffId: actorId, approvedByStaffAt: validFrom, contractValidityDays: validityDays, @@ -255,17 +261,17 @@ export class BookingTransitionService { actorId: string, ): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['SUBMITTED', 'PENDING_APPROVAL']); + assertBookingStatus(booking, ["SUBMITTED", "PENDING_APPROVAL"]); await this.bookingsRepository.createReviewNote( bookingId, reason, - 'REJECTION', + "REJECTION", actorId, ); const updated = await this.bookingsRepository.update(bookingId, { - status: 'REJECTED', + status: "REJECTED", } as never); return this.bookingsService.findById(updated!.id); } @@ -283,8 +289,8 @@ export class BookingTransitionService { let booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, [ - 'PENDING_APPROVAL', - 'APPROVED_PENDING_SIGNATURE', + "PENDING_APPROVAL", + "APPROVED_PENDING_SIGNATURE", ]); if ((booking.approvalSteps?.length ?? 0) === 0) { @@ -296,14 +302,17 @@ export class BookingTransitionService { bookingId, stepId, ); - if (!step || step.status !== 'PENDING') { - throw new BadRequestException('Approval step not found or already actioned'); + if (!step || step.status !== "PENDING") { + throw new BadRequestException( + "Approval step not found or already actioned", + ); } - const next = await this.bookingsRepository.findNextPendingApprovalStep(bookingId); + const next = + await this.bookingsRepository.findNextPendingApprovalStep(bookingId); if (!next || next.id !== step.id) { throw new BadRequestException( - 'Approval steps must be completed in order', + "Approval steps must be completed in order", ); } @@ -315,29 +324,36 @@ export class BookingTransitionService { const blocksRole = step.blocksRole; if (blocksRole && blocksRole === requiredRole) { - throw new BadRequestException(`Role ${requiredRole} is blocked for this step`); + throw new BadRequestException( + `Role ${requiredRole} is blocked for this step`, + ); } - await this.bookingsRepository.completeApprovalStep(step.id, actorId, 'APPROVED'); + await this.bookingsRepository.completeApprovalStep( + step.id, + actorId, + "APPROVED", + ); const updates: Record = {}; const now = new Date(); - if (requiredRole === 'LINE_STAFF') { - updates.status = 'APPROVED_PENDING_SIGNATURE'; + if (requiredRole === "LINE_STAFF") { + updates.status = "APPROVED_PENDING_SIGNATURE"; updates.approvedByStaffId = actorId; updates.approvedByStaffAt = now; - } else if (requiredRole === 'DIRECTOR') { + } else if (requiredRole === "DIRECTOR") { updates.signedByDirectorId = actorId; updates.signedByDirectorAt = now; - } else if (requiredRole === 'CEO') { + } else if (requiredRole === "CEO") { updates.signedByCeoId = actorId; updates.signedByCeoAt = now; } - const allDone = await this.bookingsRepository.allApprovalStepsComplete(bookingId); + const allDone = + await this.bookingsRepository.allApprovalStepsComplete(bookingId); if (allDone) { - updates.status = 'APPROVED'; + updates.status = "APPROVED"; } if (Object.keys(updates).length > 0) { @@ -359,90 +375,64 @@ export class BookingTransitionService { reason: string, ): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']); + assertBookingStatus(booking, [ + "PENDING_APPROVAL", + "APPROVED_PENDING_SIGNATURE", + ]); const step = await this.bookingsRepository.findApprovalStepById( bookingId, stepId, ); - if (!step) throw new BadRequestException('Approval step not found'); + if (!step) throw new BadRequestException("Approval step not found"); await this.bookingsRepository.completeApprovalStep( step.id, actorId, - 'REJECTED', + "REJECTED", reason, ); await this.bookingsRepository.createReviewNote( bookingId, reason, - 'REJECTION', + "REJECTION", actorId, ); const updated = await this.bookingsRepository.update(bookingId, { - status: 'REJECTED', + status: "REJECTED", } as never); return this.bookingsService.findById(updated!.id); } async customerSign(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['CONTRACT_READY']); + assertBookingStatus(booking, ["CONTRACT_READY"]); const updated = await this.bookingsRepository.update(bookingId, { - status: 'SIGNED_CUSTOMER', + status: "SIGNED_CUSTOMER", customerSignedAt: new Date(), } as never); return this.bookingsService.findById(updated!.id); } - async marketingApprove(bookingId: string, actorId: string): Promise { - const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['SIGNED_CUSTOMER']); - - const updated = await this.bookingsRepository.update(bookingId, { - status: 'FULLY_EXECUTED', - fullyExecutedAt: new Date(), - marketingApprovedById: actorId, - marketingApprovedAt: new Date(), - lockedAt: new Date(), - } as never); - - 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 { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['PAID']); + assertBookingStatus(booking, ["PAID"]); const updated = await this.bookingsRepository.update(bookingId, { - status: 'IN_TRANSIT', + status: "IN_TRANSIT", } as never); return this.bookingsService.findById(updated!.id); } async complete(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['IN_TRANSIT']); + assertBookingStatus(booking, ["IN_TRANSIT"]); const updated = await this.bookingsRepository.update(bookingId, { - status: 'COMPLETED', + status: "COMPLETED", endDate: new Date(), } as never); return this.bookingsService.findById(updated!.id); @@ -451,22 +441,22 @@ export class BookingTransitionService { async cancel(bookingId: string, reason: string): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, [ - 'DRAFT', - 'SUBMITTED', - 'PRICE_CHANGED_PENDING_CONFIRM', - 'CHANGES_REQUESTED', - 'PENDING_APPROVAL', - 'CONTRACT_READY', + "DRAFT", + "SUBMITTED", + "PRICE_CHANGED_PENDING_CONFIRM", + "CHANGES_REQUESTED", + "PENDING_APPROVAL", + "CONTRACT_READY", ]); await this.bookingsRepository.createReviewNote( bookingId, reason, - 'REJECTION', + "REJECTION", ); const updated = await this.bookingsRepository.update(bookingId, { - status: 'CANCELLED', + status: "CANCELLED", } as never); return this.bookingsService.findById(updated!.id); } @@ -479,20 +469,20 @@ export class BookingTransitionService { async reject(bookingId: string, reason?: string): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, [ - 'DRAFT', - 'SUBMITTED', - 'PRICE_CHANGED_PENDING_CONFIRM', - 'PENDING_CONSOLIDATION', + "DRAFT", + "SUBMITTED", + "PRICE_CHANGED_PENDING_CONFIRM", + "PENDING_CONSOLIDATION", ]); await this.bookingsRepository.createReviewNote( bookingId, - reason?.trim() || 'Customer rejected the price estimate.', - 'REJECTION', + reason?.trim() || "Customer rejected the price estimate.", + "REJECTION", ); const updated = await this.bookingsRepository.update(bookingId, { - status: 'REJECTED', + status: "REJECTED", } as never); return this.bookingsService.findById(updated!.id); } @@ -513,10 +503,10 @@ export class BookingTransitionService { fileKey: string; label: string; required: boolean; - uploadedBy: 'customer' | 'gl'; + uploadedBy: "customer" | "gl"; settingCode: string; file: { id: string; name: string; url: string } | null; - reviewStatus: 'PENDING' | 'APPROVED' | 'QUERIED' | null; + reviewStatus: "PENDING" | "APPROVED" | "QUERIED" | null; note: string | null; }>; allApproved: boolean; @@ -525,20 +515,21 @@ export class BookingTransitionService { const { inputCode, outputCode, includesCustoms } = clearanceCodesForBooking(booking); - const files = await this.filesService.findByResource(bookingId, 'bookings'); + const files = await this.filesService.findByResource(bookingId, "bookings"); const fileByCode = new Map(files.map((f) => [f.code, f])); - const reviews = await this.bookingsRepository.findDocumentReviews(bookingId); + const reviews = + await this.bookingsRepository.findDocumentReviews(bookingId); const reviewByKey = new Map( reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r]), ); const documents: Awaited< - ReturnType - >['documents'] = []; + ReturnType + >["documents"] = []; const pushSetting = async ( code: string | null, - uploadedBy: 'customer' | 'gl', + uploadedBy: "customer" | "gl", ) => { if (!code) return; let setting; @@ -556,28 +547,26 @@ export class BookingTransitionService { required: field.isRequired, uploadedBy, settingCode: code, - file: file - ? { id: file.id, name: file.name, url: file.url } - : null, + file: file ? { id: file.id, name: file.name, url: file.url } : null, reviewStatus: review?.status ?? null, note: review?.note ?? null, }); } }; - await pushSetting(inputCode, 'customer'); - await pushSetting(outputCode, 'gl'); + await pushSetting(inputCode, "customer"); + await pushSetting(outputCode, "gl"); // Ad-hoc / unknown documents (code custom_*) appear alongside the seeded set. for (const f of files) { - if (!f.code?.startsWith('custom_')) continue; + if (!f.code?.startsWith("custom_")) continue; const review = reviewByKey.get(`custom:${f.code}`) ?? null; documents.push({ fileKey: f.code, label: f.name, required: false, - uploadedBy: 'customer', - settingCode: 'custom', + uploadedBy: "customer", + settingCode: "custom", file: { id: f.id, name: f.name, url: f.url }, reviewStatus: review?.status ?? null, note: review?.note ?? null, @@ -611,13 +600,15 @@ export class BookingTransitionService { } const required = (setting.fields ?? []).filter((f) => f.isRequired); if (required.length === 0) return true; - const reviews = await this.bookingsRepository.findDocumentReviews(booking.id); + const reviews = await this.bookingsRepository.findDocumentReviews( + booking.id, + ); return required.every((field) => reviews.some( (r) => r.settingCode === inputCode && r.fileKey === field.fileKey && - r.status === 'APPROVED', + r.status === "APPROVED", ), ); } @@ -632,33 +623,38 @@ export class BookingTransitionService { files: Express.Multer.File[], ): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['AWAITING_DOCUMENTS', 'DOCUMENTS_UNDER_REVIEW']); + assertBookingStatus(booking, [ + "AWAITING_DOCUMENTS", + "DOCUMENTS_UNDER_REVIEW", + ]); const { inputCode } = clearanceCodesForBooking(booking); if (!inputCode) { - throw new BadRequestException('This booking has no document-clearance step'); + throw new BadRequestException( + "This booking has no document-clearance step", + ); } if (files.length === 0) { - throw new BadRequestException('No documents uploaded'); + throw new BadRequestException("No documents uploaded"); } // First submission (nothing in review yet): every required input field must // be provided. Once review has started (DOCUMENTS_UNDER_REVIEW) the customer // is only fixing queried/pending docs, so the already-uploaded required docs // stay in place and we don't re-gate on the full required set. - if (booking.status === 'AWAITING_DOCUMENTS') { + if (booking.status === "AWAITING_DOCUMENTS") { await this.assertRequiredInputsPresent(bookingId, inputCode, files); } for (const file of files) { const record = await this.filesService.upsertByCode({ resourceId: bookingId, - resource: 'bookings', + resource: "bookings", code: file.fieldname, file, }); // Ad-hoc docs (custom_*) are not part of the required gate; still tracked. - const settingCode = file.fieldname.startsWith('custom_') - ? 'custom' + const settingCode = file.fieldname.startsWith("custom_") + ? "custom" : inputCode; await this.bookingsRepository.upsertDocumentReviewPending({ bookingId, @@ -669,7 +665,7 @@ export class BookingTransitionService { } await this.bookingsRepository.update(bookingId, { - status: 'DOCUMENTS_UNDER_REVIEW', + status: "DOCUMENTS_UNDER_REVIEW", } as never); return this.bookingsService.findById(bookingId); } @@ -694,7 +690,10 @@ export class BookingTransitionService { const required = (setting.fields ?? []).filter((f) => f.isRequired); if (required.length === 0) return; - const existing = await this.filesService.findByResource(bookingId, 'bookings'); + const existing = await this.filesService.findByResource( + bookingId, + "bookings", + ); const presentKeys = new Set([ ...existing.map((f) => f.code), ...files.map((f) => f.fieldname), @@ -702,7 +701,7 @@ export class BookingTransitionService { const missing = required.filter((f) => !presentKeys.has(f.fileKey)); if (missing.length > 0) { - const labels = missing.map((f) => f.fileLabel).join(', '); + const labels = missing.map((f) => f.fileLabel).join(", "); throw new BadRequestException( `Please upload all required documents before submitting: ${labels}`, ); @@ -713,22 +712,27 @@ export class BookingTransitionService { async reviewDocument( bookingId: string, fileKey: string, - status: 'APPROVED' | 'QUERIED', + status: "APPROVED" | "QUERIED", staffId: string, note?: string, ): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']); + assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]); const { inputCode, outputCode } = clearanceCodesForBooking(booking); - const existing = await this.bookingsRepository.findDocumentReviews(bookingId); + const existing = + await this.bookingsRepository.findDocumentReviews(bookingId); const match = existing.find((r) => r.fileKey === fileKey); const settingCode = match?.settingCode ?? - (fileKey.startsWith('custom_') ? 'custom' : (inputCode ?? outputCode ?? 'custom')); + (fileKey.startsWith("custom_") + ? "custom" + : (inputCode ?? outputCode ?? "custom")); - if (status === 'QUERIED' && !note?.trim()) { - throw new BadRequestException('A note is required when querying a document'); + if (status === "QUERIED" && !note?.trim()) { + throw new BadRequestException( + "A note is required when querying a document", + ); } await this.bookingsRepository.setDocumentReviewStatus( @@ -739,11 +743,11 @@ export class BookingTransitionService { staffId, note, ); - if (status === 'QUERIED') { + if (status === "QUERIED") { await this.bookingsRepository.createReviewNote( bookingId, `Document "${fileKey}" queried: ${note}`, - 'CHANGES_REQUESTED', + "CHANGES_REQUESTED", staffId, ); } @@ -756,18 +760,20 @@ export class BookingTransitionService { files: Express.Multer.File[], ): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']); + assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]); const { outputCode } = clearanceCodesForBooking(booking); if (!outputCode) { - throw new BadRequestException('This booking has no customs output documents'); + throw new BadRequestException( + "This booking has no customs output documents", + ); } if (files.length === 0) { - throw new BadRequestException('No documents uploaded'); + throw new BadRequestException("No documents uploaded"); } for (const file of files) { await this.filesService.upsertByCode({ resourceId: bookingId, - resource: 'bookings', + resource: "bookings", code: file.fieldname, file, }); @@ -781,19 +787,23 @@ export class BookingTransitionService { */ async finalizeClearance(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']); + assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]); const approved = await this.isClearanceFullyApproved(booking); if (!approved) { throw new BadRequestException( - 'All required documents must be approved before clearance can be finalized', + "All required documents must be approved before clearance can be finalized", ); } const { outputCode } = clearanceCodesForBooking(booking); if (outputCode) { - const setting = await this.fileUploadSettingsService.getByCode(outputCode); - const files = await this.filesService.findByResource(bookingId, 'bookings'); + const setting = + await this.fileUploadSettingsService.getByCode(outputCode); + const files = await this.filesService.findByResource( + bookingId, + "bookings", + ); const uploaded = new Set(files.map((f) => f.code)); const missing = (setting.fields ?? []).filter( (f) => f.isRequired && !uploaded.has(f.fileKey), @@ -802,13 +812,13 @@ export class BookingTransitionService { throw new BadRequestException( `Upload all required customs output documents first: ${missing .map((m) => m.fileLabel) - .join(', ')}`, + .join(", ")}`, ); } } await this.bookingsRepository.update(bookingId, { - status: 'CLEARANCE_READY', + status: "CLEARANCE_READY", } as never); return this.bookingsService.findById(bookingId); } @@ -827,11 +837,14 @@ export class BookingTransitionService { scheduledDate: string, ): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['CLEARANCE_READY', 'OPERATION_CHANGES_REQUESTED']); + assertBookingStatus(booking, [ + "CLEARANCE_READY", + "OPERATION_CHANGES_REQUESTED", + ]); const date = new Date(scheduledDate); if (Number.isNaN(date.getTime())) { - throw new BadRequestException('A valid schedule date is required'); + throw new BadRequestException("A valid schedule date is required"); } // The binding shipment day must have at least one OPEN departure on the @@ -844,12 +857,12 @@ export class BookingTransitionService { ); if (!hasDeparture) { throw new BadRequestException( - 'No departures available on the selected day for this route', + "No departures available on the selected day for this route", ); } await this.bookingsRepository.update(bookingId, { - status: 'OPERATION_REQUEST_PENDING', + status: "OPERATION_REQUEST_PENDING", scheduledDate: date, } as never); return this.bookingsService.findById(bookingId); @@ -865,27 +878,27 @@ export class BookingTransitionService { */ async reviewOperationRequest( bookingId: string, - decision: 'ACCEPT' | 'REQUEST_CHANGES', + decision: "ACCEPT" | "REQUEST_CHANGES", actorId: string, options: { note?: string } = {}, ): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['OPERATION_REQUEST_PENDING']); + assertBookingStatus(booking, ["OPERATION_REQUEST_PENDING"]); - if (decision === 'REQUEST_CHANGES') { + if (decision === "REQUEST_CHANGES") { if (!options.note?.trim()) { throw new BadRequestException( - 'A note is required when requesting changes', + "A note is required when requesting changes", ); } await this.bookingsRepository.createReviewNote( bookingId, options.note, - 'CHANGES_REQUESTED', + "CHANGES_REQUESTED", actorId, ); await this.bookingsRepository.update(bookingId, { - status: 'OPERATION_CHANGES_REQUESTED', + status: "OPERATION_CHANGES_REQUESTED", } as never); return this.bookingsService.findById(bookingId); } @@ -907,9 +920,17 @@ export class BookingTransitionService { private async acceptOperationRequest(booking: Booking): Promise { const now = new Date(); + const invoice = await this.invoiceService.ensureInvoiceForBooking(booking); + this.logger.log( + `Generated invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id}`, + ); + await this.invoiceService.updateStatus( + invoice.id, + Freight.InvoiceStatus.Pending, + ); if (isRoadService(booking.serviceType)) { await this.bookingsRepository.update(booking.id, { - status: 'ROAD_DISPATCH_PENDING', + status: "ROAD_DISPATCH_PENDING", fullyExecutedAt: now, lockedAt: booking.lockedAt ?? now, } as never); @@ -917,7 +938,7 @@ export class BookingTransitionService { } await this.bookingsRepository.update(booking.id, { - status: 'FULLY_EXECUTED', + status: "FULLY_EXECUTED", fullyExecutedAt: now, lockedAt: booking.lockedAt ?? now, } as never); @@ -932,21 +953,23 @@ export class BookingTransitionService { return this.bookingsService.findById(booking.id); } - async enrichBookingResponse(booking: Booking): Promise { + async enrichBookingResponse(booking: Booking): Promise< + Booking & { + latestChangeRequestNote?: string | null; + contractSummary?: string | null; + nextStep: BookingNextStep | null; + } + > { const note = await this.bookingsRepository.findLatestReviewNote( booking.id, - 'CHANGES_REQUESTED', + "CHANGES_REQUESTED", ); const summary = booking.contractSummary ?? this.contractService.buildContractSummary(booking); const nextPending = - booking.status === 'PENDING_APPROVAL' || - booking.status === 'APPROVED_PENDING_SIGNATURE' + booking.status === "PENDING_APPROVAL" || + booking.status === "APPROVED_PENDING_SIGNATURE" ? await this.bookingsRepository.findNextPendingApprovalStep(booking.id) : null; const nextStep = computeNextStep(booking, nextPending); @@ -957,4 +980,4 @@ export class BookingTransitionService { nextStep, }; } -} \ No newline at end of file +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 0f413199b..9548eee88 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -14,12 +14,12 @@ import { UnauthorizedException, UploadedFiles, UseInterceptors, -} from '@nestjs/common'; -import { CurrentUser } from '@edr/api-common'; -import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; -import { BookingStaff } from '../../common/booking-guards'; -import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; -import { AnyFilesInterceptor } from '@nestjs/platform-express'; +} from "@nestjs/common"; +import { CurrentUser } from "@edr/api-common"; +import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; +import { BookingStaff } from "../../common/booking-guards"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; +import { AnyFilesInterceptor } from "@nestjs/platform-express"; import { ApiBearerAuth, ApiBody, @@ -27,20 +27,20 @@ import { ApiOkResponse, ApiOperation, ApiTags, -} from '@nestjs/swagger'; -import type { Response } from 'express'; +} from "@nestjs/swagger"; +import type { Response } from "express"; -import { BookingContractService } from './booking-contract.service'; -import { BookingPricingService } from './booking-pricing.service'; -import { BookingTransitionService } from './booking-transition.service'; -import { BookingReferenceDataService } from './booking-reference-data.service'; -import { BookingsService } from './bookings.service'; -import { BookingReferenceDataDto } from './dto/booking-reference-data.dto'; -import { CreateBookingDto } from './dto/create-booking.dto'; -import { BookingListSummaryDto } from './dto/booking-list-summary.dto'; -import { FilterBookingDto } from './dto/filter-booking.dto'; -import { GeneratePriceResponseDto } from './dto/generate-price-response.dto'; -import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto'; +import { BookingContractService } from "./booking-contract.service"; +import { BookingPricingService } from "./booking-pricing.service"; +import { BookingTransitionService } from "./booking-transition.service"; +import { BookingReferenceDataService } from "./booking-reference-data.service"; +import { BookingsService } from "./bookings.service"; +import { BookingReferenceDataDto } from "./dto/booking-reference-data.dto"; +import { CreateBookingDto } from "./dto/create-booking.dto"; +import { BookingListSummaryDto } from "./dto/booking-list-summary.dto"; +import { FilterBookingDto } from "./dto/filter-booking.dto"; +import { GeneratePriceResponseDto } from "./dto/generate-price-response.dto"; +import { SubmitBookingResponseDto } from "./dto/submit-booking-response.dto"; import { AcceptIntakeDto, ApproveStepDto, @@ -52,18 +52,21 @@ import { RequestOperationDto, OperationReviewDto, StaffRejectDto, -} from './dto/request-changes.dto'; -import { ContractViewDto } from './dto/contract-view.dto'; -import { SignContractDto } from './dto/sign-contract.dto'; -import { UpdateBookingDto } from './dto/update-booking.dto'; +} from "./dto/request-changes.dto"; +import { ContractViewDto } from "./dto/contract-view.dto"; +import { SignContractDto } from "./dto/sign-contract.dto"; +import { UpdateBookingDto } from "./dto/update-booking.dto"; import { type AuthUserPayload, resolveAuthUserId, -} from '../../common/resolve-auth-user-id'; -import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util'; +} from "../../common/resolve-auth-user-id"; +import { + assertFreightPermission, + hasFreightPermission, +} from "../../common/freight-permission.util"; -@ApiTags('bookings') -@Controller('bookings') +@ApiTags("bookings") +@Controller("bookings") @ApiBearerAuth() export class BookingsController { constructor( @@ -72,12 +75,12 @@ export class BookingsController { private readonly pricingService: BookingPricingService, private readonly transitionService: BookingTransitionService, private readonly contractService: BookingContractService, - ) {} + ) { } @Post() @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes('multipart/form-data') - @ApiOperation({ summary: 'Create a new freight booking (DRAFT)' }) + @ApiConsumes("multipart/form-data") + @ApiOperation({ summary: "Create a new freight booking (DRAFT)" }) @ApiBody({ type: CreateBookingDto }) async create( @Body() dto: CreateBookingDto, @@ -87,15 +90,24 @@ export class BookingsController { if (dto.isGovernment) { assertFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept); } - const result = await this.bookingsService.create(dto, files ?? [], user?.id); + const result = await this.bookingsService.create( + dto, + files ?? [], + user?.id, + ); // Staff-created commercial bookings skip the draft stage: auto generate-price + submit. - const isStaff = hasFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept); + const isStaff = hasFreightPermission( + user, + FREIGHT_PERMS.bookings.staffAccept, + ); if (isStaff && !dto.isGovernment) { try { await this.pricingService.generatePrice(result.booking.id); await this.transitionService.submit(result.booking.id); - const submitted = await this.bookingsService.findById(result.booking.id); + const submitted = await this.bookingsService.findById( + result.booking.id, + ); return { booking: submitted, warnings: result.warnings }; } catch { // If auto-pricing/submit fails, fall back to the DRAFT so staff can finish manually. @@ -105,16 +117,16 @@ export class BookingsController { return result; } - @Patch(':id') + @Patch(":id") @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes('multipart/form-data') + @ApiConsumes("multipart/form-data") @ApiOperation({ - summary: 'Update booking', - description: 'Allowed when status is DRAFT or CHANGES_REQUESTED.', + summary: "Update booking", + description: "Allowed when status is DRAFT or CHANGES_REQUESTED.", }) @ApiBody({ type: UpdateBookingDto }) update( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdateBookingDto, @UploadedFiles() files: Express.Multer.File[], ) { @@ -122,7 +134,7 @@ export class BookingsController { } @Get() - @ApiOperation({ summary: 'List freight bookings (paginated)' }) + @ApiOperation({ summary: "List freight bookings (paginated)" }) async findAll( @Query() filter: FilterBookingDto, @CurrentUser() user: TCurrentUser, @@ -139,7 +151,7 @@ export class BookingsController { return this.bookingsService.findClearanceQueue(filter); } const userId = user?.id; - if (!userId) throw new UnauthorizedException('Authentication required'); + if (!userId) throw new UnauthorizedException("Authentication required"); const companyId = await this.bookingsService.resolveCustomerCompanyId(userId); // No linked company yet → no bookings to show (avoids leaking all bookings). @@ -165,27 +177,29 @@ export class BookingsController { return this.bookingsService.findAll(filter, companyId); } - @Get('by-company/:companyId/customer-view') - @ApiOperation({ summary: 'List bookings for a company (customer-view shape, backoffice)' }) + @Get("by-company/:companyId/customer-view") + @ApiOperation({ + summary: "List bookings for a company (customer-view shape, backoffice)", + }) findByCompanyCustomerView( - @Param('companyId', ParseUUIDPipe) companyId: string, + @Param("companyId", ParseUUIDPipe) companyId: string, ) { return this.bookingsService.findCustomerBookings(companyId); } - @Get('list-summary') - @ApiOperation({ summary: 'Booking list metrics and tab counts (backoffice)' }) + @Get("list-summary") + @ApiOperation({ summary: "Booking list metrics and tab counts (backoffice)" }) @ApiOkResponse({ type: BookingListSummaryDto }) findListSummary(@Query() filter: FilterBookingDto) { return this.bookingsService.getListSummary(filter); } - @Get('my') + @Get("my") @ApiOperation({ summary: "List the current customer's bookings ready for payment", description: - 'Bookings owned by the authenticated user\'s company that are payable ' + - '(FULLY_EXECUTED, SELECTED_FOR_BATCH, AWAITING_PAYMENT) and not yet PAID.', + "Bookings owned by the authenticated user's company that are payable " + + "(FULLY_EXECUTED, SELECTED_FOR_BATCH, AWAITING_PAYMENT) and not yet PAID.", }) findMyPayable( @CurrentUser() user: AuthUserPayload, @@ -194,32 +208,32 @@ export class BookingsController { return this.bookingsService.findMyPayable(resolveAuthUserId(user), filter); } - @Get('queues/:queue') + @Get("queues/:queue") @ApiOperation({ - summary: 'List bookings for a dashboard queue', - description: 'Queues: intake, approval, signatures, marketing, finance', + summary: "List bookings for a dashboard queue", + description: "Queues: intake, approval, signatures, marketing, finance", }) findQueue( - @Param('queue') queue: string, + @Param("queue") queue: string, @Query() filter: FilterBookingDto, - @Query('excludeBulk') excludeBulk?: string, + @Query("excludeBulk") excludeBulk?: string, ) { return this.bookingsService.findQueue(queue, filter, { - excludeBulk: excludeBulk === 'true', + excludeBulk: excludeBulk === "true", }); } - @Get('reference-data') - @ApiOperation({ summary: 'Booking form catalog' }) + @Get("reference-data") + @ApiOperation({ summary: "Booking form catalog" }) @ApiOkResponse({ type: BookingReferenceDataDto }) getReferenceData(): Promise { return this.bookingReferenceDataService.getReferenceData(); } - @Get('by-reference/:reference') - @ApiOperation({ summary: 'Get booking by reference' }) + @Get("by-reference/:reference") + @ApiOperation({ summary: "Get booking by reference" }) async findByReference( - @Param('reference') reference: string, + @Param("reference") reference: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findByReference(reference); @@ -233,10 +247,10 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Get(':id') - @ApiOperation({ summary: 'Get booking by ID' }) + @Get(":id") + @ApiOperation({ summary: "Get booking by ID" }) async findOne( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); @@ -254,15 +268,15 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Get(':id/tracking') + @Get(":id/tracking") @ApiOperation({ - summary: 'Shipment tracking timeline for a booking', + summary: "Shipment tracking timeline for a booking", description: "Returns the booking's consignment (once dispatched) and its ordered " + - 'tracking events. Scoped to the customer\'s own company.', + "tracking events. Scoped to the customer's own company.", }) async findTracking( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); @@ -276,66 +290,66 @@ export class BookingsController { return this.bookingsService.getBookingTracking(id); } - @Delete(':id') + @Delete(":id") @HttpCode(204) - @ApiOperation({ summary: 'Soft-delete DRAFT booking' }) - remove(@Param('id', ParseUUIDPipe) id: string) { + @ApiOperation({ summary: "Soft-delete DRAFT booking" }) + remove(@Param("id", ParseUUIDPipe) id: string) { return this.bookingsService.remove(id); } - @Post(':id/documents') + @Post(":id/documents") @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes('multipart/form-data') - @ApiOperation({ summary: 'Upload documents for a booking (DRAFT only)' }) + @ApiConsumes("multipart/form-data") + @ApiOperation({ summary: "Upload documents for a booking (DRAFT only)" }) async uploadDocuments( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @UploadedFiles() files: Express.Multer.File[], ) { const booking = await this.bookingsService.uploadDocuments(id, files ?? []); return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/generate-price') + @Post(":id/generate-price") @ApiOperation({ - summary: 'Generate price preview (DRAFT or CHANGES_REQUESTED)', + summary: "Generate price preview (DRAFT or CHANGES_REQUESTED)", description: - 'Computes and stores a price preview on the booking. Does not create rate snapshots.', + "Computes and stores a price preview on the booking. Does not create rate snapshots.", }) @ApiOkResponse({ type: GeneratePriceResponseDto }) - generatePrice(@Param('id', ParseUUIDPipe) id: string) { + generatePrice(@Param("id", ParseUUIDPipe) id: string) { return this.pricingService.generatePrice(id); } - @Post(':id/submit') + @Post(":id/submit") @ApiOperation({ - summary: 'Customer submit booking', + summary: "Customer submit booking", description: - 'Recomputes price against live rates. If unchanged, creates rate snapshots and submits. If changed, updates the booking price and returns priceChanged=true for confirmation.', + "Recomputes price against live rates. If unchanged, creates rate snapshots and submits. If changed, updates the booking price and returns priceChanged=true for confirmation.", }) @ApiOkResponse({ type: SubmitBookingResponseDto }) - submit(@Param('id', ParseUUIDPipe) id: string) { + submit(@Param("id", ParseUUIDPipe) id: string) { return this.transitionService.submit(id); } - @Post(':id/confirm-submit') + @Post(":id/confirm-submit") @ApiOperation({ - summary: 'Confirm submit after price change', + summary: "Confirm submit after price change", description: - 'Creates rate snapshots for the updated booking price and moves the booking to SUBMITTED.', + "Creates rate snapshots for the updated booking price and moves the booking to SUBMITTED.", }) @ApiOkResponse({ type: SubmitBookingResponseDto }) - confirmSubmit(@Param('id', ParseUUIDPipe) id: string) { + confirmSubmit(@Param("id", ParseUUIDPipe) id: string) { return this.transitionService.confirmSubmit(id); } - @Post(':id/reject') + @Post(":id/reject") @ApiOperation({ - summary: 'Customer reject price estimate', + summary: "Customer reject price estimate", description: - 'Customer rejects the priced booking at the confirm step. The booking becomes REJECTED (terminal); the customer must create a new booking.', + "Customer rejects the priced booking at the confirm step. The booking becomes REJECTED (terminal); the customer must create a new booking.", }) async reject( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: RejectBookingDto, ) { const booking = await this.transitionService.reject(id, dto.reason); @@ -344,22 +358,23 @@ export class BookingsController { // ── Document clearance (post counter-sign) ──────────────────────────────── - @Get(':id/clearance') + @Get(":id/clearance") @ApiOperation({ - summary: 'Document-clearance grid (required docs + upload + GL review status)', + summary: + "Document-clearance grid (required docs + upload + GL review status)", }) - getClearance(@Param('id', ParseUUIDPipe) id: string) { + getClearance(@Param("id", ParseUUIDPipe) id: string) { return this.transitionService.getClearanceView(id); } - @Post(':id/clearance/documents') + @Post(":id/clearance/documents") @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes('multipart/form-data') + @ApiConsumes("multipart/form-data") @ApiOperation({ - summary: 'Customer uploads clearance documents (fieldname = document key)', + summary: "Customer uploads clearance documents (fieldname = document key)", }) async submitClearanceDocuments( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @UploadedFiles() files: Express.Multer.File[], ) { const booking = await this.transitionService.submitClearanceDocuments( @@ -369,14 +384,14 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/proceed') + @Post(":id/clearance/proceed") @ApiOperation({ summary: - 'Customer requests operation with a schedule day ' + - '(CLEARANCE_READY | OPERATION_CHANGES_REQUESTED → OPERATION_REQUEST_PENDING)', + "Customer requests operation with a schedule day " + + "(CLEARANCE_READY | OPERATION_CHANGES_REQUESTED → OPERATION_REQUEST_PENDING)", }) async proceedToOperation( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: RequestOperationDto, ) { const booking = await this.transitionService.requestOperation( @@ -386,15 +401,15 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/operation/review') + @Post(":id/operation/review") @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: - 'Operations reviews an operation request: ACCEPT (→ batch pool), ' + - 'REQUEST_CHANGES (→ back to customer), or ADJUST_PRICE (→ customer re-confirm)', + "Operations reviews an operation request: ACCEPT (→ batch pool), " + + "REQUEST_CHANGES (→ back to customer), or ADJUST_PRICE (→ customer re-confirm)", }) async reviewOperationRequest( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: OperationReviewDto, @CurrentUser() user: AuthUserPayload, ) { @@ -407,11 +422,13 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/review') + @Post(":id/clearance/review") @BookingStaff(FREIGHT_PERMS.bookings.reviewDocuments) - @ApiOperation({ summary: 'GL reviews a clearance document (Approve | Query)' }) + @ApiOperation({ + summary: "GL reviews a clearance document (Approve | Query)", + }) async reviewClearanceDocument( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: ReviewDocumentDto, @CurrentUser() user: AuthUserPayload, ) { @@ -425,13 +442,13 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/output-documents') + @Post(":id/clearance/output-documents") @BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput) @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes('multipart/form-data') - @ApiOperation({ summary: 'GL uploads customs output documents (IM4/EX3/…)' }) + @ApiConsumes("multipart/form-data") + @ApiOperation({ summary: "GL uploads customs output documents (IM4/EX3/…)" }) async uploadClearanceOutput( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @UploadedFiles() files: Express.Multer.File[], ) { const booking = await this.transitionService.uploadClearanceOutputDocuments( @@ -441,21 +458,22 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/finalize') + @Post(":id/clearance/finalize") @BookingStaff(FREIGHT_PERMS.bookings.finalizeClearance) @ApiOperation({ - summary: 'GL finalizes clearance (requires 100% approved) → CLEARANCE_READY', + summary: + "GL finalizes clearance (requires 100% approved) → CLEARANCE_READY", }) - async finalizeClearance(@Param('id', ParseUUIDPipe) id: string) { + async finalizeClearance(@Param("id", ParseUUIDPipe) id: string) { const booking = await this.transitionService.finalizeClearance(id); return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/staff/request-changes') + @Post(":id/staff/request-changes") @BookingStaff(FREIGHT_PERMS.bookings.requestChanges) - @ApiOperation({ summary: 'Staff return booking for customer updates' }) + @ApiOperation({ summary: "Staff return booking for customer updates" }) async requestChanges( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: RequestChangesDto, @CurrentUser() user: AuthUserPayload, ) { @@ -467,14 +485,14 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/staff/accept') + @Post(":id/staff/accept") @BookingStaff(FREIGHT_PERMS.bookings.staffAccept) @ApiOperation({ summary: - 'Staff accept intake → set contract validity window + start approval chain', + "Staff accept intake → set contract validity window + start approval chain", }) async acceptIntake( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: AcceptIntakeDto, @CurrentUser() user: AuthUserPayload, ) { @@ -486,11 +504,11 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/staff/reject') + @Post(":id/staff/reject") @BookingStaff(FREIGHT_PERMS.bookings.reject) - @ApiOperation({ summary: 'Staff final reject' }) + @ApiOperation({ summary: "Staff final reject" }) async staffReject( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: StaffRejectDto, @CurrentUser() user: AuthUserPayload, ) { @@ -502,11 +520,13 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/government-expedite') + @Post(":id/government-expedite") @BookingStaff(FREIGHT_PERMS.bookings.staffAccept) - @ApiOperation({ summary: 'Expedite government booking to PAID / ELIGIBLE for scheduling' }) + @ApiOperation({ + summary: "Expedite government booking to PAID / ELIGIBLE for scheduling", + }) async governmentExpedite( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: AuthUserPayload, ) { const booking = await this.bookingsService.governmentExpedite( @@ -516,16 +536,16 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/approval-steps/:stepId/approve') + @Post(":id/approval-steps/:stepId/approve") @BookingStaff([ FREIGHT_PERMS.bookings.approveLineStaff, FREIGHT_PERMS.bookings.approveDirector, FREIGHT_PERMS.bookings.approveCeo, ]) - @ApiOperation({ summary: 'Approve one approval step in sequence' }) + @ApiOperation({ summary: "Approve one approval step in sequence" }) async approveStep( - @Param('id', ParseUUIDPipe) id: string, - @Param('stepId', ParseUUIDPipe) stepId: string, + @Param("id", ParseUUIDPipe) id: string, + @Param("stepId", ParseUUIDPipe) stepId: string, @Body() dto: ApproveStepDto, @CurrentUser() user: TCurrentUser, ) { @@ -539,12 +559,12 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/approval-steps/:stepId/reject') + @Post(":id/approval-steps/:stepId/reject") @BookingStaff(FREIGHT_PERMS.bookings.rejectApproval) - @ApiOperation({ summary: 'Reject at approval step' }) + @ApiOperation({ summary: "Reject at approval step" }) async rejectStep( - @Param('id', ParseUUIDPipe) id: string, - @Param('stepId', ParseUUIDPipe) stepId: string, + @Param("id", ParseUUIDPipe) id: string, + @Param("stepId", ParseUUIDPipe) stepId: string, @Body() dto: RejectStepDto, @CurrentUser() user: AuthUserPayload, ) { @@ -557,53 +577,53 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/contract/generate') + @Post(":id/contract/generate") @BookingStaff(FREIGHT_PERMS.bookings.generateContract) - @ApiOperation({ summary: 'Generate contract PDF from template' }) - async generateContract(@Param('id', ParseUUIDPipe) id: string) { + @ApiOperation({ summary: "Generate contract PDF from template" }) + async generateContract(@Param("id", ParseUUIDPipe) id: string) { const booking = await this.contractService.generateContract(id); return this.transitionService.enrichBookingResponse(booking); } - @Get(':id/contract/view') + @Get(":id/contract/view") @ApiOkResponse({ type: ContractViewDto }) - @ApiOperation({ summary: 'Contract HTML view for portal and backoffice' }) + @ApiOperation({ summary: "Contract HTML view for portal and backoffice" }) getContractView( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Request() req: { user?: { id?: string; sub?: string } }, ) { const userId = req.user?.id ?? req.user?.sub; return this.contractService.getContractView(id, userId); } - @Get(':id/contract/document') - @ApiOperation({ summary: 'Download contract PDF' }) + @Get(":id/contract/document") + @ApiOperation({ summary: "Download contract PDF" }) async downloadContractDocument( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Res() res: Response, ): Promise { const { stream, record } = await this.contractService.streamContract(id); - res.setHeader('Content-Type', record.mimeType ?? 'application/pdf'); + res.setHeader("Content-Type", record.mimeType ?? "application/pdf"); res.setHeader( - 'Content-Disposition', + "Content-Disposition", `attachment; filename="${record.name}"`, ); stream.pipe(res); } - @Get(':id/contract') - @ApiOperation({ summary: 'Download contract file (alias)' }) + @Get(":id/contract") + @ApiOperation({ summary: "Download contract file (alias)" }) async downloadContract( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Res() res: Response, ): Promise { return this.downloadContractDocument(id, res); } - @Post(':id/contract/sign') - @ApiOperation({ summary: 'Apply digital signature (customer or staff)' }) + @Post(":id/contract/sign") + @ApiOperation({ summary: "Apply digital signature (customer or staff)" }) async signContract( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: SignContractDto, @Request() req: { user?: { id?: string; sub?: string }; ip?: string }, ) { @@ -615,28 +635,28 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Get(':id/contract/signatures') - @ApiOperation({ summary: 'List contract signatures' }) - getContractSignatures(@Param('id', ParseUUIDPipe) id: string) { + @Get(":id/contract/signatures") + @ApiOperation({ summary: "List contract signatures" }) + getContractSignatures(@Param("id", ParseUUIDPipe) id: string) { return this.contractService.getSignatures(id); } - @Get(':id/summary') - @ApiOperation({ summary: 'Contract summary string for dashboard' }) - getSummary(@Param('id', ParseUUIDPipe) id: string) { + @Get(":id/summary") + @ApiOperation({ summary: "Contract summary string for dashboard" }) + getSummary(@Param("id", ParseUUIDPipe) id: string) { return this.contractService.getSummary(id); } - @Post(':id/customer/sign') + @Post(":id/customer/sign") @ApiOperation({ - summary: 'Customer digital signature (deprecated — use POST contract/sign)', + summary: "Customer digital signature (deprecated — use POST contract/sign)", }) async customerSign( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: SignContractDto, @Request() req: { user?: { id?: string; sub?: string }; ip?: string }, ) { - const payload: SignContractDto = { ...dto, role: 'CUSTOMER' }; + const payload: SignContractDto = { ...dto, role: "CUSTOMER" }; const booking = await this.contractService.signContract(id, payload, { signerUserId: req.user?.id ?? req.user?.sub, ipAddress: req.ip, @@ -644,20 +664,21 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/marketing/approve') + @Post(":id/marketing/approve") @BookingStaff(FREIGHT_PERMS.bookings.signStaff) @ApiOperation({ - summary: 'Staff contract signature and fully execute (use contract/sign STAFF preferred)', + summary: + "Staff contract signature and fully execute (use contract/sign STAFF preferred)", }) async marketingApprove( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: SignContractDto, @CurrentUser() user: AuthUserPayload, @Request() req: { ip?: string }, ) { const payload: SignContractDto = { ...dto, - role: 'STAFF', + role: "STAFF", }; const booking = await this.contractService.signContract(id, payload, { signerUserId: resolveAuthUserId(user), @@ -666,48 +687,48 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/operations/start-transit') + @Post(":id/operations/start-transit") @BookingStaff(FREIGHT_PERMS.bookings.operations) - @ApiOperation({ summary: 'Mark in transit' }) - async startTransit(@Param('id', ParseUUIDPipe) id: string) { + @ApiOperation({ summary: "Mark in transit" }) + async startTransit(@Param("id", ParseUUIDPipe) id: string) { const booking = await this.transitionService.startTransit(id); return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/operations/complete') + @Post(":id/operations/complete") @BookingStaff(FREIGHT_PERMS.bookings.operations) - @ApiOperation({ summary: 'Mark completed' }) - async complete(@Param('id', ParseUUIDPipe) id: string) { + @ApiOperation({ summary: "Mark completed" }) + async complete(@Param("id", ParseUUIDPipe) id: string) { const booking = await this.transitionService.complete(id); return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/cancel') + @Post(":id/cancel") @BookingStaff(FREIGHT_PERMS.bookings.cancel) - @ApiOperation({ summary: 'Cancel booking' }) + @ApiOperation({ summary: "Cancel booking" }) async cancel( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: CancelBookingDto, ) { const booking = await this.transitionService.cancel(id, dto.reason); return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/consolidation') - @ApiOperation({ summary: 'Request freight consolidation' }) - requestConsolidation(@Param('id', ParseUUIDPipe) id: string) { + @Post(":id/consolidation") + @ApiOperation({ summary: "Request freight consolidation" }) + requestConsolidation(@Param("id", ParseUUIDPipe) id: string) { return this.bookingsService.requestConsolidation(id); } - @Delete(':id/consolidation') - @ApiOperation({ summary: 'Remove consolidation pairing' }) - removeConsolidation(@Param('id', ParseUUIDPipe) id: string) { + @Delete(":id/consolidation") + @ApiOperation({ summary: "Remove consolidation pairing" }) + removeConsolidation(@Param("id", ParseUUIDPipe) id: string) { return this.bookingsService.removeConsolidation(id); } - @Get(':id/consolidation') - @ApiOperation({ summary: 'Get consolidation details' }) - getConsolidationDetails(@Param('id', ParseUUIDPipe) id: string) { + @Get(":id/consolidation") + @ApiOperation({ summary: "Get consolidation details" }) + getConsolidationDetails(@Param("id", ParseUUIDPipe) id: string) { return this.bookingsService.getConsolidationDetails(id); } } 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 5d7e3b2c9..4cd5d10df 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -1,44 +1,44 @@ -import { Module, forwardRef } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { ExchangeModule, ExchangeOptions } from '@edr/api-common'; +import { Module, forwardRef } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { ExchangeModule, ExchangeOptions } from "@edr/api-common"; // import { CustomersModule } from '../customers/customers.module'; -import { CompaniesModule } from '../companies/companies.module'; -import { FilesModule } from '../files/files.module'; -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 { 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'; -import { BookingApprovalStep } from './entities/booking-approval-step.entity'; -import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; -import { BookingDocumentReview } from './entities/booking-document-review.entity'; -import { BookingContainer } from './entities/booking-container.entity'; -import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity'; -import { BookingContractSignature } from './entities/booking-contract-signature.entity'; -import { BookingReviewNote } from './entities/booking-review-note.entity'; -import { Booking } from './entities/booking.entity'; -import { BookingContainerAllocation } from './entities/booking-container-allocation.entity'; -import { ContractPdfService } from '../../contracts/contract-pdf.service'; -import { ContractPricingScheduleBuilder } from '../../contracts/contract-pricing-schedule.builder'; -import { ContractRendererService } from '../../contracts/contract-renderer.service'; -import { ContractTemplateResolver } from '../../contracts/contract-template.resolver'; -import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder'; -import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; +import { CompaniesModule } from "../companies/companies.module"; +import { FilesModule } from "../files/files.module"; +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 { 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"; +import { BookingApprovalStep } from "./entities/booking-approval-step.entity"; +import { BookingCargoModifier } from "./entities/booking-cargo-modifier.entity"; +import { BookingDocumentReview } from "./entities/booking-document-review.entity"; +import { BookingContainer } from "./entities/booking-container.entity"; +import { BookingRateSnapshot } from "./entities/booking-rate-snapshot.entity"; +import { BookingContractSignature } from "./entities/booking-contract-signature.entity"; +import { BookingReviewNote } from "./entities/booking-review-note.entity"; +import { Booking } from "./entities/booking.entity"; +import { BookingContainerAllocation } from "./entities/booking-container-allocation.entity"; +import { ContractPdfService } from "../../contracts/contract-pdf.service"; +import { ContractPricingScheduleBuilder } from "../../contracts/contract-pricing-schedule.builder"; +import { ContractRendererService } from "../../contracts/contract-renderer.service"; +import { ContractTemplateResolver } from "../../contracts/contract-template.resolver"; +import { ContractViewModelBuilder } from "../../contracts/contract-view-model.builder"; +import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module"; @Module({ imports: [ @@ -66,7 +66,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu ExchangeModule.forRootAsync({ inject: [ConfigService], useFactory: (config: ConfigService): ExchangeOptions => - config.get('app.cbeExchange') ?? {}, + config.get("app.cbeExchange") ?? {}, }), ], controllers: [BookingsController, PayController, BookingPaymentController], @@ -86,6 +86,11 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu ContractRendererService, ContractPdfService, ], - exports: [BookingsService, BookingsRepository, BookingPricingService, BookingInvoiceService], + exports: [ + BookingsService, + BookingsRepository, + BookingPricingService, + BookingInvoiceService, + ], }) -export class BookingsModule {} +export class BookingsModule { } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index 5cd06091a..9e974b31e 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -110,6 +110,7 @@ describe('BookingBatchService — PAID reconcile', () => { notifier as never, { addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never, trainSchedulingService as never, + { syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never, ); }); 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 f31d8561d..112c044fc 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 @@ -4,22 +4,24 @@ import { Logger, NotFoundException, OnModuleInit, -} from '@nestjs/common'; -import { InjectDataSource } from '@nestjs/typeorm'; -import { Cron, SchedulerRegistry } from '@nestjs/schedule'; -import { DataSource } from 'typeorm'; +} from "@nestjs/common"; +import { InjectDataSource } from "@nestjs/typeorm"; +import { Cron, SchedulerRegistry } from "@nestjs/schedule"; +import { DataSource } from "typeorm"; +import { Freight } from "@edr/types"; -import { Booking } from '../bookings/entities/booking.entity'; -import { BookingsRepository } from '../bookings/bookings.repository'; -import { Locomotive } from '../locomotives/entities/locomotive.entity'; -import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; -import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; -import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; -import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository'; -import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity'; -import { BookingNotifierService } from './booking-notifier.service'; -import { TrainSchedulingService } from './train-scheduling.service'; -import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util'; +import { BillingService } from "../billing/billing.service"; +import { Booking } from "../bookings/entities/booking.entity"; +import { BookingsRepository } from "../bookings/bookings.repository"; +import { Locomotive } from "../locomotives/entities/locomotive.entity"; +import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity"; +import { TrainScheduleBooking } from "../train-schedules/entities/train-schedule-booking.entity"; +import { TrainSchedulesRepository } from "../train-schedules/train-schedules.repository"; +import { TrainScheduleBookingsRepository } from "../train-schedules/train-schedule-bookings.repository"; +import { TrainSchedulingGlobalRules } from "./entities/train-scheduling-global-rules.entity"; +import { BookingNotifierService } from "./booking-notifier.service"; +import { TrainSchedulingService } from "./train-scheduling.service"; +import { eatDay, groupBookingsIntoBoardWindows } from "./batch-window.util"; import { BATCH_CRON, BATCH_TIMEZONE, @@ -27,13 +29,13 @@ import { DEFAULT_CONTAINER_WAGON_LENGTH_METERS, DEFAULT_WAGONS_PER_BOOKING, PAYMENT_WINDOW_MS, -} from './booking-batch.constants'; +} from "./booking-batch.constants"; import { bookingTrainLengthMeters, deriveTrainCapacityFromLocomotive, wagonTypeDimensionsFromEntity, -} from './train-capacity.util'; -import { WagonType } from '../wagon-types/entities/wagon-type.entity'; +} from "./train-capacity.util"; +import { WagonType } from "../wagon-types/entities/wagon-type.entity"; /** A train's remaining capacity along the three physical limits the batch enforces. */ interface Capacity { @@ -53,12 +55,12 @@ interface RouteDayGroup { type WagonLengths = { container: number; bulk: number }; export type BatchBoardBookingState = - | 'ALLOCATED' - | 'SELECTED_FOR_BATCH' - | 'READY' - | 'WAITING' - | 'PENDING_CONTRACT' - | 'EXPIRED'; + | "ALLOCATED" + | "SELECTED_FOR_BATCH" + | "READY" + | "WAITING" + | "PENDING_CONTRACT" + | "EXPIRED"; export interface BatchBoardBooking { id: string; @@ -73,10 +75,10 @@ export interface BatchBoardBooking { } export type BookingAllocationStatus = - | 'NOT_ATTEMPTED' - | 'ASSIGNED' - | 'DEFERRED' - | 'FAILED'; + | "NOT_ATTEMPTED" + | "ASSIGNED" + | "DEFERRED" + | "FAILED"; export interface BatchBoardBookingDetail extends BatchBoardBooking { fullyExecutedAt: string | null; @@ -114,9 +116,9 @@ export interface BatchBoardScheduleDetail { scheduleDate: string | null; status: string; bookingWindowStatus: string; - locomotive: BatchBoardSchedule['locomotive']; - capacity: BatchBoardSchedule['capacity']; - counts: BatchBoardSchedule['counts']; + locomotive: BatchBoardSchedule["locomotive"]; + capacity: BatchBoardSchedule["capacity"]; + counts: BatchBoardSchedule["counts"]; windows: BatchWindowGroup[]; pendingContract: BatchWindowGroup; allocationViolations: string[]; @@ -179,7 +181,8 @@ export class BookingBatchService implements OnModuleInit { private readonly notifier: BookingNotifierService, private readonly scheduler: SchedulerRegistry, private readonly trainSchedulingService: TrainSchedulingService, - ) {} + private readonly billing: BillingService, + ) { } /** On boot, reconcile OPEN route-days and re-arm settle timers. */ async onModuleInit(): Promise { @@ -195,10 +198,10 @@ export class BookingBatchService implements OnModuleInit { } const reserved = await this.dataSource .getRepository(Booking) - .createQueryBuilder('b') - .select('DISTINCT b.train_schedule_id', 'scheduleId') + .createQueryBuilder("b") + .select("DISTINCT b.train_schedule_id", "scheduleId") .where(`b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`) - .andWhere('b.train_schedule_id IS NOT NULL') + .andWhere("b.train_schedule_id IS NOT NULL") .getRawMany<{ scheduleId: string }>(); for (const { scheduleId } of reserved) this.armSettle(scheduleId); } @@ -276,7 +279,7 @@ export class BookingBatchService implements OnModuleInit { /** Distinct (origin, destination, EAT day) groups across all OPEN schedules. */ private async openRouteDayGroups(): Promise { const open = await this.trainSchedulesRepository.findAll({ - where: { bookingWindowStatus: 'OPEN' }, + where: { bookingWindowStatus: "OPEN" }, }); const groups = new Map(); for (const s of open) { @@ -310,25 +313,29 @@ export class BookingBatchService implements OnModuleInit { if (!booking?.trainScheduleId) return; const isBatchPaid = - booking.status === 'SELECTED_FOR_BATCH' || - booking.status === 'AWAITING_PAYMENT' || - booking.status === 'PAID' || - booking.paymentStatus === 'PAID'; + booking.status === "SELECTED_FOR_BATCH" || + booking.status === "AWAITING_PAYMENT" || + booking.status === "PAID" || + booking.paymentStatus === "PAID"; if (!isBatchPaid) return; - if (booking.status === 'SELECTED_FOR_BATCH' || booking.status === 'AWAITING_PAYMENT') { + if ( + booking.status === "SELECTED_FOR_BATCH" || + booking.status === "AWAITING_PAYMENT" + ) { await this.dataSource .getRepository(Booking) - .update(bookingId, { paymentStatus: 'PAID', status: 'PAID' }); - } else if (booking.paymentStatus !== 'PAID') { + .update(bookingId, { paymentStatus: "PAID", status: "PAID" }); + } else if (booking.paymentStatus !== "PAID") { await this.dataSource .getRepository(Booking) - .update(bookingId, { paymentStatus: 'PAID' }); + .update(bookingId, { paymentStatus: "PAID" }); } - const linked = await this.trainScheduleBookingsRepository.existsForBooking(bookingId); + const linked = + await this.trainScheduleBookingsRepository.existsForBooking(bookingId); if (!linked) { - await this.allocate(booking.trainScheduleId, booking, 'paid'); + await this.allocate(booking.trainScheduleId, booking, "paid"); this.logger.log( `Linked PAID booking ${booking.reference ?? bookingId} to schedule ${booking.trainScheduleId}`, ); @@ -338,7 +345,7 @@ export class BookingBatchService implements OnModuleInit { booking.trainScheduleId, ); if (schedule && (await this.remainingWagons(schedule)) <= 0) { - await this.setWindow(booking.trainScheduleId, 'FULL'); + await this.setWindow(booking.trainScheduleId, "FULL"); } const result = await this.trainSchedulingService.tryAutoWagonAllocation( @@ -349,7 +356,11 @@ export class BookingBatchService implements OnModuleInit { `Wagon allocation for ${booking.reference ?? bookingId}: ${result.assignedBookingIds.length} assigned`, ); } - if (result.issues.some((i) => i.bookingId === bookingId && i.status !== 'ASSIGNED')) { + if ( + result.issues.some( + (i) => i.bookingId === bookingId && i.status !== "ASSIGNED", + ) + ) { const issue = result.issues.find((i) => i.bookingId === bookingId); this.logger.warn( `Wagon allocation issue for ${booking.reference ?? bookingId}: ${issue?.issue ?? issue?.status}`, @@ -364,9 +375,10 @@ export class BookingBatchService implements OnModuleInit { /** Link PAID bookings that have no train_schedule_bookings row (cron backstop). */ async reconcilePaidUnlinked(scheduleId: string): Promise { - const unlinked = await this.bookingsRepository.findPaidUnlinkedForSchedule(scheduleId); + const unlinked = + await this.bookingsRepository.findPaidUnlinkedForSchedule(scheduleId); for (const booking of unlinked) { - await this.allocate(scheduleId, booking, 'paid'); + await this.allocate(scheduleId, booking, "paid"); this.logger.log( `Reconciled PAID booking ${booking.reference ?? booking.id} → schedule ${scheduleId}`, ); @@ -375,7 +387,7 @@ export class BookingBatchService implements OnModuleInit { // ---- cron entry point ----------------------------------------------------- - @Cron(BATCH_CRON, { name: 'booking-batch-fill', timeZone: BATCH_TIMEZONE }) + @Cron(BATCH_CRON, { name: "booking-batch-fill", timeZone: BATCH_TIMEZONE }) async runBatchFill(): Promise { const groups = await this.openRouteDayGroups(); this.logger.log(`Batch fill: ${groups.length} OPEN route-day group(s).`); @@ -405,7 +417,7 @@ export class BookingBatchService implements OnModuleInit { destinationStation: true, route: true, }, - order: { scheduledDepartureDate: 'ASC' }, + order: { scheduledDepartureDate: "ASC" }, }); const wagonLengths = await this.loadWagonLengths(); @@ -413,7 +425,7 @@ export class BookingBatchService implements OnModuleInit { const board: BatchBoardSchedule[] = []; for (const s of schedules) { - if (s.status === 'ARRIVED' || s.status === 'CANCELLED') continue; + if (s.status === "ARRIVED" || s.status === "CANCELLED") continue; const links = await linkRepo.find({ where: { trainScheduleId: s.id } }); const linkedIds = new Set(links.map((l) => l.bookingId)); @@ -425,13 +437,15 @@ export class BookingBatchService implements OnModuleInit { id: b.id, reference: b.reference ?? b.id.slice(0, 8), company: b.isGovernment - ? (b.governmentInstitution ?? 'Government') - : (b.company?.name ?? '—'), + ? (b.governmentInstitution ?? "Government") + : (b.company?.name ?? "—"), isGovernment: Boolean(b.isGovernment), wagons: need.wagons, weightTons: need.weightTons, lengthMeters: need.lengthMeters, - paymentDeadline: b.paymentDeadline ? b.paymentDeadline.toISOString() : null, + paymentDeadline: b.paymentDeadline + ? b.paymentDeadline.toISOString() + : null, state: this.boardState(b, linkedIds.has(b.id)), }; }); @@ -442,11 +456,15 @@ export class BookingBatchService implements OnModuleInit { } /** Schedule-level batch board with EAT 3h windows grouped by fullyExecutedAt. */ - async getBatchBoardDetail(scheduleId: string): Promise { - const s = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); - if (!s) throw new NotFoundException(`Train schedule ${scheduleId} not found`); - if (s.status === 'ARRIVED' || s.status === 'CANCELLED') { - throw new BadRequestException('Schedule is no longer active'); + async getBatchBoardDetail( + scheduleId: string, + ): Promise { + const s = + await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!s) + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + if (s.status === "ARRIVED" || s.status === "CANCELLED") { + throw new BadRequestException("Schedule is no longer active"); } const wagonLengths = await this.loadWagonLengths(); @@ -456,12 +474,18 @@ export class BookingBatchService implements OnModuleInit { const bookings = await this.bookingsRepository.findAllBySchedule(s.id); let allocationPreview: Awaited< - ReturnType + ReturnType >; try { - allocationPreview = await this.trainSchedulingService.previewAllocationForSchedule(s.id); + allocationPreview = + await this.trainSchedulingService.previewAllocationForSchedule(s.id); } catch { - allocationPreview = { assignedBookingIds: [], deferred: [], issues: [], violations: [] }; + allocationPreview = { + assignedBookingIds: [], + deferred: [], + issues: [], + violations: [], + }; } const allocationByBooking = new Map( allocationPreview.issues.map((i) => [i.bookingId, i]), @@ -474,17 +498,23 @@ export class BookingBatchService implements OnModuleInit { id: b.id, reference: b.reference ?? b.id.slice(0, 8), company: b.isGovernment - ? (b.governmentInstitution ?? 'Government') - : (b.company?.name ?? '—'), + ? (b.governmentInstitution ?? "Government") + : (b.company?.name ?? "—"), isGovernment: Boolean(b.isGovernment), wagons: need.wagons, weightTons: need.weightTons, lengthMeters: need.lengthMeters, - paymentDeadline: b.paymentDeadline ? b.paymentDeadline.toISOString() : null, + paymentDeadline: b.paymentDeadline + ? b.paymentDeadline.toISOString() + : null, state: this.boardState(b, linkedIds.has(b.id)), - fullyExecutedAt: b.fullyExecutedAt ? b.fullyExecutedAt.toISOString() : null, - selectedForBatchAt: b.selectedForBatchAt ? b.selectedForBatchAt.toISOString() : null, - allocationStatus: alloc?.status ?? 'NOT_ATTEMPTED', + fullyExecutedAt: b.fullyExecutedAt + ? b.fullyExecutedAt.toISOString() + : null, + selectedForBatchAt: b.selectedForBatchAt + ? b.selectedForBatchAt.toISOString() + : null, + allocationStatus: alloc?.status ?? "NOT_ATTEMPTED", allocationIssue: alloc?.issue ?? null, }; }); @@ -514,11 +544,11 @@ export class BookingBatchService implements OnModuleInit { const countFor = (bookingsInWindow: BatchBoardBookingDetail[]) => { const counts = emptyCounts(); for (const b of bookingsInWindow) { - if (b.state === 'ALLOCATED') counts.allocated += 1; - else if (b.state === 'SELECTED_FOR_BATCH') counts.selectedForBatch += 1; - else if (b.state === 'READY') counts.ready += 1; - else if (b.state === 'WAITING') counts.waiting += 1; - else if (b.state === 'EXPIRED') counts.expired += 1; + if (b.state === "ALLOCATED") counts.allocated += 1; + else if (b.state === "SELECTED_FOR_BATCH") counts.selectedForBatch += 1; + else if (b.state === "READY") counts.ready += 1; + else if (b.state === "WAITING") counts.waiting += 1; + else if (b.state === "EXPIRED") counts.expired += 1; else counts.pendingContract += 1; } return counts; @@ -526,7 +556,7 @@ export class BookingBatchService implements OnModuleInit { const windows: BatchWindowGroup[] = []; for (const [key, bucket] of windowBuckets) { - if (key === 'pending-contract' || !bucket.window) continue; + if (key === "pending-contract" || !bucket.window) continue; const w = bucket.window; windows.push({ key: w.key, @@ -539,44 +569,51 @@ export class BookingBatchService implements OnModuleInit { bookings: bucket.items, }); } - windows.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime()); + windows.sort( + (a, b) => new Date(a.start).getTime() - new Date(b.start).getTime(), + ); - const pendingBookings = windowBuckets.get('pending-contract')?.items ?? []; + const pendingBookings = windowBuckets.get("pending-contract")?.items ?? []; return { scheduleId: s.id, trainNumber: s.trainNumber ?? null, routeName: s.route?.name ?? null, origin: s.originStation?.label ?? s.originStation?.code ?? null, - destination: s.destinationStation?.label ?? s.destinationStation?.code ?? null, - scheduleDate: s.scheduledDepartureDate ? s.scheduledDepartureDate.toISOString() : null, + destination: + s.destinationStation?.label ?? s.destinationStation?.code ?? null, + scheduleDate: s.scheduledDepartureDate + ? s.scheduledDepartureDate.toISOString() + : null, status: s.status, bookingWindowStatus: s.bookingWindowStatus, locomotive: loco ? { - code: loco.code, - name: loco.name ?? null, - maxPullWeightTons: Number(loco.maxPullWeightTons), - maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), - } + code: loco.code, + name: loco.name ?? null, + maxPullWeightTons: Number(loco.maxPullWeightTons), + maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), + } : null, capacity: this.computeBoardCapacity(items, loco), counts: { - allocated: items.filter((i) => i.state === 'ALLOCATED').length, - selectedForBatch: items.filter((i) => i.state === 'SELECTED_FOR_BATCH').length, - ready: items.filter((i) => i.state === 'READY').length, - waiting: items.filter((i) => i.state === 'WAITING').length, - pendingContract: items.filter((i) => i.state === 'PENDING_CONTRACT').length, - expired: items.filter((i) => i.state === 'EXPIRED').length, + allocated: items.filter((i) => i.state === "ALLOCATED").length, + selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH") + .length, + ready: items.filter((i) => i.state === "READY").length, + waiting: items.filter((i) => i.state === "WAITING").length, + pendingContract: items.filter((i) => i.state === "PENDING_CONTRACT") + .length, + expired: items.filter((i) => i.state === "EXPIRED").length, }, windows, pendingContract: { - key: 'pending-contract', - label: 'Pending contract', - date: '', - dateLabel: '', - start: '', - end: '', + key: "pending-contract", + label: "Pending contract", + date: "", + dateLabel: "", + start: "", + end: "", counts: countFor(pendingBookings), bookings: pendingBookings, }, @@ -597,17 +634,21 @@ export class BookingBatchService implements OnModuleInit { lengthMeters: number; }>, loco: Locomotive | null, - ): BatchBoardSchedule['capacity'] { - const allocated = items.filter((i) => i.state === 'ALLOCATED'); + ): BatchBoardSchedule["capacity"] { + const allocated = items.filter((i) => i.state === "ALLOCATED"); const committed = items.filter( - (i) => i.state === 'ALLOCATED' || i.state === 'SELECTED_FOR_BATCH', + (i) => i.state === "ALLOCATED" || i.state === "SELECTED_FOR_BATCH", ); return { allocatedWagons: allocated.reduce((sum, i) => sum + i.wagons, 0), allocatedLengthMeters: - Math.round(allocated.reduce((sum, i) => sum + i.lengthMeters, 0) * 100) / 100, + Math.round( + allocated.reduce((sum, i) => sum + i.lengthMeters, 0) * 100, + ) / 100, maxLengthMeters: loco ? Number(loco.maxTrainLengthMeters) : null, - usedWeightTons: Math.round(committed.reduce((sum, i) => sum + i.weightTons, 0) * 100) / 100, + usedWeightTons: + Math.round(committed.reduce((sum, i) => sum + i.weightTons, 0) * 100) / + 100, maxWeightTons: loco ? Number(loco.maxPullWeightTons) : null, }; } @@ -623,51 +664,66 @@ export class BookingBatchService implements OnModuleInit { trainNumber: s.trainNumber ?? null, routeName: s.route?.name ?? null, origin: s.originStation?.label ?? s.originStation?.code ?? null, - destination: s.destinationStation?.label ?? s.destinationStation?.code ?? null, - scheduleDate: s.scheduledDepartureDate ? s.scheduledDepartureDate.toISOString() : null, + destination: + s.destinationStation?.label ?? s.destinationStation?.code ?? null, + scheduleDate: s.scheduledDepartureDate + ? s.scheduledDepartureDate.toISOString() + : null, status: s.status, bookingWindowStatus: s.bookingWindowStatus, locomotive: loco ? { - code: loco.code, - name: loco.name ?? null, - maxPullWeightTons: Number(loco.maxPullWeightTons), - maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), - } + code: loco.code, + name: loco.name ?? null, + maxPullWeightTons: Number(loco.maxPullWeightTons), + maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), + } : null, capacity: this.computeBoardCapacity(items, loco), counts: { - allocated: items.filter((i) => i.state === 'ALLOCATED').length, - selectedForBatch: items.filter((i) => i.state === 'SELECTED_FOR_BATCH').length, - ready: items.filter((i) => i.state === 'READY').length, - waiting: items.filter((i) => i.state === 'WAITING').length, - pendingContract: items.filter((i) => i.state === 'PENDING_CONTRACT').length, - expired: items.filter((i) => i.state === 'EXPIRED').length, + allocated: items.filter((i) => i.state === "ALLOCATED").length, + selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH") + .length, + ready: items.filter((i) => i.state === "READY").length, + waiting: items.filter((i) => i.state === "WAITING").length, + pendingContract: items.filter((i) => i.state === "PENDING_CONTRACT") + .length, + expired: items.filter((i) => i.state === "EXPIRED").length, }, bookings: items.slice(0, 3), }; } - private boardState(booking: Booking, linked: boolean): BatchBoardBookingState { - if (linked) return 'ALLOCATED'; - if (booking.status === 'SELECTED_FOR_BATCH' || booking.status === 'AWAITING_PAYMENT') { - return 'SELECTED_FOR_BATCH'; + private boardState( + booking: Booking, + linked: boolean, + ): BatchBoardBookingState { + if (linked) return "ALLOCATED"; + if ( + booking.status === "SELECTED_FOR_BATCH" || + booking.status === "AWAITING_PAYMENT" + ) { + return "SELECTED_FOR_BATCH"; } - if (booking.status === 'EXPIRED') return 'EXPIRED'; - if (booking.status === 'FULLY_EXECUTED' && booking.fullyExecutedAt) return 'READY'; - if (booking.status === 'PAID') return 'WAITING'; - return 'PENDING_CONTRACT'; + if (booking.status === "EXPIRED") return "EXPIRED"; + if (booking.status === "FULLY_EXECUTED" && booking.fullyExecutedAt) + return "READY"; + if (booking.status === "PAID") return "WAITING"; + return "PENDING_CONTRACT"; } // ---- core fill ------------------------------------------------------------ /** Fill one schedule from its priority-ordered pool until full. */ async fillSchedule(scheduleId: string): Promise { - const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); - if (!schedule || schedule.bookingWindowStatus !== 'OPEN') return; + const schedule = + await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule || schedule.bookingWindowStatus !== "OPEN") return; const locomotive = schedule.trainSet?.locomotive; if (!schedule.trainSetId || !locomotive) { - this.logger.warn(`Schedule ${scheduleId} has no locomotive/train set — skipped.`); + this.logger.warn( + `Schedule ${scheduleId} has no locomotive/train set — skipped.`, + ); return; } @@ -677,7 +733,7 @@ export class BookingBatchService implements OnModuleInit { await this.syncScheduleMaxWagons(schedule, locomotive, rules); let budget = await this.remainingCapacity(schedule, limits, wagonLengths); if (budget.wagons <= 0) { - await this.setWindow(scheduleId, 'FULL'); + await this.setWindow(scheduleId, "FULL"); return; } @@ -689,7 +745,12 @@ export class BookingBatchService implements OnModuleInit { if (!this.fits(need, budget)) { if (booking.isGovernment) { - budget = await this.preemptForGovernment(scheduleId, need, budget, wagonLengths); + budget = await this.preemptForGovernment( + scheduleId, + need, + budget, + wagonLengths, + ); if (!this.fits(need, budget)) continue; // still doesn't fit even after preempt } else { continue; // skip a booking that exceeds weight/length/wagons, try the next @@ -697,7 +758,7 @@ export class BookingBatchService implements OnModuleInit { } if (booking.isGovernment) { - await this.allocate(scheduleId, booking, 'gov'); + await this.allocate(scheduleId, booking, "gov"); } else { await this.reserve(booking, scheduleId); armed = true; @@ -706,7 +767,7 @@ export class BookingBatchService implements OnModuleInit { if (budget.wagons <= 0) break; // no wagon slots left — nothing more can board } - if (budget.wagons <= 0) await this.setWindow(scheduleId, 'FULL'); + if (budget.wagons <= 0) await this.setWindow(scheduleId, "FULL"); if (armed) this.armSettle(scheduleId); void this.triggerWagonAllocation(scheduleId); } @@ -732,13 +793,14 @@ export class BookingBatchService implements OnModuleInit { const scheduleIds = bookable .filter( (s) => - s.bookingWindowStatus === 'OPEN' && + s.bookingWindowStatus === "OPEN" && s.scheduleDate != null && eatDay(new Date(s.scheduleDate)) === day, ) .sort( (a, b) => - new Date(a.scheduleDate).getTime() - new Date(b.scheduleDate).getTime(), + new Date(a.scheduleDate).getTime() - + new Date(b.scheduleDate).getTime(), ) .map((s) => s.id); @@ -750,15 +812,22 @@ export class BookingBatchService implements OnModuleInit { // Live per-schedule budget + arm flag, in departure order. const trains: Array<{ id: string; budget: Capacity; armed: boolean }> = []; for (const id of scheduleIds) { - const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id); + const schedule = + await this.trainSchedulesRepository.findByIdWithFullGraph(id); const locomotive = schedule?.trainSet?.locomotive; if (!schedule || !schedule.trainSetId || !locomotive) { - this.logger.warn(`Schedule ${id} has no locomotive/train set — skipped.`); + this.logger.warn( + `Schedule ${id} has no locomotive/train set — skipped.`, + ); continue; } const limits = await this.capacityLimits(locomotive, rules); await this.syncScheduleMaxWagons(schedule, locomotive, rules); - const budget = await this.remainingCapacity(schedule, limits, wagonLengths); + const budget = await this.remainingCapacity( + schedule, + limits, + wagonLengths, + ); trains.push({ id, budget, armed: false }); } if (trains.length === 0) return []; @@ -779,7 +848,12 @@ export class BookingBatchService implements OnModuleInit { // Government booking fits nowhere on its own — try to preempt commercial // on each train (earliest first) until one frees enough room. for (const t of trains) { - t.budget = await this.preemptForGovernment(t.id, need, t.budget, wagonLengths); + t.budget = await this.preemptForGovernment( + t.id, + need, + t.budget, + wagonLengths, + ); if (this.fits(need, t.budget)) { target = t; break; @@ -794,7 +868,7 @@ export class BookingBatchService implements OnModuleInit { } if (booking.isGovernment) { - await this.allocate(target.id, booking, 'gov'); + await this.allocate(target.id, booking, "gov"); } else { await this.reserve(booking, target.id); target.armed = true; @@ -803,7 +877,7 @@ export class BookingBatchService implements OnModuleInit { } for (const t of trains) { - if (t.budget.wagons <= 0) await this.setWindow(t.id, 'FULL'); + if (t.budget.wagons <= 0) await this.setWindow(t.id, "FULL"); if (t.armed) this.armSettle(t.id); void this.triggerWagonAllocation(t.id); } @@ -813,18 +887,20 @@ export class BookingBatchService implements OnModuleInit { /** Durable settle: allocate paid / expire overdue reservations, then top up. */ async settleDueReservations(scheduleId: string): Promise { - const reserved = await this.bookingsRepository.findReservedForSchedule(scheduleId); + const reserved = + await this.bookingsRepository.findReservedForSchedule(scheduleId); const now = Date.now(); let anySettled = false; for (const booking of reserved) { - const paid = booking.paymentStatus === 'PAID' || booking.status === 'PAID'; + const paid = + booking.paymentStatus === "PAID" || booking.status === "PAID"; const expired = booking.paymentDeadline ? booking.paymentDeadline.getTime() <= now : false; if (paid) { - await this.allocate(scheduleId, booking, 'paid'); + await this.allocate(scheduleId, booking, "paid"); anySettled = true; } else if (expired) { await this.expire(booking); @@ -840,17 +916,19 @@ export class BookingBatchService implements OnModuleInit { /** Allocate paid reservations, expire the rest, then top up. */ async settleBatch(scheduleId: string): Promise { this.removeTimeout(scheduleId); - const reserved = await this.bookingsRepository.findReservedForSchedule(scheduleId); + const reserved = + await this.bookingsRepository.findReservedForSchedule(scheduleId); const now = Date.now(); for (const booking of reserved) { - const paid = booking.paymentStatus === 'PAID' || booking.status === 'PAID'; + const paid = + booking.paymentStatus === "PAID" || booking.status === "PAID"; const expired = booking.paymentDeadline ? booking.paymentDeadline.getTime() <= now : true; if (paid) { - await this.allocate(scheduleId, booking, 'paid'); + await this.allocate(scheduleId, booking, "paid"); } else if (expired) { await this.expire(booking); } @@ -862,11 +940,13 @@ export class BookingBatchService implements OnModuleInit { } private triggerWagonAllocation(scheduleId: string): void { - void this.trainSchedulingService.tryAutoWagonAllocation(scheduleId).catch((err) => - this.logger.warn( - `Auto wagon allocation failed for ${scheduleId}: ${(err as Error).message}`, - ), - ); + void this.trainSchedulingService + .tryAutoWagonAllocation(scheduleId) + .catch((err) => + this.logger.warn( + `Auto wagon allocation failed for ${scheduleId}: ${(err as Error).message}`, + ), + ); } // ---- staff override actions ---------------------------------------------- @@ -878,18 +958,20 @@ export class BookingBatchService implements OnModuleInit { .findOne({ where: { id: bookingId } }); if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); if (!booking.trainScheduleId) { - throw new BadRequestException('Booking has no target schedule to allocate to'); + throw new BadRequestException( + "Booking has no target schedule to allocate to", + ); } await this.dataSource .getRepository(Booking) - .update(bookingId, { paymentStatus: 'PAID' }); - await this.allocate(booking.trainScheduleId, booking, 'paid'); + .update(bookingId, { paymentStatus: "PAID" }); + await this.allocate(booking.trainScheduleId, booking, "paid"); const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( booking.trainScheduleId, ); if (schedule && (await this.remainingWagons(schedule)) <= 0) { - await this.setWindow(booking.trainScheduleId, 'FULL'); + await this.setWindow(booking.trainScheduleId, "FULL"); } void this.triggerWagonAllocation(booking.trainScheduleId!); } @@ -898,7 +980,10 @@ export class BookingBatchService implements OnModuleInit { * Re-point a booking to another OPEN same-route schedule (keeps approval/contract + priority). * Used for EXPIRED or full-schedule bookings — no re-approval. */ - async moveToSchedule(bookingId: string, newScheduleId: string): Promise { + async moveToSchedule( + bookingId: string, + newScheduleId: string, + ): Promise { const booking = await this.dataSource .getRepository(Booking) .findOne({ where: { id: bookingId } }); @@ -907,15 +992,20 @@ export class BookingBatchService implements OnModuleInit { const schedule = await this.dataSource .getRepository(TrainSchedule) .findOne({ where: { id: newScheduleId } }); - if (!schedule) throw new NotFoundException(`Train schedule ${newScheduleId} not found`); - if (schedule.bookingWindowStatus !== 'OPEN') { - throw new BadRequestException('Target schedule is not accepting bookings'); + if (!schedule) + throw new NotFoundException(`Train schedule ${newScheduleId} not found`); + if (schedule.bookingWindowStatus !== "OPEN") { + throw new BadRequestException( + "Target schedule is not accepting bookings", + ); } if ( schedule.originStationId !== booking.originYardId || schedule.destinationStationId !== booking.destinationYardId ) { - throw new BadRequestException('Target schedule is not on the booking route'); + throw new BadRequestException( + "Target schedule is not on the booking route", + ); } await this.dataSource.transaction(async (manager) => { @@ -927,15 +1017,15 @@ export class BookingBatchService implements OnModuleInit { ); } const restoredStatus = - booking.status === 'EXPIRED' + booking.status === "EXPIRED" ? booking.isGovernment - ? 'APPROVED' - : 'FULLY_EXECUTED' + ? "APPROVED" + : "FULLY_EXECUTED" : booking.status; await manager.getRepository(Booking).update(bookingId, { trainScheduleId: newScheduleId, status: restoredStatus, - schedulingStatus: 'ELIGIBLE', + schedulingStatus: "ELIGIBLE", paymentDeadline: null, selectedForBatchAt: null, } as never); @@ -949,7 +1039,8 @@ export class BookingBatchService implements OnModuleInit { .findOne({ where: { id: bookingId } }); if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); await this.expire(booking); - if (booking.trainScheduleId) await this.fillSchedule(booking.trainScheduleId); + if (booking.trainScheduleId) + await this.fillSchedule(booking.trainScheduleId); } // ---- mutations ------------------------------------------------------------ @@ -967,11 +1058,18 @@ export class BookingBatchService implements OnModuleInit { const deadline = new Date(now.getTime() + PAYMENT_WINDOW_MS); await this.bookingsRepository.update(booking.id, { trainScheduleId: scheduleId, - status: 'SELECTED_FOR_BATCH', + status: "SELECTED_FOR_BATCH", selectedForBatchAt: now, paymentDeadline: deadline, } as never); booking.trainScheduleId = scheduleId; + // The invoice was generated at booking creation/approval, before this pay + // window opened — refresh its printed due date to the real deadline. + await this.billing.syncPayableDueDate( + Freight.InvoiceSource.Booking, + booking.id, + deadline, + ); await this.notifier.payNow(booking, deadline); } @@ -979,13 +1077,14 @@ export class BookingBatchService implements OnModuleInit { private async allocate( scheduleId: string, booking: Booking, - reason: 'paid' | 'gov', + reason: "paid" | "gov", ): Promise { await this.dataSource.transaction(async (manager) => { - const exists = await this.trainScheduleBookingsRepository.existsForBooking( - booking.id, - manager, - ); + const exists = + await this.trainScheduleBookingsRepository.existsForBooking( + booking.id, + manager, + ); if (!exists) { await this.trainScheduleBookingsRepository.createMany( [{ trainScheduleId: scheduleId, bookingId: booking.id }], @@ -993,8 +1092,8 @@ export class BookingBatchService implements OnModuleInit { ); } await manager.getRepository(Booking).update(booking.id, { - status: reason === 'paid' ? 'PAID' : booking.status, - schedulingStatus: 'SCHEDULED', + status: reason === "paid" ? "PAID" : booking.status, + schedulingStatus: "SCHEDULED", scheduledAt: new Date(), paymentDeadline: null, selectedForBatchAt: null, @@ -1012,12 +1111,16 @@ export class BookingBatchService implements OnModuleInit { private async expire(booking: Booking): Promise { await this.bookingsRepository.update(booking.id, { trainScheduleId: null, - status: 'EXPIRED', - schedulingStatus: 'ELIGIBLE', + status: "EXPIRED", + schedulingStatus: "ELIGIBLE", paymentDeadline: null, selectedForBatchAt: null, } as never); booking.trainScheduleId = null; + // 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); this.notifier.expired(booking); } @@ -1035,7 +1138,9 @@ export class BookingBatchService implements OnModuleInit { await this.bookingsRepository.findReservedForSchedule(scheduleId) ).filter((b) => !b.isGovernment); const allocatedCommercial = - await this.bookingsRepository.findAllocatedCommercialForSchedule(scheduleId); + await this.bookingsRepository.findAllocatedCommercialForSchedule( + scheduleId, + ); // lowest priority first; reserved are cheaper to free than allocated const candidates = [...reservedCommercial, ...allocatedCommercial].sort( @@ -1052,11 +1157,18 @@ export class BookingBatchService implements OnModuleInit { manager, ); await manager.getRepository(Booking).update(victim.id, { - status: 'EXPIRED', - schedulingStatus: 'ELIGIBLE', + status: "EXPIRED", + schedulingStatus: "ELIGIBLE", paymentDeadline: null, selectedForBatchAt: null, } as never); + // Displaced → EXPIRED: close its open invoice too, so a dead booking + // can't still be paid (mirrors `expire()`; enlisted in this txn). + await this.billing.expirePayable( + Freight.InvoiceSource.Booking, + victim.id, + manager, + ); }); this.notifier.displaced(victim); freed = this.add(freed, this.needFor(victim, wagonLengths)); @@ -1074,7 +1186,10 @@ export class BookingBatchService implements OnModuleInit { (sum, c) => sum + Number(c.quantity ?? 0), 0, ); - return Math.max(DEFAULT_WAGONS_PER_BOOKING, fromContainers || DEFAULT_WAGONS_PER_BOOKING); + return Math.max( + DEFAULT_WAGONS_PER_BOOKING, + fromContainers || DEFAULT_WAGONS_PER_BOOKING, + ); } /** What one booking consumes along all three capacity axes. */ @@ -1161,7 +1276,7 @@ export class BookingBatchService implements OnModuleInit { Array<{ lengthMeters: number; capacityTons: number }> > { const types = await this.dataSource.getRepository(WagonType).find({ - where: [{ code: 'NW5' }, { code: 'CW3' }], + where: [{ code: "NW5" }, { code: "CW3" }], }); if (types.length) return types.map(wagonTypeDimensionsFromEntity); return [ @@ -1172,17 +1287,23 @@ export class BookingBatchService implements OnModuleInit { private async loadWagonLengths(): Promise { const types = await this.dataSource.getRepository(WagonType).find({ - where: [{ code: 'NW5' }, { code: 'CW3' }], + where: [{ code: "NW5" }, { code: "CW3" }], }); - const byCode = new Map(types.map((t) => [t.code, wagonTypeDimensionsFromEntity(t)])); + const byCode = new Map( + types.map((t) => [t.code, wagonTypeDimensionsFromEntity(t)]), + ); return { - container: byCode.get('NW5')?.lengthMeters ?? DEFAULT_CONTAINER_WAGON_LENGTH_METERS, - bulk: byCode.get('CW3')?.lengthMeters ?? DEFAULT_BULK_WAGON_LENGTH_METERS, + container: + byCode.get("NW5")?.lengthMeters ?? + DEFAULT_CONTAINER_WAGON_LENGTH_METERS, + bulk: byCode.get("CW3")?.lengthMeters ?? DEFAULT_BULK_WAGON_LENGTH_METERS, }; } private async loadGlobalRules(): Promise { - return this.dataSource.getRepository(TrainSchedulingGlobalRules).findOne({ where: {} }); + return this.dataSource + .getRepository(TrainSchedulingGlobalRules) + .findOne({ where: {} }); } /** Remaining capacity = hard caps minus what allocated + reserved bookings already use. */ @@ -1194,7 +1315,9 @@ export class BookingBatchService implements OnModuleInit { const allocated = (schedule.scheduleBookings ?? []) .map((sb) => sb.booking) .filter((b): b is Booking => Boolean(b)); - const reserved = await this.bookingsRepository.findReservedForSchedule(schedule.id); + const reserved = await this.bookingsRepository.findReservedForSchedule( + schedule.id, + ); const used = [...allocated, ...reserved].reduce( (acc, b) => this.add(acc, this.needFor(b, wagonLengths)), { wagons: 0, weightTons: 0, lengthMeters: 0 }, @@ -1207,7 +1330,9 @@ export class BookingBatchService implements OnModuleInit { const allocated = (schedule.scheduleBookings ?? []) .map((sb) => sb.booking) .filter((b): b is Booking => Boolean(b)); - const reserved = await this.bookingsRepository.findReservedForSchedule(schedule.id); + const reserved = await this.bookingsRepository.findReservedForSchedule( + schedule.id, + ); const used = allocated.reduce((s, b) => s + this.wagonsFor(b), 0) + reserved.reduce((s, b) => s + this.wagonsFor(b), 0); @@ -1216,7 +1341,7 @@ export class BookingBatchService implements OnModuleInit { private async setWindow( scheduleId: string, - status: 'OPEN' | 'FULL' | 'CLOSED', + status: "OPEN" | "FULL" | "CLOSED", ): Promise { await this.dataSource .getRepository(TrainSchedule) @@ -1233,7 +1358,9 @@ export class BookingBatchService implements OnModuleInit { this.removeTimeout(scheduleId); const handle = setTimeout(() => { void this.settleBatch(scheduleId).catch((err) => - this.logger.error(`settleBatch ${scheduleId} failed: ${(err as Error).message}`), + this.logger.error( + `settleBatch ${scheduleId} failed: ${(err as Error).message}`, + ), ); }, PAYMENT_WINDOW_MS); this.scheduler.addTimeout(this.timeoutName(scheduleId), handle); @@ -1242,7 +1369,7 @@ export class BookingBatchService implements OnModuleInit { private removeTimeout(scheduleId: string): void { const name = this.timeoutName(scheduleId); try { - if (this.scheduler.doesExist('timeout', name)) { + if (this.scheduler.doesExist("timeout", name)) { this.scheduler.deleteTimeout(name); } } catch { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts index 9b8efd9e0..0a56865d5 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts @@ -1,6 +1,7 @@ import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { BillingModule } from '../billing/billing.module'; import { BookingsModule } from '../bookings/bookings.module'; import { Container } from '../container-management/entities/container.entity'; import { LocomotivesModule } from '../locomotives/locomotives.module'; @@ -42,6 +43,7 @@ import { NotificationsModule } from '../notifications/notifications.module'; ImportDjiboutiOperation, ]), forwardRef(() => BookingsModule), + BillingModule, NotificationsModule, LocomotivesModule, WagonTypesModule, diff --git a/apps/edr-freight-api/src/scripts/cmds/index.ts b/apps/edr-freight-api/src/scripts/cmds/index.ts index 369aff010..810945bbb 100644 --- a/apps/edr-freight-api/src/scripts/cmds/index.ts +++ b/apps/edr-freight-api/src/scripts/cmds/index.ts @@ -3,8 +3,10 @@ import type { CommandContext } from "./types"; import { registerSeedTestContracts } from "./seed-test-contracts.cmd"; import { registerSeedTestSchedules } from "./seed-test-schedules.cmd"; +import { registerSeedTestCompany } from "./seed-test-company.cmd"; export function registerCommands(vorpal: Vorpal, ctx: CommandContext): void { registerSeedTestContracts(vorpal, ctx); registerSeedTestSchedules(vorpal, ctx); + registerSeedTestCompany(vorpal, ctx); } diff --git a/apps/edr-freight-api/src/scripts/cmds/seed-test-company.cmd.ts b/apps/edr-freight-api/src/scripts/cmds/seed-test-company.cmd.ts new file mode 100644 index 000000000..bef4ed031 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/cmds/seed-test-company.cmd.ts @@ -0,0 +1,85 @@ +import type Vorpal from "vorpal"; +import { DataSource } from "typeorm"; +import type { CommandContext } from "./types"; +import { Company, CompanyType, CompanyKind, CompanyStatus, CompanyNationality } from "../../modules/companies/entities/company.entity"; +import { CompanyProfile, ProfileStatus, ProfileType } from "../../modules/companies/entities/company-profile.entity"; +import { ExternalProfile } from "../../modules/companies/entities/external-profile.entity"; + +export function registerSeedTestCompany( + vorpal: Vorpal, + ctx: CommandContext, +): void { + vorpal + .command("seed:test-company", "Generate a test company with approved importer/exporter profiles and an external user") + .option("--name ", "Company name (default: Test Company)") + .option("--email ", "Company email (default: company@test.com)") + .option("--tin ", "Tax ID (default: auto-generated TSTxxxxx)") + .action(async function (this: any, args: any) { + const { app } = ctx; + const ds = app.get(DataSource); + + const raw = await ds.query( + `SELECT "tin" FROM "freight"."companies" WHERE "tin" LIKE 'TST%' AND "deleted_at" IS NULL ORDER BY "tin" DESC LIMIT 1`, + ); + let nextTinNum = 1; + if (raw.length > 0) { + const num = parseInt((raw[0] as any).tin.replace("TST", ""), 10); + if (!isNaN(num)) nextTinNum = num + 1; + } + + const name = args.options?.name ?? "Test Company"; + const email = args.options?.email ?? "company@test.com"; + const tin = args.options?.tin ?? `TST${String(nextTinNum).padStart(6, "0")}`; + const userId = `ffffffff-0000-4000-8000-${String(nextTinNum).padStart(12, "0")}`; + + const existing = await ds.getRepository(Company).findOne({ where: { tin } }); + if (existing) { + this.log(`Company with TIN ${tin} already exists (${existing.name})`); + return; + } + + const company = await ds.getRepository(Company).save( + ds.getRepository(Company).create({ + name, + type: CompanyType.Customer, + kind: CompanyKind.Commercial, + status: CompanyStatus.Active, + tin, + country: "Ethiopia", + nationality: CompanyNationality.Ethiopian, + email, + phone: "+251911000000", + address: "Test Address", + }), + ); + this.log(` Created company: ${company.name} (TIN: ${tin})`); + + for (const type of [ProfileType.importer, ProfileType.exporter]) { + await ds.getRepository(CompanyProfile).save( + ds.getRepository(CompanyProfile).create({ + companyId: company.id, + type, + reference: `TST-${type.toUpperCase()}-${String(nextTinNum).padStart(3, "0")}`, + status: ProfileStatus.Active, + }), + ); + this.log(` Created ${type} profile (approved)`); + } + + await ds.getRepository(ExternalProfile).save( + ds.getRepository(ExternalProfile).create({ + userId, + companyId: company.id, + firstName: "Test", + lastName: "User", + isPrimaryContact: true, + activeProfileType: ProfileType.importer, + onboardingCompleted: true, + onboardingStep: "done", + }), + ); + this.log(` Created external profile: Test User (userId: ${userId})`); + + this.log(`\nDone — login with email "${email}" and password "password"`); + }); +} diff --git a/apps/edr-freight-api/src/scripts/cmds/seed-test-contracts.cmd.ts b/apps/edr-freight-api/src/scripts/cmds/seed-test-contracts.cmd.ts index 17ba79182..a46a9dee8 100644 --- a/apps/edr-freight-api/src/scripts/cmds/seed-test-contracts.cmd.ts +++ b/apps/edr-freight-api/src/scripts/cmds/seed-test-contracts.cmd.ts @@ -13,35 +13,6 @@ import { ContractRoute } from "../../modules/contracts/entities/contract-route.e import { ContractCargoScope } from "../../modules/contracts/entities/contract-cargo-scope.entity"; import { ContractRateSnapshot } from "../../modules/contracts/entities/contract-rate-snapshot.entity"; -const COMPANY_IDS = [ - "a0000001-0000-4000-8000-000000000001", - "a0000001-0000-4000-8000-000000000002", - "a0000001-0000-4000-8000-000000000003", - "a0000001-0000-4000-8000-000000000004", -]; - -const PROFILE_IDS = [ - "b0000001-0000-4000-8000-000000000001", - "b0000001-0000-4000-8000-000000000002", - "b0000001-0000-4000-8000-000000000003", - "b0000001-0000-4000-8000-000000000004", - "b0000001-0000-4000-8000-000000000005", - "b0000001-0000-4000-8000-000000000006", - "b0000001-0000-4000-8000-000000000007", - "b0000001-0000-4000-8000-000000000008", -]; - -const EXTERNAL_PROFILE_IDS = [ - "c0000001-0000-4000-8000-000000000001", - "c0000001-0000-4000-8000-000000000002", - "c0000001-0000-4000-8000-000000000003", - "c0000001-0000-4000-8000-000000000004", -]; - -function generateRef(index: number): string { - return `TST-CTR-${String(index).padStart(5, "0")}`; -} - export function registerSeedTestContracts( vorpal: Vorpal, ctx: CommandContext, @@ -124,7 +95,15 @@ export function registerSeedTestContracts( } const contractRepo = ds.getRepository(Contract); - let contractCount = 0; + + const maxRaw = await ds.query( + `SELECT "reference" FROM "freight"."contracts" WHERE "reference" LIKE 'TST-CTR-%' AND "deleted_at" IS NULL ORDER BY "reference" DESC LIMIT 1`, + ); + let nextRef = 1; + if (maxRaw.length > 0) { + const num = parseInt(maxRaw[0].reference.replace("TST-CTR-", ""), 10); + if (!isNaN(num)) nextRef = num + 1; + } for (let i = 0; i < count; i++) { const statusIdx = i % statusList.length; @@ -145,13 +124,7 @@ export function registerSeedTestContracts( }); if (!profile) continue; - const ref = generateRef(i + 1); - const exists = await contractRepo.findOne({ where: { reference: ref } }); - if (exists) { - this.log(` Contract ${ref} already exists — skipping`); - contractCount++; - continue; - } + const ref = `TST-CTR-${String(nextRef + i).padStart(5, "0")}`; const serviceTypeId = freightType === "BULK" && railBulk @@ -239,54 +212,52 @@ export function registerSeedTestContracts( ); } - contractCount++; this.log(` Created ${status} ${freightType} ${direction} contract: ${ref} (${company.name})`); } - this.log(`Done — ${contractCount} contracts ensured`); + this.log(`Done — ${count} new contracts created`); }); } interface CompanySeed { - id: string; name: string; tin: string; - profiles: Array<{ id: string; type: ProfileType; reference: string }>; - externalProfile: { id: string; userId: string; firstName: string; lastName: string }; + profiles: Array<{ type: ProfileType; reference: string }>; + externalProfile: { userId: string; firstName: string; lastName: string }; } const TEST_COMPANIES: CompanySeed[] = [ { - id: COMPANY_IDS[0], name: "Test Importer Co.", tin: "TST000001", + name: "Test Importer Co.", tin: "TST000001", profiles: [ - { id: PROFILE_IDS[0], type: ProfileType.importer, reference: "TST-IM-001" }, - { id: PROFILE_IDS[1], type: ProfileType.exporter, reference: "TST-EX-001" }, + { type: ProfileType.importer, reference: "TST-IM-001" }, + { type: ProfileType.exporter, reference: "TST-EX-001" }, ], - externalProfile: { id: EXTERNAL_PROFILE_IDS[0], userId: "00000000-0000-0000-0000-000000000001", firstName: "Abebe", lastName: "Kebede" }, + externalProfile: { userId: "00000000-0000-0000-0000-000000000001", firstName: "Abebe", lastName: "Kebede" }, }, { - id: COMPANY_IDS[1], name: "Test Exporter Ltd.", tin: "TST000002", + name: "Test Exporter Ltd.", tin: "TST000002", profiles: [ - { id: PROFILE_IDS[2], type: ProfileType.importer, reference: "TST-IM-002" }, - { id: PROFILE_IDS[3], type: ProfileType.exporter, reference: "TST-EX-002" }, + { type: ProfileType.importer, reference: "TST-IM-002" }, + { type: ProfileType.exporter, reference: "TST-EX-002" }, ], - externalProfile: { id: EXTERNAL_PROFILE_IDS[1], userId: "00000000-0000-0000-0000-000000000002", firstName: "Bekele", lastName: "Alemu" }, + externalProfile: { userId: "00000000-0000-0000-0000-000000000002", firstName: "Bekele", lastName: "Alemu" }, }, { - id: COMPANY_IDS[2], name: "Bulk Commodities PLC", tin: "TST000003", + name: "Bulk Commodities PLC", tin: "TST000003", profiles: [ - { id: PROFILE_IDS[4], type: ProfileType.importer, reference: "TST-IM-003" }, - { id: PROFILE_IDS[5], type: ProfileType.exporter, reference: "TST-EX-003" }, + { type: ProfileType.importer, reference: "TST-IM-003" }, + { type: ProfileType.exporter, reference: "TST-EX-003" }, ], - externalProfile: { id: EXTERNAL_PROFILE_IDS[2], userId: "00000000-0000-0000-0000-000000000003", firstName: "Chala", lastName: "Tesfaye" }, + externalProfile: { userId: "00000000-0000-0000-0000-000000000003", firstName: "Chala", lastName: "Tesfaye" }, }, { - id: COMPANY_IDS[3], name: "Hazardous Logistics Inc.", tin: "TST000004", + name: "Hazardous Logistics Inc.", tin: "TST000004", profiles: [ - { id: PROFILE_IDS[6], type: ProfileType.importer, reference: "TST-IM-004" }, - { id: PROFILE_IDS[7], type: ProfileType.exporter, reference: "TST-EX-004" }, + { type: ProfileType.importer, reference: "TST-IM-004" }, + { type: ProfileType.exporter, reference: "TST-EX-004" }, ], - externalProfile: { id: EXTERNAL_PROFILE_IDS[3], userId: "00000000-0000-0000-0000-000000000004", firstName: "Desta", lastName: "Hailu" }, + externalProfile: { userId: "00000000-0000-0000-0000-000000000004", firstName: "Desta", lastName: "Hailu" }, }, ]; @@ -297,11 +268,10 @@ async function seedTestCompanies(ds: DataSource, log: (msg: string) => void): Pr const result: Company[] = []; for (const seed of TEST_COMPANIES) { - let company = await companyRepo.findOne({ where: { id: seed.id } }); + let company = await companyRepo.findOne({ where: { tin: seed.tin } }); if (!company) { company = await companyRepo.save( companyRepo.create({ - id: seed.id, name: seed.name, type: CompanyType.Customer, kind: CompanyKind.Commercial, @@ -319,12 +289,13 @@ async function seedTestCompanies(ds: DataSource, log: (msg: string) => void): Pr } for (const p of seed.profiles) { - const existing = await profileRepo.findOne({ where: { id: p.id } }); + const existing = await profileRepo.findOne({ + where: { companyId: company.id, type: p.type }, + }); if (!existing) { await profileRepo.save( profileRepo.create({ - id: p.id, - companyId: seed.id, + companyId: company.id, type: p.type, reference: p.reference, status: ProfileStatus.Active, @@ -335,13 +306,14 @@ async function seedTestCompanies(ds: DataSource, log: (msg: string) => void): Pr } const ext = seed.externalProfile; - const existingExt = await extProfileRepo.findOne({ where: { id: ext.id } }); + const existingExt = await extProfileRepo.findOne({ + where: { companyId: company.id, userId: ext.userId }, + }); if (!existingExt) { await extProfileRepo.save( extProfileRepo.create({ - id: ext.id, userId: ext.userId, - companyId: seed.id, + companyId: company.id, firstName: ext.firstName, lastName: ext.lastName, isPrimaryContact: true, diff --git a/apps/edr-freight-api/src/scripts/cmds/seed-test-schedules.cmd.ts b/apps/edr-freight-api/src/scripts/cmds/seed-test-schedules.cmd.ts index 2d9002555..eeab0e2dd 100644 --- a/apps/edr-freight-api/src/scripts/cmds/seed-test-schedules.cmd.ts +++ b/apps/edr-freight-api/src/scripts/cmds/seed-test-schedules.cmd.ts @@ -12,8 +12,38 @@ import { TrainSet } from "../../modules/train-sets/entities/train-set.entity"; import { TrainSetLocomotive } from "../../modules/train-sets/entities/train-set-locomotive.entity"; import { TrainSetWagon } from "../../modules/train-sets/entities/train-set-wagon.entity"; import { TrainSchedule } from "../../modules/train-schedules/entities/train-schedule.entity"; -import { TrainScheduleBooking } from "../../modules/train-schedules/entities/train-schedule-booking.entity"; -import { Booking } from "../../modules/bookings/entities/booking.entity"; + +async function nextSequence(ds: DataSource, pattern: string): Promise { + const like = pattern.replace(/\*/g, "%"); + const raw = await ds.query( + `SELECT "train_number" FROM "freight"."train_schedules" WHERE "train_number" LIKE $1 AND "deleted_at" IS NULL ORDER BY "train_number" DESC LIMIT 1`, + [like.replace(/%/g, "") + "%"], + ); + if (raw.length === 0) return 1; + const ref: string = raw[0].train_number; + const num = parseInt(ref.replace(pattern.split("*")[0], ""), 10); + return isNaN(num) ? 1 : num + 1; +} + +async function nextRouteSeq(ds: DataSource, prefix: string): Promise { + const raw = await ds.query( + `SELECT "name" FROM "freight"."routes" WHERE "name" LIKE $1 AND "deleted_at" IS NULL ORDER BY "name" DESC LIMIT 1`, + [prefix + "%"], + ); + if (raw.length === 0) return 1; + const num = parseInt(raw[0].name.replace(prefix, ""), 10); + return isNaN(num) ? 1 : num + 1; +} + +async function nextWagonSeq(ds: DataSource, prefix: string): Promise { + const raw = await ds.query( + `SELECT "wagon_number" FROM "freight"."wagons" WHERE "wagon_number" LIKE $1 AND "deleted_at" IS NULL ORDER BY "wagon_number" DESC LIMIT 1`, + [prefix + "%"], + ); + if (raw.length === 0) return 1; + const num = parseInt(raw[0].wagon_number.replace(prefix, ""), 10); + return isNaN(num) ? 1 : num + 1; +} export function registerSeedTestSchedules( vorpal: Vorpal, @@ -43,8 +73,6 @@ export function registerSeedTestSchedules( return; } - this.log(`Seeding ${count} schedules (direction=${directionList.join(",")}, status=${statusList.join(",")}, days_ahead=${daysAhead})...`); - const yards = await ds.getRepository(Yard).find({ where: { isActive: true } }); const yardByCode = new Map(yards.map((y) => [y.code.toUpperCase(), y])); const djibouti = yardByCode.get("DJIBOUTI") ?? yards.find((y) => y.country === "Djibouti"); @@ -61,50 +89,90 @@ export function registerSeedTestSchedules( return; } - const route = await ensureTestRoute(ds, yards, djibouti, addis, (msg) => this.log(msg)); + const wagonType = wagonTypes[0]; + const wagonCapacity = Number(wagonType.capacityTons) || 70; + const wagonLength = Number(wagonType.lengthMeters) || 14; + const tareWeight = Number(wagonType.tareWeightTons) || 14; + const locomotiveRepo = ds.getRepository(Locomotive); const scheduleRepo = ds.getRepository(TrainSchedule); const trainSetRepo = ds.getRepository(TrainSet); - let seeded = 0; + const wagonRepo = ds.getRepository(Wagon); + const routeRepo = ds.getRepository(Route); + const milestoneRepo = ds.getRepository(RouteMilestone); + + let nextTrainNum = await nextSequence(ds, "TST-SCH-*"); + const routePrefix = "TST-RTE-"; + let nextRouteNum = await nextRouteSeq(ds, routePrefix); + + const now = new Date(); + const travelHours = 11; + const intermediateYards = yards.filter( + (y) => y.id !== djibouti.id && y.id !== addis.id, + ); + + let loco = await locomotiveRepo.findOne({ where: { code: "TST-LOCO-01" } }); + if (!loco) { + loco = await locomotiveRepo.save( + locomotiveRepo.create({ + code: "TST-LOCO-01", + name: "Test Locomotive", + locomotiveType: "DIESEL", + maxPullWeightTons: 4200, + maxTrainLengthMeters: 760, + status: "AVAILABLE", + currentYardId: djibouti.id, + }), + ); + } for (let i = 0; i < count; i++) { + const seq = nextTrainNum + i; + const trainNumber = `TST-SCH-${String(seq).padStart(5, "0")}`; const dir = directionList[i % directionList.length]; const status = statusList[i % statusList.length]; - const trainNumber = `TST-SCH-${String(i + 1).padStart(3, "0")}`; - - const existing = await scheduleRepo.findOne({ where: { trainNumber } }); - if (existing) { - this.log(` Schedule ${trainNumber} already exists — skipping`); - seeded++; - continue; - } - + const isDispatched = status === "DISPATCHED"; const originYard = dir === "IMPORT" ? djibouti : addis; const destYard = dir === "IMPORT" ? addis : djibouti; - const isDispatched = status === "DISPATCHED"; + const routeName = `${routePrefix}${String(nextRouteNum + i).padStart(3, "0")}`; - const now = new Date(); const departure = new Date(now); - departure.setDate(departure.getDate() + daysAhead); + departure.setDate(departure.getDate() + daysAhead + i); departure.setHours(7, 0, 0, 0); - - const travelHours = dir === "IMPORT" ? 12 : 10; const arrival = new Date(departure.getTime() + travelHours * 60 * 60 * 1000); - const locomotive = await ensureTestLocomotive(ds, originYard.id, (msg) => this.log(msg)); - const wagonCount = 4; - const wagonType = wagonTypes[0]; - const wagonCapacity = Number(wagonType.capacityTons) || 70; - const wagonLength = Number(wagonType.lengthMeters) || 14; - const tareWeight = Number(wagonType.tareWeightTons) || 14; - const totalWagonWeight = wagonCount * (tareWeight + 20); + const route = await routeRepo.save( + routeRepo.create({ + name: routeName, + originYardId: originYard.id, + destinationYardId: destYard.id, + isActive: true, + }), + ); + await milestoneRepo.save( + milestoneRepo.create({ routeId: route.id, yardId: originYard.id, sequenceNo: 1 }), + ); + for (const [mi, y] of intermediateYards.entries()) { + await milestoneRepo.save( + milestoneRepo.create({ routeId: route.id, yardId: y.id, sequenceNo: (mi + 1) * 2 }), + ); + } + await milestoneRepo.save( + milestoneRepo.create({ + routeId: route.id, + yardId: destYard.id, + sequenceNo: (intermediateYards.length + 1) * 2, + }), + ); + + const totalWagonWeight = 4 * (tareWeight + 20); const trainSet = await trainSetRepo.save( trainSetRepo.create({ - locomotiveId: locomotive.id, + locomotiveId: loco.id, totalWeightTons: totalWagonWeight, - totalLengthMeters: wagonLength * wagonCount, - wagonCount, + totalLengthMeters: wagonLength * 4, + wagonCount: 4, status: isDispatched ? "DISPATCHED" : status === "DRAFT" ? "DRAFT" : "ASSIGNED", }), ); @@ -112,7 +180,7 @@ export function registerSeedTestSchedules( await ds.getRepository(TrainSetLocomotive).save( ds.getRepository(TrainSetLocomotive).create({ trainSetId: trainSet.id, - locomotiveId: locomotive.id, + locomotiveId: loco.id, sequenceNo: 0, }), ); @@ -134,26 +202,31 @@ export function registerSeedTestSchedules( }), ); - for (let w = 0; w < wagonCount; w++) { - const seq = w + 1; - const wagonNumber = `${trainNumber}-W${String(seq).padStart(2, "0")}`; + const wagonPrefix = `${trainNumber}-W`; + let nextWagon = await nextWagonSeq(ds, wagonPrefix); + for (let w = 0; w < 4; w++) { + const ws = nextWagon + w; + const wagonNumber = `${wagonPrefix}${String(ws).padStart(2, "0")}`; - const physicalWagon = await ensureTestWagon(ds, { + const wagon = wagonRepo.create({ wagonNumber, wagonTypeId: wagonType.id, - yardId: originYard.id, - trainScheduleId: schedule.id, + currentYardId: originYard.id, + currentTrainScheduleId: schedule.id, tareWeight, - capacityTons: wagonCapacity, - dispatched: isDispatched, + maxPayloadWeight: wagonCapacity, + status: isDispatched ? WagonStatus.Assigned : WagonStatus.Available, + notes: "Test seed wagon", }); + const saved = await wagonRepo.save(wagon as any); + const physicalWagon = Array.isArray(saved) ? saved[0] : saved; await ds.getRepository(TrainSetWagon).save( ds.getRepository(TrainSetWagon).create({ trainSetId: trainSet.id, wagonTypeId: wagonType.id, physicalWagonId: physicalWagon.id, - sequenceNo: seq, + sequenceNo: w + 1, capacityTons: wagonCapacity, lengthMeters: wagonLength, assignedWeightTons: 20, @@ -162,137 +235,9 @@ export function registerSeedTestSchedules( ); } - const scheduleBookings = await ds.getRepository(TrainScheduleBooking).find({ - where: { trainScheduleId: schedule.id }, - }); - if (scheduleBookings.length === 0) { - const booking = await ds.getRepository(Booking).findOne({ where: {}, order: { createdAt: "DESC" } }); - if (booking) { - await ds.getRepository(TrainScheduleBooking).save( - ds.getRepository(TrainScheduleBooking).create({ - trainScheduleId: schedule.id, - bookingId: booking.id, - }), - ); - } - } - - seeded++; this.log(` Created ${status} ${dir} schedule: ${trainNumber} (${originYard.label} → ${destYard.label})`); } - this.log(`Done — ${seeded} train schedules ensured`); + this.log(`Done — ${count} new train schedules created`); }); } - -async function ensureTestRoute( - ds: DataSource, - yards: Yard[], - originYard: Yard, - destYard: Yard, - log: (msg: string) => void, -): Promise { - const repo = ds.getRepository(Route); - const name = `TST-RTE-${originYard.code}_${destYard.code}`; - let route = await repo.findOne({ where: { name } }); - if (route) return route; - - route = await repo.save( - repo.create({ - name, - originYardId: originYard.id, - destinationYardId: destYard.id, - isActive: true, - }), - ); - log(` Created route: ${name}`); - - const milestoneRepo = ds.getRepository(RouteMilestone); - const intermediateYards = yards.filter( - (y) => y.id !== originYard.id && y.id !== destYard.id, - ); - - await milestoneRepo.save( - milestoneRepo.create({ - routeId: route.id, - yardId: originYard.id, - sequenceNo: 0, - }), - ); - - for (const [idx, y] of intermediateYards.entries()) { - await milestoneRepo.save( - milestoneRepo.create({ - routeId: route.id, - yardId: y.id, - sequenceNo: (idx + 1) * 2, - }), - ); - } - - await milestoneRepo.save( - milestoneRepo.create({ - routeId: route.id, - yardId: destYard.id, - sequenceNo: (intermediateYards.length + 1) * 2, - }), - ); - - log(` Added ${intermediateYards.length + 2} milestones`); - return route; -} - -async function ensureTestLocomotive( - ds: DataSource, - yardId: string, - log: (msg: string) => void, -): Promise { - const repo = ds.getRepository(Locomotive); - const code = "TST-LOCO-01"; - let loco = await repo.findOne({ where: { code } }); - if (loco) return loco; - - loco = await repo.save( - repo.create({ - code, - name: "Test Locomotive", - locomotiveType: "DIESEL", - maxPullWeightTons: 4200, - maxTrainLengthMeters: 760, - status: "AVAILABLE", - currentYardId: yardId, - }), - ); - log(` Created locomotive: ${code}`); - return loco; -} - -async function ensureTestWagon( - ds: DataSource, - input: { - wagonNumber: string; - wagonTypeId: string; - yardId: string; - trainScheduleId: string; - tareWeight: number; - capacityTons: number; - dispatched: boolean; - }, -): Promise { - const repo = ds.getRepository(Wagon); - const existing = await repo.findOne({ where: { wagonNumber: input.wagonNumber } }); - if (existing) return existing; - - const wagon = repo.create({ - wagonNumber: input.wagonNumber, - wagonTypeId: input.wagonTypeId, - currentYardId: input.yardId, - currentTrainScheduleId: input.trainScheduleId, - tareWeight: input.tareWeight, - maxPayloadWeight: input.capacityTons, - status: input.dispatched ? WagonStatus.Assigned : WagonStatus.Available, - notes: "Test seed wagon", - }); - const saved = await repo.save(wagon as any); - return Array.isArray(saved) ? saved[0] : saved; -} diff --git a/apps/edr-freight-api/src/scripts/cmds/types.ts b/apps/edr-freight-api/src/scripts/cmds/types.ts index bd0fbb9c9..6c805e07d 100644 --- a/apps/edr-freight-api/src/scripts/cmds/types.ts +++ b/apps/edr-freight-api/src/scripts/cmds/types.ts @@ -1,4 +1,3 @@ -import type Vorpal from "vorpal"; import type { INestApplicationContext } from "@nestjs/common"; export type CommandContext = { diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/warehousePdf.ts b/apps/edr-freight-web/backoffice/src/components/warehouses/warehousePdf.ts index 8b526fb3d..39daf7b57 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/warehousePdf.ts +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/warehousePdf.ts @@ -31,9 +31,6 @@ export interface WarehouseHandoverPdfContext { const escapePdfText = (value: string) => value.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)'); -const money = (amount: unknown, currency = 'USD') => - `${Number(amount ?? 0).toLocaleString()} ${currency === 'ETB' ? 'Birr (ETB)' : currency}`; - const fmtDate = (value: unknown) => { if (!value) return '-'; const date = new Date(value as string | Date); @@ -96,12 +93,6 @@ const textOp = ( color = '0 0 0', ) => `BT\n${color} rg\n/${bold ? 'F2' : 'F1'} ${size} Tf\n${x} ${y} Td\n(${escapePdfText(text)}) Tj\nET`; -const buildAuthorizationBand = (label: 'PAID' | 'CLEARED') => [ - lineOp(60, 242, 535, 242), - textOp('AUTHORIZED SEAL', 382, 218, 9, true, GREEN), - buildCircularSeal(452, 155, label), -]; - const buildWarehouseOfficerSealBand = () => [ lineOp(60, 218, 535, 218), textOp('WAREHOUSE OFFICER SEAL', 92, 194, 9, true, GREEN), @@ -146,57 +137,6 @@ function buildSimplePdf(lines: PdfLine[], rawOps: string[] = []): Blob { return new Blob([pdf], { type: 'application/pdf' }); } -export function buildWarehouseInvoicePdf(invoice: WarehouseFeeInvoice, kind: 'INVOICE' | 'RECEIPT') { - const paid = kind === 'RECEIPT' || invoice.status === 'PAID'; - const title = `Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'}`; - const bookingReference = firstText(invoice.bookingReference); - const customerName = firstText(invoice.customerName); - const inventoryReference = firstText(invoice.inventoryReference); - const inventoryInfo = firstText(invoice.inventoryInfo, invoice.containerNumber, invoice.cargoDescription); - const clearanceStatus = firstText( - invoice.clearanceStatus, - paid ? 'FEE PAID - READY FOR RELEASE' : 'PENDING PAYMENT', - ); - const lines: PdfLine[] = [ - { text: 'Ethio-Djibouti Railway S.C.', size: 12, bold: true, yGap: 0, align: 'center' }, - { text: title, size: 23, bold: true, yGap: 28, align: 'center' }, - { text: `Document No: ${invoice.invoiceNumber}`, size: 12, bold: true, yGap: 32, align: 'center' }, - { text: `Status: ${invoice.status.replace(/_/g, ' ')} Type: ${invoice.invoiceType.replace(/_/g, ' ')}`, align: 'center' }, - { text: `Booking Reference: ${bookingReference} Customer: ${customerName}`, align: 'center' }, - { text: `Inventory Reference: ${inventoryReference} Inventory Info: ${inventoryInfo}`, align: 'center' }, - { text: `Clearance: ${clearanceStatus}`, align: 'center' }, - { text: `Issued: ${fmtDate(invoice.issuedAt)} Paid At: ${fmtDate(invoice.paidAt)}`, align: 'center' }, - { text: 'ITEMS', size: 13, bold: true, yGap: 30, align: 'center' }, - ...(invoice.items ?? []).flatMap((item) => [ - { text: item.description, bold: true, align: 'center' as const }, - { - text: `${item.feeType.replace(/_/g, ' ')} | Qty ${Number(item.quantity ?? 0).toLocaleString()} | Rate ${money(item.unitRate, item.currency)} | Amount ${money(item.amount, item.currency)}`, - yGap: 13, - align: 'center' as const, - }, - ]), - { text: 'TOTALS', size: 13, bold: true, yGap: 30, align: 'center' }, - { text: `Subtotal: ${money(invoice.subtotalAmount, invoice.currency)}`, align: 'center' }, - { text: `Tax: ${money(invoice.taxAmount, invoice.currency)}`, align: 'center' }, - { text: `Total: ${money(invoice.totalAmount, invoice.currency)}`, bold: true, align: 'center' }, - { text: `Paid: ${money(invoice.paidAmount, invoice.currency)}`, align: 'center' }, - { text: `Balance: ${money(invoice.balanceAmount, invoice.currency)}`, bold: true, align: 'center' }, - ]; - const authorizationOps = [ - ...buildAuthorizationBand('PAID'), - textOp('Prepared by EDR warehouse finance', 72, 196, 10), - textOp('Finance officer name / signature / date:', 72, 164, 10), - lineOp(245, 162, 360, 162, '0 0 0'), - ]; - const invoiceOps = [ - lineOp(60, 242, 535, 242), - textOp('Prepared by EDR warehouse finance', 72, 196, 10), - textOp('Finance officer name / signature / date:', 72, 164, 10), - lineOp(245, 162, 360, 162, '0 0 0'), - ]; - return buildSimplePdf(lines, paid ? authorizationOps : invoiceOps); -} - const firstText = (...values: Array) => { for (const value of values) { if (value !== null && value !== undefined && String(value).trim()) return String(value); diff --git a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts index 07e271cb6..842276815 100644 --- a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts +++ b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts @@ -1,12 +1,7 @@ - -export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; -//export const API_BASE_URL = 'http://localhost:3001'; - - +export const API_BASE_URL = import.meta.env.VITE_BASE_API_URL; // export const API_BASE_URL = 'http://localhost:3001'; - /** * URL that streams an uploaded file through the API by its UUID. Routes the * bytes through `GET /api/files/:id` (served from MinIO with backend diff --git a/apps/edr-freight-web/backoffice/src/hooks/useScrollToHash.ts b/apps/edr-freight-web/backoffice/src/hooks/useScrollToHash.ts new file mode 100644 index 000000000..f381f49d5 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/hooks/useScrollToHash.ts @@ -0,0 +1,30 @@ +import { useEffect } from "react"; +import { useLocation } from "react-router-dom"; + +/** + * Scroll to the element whose `id` matches the URL hash. Retries for a short + * window so it still lands on sections that mount after an async fetch (there is + * no router-level hash handling). Deep-link targets give a card an `id`. + */ +export function useScrollToHash(): void { + const { hash } = useLocation(); + + useEffect(() => { + if (!hash) return; + const id = decodeURIComponent(hash.slice(1)); + let tries = 0; + let timer: ReturnType; + + const tick = () => { + const el = document.getElementById(id); + if (el) { + el.scrollIntoView({ behavior: "smooth", block: "start" }); + return; + } + if (tries++ < 20) timer = setTimeout(tick, 100); + }; + + timer = setTimeout(tick, 100); + return () => clearTimeout(timer); + }, [hash]); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx index 75badc5b3..7a13888fe 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx @@ -50,6 +50,7 @@ import { useBookingDetail, useBookingMutations, } from "@/hooks/bookings/useBookings"; +import { useScrollToHash } from "@/hooks/useScrollToHash"; import toast from "react-hot-toast"; // Signature / generated-contract files are surfaced on the contract page, not @@ -65,6 +66,8 @@ export default function BookingRequestDetailPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); + // Deep-link from a warehouse fee invoice → this booking's warehouse section. + useScrollToHash(); const { data: booking, isLoading, @@ -281,10 +284,12 @@ export default function BookingRequestDetailPage() { - + + + = { @@ -155,6 +156,7 @@ export default function WarehouseInvoicesPage() { function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () => void }) { const { toast } = useToast(); + const navigate = useNavigate(); const { data: inv, isLoading } = useQuery( api.warehouses.invoice.queryOptions({ input: { id: id ?? '' }, @@ -172,13 +174,33 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () => const canGateClear = inv?.status === 'PAID' && Boolean(inv.inventoryId); const downloadInvoicePdf = async (invoice: WarehouseFeeInvoice) => { - const blob = buildWarehouseInvoicePdf(invoice, 'INVOICE'); - openPdfBlob(blob, `warehouse-invoice-${invoice.invoiceNumber}.pdf`); + const pdfWindow = window.open('', '_blank'); + try { + const { data } = await warehouseService.downloadInvoiceDocument(invoice.id); + openPdfBlob(data, `warehouse-invoice-${invoice.invoiceNumber}.pdf`, pdfWindow); + } catch (error) { + pdfWindow?.close(); + toast({ + variant: 'destructive', + title: 'Download failed', + description: extractErrorMessage(error), + }); + } }; const downloadReceiptPdf = async (invoice: WarehouseFeeInvoice) => { - const blob = buildWarehouseInvoicePdf(invoice, 'RECEIPT'); - openPdfBlob(blob, `warehouse-receipt-${invoice.invoiceNumber}.pdf`); + const pdfWindow = window.open('', '_blank'); + try { + const { data } = await warehouseService.downloadInvoiceReceipt(invoice.id); + openPdfBlob(data, `warehouse-receipt-${invoice.invoiceNumber}.pdf`, pdfWindow); + } catch (error) { + pdfWindow?.close(); + toast({ + variant: 'destructive', + title: 'Download failed', + description: extractErrorMessage(error), + }); + } }; const getExitPaperContext = async (invoice: WarehouseFeeInvoice) => { @@ -366,6 +388,20 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () => )} + {inv.bookingId && ( + + )} + )} - )} + {hasReceipt && ( + + )} + {payable && ( + + )} + - {payMutation.isError && ( - } title="Payment could not be started"> - Please try again, or contact support if the problem persists. - - )} - {/* Summary */} @@ -241,6 +340,27 @@ export default function InvoiceDetailPage() { + + { + if (!payMutation.isPending) { + setPayModalOpen(false); + payMutation.reset(); + } + }} + amountLabel={formatCurrency(Number(invoice.totalAmount), invoice.currency)} + currency={invoice.currency} + processing={payMutation.isPending} + error={ + payMutation.isError + ? payMutation.error instanceof Error + ? payMutation.error.message + : "Could not start payment. Please try again." + : null + } + onConfirm={(method) => payMutation.mutate(method)} + /> ); diff --git a/apps/edr-freight-web/portal/src/pages/billing/invoice-ui.tsx b/apps/edr-freight-web/portal/src/pages/billing/invoice-ui.tsx index 1e19b1c9a..3503fbc71 100644 --- a/apps/edr-freight-web/portal/src/pages/billing/invoice-ui.tsx +++ b/apps/edr-freight-web/portal/src/pages/billing/invoice-ui.tsx @@ -22,6 +22,7 @@ const STATUS_STYLE: Record< [Freight.InvoiceStatus.Overdue]: { label: "Overdue", bg: "#FDECEC", fg: "#C0392B" }, [Freight.InvoiceStatus.Cancelled]: { label: "Cancelled", bg: "#EEF2F6", fg: "#64748B" }, [Freight.InvoiceStatus.Refunded]: { label: "Refunded", bg: "#EAF1FB", fg: "#2563EB" }, + [Freight.InvoiceStatus.Expired]: { label: "Expired", bg: "#FBEAE7", fg: "#C0392B" }, }; export function InvoiceStatusBadge({ status }: { status: Freight.InvoiceStatus }) { 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 065ee8385..7c2c2631c 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 @@ -24,19 +24,22 @@ import { ConsolidationPairedNotice, ConsolidationWaitingBanner, } from "./components/Notices"; +import { BookingPaymentPanel } from "./components/BookingPaymentPanel"; import { HeaderButton, PageHeader } from "./components/PageHeader"; -import { PaymentDeadlineCard } from "./components/PaymentDeadlineCard"; import { PaymentMethodModal } from "./components/PaymentMethodModal"; -import { PaymentCard } from "./components/pricing"; import { ScheduleCard } from "./components/ScheduleCard"; +import { WarehousePaymentsSection } from "./components/WarehousePaymentsSection"; import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard"; import { ShipmentTrackingCard } from "./components/ShipmentTrackingCard"; import { StatusHero } from "./components/StatusHero"; import { SupportCard } from "./components/SupportCard"; import { fmtDate, isNegative, priceTotal } from "./utils"; +import { useScrollToHash } from "@/hooks/useScrollToHash"; export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) { const navigate = useNavigate(); + // Deep-link support: e.g. /bookings/:id#warehouse-payments from an invoice. + useScrollToHash(); const status = booking.status as string; const [payModalOpen, setPayModalOpen] = useState(false); const { view, viewer } = useFileViewer(); @@ -164,6 +167,8 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) + + {booking.files && booking.files.length > 0 && ( @@ -215,14 +220,13 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) } right={ <> - {showCountdown && ( - setPayModalOpen(true)} - paying={payMutation.isPending} - /> - )} - + setPayModalOpen(true)} + paying={payMutation.isPending} + showCountdown={showCountdown} + /> ; + +// ── Pay-window countdown ───────────────────────────────────────────────────── + +interface Remaining { + days: number; + hours: number; + minutes: number; + seconds: number; + expired: boolean; +} + +function getRemaining(deadlineMs: number): Remaining { + const diff = deadlineMs - Date.now(); + if (diff <= 0) return { days: 0, hours: 0, minutes: 0, seconds: 0, expired: true }; + const total = Math.floor(diff / 1000); + return { + days: Math.floor(total / 86400), + hours: Math.floor((total % 86400) / 3600), + minutes: Math.floor((total % 3600) / 60), + seconds: total % 60, + expired: false, + }; +} + +function Segment({ value, label }: { value: number; label: string }) { + return ( + + + {String(value).padStart(2, "0")} + + + {label} + + + ); +} + +function Countdown({ + deadline, + onPay, + paying, +}: { + deadline: string; + onPay?: () => void; + paying?: boolean; +}) { + const deadlineMs = new Date(deadline).getTime(); + const [remaining, setRemaining] = useState(() => getRemaining(deadlineMs)); + + useEffect(() => { + setRemaining(getRemaining(deadlineMs)); + const interval = setInterval(() => { + const next = getRemaining(deadlineMs); + setRemaining(next); + if (next.expired) clearInterval(interval); + }, 1000); + return () => clearInterval(interval); + }, [deadlineMs]); + + if (remaining.expired) { + return ( + + The payment window has closed. Move this booking to another schedule or + contact support. + + ); + } + + return ( + <> + + + + + + + + Deadline:{" "} + {new Date(deadline).toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + })} + + {onPay && ( + + )} + + ); +} + +// ── Merged payment panel ───────────────────────────────────────────────────── + +/** + * One card covering the whole payment story for a booking: the live pay-window + * countdown (when open), the price breakdown, and the invoice(s) — each with a + * link to its detail page and a download. Replaces the separate deadline + + * breakdown cards. + */ +export function BookingPaymentPanel({ + booking, + pricing, + onPay, + paying, + showCountdown, +}: { + booking: Freight.IBooking; + pricing: Pricing; + onPay?: () => void; + paying?: boolean; + showCountdown?: boolean; +}) { + const navigate = useNavigate(); + const paid = booking.paymentStatus === "PAID"; + const isAdjusted = + booking.adjustedTotalAmount !== null && + booking.adjustedTotalAmount !== undefined; + const currency = pricing?.currency ?? booking.paymentCurrency; + const total = isAdjusted + ? `${Number(booking.adjustedTotalAmount).toLocaleString()} ${currency}` + : priceTotal(pricing); + const items = priceLineItems(pricing); + + const { data: invoices = [] } = useQuery({ + queryKey: ["booking-invoices", booking.id], + queryFn: () => invoicesService.listForSource("booking", booking.id), + }); + // The invoice worth a prominent "Download" — the first issued one, else any. + const primary = + invoices.find((inv) => inv.status !== "DRAFT") ?? invoices[0]; + const primaryPaid = primary ? Number(primary.paidAmount) > 0 : false; + + const downloadInvoice = async (inv: PortalInvoice) => { + try { + saveBlob( + await invoicesService.downloadDocument(inv.id), + `invoice-${inv.invoiceNumber}.pdf`, + ); + } catch { + toast.error("Invoice PDF isn't ready yet. Contact EDR if this persists."); + } + }; + + const downloadReceipt = async (inv: PortalInvoice) => { + try { + saveBlob( + await invoicesService.downloadReceipt(inv.id), + `receipt-${inv.invoiceNumber}.pdf`, + ); + } catch { + toast.error("Receipt isn't available yet."); + } + }; + + return ( + + + Payment + + {paid ? : showCountdown ? : null} + {paid + ? "Paid" + : showCountdown + ? "Pay window open" + : (booking.paymentStatus?.replace(/_/g, " ") ?? "Pending")} + + + + {showCountdown && booking.paymentDeadline && ( + + + + + )} + + + + {total} + + {isAdjusted && ( + + Adjusted by EDR + + )} + {isAdjusted && booking.adjustmentReason && ( + + {booking.adjustmentReason} + + )} + {paid && ( + + Paid · {fmtDate(booking.updatedAt)} + + )} + + + {items.length > 0 && ( + <> + + + {items.map((it) => ( + + + {it.label} + + + {it.value} + + + ))} + + + + {isAdjusted ? "Adjusted total" : "Total"} + + + {total} + + + + )} + + {invoices.length > 0 && ( + <> + + + Invoices + + {invoices.length} + + + + {invoices.map((inv) => ( + + + navigate(`/billing/${inv.id}`)} + > + {inv.invoiceNumber} + + + {titleCase(inv.type)} + + + + + downloadInvoice(inv)} + > + + + + + ))} + + + )} + + {primary && ( + + )} + {primary && primaryPaid && ( + + )} + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentDeadlineCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentDeadlineCard.tsx deleted file mode 100644 index 54f900ab5..000000000 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentDeadlineCard.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import { Box, Button, Group, Stack, Text } from "@mantine/core"; -import { CreditCard, Timer } from "lucide-react"; -import { useEffect, useState } from "react"; - -import { CardTitle, SectionCard } from "./layout"; - -interface Remaining { - days: number; - hours: number; - minutes: number; - seconds: number; - expired: boolean; -} - -function getRemaining(deadlineMs: number): Remaining { - const diff = deadlineMs - Date.now(); - if (diff <= 0) { - return { days: 0, hours: 0, minutes: 0, seconds: 0, expired: true }; - } - const totalSeconds = Math.floor(diff / 1000); - return { - days: Math.floor(totalSeconds / 86400), - hours: Math.floor((totalSeconds % 86400) / 3600), - minutes: Math.floor((totalSeconds % 3600) / 60), - seconds: totalSeconds % 60, - expired: false, - }; -} - -function Segment({ value, label }: { value: number; label: string }) { - return ( - - - {String(value).padStart(2, "0")} - - - {label} - - - ); -} - -export function PaymentDeadlineCard({ - paymentDeadline, - onPay, - paying, -}: { - /** ISO timestamp marking the end of the pay window. */ - paymentDeadline: string; - onPay?: () => void; - paying?: boolean; -}) { - const deadlineMs = new Date(paymentDeadline).getTime(); - const [remaining, setRemaining] = useState(() => getRemaining(deadlineMs)); - - useEffect(() => { - setRemaining(getRemaining(deadlineMs)); - const interval = setInterval(() => { - const next = getRemaining(deadlineMs); - setRemaining(next); - if (next.expired) clearInterval(interval); - }, 1000); - return () => clearInterval(interval); - }, [deadlineMs]); - - const accentBg = remaining.expired ? "#FBEAE7" : "#FEF6E6"; - const accentFg = remaining.expired ? "#C0392B" : "#B07D14"; - - return ( - - - Payment deadline - - - {remaining.expired ? "Expired" : "Pay window open"} - - - - {remaining.expired ? ( - - The payment window has closed. Move this booking to another schedule or - contact support. - - ) : ( - <> - - - - - - - - Complete payment before the window closes to secure your slot. - - {onPay && ( - - )} - - )} - - - - Deadline:{" "} - {new Date(paymentDeadline).toLocaleString(undefined, { - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - })} - - - ); -} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx new file mode 100644 index 000000000..146e31c1e --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx @@ -0,0 +1,156 @@ +import { ActionIcon, Box, Group, Stack, Text } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { Download, Receipt } from "lucide-react"; +import toast from "react-hot-toast"; + +import { + warehouseInvoicesService, + type PortalWarehouseInvoice, +} from "@/services/warehouse-invoices.service"; +import { saveBlob } from "@/utils/download"; + +import { CardTitle, SectionCard } from "./layout"; + +const money = (amount: number | string | null | undefined, currency: string) => + `${Number(amount ?? 0).toLocaleString()} ${currency}`; + +const STATUS_STYLE: Record = { + DRAFT: { bg: "#EEF2F6", fg: "#64748B" }, + ISSUED: { bg: "#FEF3E2", fg: "#B45309" }, + PARTIALLY_PAID: { bg: "#FEF9E7", fg: "#A16207" }, + PAID: { bg: "#E6F7EF", fg: "#0A6F4D" }, + CANCELLED: { bg: "#EEF2F6", fg: "#64748B" }, +}; + +function StatusPill({ status }: { status: string }) { + const s = STATUS_STYLE[status] ?? { bg: "#EEF2F6", fg: "#64748B" }; + return ( + + {status.replace(/_/g, " ")} + + ); +} + +/** + * Warehouse fee invoices linked to this booking — display + PDF download only. + * Paying them online is tracked separately (in-system demurrage/storage + * payment). Renders nothing when the booking has no warehouse fees. Carries + * `id="warehouse-payments"` so the invoice detail page can deep-link here. + */ +export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) { + const { data: invoices = [] } = useQuery({ + queryKey: ["booking-warehouse-invoices", bookingId], + queryFn: () => warehouseInvoicesService.listForBooking(bookingId), + }); + + if (invoices.length === 0) return null; + + const download = async (inv: PortalWarehouseInvoice) => { + try { + saveBlob( + await warehouseInvoicesService.downloadDocument(inv.id), + `warehouse-invoice-${inv.invoiceNumber}.pdf`, + ); + } catch { + toast.error("Warehouse invoice PDF isn't ready yet."); + } + }; + + const downloadReceipt = async (inv: PortalWarehouseInvoice) => { + try { + saveBlob( + await warehouseInvoicesService.downloadReceipt(inv.id), + `warehouse-receipt-${inv.invoiceNumber}.pdf`, + ); + } catch { + toast.error("Receipt isn't available yet."); + } + }; + + return ( + + + Warehouse payments + + {invoices.length} {invoices.length === 1 ? "invoice" : "invoices"} + + + + {invoices.map((inv) => { + const detail = [ + inv.invoiceType?.replace(/_/g, " "), + inv.cargoDescription ?? + inv.containerNumber ?? + inv.inventoryReference ?? + undefined, + ] + .filter(Boolean) + .join(" · "); + return ( + + + + + {inv.invoiceNumber} + + + + {detail && ( + + {detail} + + )} + + Total {money(inv.totalAmount, inv.currency)} · Balance{" "} + {money(inv.balanceAmount, inv.currency)} + + + + download(inv)} + > + + + {Number(inv.paidAmount) > 0 && ( + downloadReceipt(inv)} + > + + + )} + + + ); + })} + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/pricing.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/pricing.tsx index 93d843065..8c4ab9bcb 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/pricing.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/pricing.tsx @@ -1,9 +1,7 @@ import { Box, Group, Stack, Text } from "@mantine/core"; -import { CheckCircle2, Clock } from "lucide-react"; +import { Clock } from "lucide-react"; -import type { Freight } from "@edr/types"; - -import { fmtDate, priceLineItems, priceTotal, type Pricing } from "../utils"; +import { priceLineItems, priceTotal, type Pricing } from "../utils"; import { CardTitle, SectionCard } from "./layout"; function LineItems({ pricing }: { pricing: Pricing }) { @@ -107,113 +105,6 @@ export function EstimateCard({ ); } -export function PaymentCard({ - booking, - pricing, -}: { - booking: Freight.IBooking; - pricing: Pricing; -}) { - const paid = booking.paymentStatus === "PAID"; - // Customer sees the grand total plus the price breakdown that makes it up. - // A staff adjustment, when present, overrides the computed total and is - // flagged with an "Adjusted by EDR" badge. - const isAdjusted = - booking.adjustedTotalAmount !== null && - booking.adjustedTotalAmount !== undefined; - const currency = pricing?.currency ?? booking.paymentCurrency; - const total = isAdjusted - ? `${Number(booking.adjustedTotalAmount).toLocaleString()} ${currency}` - : priceTotal(pricing); - const hasItems = priceLineItems(pricing).length > 0; - - return ( - - - Payment - - {paid && } - {paid - ? "Paid" - : (booking.paymentStatus?.replace(/_/g, " ") ?? "Pending")} - - - - - {total} - - {isAdjusted && ( - - Adjusted by EDR - - )} - {isAdjusted && booking.adjustmentReason && ( - - {booking.adjustmentReason} - - )} - {paid && ( - - Paid · {fmtDate(booking.updatedAt)} - - )} - - {hasItems && ( - <> - - - - - {isAdjusted ? "Adjusted total" : "Total"} - - - {total} - - - - )} - {/* */} - - ); -} +// The booking payment card (countdown + breakdown + invoices + download) now +// lives in ./BookingPaymentPanel. EstimateCard above stays for the draft and +// changes-requested views, which only show an estimate. diff --git a/apps/edr-freight-web/portal/src/services/invoices.service.ts b/apps/edr-freight-web/portal/src/services/invoices.service.ts index 99b1d0dee..2ee18ec4e 100644 --- a/apps/edr-freight-web/portal/src/services/invoices.service.ts +++ b/apps/edr-freight-web/portal/src/services/invoices.service.ts @@ -29,12 +29,39 @@ export const invoicesService = { return data.data ?? data; }, + /** The customer's invoices for one source record (e.g. a booking). */ + listForSource: async ( + source: string, + sourceId: string, + ): Promise => { + const { data } = await client.get(B.MY_INVOICES, { + params: { source, sourceId }, + }); + return data.data ?? data; + }, + /** One of the customer's invoices, with its line items. */ get: async (id: string): Promise => { const { data } = await client.get(B.MY_INVOICE_BY_ID(id)); return data.data ?? data; }, + /** The sealed invoice PDF for one of the customer's invoices. */ + downloadDocument: async (id: string): Promise => { + const { data } = await client.get(B.MY_INVOICE_DOCUMENT(id), { + responseType: "blob", + }); + return data; + }, + + /** The sealed payment-receipt PDF (available once paid). */ + downloadReceipt: async (id: string): Promise => { + const { data } = await client.get(B.MY_INVOICE_RECEIPT(id), { + responseType: "blob", + }); + return data; + }, + /** Initiate gateway payment for an open invoice; returns the client action. */ pay: async ( id: string, diff --git a/apps/edr-freight-web/portal/src/services/warehouse-invoices.service.ts b/apps/edr-freight-web/portal/src/services/warehouse-invoices.service.ts new file mode 100644 index 000000000..f1fa4e841 --- /dev/null +++ b/apps/edr-freight-web/portal/src/services/warehouse-invoices.service.ts @@ -0,0 +1,54 @@ +import { URL_CONSTANTS } from "@/constants/URLS"; +import { client } from "../utils/api"; + +const W = URL_CONSTANTS.WAREHOUSE_INVOICES; + +/** + * A warehouse fee invoice as the freight API projects it for the customer + * (the historical `WarehouseFeeInvoice` view shape — a subset is used here). + */ +export interface PortalWarehouseInvoice { + id: string; + invoiceNumber: string; + invoiceType: string; + status: string; + currency: string; + totalAmount: number | string; + paidAmount: number | string; + balanceAmount: number | string; + issuedAt?: string | null; + dueDate?: string | null; + paidAt?: string | null; + bookingId?: string | null; + inventoryId?: string | null; + bookingReference?: string | null; + inventoryReference?: string | null; + cargoDescription?: string | null; + containerNumber?: string | null; +} + +export const warehouseInvoicesService = { + /** Warehouse fee invoices linked to a booking (via its inventory items). */ + listForBooking: async (bookingId: string): Promise => { + const { data } = await client.get(W.FOR_BOOKING(bookingId)); + return data.data ?? data; + }, + + /** A single warehouse fee invoice (carries `bookingId` for source linking). */ + get: async (id: string): Promise => { + const { data } = await client.get(W.BY_ID(id)); + return data.data ?? data; + }, + + /** The sealed warehouse fee invoice PDF. */ + downloadDocument: async (id: string): Promise => { + const { data } = await client.get(W.DOCUMENT(id), { responseType: "blob" }); + return data; + }, + + /** The sealed warehouse fee payment receipt PDF (available once paid). */ + downloadReceipt: async (id: string): Promise => { + const { data } = await client.get(W.RECEIPT(id), { responseType: "blob" }); + return data; + }, +}; diff --git a/apps/edr-freight-web/portal/src/utils/download.ts b/apps/edr-freight-web/portal/src/utils/download.ts new file mode 100644 index 000000000..594513566 --- /dev/null +++ b/apps/edr-freight-web/portal/src/utils/download.ts @@ -0,0 +1,11 @@ +/** Trigger a browser download of a Blob under `filename`. */ +export function saveBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 171a294fb..3d92a5652 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -140,6 +140,8 @@ export enum InvoiceStatus { Overdue = "OVERDUE", Cancelled = "CANCELLED", Refunded = "REFUNDED", + /** Pay window closed before settlement; terminal, cannot be paid. */ + Expired = "EXPIRED", } /** Originating subsystem an invoice bills for; namespaces invoice events. */ @@ -149,14 +151,6 @@ export enum InvoiceSource { Demurrage = "demurrage", } -/** - * What an invoice bills for within its source — the discriminator when one - * entity carries several invoices (e.g. a booking's up-front vs final charge). - */ -export enum InvoiceType { - Prepaid = "PREPAID", -} - export enum SchedulingStatus { NotScheduled = "NOT_SCHEDULED", Holding = "HOLDING",