From fc44eee25ece960f2cb1574302be31e2de027fdb Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 15 Jul 2026 20:14:39 +0000 Subject: [PATCH] implement update train details feature: add DTO, service method, and UI modal for editing train name and run numbers --- .../2260000000000-AddClearanceFeePayment.ts | 44 ++++ .../bookings/booking-transition.service.ts | 6 + .../bookings/entities/booking.entity.ts | 5 + .../contracts/clearance-fee.service.ts | 215 ++++++++++++++++++ .../contract-booking.completion.spec.ts | 1 + .../contract-booking.consolidation.spec.ts | 1 + .../contracts/contract-booking.service.ts | 13 +- .../contracts/contract-clearance.service.ts | 5 + .../contracts/contract-notifier.service.ts | 20 ++ .../contracts/contract-pricing.service.ts | 33 ++- .../contracts/contract-transition.service.ts | 15 +- .../src/modules/contracts/contracts.module.ts | 2 + .../entities/contract-rate-snapshot.entity.ts | 7 + .../contracts/entities/contract.entity.ts | 6 + .../rule-engine/entities/rate-type.util.ts | 2 + .../rule-engine/entities/rate-unit.util.ts | 3 + .../rule-engine/entities/rate.entity.ts | 4 + .../trains/dto/update-train-details.dto.ts | 29 +++ .../modules/trains/entities/train.entity.ts | 2 +- .../trains/train-builder.controller.ts | 13 ++ .../modules/trains/train-builder.service.ts | 48 ++++ .../trainBuilder/EditTrainDetailsModal.tsx | 123 ++++++++++ .../bookings/booking-status.config.ts | 7 +- .../contracts/contract-status.config.ts | 12 + .../src/pages/ruleEngine/config/resources.ts | 4 + .../trainBuilder/TrainBuilderListPage.tsx | 26 +++ .../backoffice/src/services/api.ts | 13 ++ .../src/services/trainBuilder.service.ts | 11 + .../backoffice/src/types/booking.ts | 1 + .../ContractCustomerAction.tsx | 12 + .../deriveContractCustomerAction.ts | 19 ++ .../portal/src/pages/MyPortalPage/actions.ts | 27 ++- .../components/ActionNeededSection.tsx | 15 +- .../src/pages/MyPortalPage/constants.ts | 13 ++ .../BookingDetailPage/ReadonlyBookingView.tsx | 27 ++- .../clearance/BookingActionButton.tsx | 16 ++ .../bookings/clearance/bookingNextAction.ts | 6 + .../payments/PayClearanceFeeButton.tsx | 133 +++++++++++ .../pages/contracts/ContractDetailPage.tsx | 17 ++ .../src/pages/contracts/NewContractPage.tsx | 6 + .../contracts/NewShipmentRequestPage.tsx | 5 +- .../src/pages/contracts/contract-ui.tsx | 4 + packages/types/src/freight/contracts.ts | 7 + packages/types/src/freight/index.ts | 6 +- 44 files changed, 970 insertions(+), 14 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/2260000000000-AddClearanceFeePayment.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/clearance-fee.service.ts create mode 100644 apps/edr-freight-api/src/modules/trains/dto/update-train-details.dto.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/trainBuilder/EditTrainDetailsModal.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/payments/PayClearanceFeeButton.tsx diff --git a/apps/edr-freight-api/src/migrations/2260000000000-AddClearanceFeePayment.ts b/apps/edr-freight-api/src/migrations/2260000000000-AddClearanceFeePayment.ts new file mode 100644 index 000000000..c0dc2c818 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2260000000000-AddClearanceFeePayment.ts @@ -0,0 +1,44 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Prepaid customs clearance service fee (Path B): + * - contract_rate_snapshots.is_clearance — flags the frozen CUSTOMS_CLEARANCE + * fee line so it is billed via its own clearance invoice and excluded from + * shipment booking totals; + * - contracts.clearance_fee_paid_at — when the ONE_TIME contract-level fee + * settled (gate: AWAITING_CLEARANCE_PAYMENT → AWAITING_CLEARANCE_DOCUMENTS); + * - bookings.clearance_fee_paid_at — when a GENERAL shipment-request instance's + * fee settled (gate: AWAITING_CLEARANCE_PAYMENT → AWAITING_DOCUMENTS). + * All nullable/defaulted — existing rows are untouched and keep today's flow. + */ +export class AddClearanceFeePayment2260000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.contract_rate_snapshots + ADD COLUMN IF NOT EXISTS is_clearance BOOLEAN NOT NULL DEFAULT FALSE; + `); + await queryRunner.query(` + ALTER TABLE freight.contracts + ADD COLUMN IF NOT EXISTS clearance_fee_paid_at TIMESTAMPTZ; + `); + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS clearance_fee_paid_at TIMESTAMPTZ; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS clearance_fee_paid_at; + `); + await queryRunner.query(` + ALTER TABLE freight.contracts + DROP COLUMN IF EXISTS clearance_fee_paid_at; + `); + await queryRunner.query(` + ALTER TABLE freight.contract_rate_snapshots + DROP COLUMN IF EXISTS is_clearance; + `); + } +} 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 698759901..581ad917c 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 @@ -1,5 +1,6 @@ import { BadRequestException, + ConflictException, forwardRef, Inject, Injectable, @@ -714,6 +715,11 @@ export class BookingTransitionService { files: Express.Multer.File[], ): Promise { const booking = await this.bookingsService.findById(bookingId); + if (booking.status === "AWAITING_CLEARANCE_PAYMENT") { + throw new ConflictException( + "The customs clearance service fee for this shipment has not been paid yet — pay it from the portal to unlock document upload.", + ); + } assertBookingStatus(booking, [ "AWAITING_DOCUMENTS", "DOCUMENTS_UNDER_REVIEW", diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 79d15069b..f74cb515e 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -46,6 +46,7 @@ export const BOOKING_STATUSES = [ 'CONTRACT_ACTIVE', 'CONTRACT_CLOSED', // Post counter-sign document-clearance gate (GL workflow). + 'AWAITING_CLEARANCE_PAYMENT', // clearance fee invoiced, unpaid — docs locked 'AWAITING_DOCUMENTS', 'DOCUMENTS_UNDER_REVIEW', 'CLEARANCE_READY', @@ -521,6 +522,10 @@ export class Booking extends BaseEntity { @Column({ name: 'clearance_current_phase', type: 'varchar', length: 40, nullable: true }) clearanceCurrentPhase?: string | null; + /** When the prepaid customs clearance service fee settled (GENERAL + customs). */ + @Column({ name: 'clearance_fee_paid_at', type: 'timestamptz', nullable: true }) + clearanceFeePaidAt?: Date | null; + @Column({ name: 'duty_required', type: 'boolean', nullable: true }) dutyRequired?: boolean | null; diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-fee.service.ts b/apps/edr-freight-api/src/modules/contracts/clearance-fee.service.ts new file mode 100644 index 000000000..3d5dc617b --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/clearance-fee.service.ts @@ -0,0 +1,215 @@ +import { Injectable, Logger, UnprocessableEntityException } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { Freight } from '@edr/types'; + +import { BillingService, InvoiceEventPayload } from '../billing/billing.service'; +import { Invoice } from '../billing/entities/invoice.entity'; +import { BookingsRepository } from '../bookings/bookings.repository'; +import { Booking } from '../bookings/entities/booking.entity'; +import { ContractPricingBreakdown } from './contract-pricing.service'; +import { ContractNotifierService } from './contract-notifier.service'; +import { ContractsRepository } from './contracts.repository'; +import { Contract } from './entities/contract.entity'; + +/** Invoice `type` for the contract-level fee (Path B ONE_TIME, after counter-sign). */ +export const CLEARANCE_CONTRACT_INVOICE_TYPE = 'CLEARANCE_CONTRACT'; +/** Invoice `type` for the per-shipment fee (Path B GENERAL, at shipment request). */ +export const CLEARANCE_BOOKING_INVOICE_TYPE = 'CLEARANCE_BOOKING'; + +/** + * The prepaid customs clearance service fee (Path B) — the GL service charge, + * separate from both freight (booking invoice) and duty/tax (paid offline). + * Issued as its own `clearance`-source invoice and paid BEFORE the clearance + * document step opens and before GL touches the file: + * - ONE_TIME: once per contract, at staff counter-sign + * (AWAITING_CLEARANCE_PAYMENT → paid → AWAITING_CLEARANCE_DOCUMENTS); + * - GENERAL: once per shipment request, on the initiated booking instance + * (booking AWAITING_CLEARANCE_PAYMENT → paid → AWAITING_DOCUMENTS). + * The fee amount is the frozen CUSTOMS_CLEARANCE contract rate snapshot, so + * customers pay what their contract shows, not the live rate of the day. + */ +@Injectable() +export class ClearanceFeeService { + private readonly logger = new Logger(ClearanceFeeService.name); + + constructor( + private readonly billing: BillingService, + private readonly contractsRepository: ContractsRepository, + private readonly bookingsRepository: BookingsRepository, + private readonly notifier: ContractNotifierService, + ) {} + + /** The frozen flat fee for a contract; falls back to the pricing breakdown. */ + private async feeAmountOrNull( + contract: Contract, + ): Promise<{ amount: number; currency: string } | null> { + const snapshots = await this.contractsRepository.findRateSnapshots(contract.id); + const snapshot = snapshots.find( + (s) => s.isClearance || s.rateCode === 'CUSTOMS_CLEARANCE', + ); + if (snapshot && Number(snapshot.unitPrice) > 0) { + return { amount: Number(snapshot.unitPrice), currency: snapshot.currency }; + } + const breakdown = contract.pricingBreakdown as ContractPricingBreakdown | null; + const line = breakdown?.lineItems?.find((l) => l.code === 'CUSTOMS_CLEARANCE'); + if (line && Number(line.unitPrice) > 0) { + return { amount: Number(line.unitPrice), currency: breakdown!.currency }; + } + return null; + } + + private async feeAmount( + contract: Contract, + ): Promise<{ amount: number; currency: string }> { + const fee = await this.feeAmountOrNull(contract); + if (!fee) { + throw new UnprocessableEntityException( + `Contract ${contract.reference} has no frozen customs clearance fee — regenerate its price with a live CUSTOMS_CLEARANCE rate.`, + ); + } + return fee; + } + + /** + * Whether the payment gate applies. Skipped for government/unlinked + * contracts (no company to bill — invoices require one, same rule the + * booking invoice applies) and for legacy customs contracts frozen before + * the fee existed (no CUSTOMS_CLEARANCE snapshot to bill from) — both keep + * the pre-fee flow instead of dead-ending. + */ + async gateApplies(contract: Contract): Promise { + if (!contract.customsClearingEnabled || !contract.companyId) return false; + if ((await this.feeAmountOrNull(contract)) !== null) return true; + this.logger.warn( + `Contract ${contract.reference} has customs enabled but no frozen clearance fee — skipping the prepay gate (legacy contract).`, + ); + return false; + } + + /** Issue (idempotently) the ONE_TIME contract-level fee invoice. */ + async issueForContract(contract: Contract): Promise { + const existing = await this.billing.findPayable( + Freight.InvoiceSource.Clearance, + contract.id, + CLEARANCE_CONTRACT_INVOICE_TYPE, + ); + if (existing) return existing; + + const { amount, currency } = await this.feeAmount(contract); + const invoice = await this.billing.generateInvoice({ + source: Freight.InvoiceSource.Clearance, + sourceId: contract.id, + type: CLEARANCE_CONTRACT_INVOICE_TYPE, + companyId: contract.companyId!, + companyProfileId: contract.companyProfileId!, + currency, + lines: [ + { + chargeType: 'CUSTOMS_CLEARANCE', + description: `Customs clearance service fee — contract ${contract.reference}`, + quantity: 1, + unitRate: amount, + amount, + currency, + }, + ], + status: Freight.InvoiceStatus.Pending, + }); + this.notifier.clearanceFeeDue(contract, amount, currency); + return invoice; + } + + /** Issue (idempotently) the GENERAL per-shipment fee invoice on the booking. */ + async issueForBooking(booking: Booking, contract: Contract): Promise { + const existing = await this.billing.findPayable( + Freight.InvoiceSource.Clearance, + booking.id, + CLEARANCE_BOOKING_INVOICE_TYPE, + ); + if (existing) return existing; + + const { amount, currency } = await this.feeAmount(contract); + const invoice = await this.billing.generateInvoice({ + source: Freight.InvoiceSource.Clearance, + sourceId: booking.id, + type: CLEARANCE_BOOKING_INVOICE_TYPE, + companyId: booking.companyId ?? contract.companyId!, + companyProfileId: booking.companyProfileId ?? contract.companyProfileId!, + currency, + lines: [ + { + chargeType: 'CUSTOMS_CLEARANCE', + description: `Customs clearance service fee — shipment ${booking.reference}`, + quantity: 1, + unitRate: amount, + amount, + currency, + }, + ], + status: Freight.InvoiceStatus.Pending, + }); + this.notifier.clearanceFeeDue(contract, amount, currency, booking.reference); + return invoice; + } + + /** + * Settlement branch point for `clearance`-source invoices: unlock the + * document-upload step the fee was gating. Idempotent — a replayed event on + * an already-advanced contract/booking is a no-op. + */ + @OnEvent('clearance.invoice.paid') + async onClearanceInvoicePaid(payload: InvoiceEventPayload): Promise { + this.logger.log( + `clearance.invoice.paid (${payload.type}) for ${payload.sourceId} from ${payload.invoiceId}`, + ); + switch (payload.type) { + case CLEARANCE_CONTRACT_INVOICE_TYPE: + await this.advanceContract(payload.sourceId); + break; + case CLEARANCE_BOOKING_INVOICE_TYPE: + await this.advanceBooking(payload.sourceId); + break; + default: + this.logger.warn( + `Unhandled clearance invoice type "${payload.type}" paid (${payload.invoiceId})`, + ); + } + } + + private async advanceContract(contractId: string): Promise { + const contract = await this.contractsRepository.findById(contractId); + if (!contract) { + this.logger.warn(`Cannot advance unknown contract ${contractId} on clearance fee payment.`); + return; + } + if (contract.status !== 'AWAITING_CLEARANCE_PAYMENT') return; + + await this.contractsRepository.update(contractId, { + status: 'AWAITING_CLEARANCE_DOCUMENTS', + clearanceStatus: 'AWAITING_DOCUMENTS', + clearanceFeePaidAt: new Date(), + } as never); + const updated = await this.contractsRepository.findByIdWithRelations(contractId); + if (updated) this.notifier.clearanceFeePaid(updated); + } + + private async advanceBooking(bookingId: string): Promise { + const booking = await this.bookingsRepository.findById(bookingId); + if (!booking) { + this.logger.warn(`Cannot advance unknown booking ${bookingId} on clearance fee payment.`); + return; + } + if (booking.status !== 'AWAITING_CLEARANCE_PAYMENT') return; + + await this.bookingsRepository.update(bookingId, { + status: 'AWAITING_DOCUMENTS', + clearanceFeePaidAt: new Date(), + } as never); + if (booking.contractId) { + const contract = await this.contractsRepository.findByIdWithRelations( + booking.contractId, + ); + if (contract) this.notifier.clearanceFeePaid(contract, booking.reference); + } + } +} diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts index 7dc676398..4f3deb513 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts @@ -26,6 +26,7 @@ describe('ContractBookingService — quantity-cap completion', () => { {} as never, // milestoneService {} as never, // workflowService {} as never, // invoiceService + {} as never, // clearanceFeeService {} as never, // dataSource {} as never, // trainSchedulingService {} as never, // bookingBatchService diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts index c2e3a107b..f28468936 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts @@ -57,6 +57,7 @@ describe('ContractBookingService — drawdown consolidation gate', () => { milestoneService as never, {} as never, // workflowService invoiceService as never, + {} as never, // clearanceFeeService {} as never, // dataSource {} as never, // trainSchedulingService {} as never, // bookingBatchService diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index f055a0c15..6078dee0f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -35,6 +35,7 @@ import { hasFreightPermission } from '../../common/freight-permission.util'; import { Contract } from './entities/contract.entity'; import { ContractRoute } from './entities/contract-route.entity'; import { ContractsRepository } from './contracts.repository'; +import { ClearanceFeeService } from './clearance-fee.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ClearanceWorkflowService } from './clearance-workflow.service'; import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto'; @@ -78,6 +79,7 @@ export class ContractBookingService { private readonly milestoneService: ClearanceMilestoneService, private readonly workflowService: ClearanceWorkflowService, private readonly invoiceService: BookingInvoiceService, + private readonly clearanceFeeService: ClearanceFeeService, private readonly dataSource: DataSource, @Inject(forwardRef(() => TrainSchedulingService)) private readonly trainSchedulingService: TrainSchedulingService, @@ -492,6 +494,11 @@ export class ContractBookingService { const route = await this.resolveRoute(contract, opts.contractRouteId); + // Prepay gate: each shipment request owes its own flat clearance service + // fee before the document step opens (the paid event advances the booking + // to AWAITING_DOCUMENTS). Government/unlinked contracts skip the gate. + const feeGate = await this.clearanceFeeService.gateApplies(contract); + const booking = await insertWithGeneratedReference( () => this.generateReference(), (reference) => @@ -501,7 +508,7 @@ export class ContractBookingService { companyProfileId: contract.companyProfileId ?? null, isGovernment: contract.isGovernment, governmentInstitution: contract.governmentInstitution ?? null, - status: 'AWAITING_DOCUMENTS', + status: feeGate ? 'AWAITING_CLEARANCE_PAYMENT' : 'AWAITING_DOCUMENTS', bookingType: 'ONE_TIME', contractId: contract.id, contractRouteId: route?.id ?? null, @@ -540,6 +547,10 @@ export class ContractBookingService { contract.tradeDirection, ); + if (feeGate) { + await this.clearanceFeeService.issueForBooking(booking, contract); + } + return (await this.bookingsRepository.findByIdWithFiles(booking.id)) ?? booking; } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index 60eebf3da..d3bbaf098 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -489,6 +489,11 @@ export class ContractClearanceService { files: Express.Multer.File[], ): Promise { const contract = await this.contractsService.findById(contractId); + if (contract.status === 'AWAITING_CLEARANCE_PAYMENT') { + throw new ConflictException( + 'The customs clearance service fee has not been paid yet — pay it from the portal to unlock document upload.', + ); + } if ( contract.status !== 'AWAITING_CLEARANCE_DOCUMENTS' && contract.status !== 'CLEARANCE_UNDER_REVIEW' diff --git a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts index 3834b55f0..575767a87 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts @@ -158,6 +158,26 @@ export class ContractNotifierService { }); } + /** Clearance service fee invoiced — customer must pay before document upload. */ + clearanceFeeDue(c: Contract, amount: number, currency: string, shipmentRef?: string): void { + const scope = shipmentRef ? `shipment ${shipmentRef} under contract ${c.reference}` : `contract ${c.reference}`; + const msg = + `A customs clearance service fee of ${amount} ${currency} is due for ${scope}. ` + + `Please pay from the portal to unlock the clearance document upload.`; + void this.notifyContact(c, msg, 'CLEARANCE FEE DUE'); + this.inApp(c, 'Clearance fee due', msg); + } + + /** Clearance service fee settled — document upload is now open. */ + clearanceFeePaid(c: Contract, shipmentRef?: string): void { + const scope = shipmentRef ? `shipment ${shipmentRef} under contract ${c.reference}` : `contract ${c.reference}`; + const msg = + `Your customs clearance service fee for ${scope} has been received. ` + + `You can now upload the clearance documents from the portal.`; + void this.notifyContact(c, msg, 'CLEARANCE FEE PAID'); + this.inApp(c, 'Clearance fee paid', msg); + } + // ── Clearance milestones needing customer action ────────────────────────── /** GL advised duty & tax on the contract cycle — customer pays + uploads slip. */ diff --git a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts index 286cd9a01..1646d638c 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, UnprocessableEntityException } from '@nestjs/common'; import { RatesService } from '../rule-engine/services/rates.service'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; @@ -15,6 +15,11 @@ export interface ContractUnitRateLineItem { containerSize?: string | null; conditionalOn?: string | null; cargoTypeCode?: string | null; + /** + * Customs clearance service fee — billed separately in advance (before the + * clearance document step), never part of shipment booking totals. + */ + isClearance?: boolean; } /** The contract `pricing_breakdown` shape (doc §9.1). */ @@ -184,6 +189,31 @@ export class ContractPricingService { } } + // Customs clearance service fee (Path B) — a FLAT prepaid fee, shown on the + // contract and billed via its own clearance invoice: after counter-sign for + // ONE_TIME, per shipment request for GENERAL. Excluded from booking totals. + // A customs contract may not proceed without a configured live rate. + if (contract.customsClearingEnabled) { + const clearance = liveRates.find( + (r) => r.rateType === 'CUSTOMS_CLEARANCE' && r.currency === 'USD', + ); + if (!clearance || Number(clearance.rateValue) <= 0) { + throw new UnprocessableEntityException( + 'No customs clearance service fee is configured. Ask the rates team to set a live CUSTOMS_CLEARANCE rate before submitting customs contracts.', + ); + } + lineItems.push({ + code: 'CUSTOMS_CLEARANCE', + label: + contract.contractKind === 'GENERAL' + ? 'Customs clearance service fee (per shipment request, prepaid)' + : 'Customs clearance service fee (prepaid)', + unit: toContractUnit(clearance.rateUnit), + unitPrice: convert(Number(clearance.rateValue)), + isClearance: true, + }); + } + return { displayMode: 'UNIT_RATES', currency, @@ -229,6 +259,7 @@ export class ContractPricingService { containerSize: line.containerSize ?? null, isSurcharge: !!line.conditionalOn, conditionalOn: line.conditionalOn ?? null, + isClearance: !!line.isClearance, }); } } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index 049db2a94..5fb935e74 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -24,6 +24,7 @@ import { SignaturesService } from '../signatures/signatures.service'; import { OtpService } from '../otp/otp.service'; import { ContractTemplatesService } from '../contract-templates/contract-templates.service'; import { ContractPricingService } from './contract-pricing.service'; +import { ClearanceFeeService } from './clearance-fee.service'; import { ContractNotifierService } from './contract-notifier.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ContractsRepository } from './contracts.repository'; @@ -89,6 +90,7 @@ export class ContractTransitionService { private readonly otpService: OtpService, private readonly notifier: ContractNotifierService, private readonly contractTemplates: ContractTemplatesService, + private readonly clearanceFeeService: ClearanceFeeService, ) {} /** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */ @@ -866,8 +868,17 @@ export class ContractTransitionService { const cycleNumber = (contract.clearanceCycleNumber ?? 0) + 1; const cycle = await this.contractsRepository.openCycle(contractId, cycleNumber); await this.milestoneService.seedPreBookingMilestones(contract, cycle.id); - updates.status = 'AWAITING_CLEARANCE_DOCUMENTS'; - updates.clearanceStatus = 'AWAITING_DOCUMENTS'; + // Path B prepay gate: the customs clearance service fee is invoiced here + // and must settle before the document step opens (the paid event advances + // to AWAITING_CLEARANCE_DOCUMENTS). Path A (self-clearance) has no GL fee. + if (await this.clearanceFeeService.gateApplies(contract)) { + await this.clearanceFeeService.issueForContract(contract); + updates.status = 'AWAITING_CLEARANCE_PAYMENT'; + updates.clearanceStatus = 'AWAITING_PAYMENT'; + } else { + updates.status = 'AWAITING_CLEARANCE_DOCUMENTS'; + updates.clearanceStatus = 'AWAITING_DOCUMENTS'; + } updates.clearanceCycleNumber = cycleNumber; } else { // No contract-level clearance gate — DOMESTIC, or any GENERAL contract diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts index 96bdf22b1..5177b6a39 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts @@ -22,6 +22,7 @@ import { ContractsController } from './contracts.controller'; import { ContractsService } from './contracts.service'; import { ContractsRepository } from './contracts.repository'; import { ContractPricingService } from './contract-pricing.service'; +import { ClearanceFeeService } from './clearance-fee.service'; import { ContractNotifierService } from './contract-notifier.service'; import { ContractTransitionService } from './contract-transition.service'; import { ContractClearanceService } from './contract-clearance.service'; @@ -103,6 +104,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum ContractsService, ContractsRepository, ContractPricingService, + ClearanceFeeService, ContractNotifierService, ContractTransitionService, ContractClearanceService, diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract-rate-snapshot.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract-rate-snapshot.entity.ts index eb0a607cc..52fcd437f 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract-rate-snapshot.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract-rate-snapshot.entity.ts @@ -44,4 +44,11 @@ export class ContractRateSnapshot extends BaseEntity { /** is_hazardous | is_reefer when this is a conditional surcharge. */ @Column({ name: 'conditional_on', type: 'varchar', length: 32, nullable: true }) conditionalOn?: string | null; + + /** + * Customs clearance service fee line — billed up front via a clearance + * invoice, excluded from shipment booking totals. + */ + @Column({ name: 'is_clearance', type: 'boolean', default: false }) + isClearance!: boolean; } diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts index f29b9093b..a526d632f 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts @@ -25,6 +25,7 @@ export const CONTRACT_STATUSES = [ 'SIGNED_CUSTOMER', 'FULLY_EXECUTED', 'CONTRACT_ACTIVE', + 'AWAITING_CLEARANCE_PAYMENT', // Path B — clearance fee invoiced, unpaid 'AWAITING_CLEARANCE_DOCUMENTS', 'CLEARANCE_UNDER_REVIEW', 'CLEARANCE_READY_FOR_BOOKING', @@ -84,6 +85,7 @@ export type ContractKindValue = (typeof CONTRACT_KINDS)[number]; export const CONTRACT_CLEARANCE_STATUSES = [ 'NOT_APPLICABLE', + 'AWAITING_PAYMENT', // Path B — clearance service fee must be paid first 'AWAITING_DOCUMENTS', 'DOCUMENTS_UNDER_REVIEW', 'CLEARANCE_READY_FOR_BOOKING', // Path B — GL may create the booking @@ -215,6 +217,10 @@ export class Contract extends BaseEntity { @Column({ name: 'clearance_cycle_number', type: 'int', default: 0 }) clearanceCycleNumber!: number; + /** When the prepaid customs clearance service fee settled (Path B ONE_TIME). */ + @Column({ name: 'clearance_fee_paid_at', type: 'timestamptz', nullable: true }) + clearanceFeePaidAt?: Date | null; + @Column({ name: 'pricing_breakdown', type: 'jsonb', nullable: true }) pricingBreakdown?: Record | null; diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts index 579f7da9d..5ecc006c1 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts @@ -37,6 +37,8 @@ export function deriveRateType(input: { return 'DEMURRAGE'; case 'PIL_EXTRA_FEE': return 'PIL_EXTRA_FEE'; + case 'CUSTOMS_CLEARANCE': + return 'CUSTOMS_CLEARANCE'; } } diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts index cef613412..b7ffdc485 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts @@ -30,6 +30,9 @@ export function allowedRateUnits(input: { return ['PER_CONTAINER', 'PER_TON']; case 'CANCELLATION': return ['FLAT', 'PER_INVOICE']; + case 'CUSTOMS_CLEARANCE': + // Flat per clearance (ONE_TIME contract) / per shipment request (GENERAL). + return ['FLAT']; case 'CONSOLIDATION': return ['PER_CONTAINER', 'FLAT']; case 'SHIPPING_LINE': diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts index 50f8b3b99..d66358cc9 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts @@ -21,6 +21,7 @@ export const RATE_TYPES = [ 'HAZARD_SURCHARGE', 'REEFER_SURCHARGE', 'PIL_EXTRA_FEE', + 'CUSTOMS_CLEARANCE', ] as const; export type RateType = typeof RATE_TYPES[number]; @@ -75,6 +76,9 @@ export const RATE_TRIGGERS = [ 'CANCELLATION', 'DEMURRAGE', 'PIL_EXTRA_FEE', + // Customs clearance service fee — billed up front via a clearance invoice, + // never auto-applied to booking pricing (matchesTrigger returns false). + 'CUSTOMS_CLEARANCE', ] as const; export type RateTrigger = typeof RATE_TRIGGERS[number]; diff --git a/apps/edr-freight-api/src/modules/trains/dto/update-train-details.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/update-train-details.dto.ts new file mode 100644 index 000000000..c91bb30a1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/trains/dto/update-train-details.dto.ts @@ -0,0 +1,29 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator'; + +/** + * Edit a built train's display identity: its name and its fixed import/export + * run numbers. Composition (yard, locomotives, wagons) has its own endpoints. + * Omitted fields keep their current value; an empty trainName clears the name. + */ +export class UpdateTrainDetailsDto { + @ApiPropertyOptional({ description: 'Display name; empty string clears it' }) + @IsOptional() + @IsString() + @MaxLength(100) + trainName?: string; + + @ApiPropertyOptional({ description: 'Fixed IMPORT (even) run number' }) + @IsOptional() + @IsString() + @IsNotEmpty() + @MaxLength(20) + importTrainNumber?: string; + + @ApiPropertyOptional({ description: 'Fixed EXPORT (odd) run number' }) + @IsOptional() + @IsString() + @IsNotEmpty() + @MaxLength(20) + exportTrainNumber?: string; +} diff --git a/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts b/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts index 5b26c696a..493e564e8 100644 --- a/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts +++ b/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts @@ -45,7 +45,7 @@ export class Train extends BaseEntity { trainNumber?: string; @Column({ name: 'train_name', type: 'varchar', length: 100, nullable: true }) - trainName?: string; + trainName?: string | null; @Column({ name: 'route_id', type: 'uuid', nullable: true }) routeId?: string; diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts index eec68fcfc..9b7454f36 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts @@ -19,6 +19,7 @@ import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto'; import { BuildTrainDto } from './dto/build-train.dto'; import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto'; import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto'; +import { UpdateTrainDetailsDto } from './dto/update-train-details.dto'; import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto'; import { UpdateTrainYardDto } from './dto/update-train-yard.dto'; import { TrainBuilderService } from './train-builder.service'; @@ -59,6 +60,18 @@ export class TrainBuilderController { return this.trainBuilderService.setLocomotives(id, dto); } + @Patch(':id/details') + @FleetManage() + @ApiOperation({ + summary: "Edit the train's name and fixed import/export run numbers", + }) + updateDetails( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateTrainDetailsDto, + ) { + return this.trainBuilderService.updateDetails(id, dto); + } + @Patch(':id/yard') @FleetManage() @ApiOperation({ diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts index 3b5ad16cd..50fa77afa 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts @@ -17,6 +17,7 @@ import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto'; import { BuildTrainDto } from './dto/build-train.dto'; import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto'; import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto'; +import { UpdateTrainDetailsDto } from './dto/update-train-details.dto'; import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto'; import { TrainLocomotive } from './entities/train-locomotive.entity'; import { Train } from './entities/train.entity'; @@ -331,6 +332,53 @@ export class TrainBuilderService { return this.getComposition(id); } + /** + * Edit a built train's display identity: name and fixed import/export run + * numbers. Mirrors the build-time number rules — the pair may not collide + * with any other train's pair or legacy number (friendly 409 ahead of the + * partial unique indexes). Blocked while the train is out on a dispatched + * run, like every other composition edit. + */ + async updateDetails(id: string, dto: UpdateTrainDetailsDto) { + await this.dataSource.transaction(async (manager) => { + const train = await this.getEditableTrain(manager, id); + + const patch: Partial = {}; + if (dto.trainName !== undefined) { + patch.trainName = dto.trainName.trim() || null; + } + const importTrainNumber = dto.importTrainNumber?.trim(); + const exportTrainNumber = dto.exportTrainNumber?.trim(); + if (importTrainNumber) patch.importTrainNumber = importTrainNumber; + if (exportTrainNumber) patch.exportTrainNumber = exportTrainNumber; + + if (importTrainNumber || exportTrainNumber) { + const nextImport = importTrainNumber ?? train.importTrainNumber ?? ''; + const nextExport = exportTrainNumber ?? train.exportTrainNumber ?? ''; + const numberClash: { code: string }[] = await manager.query( + `SELECT code FROM freight.trains + WHERE deleted_at IS NULL + AND id != $3 + AND (import_train_number IN ($1, $2) + OR export_train_number IN ($1, $2) + OR train_number IN ($1, $2)) + LIMIT 1`, + [nextImport, nextExport, train.id], + ); + if (numberClash.length) { + throw new ConflictException( + `Train number ${nextImport}/${nextExport} is already used by train ${numberClash[0].code}`, + ); + } + } + + if (Object.keys(patch).length) { + await manager.getRepository(Train).update(train.id, patch); + } + }); + return this.getComposition(id); + } + /** * Relocate the train to another yard. The consist moves as one unit: every * coupled locomotive and wagon follows to the new yard (so their current diff --git a/apps/edr-freight-web/backoffice/src/components/trainBuilder/EditTrainDetailsModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainBuilder/EditTrainDetailsModal.tsx new file mode 100644 index 000000000..0a2371138 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainBuilder/EditTrainDetailsModal.tsx @@ -0,0 +1,123 @@ +import { Button, Group, Modal, Stack, Text, TextInput } from "@mantine/core"; +import { useMutation } from "@tanstack/react-query"; +import { Pencil } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { useToast } from "@/hooks/use-toast"; +import { api } from "@/services/api"; +import type { BuiltTrainSummary } from "@/services/trainBuilder.service"; + +export interface EditTrainDetailsModalProps { + /** Train being edited; null closes the modal. */ + train: BuiltTrainSummary | null; + onClose: () => void; +} + +/** + * Edit a built train's display identity from the list: its name and its fixed + * import/export run numbers. Composition (yard, locomotives, wagons) is edited + * on the detail page. Number collisions come back as a 409 with the owning + * train's code and surface verbatim. + */ +const EditTrainDetailsModal = ({ train, onClose }: EditTrainDetailsModalProps) => { + const { toast } = useToast(); + const [name, setName] = useState(""); + const [importNo, setImportNo] = useState(""); + const [exportNo, setExportNo] = useState(""); + + useEffect(() => { + if (train) { + setName(train.trainName ?? ""); + setImportNo(train.importTrainNumber ?? ""); + setExportNo(train.exportTrainNumber ?? ""); + } + }, [train]); + + const update = useMutation(api.trainBuilder.updateDetails.mutationOptions()); + + const handleSave = async () => { + if (!train) return; + try { + await update.mutateAsync({ + id: train.id, + payload: { + trainName: name.trim(), + // Numbers cannot be cleared — only replaced; empty inputs keep the + // current value (legacy trains may have none yet). + ...(importNo.trim() ? { importTrainNumber: importNo.trim() } : {}), + ...(exportNo.trim() ? { exportTrainNumber: exportNo.trim() } : {}), + }, + }); + toast({ title: `Train ${train.code} updated` }); + onClose(); + } catch (err) { + const message = + (err as { response?: { data?: { message?: string } } })?.response?.data + ?.message ?? "Update failed"; + toast({ + title: "Could not update train", + description: String(message), + variant: "destructive", + }); + } + }; + + return ( + + + Edit train {train?.code ?? ""} + + } + centered + size="md" + radius="lg" + > + + setName(e.currentTarget.value)} + maxLength={100} + radius="md" + /> + + setImportNo(e.currentTarget.value)} + maxLength={20} + radius="md" + /> + setExportNo(e.currentTarget.value)} + maxLength={20} + radius="md" + /> + + + + + + + + ); +}; + +export default EditTrainDetailsModal; diff --git a/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts b/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts index f87f96433..e5961240f 100644 --- a/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts +++ b/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts @@ -285,7 +285,12 @@ export const BOOKING_LIST_TABS = [ { key: "clearance", label: "Clearance", - statuses: ["AWAITING_DOCUMENTS", "DOCUMENTS_UNDER_REVIEW", "CLEARANCE_READY"], + statuses: [ + "AWAITING_CLEARANCE_PAYMENT", + "AWAITING_DOCUMENTS", + "DOCUMENTS_UNDER_REVIEW", + "CLEARANCE_READY", + ], }, { key: "payment", diff --git a/apps/edr-freight-web/backoffice/src/features/contracts/contract-status.config.ts b/apps/edr-freight-web/backoffice/src/features/contracts/contract-status.config.ts index 2277d702b..cf1f04c90 100644 --- a/apps/edr-freight-web/backoffice/src/features/contracts/contract-status.config.ts +++ b/apps/edr-freight-web/backoffice/src/features/contracts/contract-status.config.ts @@ -51,6 +51,10 @@ export const CONTRACT_STATUS_STYLES: Record = { label: "Active", color: "bg-[color:var(--freight-brand-muted)] text-[color:var(--freight-brand)] border-[color:var(--freight-brand-border)]", }, + AWAITING_CLEARANCE_PAYMENT: { + label: "Clearance Fee Due", + color: "bg-orange-50 text-orange-700 border-orange-200", + }, AWAITING_CLEARANCE_DOCUMENTS: { label: "Awaiting Documents", color: "bg-amber-50 text-amber-700 border-amber-200", @@ -118,6 +122,7 @@ export const CONTRACT_STATUS_COLOR: Record = { SIGNED_CUSTOMER: "cyan", FULLY_EXECUTED: "indigo", CONTRACT_ACTIVE: "edr-green", + AWAITING_CLEARANCE_PAYMENT: "orange", AWAITING_CLEARANCE_DOCUMENTS: "yellow", CLEARANCE_UNDER_REVIEW: "yellow", CLEARANCE_READY_FOR_BOOKING: "edr-green", @@ -207,6 +212,13 @@ export const CONTRACT_STATUS_META: Record = { color: "text-[color:var(--freight-brand)]", stage: 3, }, + AWAITING_CLEARANCE_PAYMENT: { + title: "Clearance Fee Due", + description: + "Customer must pay the prepaid clearance service fee before uploading documents.", + color: "text-orange-600", + stage: 3, + }, AWAITING_CLEARANCE_DOCUMENTS: { title: "Awaiting Documents", description: "Customer is uploading pre-booking clearance documents.", diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index 4423b4f93..9e248eee2 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -141,6 +141,7 @@ const RATE_TRIGGERS = [ { label: "Cancellation", value: "CANCELLATION" }, { label: "Demurrage", value: "DEMURRAGE" }, { label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" }, + { label: "Customs clearance service fee (prepaid)", value: "CUSTOMS_CLEARANCE" }, ]; const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value }); @@ -162,6 +163,9 @@ const allowedRateUnits = (appliesTo: string, trigger: string): string[] => { return ["PER_CONTAINER", "PER_TON"]; case "CANCELLATION": return ["FLAT", "PER_INVOICE"]; + case "CUSTOMS_CLEARANCE": + // Flat per clearance (ONE_TIME) / per shipment request (GENERAL). + return ["FLAT"]; case "CONSOLIDATION": case "SHIPPING_LINE": case "PIL_EXTRA_FEE": diff --git a/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderListPage.tsx index de6b96aa5..0d6b7a892 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderListPage.tsx @@ -1,6 +1,7 @@ import type { ColumnDef } from "@edr/ui-common"; import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common"; import { + ActionIcon, Badge, Box, Button, @@ -15,6 +16,7 @@ import { useDebouncedValue } from "@mantine/hooks"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { Hammer, + Pencil, Ruler, Search, Train as TrainIcon, @@ -27,6 +29,7 @@ import { useNavigate } from "react-router-dom"; import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; import BuildTrainModal from "@/components/trainBuilder/BuildTrainModal"; +import EditTrainDetailsModal from "@/components/trainBuilder/EditTrainDetailsModal"; import { directionColor, directionRowStyle, @@ -52,6 +55,7 @@ export default function TrainBuilderListPage() { const [statusFilter, setStatusFilter] = useState<"ALL" | BuiltTrainStatus>("ALL"); const [yardFilter, setYardFilter] = useState("ALL"); const [buildOpen, setBuildOpen] = useState(false); + const [editTarget, setEditTarget] = useState(null); const resetPage = useCallback(() => { setPagination((prev) => @@ -239,6 +243,26 @@ export default function TrainBuilderListPage() { ), }, + { + id: "actions", + header: "", + meta: { headerClassName, cellClassName }, + cell: ({ row }) => ( + { + // Row click navigates to the detail page — keep the edit local. + e.stopPropagation(); + setEditTarget(row.original); + }} + > + + + ), + }, ]; }, []); @@ -363,6 +387,8 @@ export default function TrainBuilderListPage() { onClose={() => setBuildOpen(false)} onBuilt={(composition) => navigate(`/dashboard/train-builder/${composition.id}`)} /> + + setEditTarget(null)} /> ); } diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index 88d922829..7a2a478c1 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -191,6 +191,7 @@ import { type BuiltTrainListResponse, type ScheduleConsist, type TrainComposition, + type UpdateTrainDetailsPayload, } from "./trainBuilder.service"; import { trainSchedulingService } from "./trainScheduling.service"; import { wagonTypesService, type WagonType } from "./wagon-types.service"; @@ -1842,6 +1843,18 @@ export const api = { () => TRAIN_BUILDER_INVALIDATIONS, ), + updateDetails: endpoint< + { id: string; payload: UpdateTrainDetailsPayload }, + TrainComposition + >( + "train-builder", + "updateDetails", + ({ id, payload }) => + trainBuilderService.updateDetails(id, payload).then((r) => r.data), + undefined, + () => TRAIN_BUILDER_INVALIDATIONS, + ), + assignWagons: endpoint<{ id: string; wagonIds: string[] }, TrainComposition>( "train-builder", "assignWagons", diff --git a/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts b/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts index f78792b81..62ae9f7a3 100644 --- a/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts @@ -140,6 +140,14 @@ export interface BuildTrainPayload { notes?: string; } +/** Edit a built train's display identity; omitted fields keep their value. */ +export interface UpdateTrainDetailsPayload { + /** Empty string clears the name. */ + trainName?: string; + importTrainNumber?: string; + exportTrainNumber?: string; +} + /** Built train annotated for the schedule-creation picker. */ export interface AvailableTrain { id: string; @@ -241,6 +249,9 @@ export const trainBuilderService = { build: (payload: BuildTrainPayload) => apiClient.post(BASE, payload), setLocomotives: (id: string, locomotiveIds: string[]) => apiClient.put(`${BASE}/${id}/locomotives`, { locomotiveIds }), + /** Edit the train's name and fixed import/export run numbers. */ + updateDetails: (id: string, payload: UpdateTrainDetailsPayload) => + apiClient.patch(`${BASE}/${id}/details`, payload), /** Relocate the train — coupled locomotives and wagons move with it. */ setYard: (id: string, currentYardId: string) => apiClient.patch(`${BASE}/${id}/yard`, { currentYardId }), diff --git a/apps/edr-freight-web/backoffice/src/types/booking.ts b/apps/edr-freight-web/backoffice/src/types/booking.ts index c31118cc7..ec31e11fa 100644 --- a/apps/edr-freight-web/backoffice/src/types/booking.ts +++ b/apps/edr-freight-web/backoffice/src/types/booking.ts @@ -28,6 +28,7 @@ export const BOOKING_STATUSES = [ "CONTRACT_ACTIVE", "CONTRACT_CLOSED", // Post counter-sign document-clearance gate. + "AWAITING_CLEARANCE_PAYMENT", "AWAITING_DOCUMENTS", "DOCUMENTS_UNDER_REVIEW", "CLEARANCE_READY", diff --git a/apps/edr-freight-web/portal/src/components/customer-actions/ContractCustomerAction.tsx b/apps/edr-freight-web/portal/src/components/customer-actions/ContractCustomerAction.tsx index 86c37585c..019f83143 100644 --- a/apps/edr-freight-web/portal/src/components/customer-actions/ContractCustomerAction.tsx +++ b/apps/edr-freight-web/portal/src/components/customer-actions/ContractCustomerAction.tsx @@ -14,6 +14,7 @@ import { useNavigate } from "react-router-dom"; import type { Freight } from "@edr/types"; +import { PayClearanceFeeButton } from "@/pages/bookings/payments/PayClearanceFeeButton"; import { PayNowButton } from "@/pages/bookings/payments/PayNowButton"; import { api } from "@/services/api"; import { ContractClearanceAction } from "./ContractClearanceAction"; @@ -69,6 +70,17 @@ export function ContractCustomerAction({ ); } + if (action.type === "pay-clearance") { + return ( + + ); + } + if (action.type === "initiate") { return ( invoicesService.listForSource("booking", payItem!.targetId), + queryKey: [`${payItemSource}-invoices`, payItem?.targetId], + queryFn: () => + invoicesService.listForSource(payItemSource, payItem!.targetId), enabled: payItem !== null, }); const payableInvoiceId = payItemInvoices.find((inv) => @@ -182,6 +188,7 @@ export function ActionNeededSection({ navigate(`/contracts/${item.targetId}`); break; case "pay": + case "clearance-fee": setPayItem(item); break; case "sign": @@ -277,7 +284,9 @@ export function ActionNeededSection({ > {item.kind === "pay" ? "Pay now" - : item.kind === "duty" + : item.kind === "clearance-fee" + ? "Pay clearance fee" + : item.kind === "duty" ? "Pay duty & upload slip" : item.kind === "sign" ? "Sign" diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts b/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts index 7be5a0aed..b67b11315 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts @@ -165,6 +165,19 @@ export const STATUS_CONFIG: Record = { badgeDot: "edr-green.5", action: { label: "View", kind: "outline" }, }, + AWAITING_CLEARANCE_PAYMENT: { + stage: 3, + icon: Wallet, + iconColor: "edr-amber-text", + tile: "edr-amber-soft", + hint: "Clearance service fee due · pay to unlock document upload", + step: "edr-accent", + badgeLabel: "Clearance fee due", + badgeBg: "edr-amber-soft", + badgeText: "edr-amber-text", + badgeDot: "edr-accent", + action: { label: "Pay clearance fee", kind: "amber", icon: ArrowRight }, + }, AWAITING_DOCUMENTS: { stage: 3, icon: FileUp, 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 04739b55d..244a38739 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 @@ -1,4 +1,4 @@ -import { Group, Tabs } from "@mantine/core"; +import { Group, Paper, Tabs, Text } from "@mantine/core"; import { useMutation, useQuery } from "@tanstack/react-query"; import { CreditCard, FileText, LayoutGrid } from "lucide-react"; import { useState } from "react"; @@ -12,6 +12,7 @@ import { isPayable } from "@/pages/billing/invoice-ui"; import type { Freight } from "@edr/types"; import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton"; +import { PayClearanceFeeButton } from "../payments/PayClearanceFeeButton"; import { ActivityCard } from "./components/ActivityCard"; import { ClearanceCard } from "./components/ClearanceCard"; import { DocumentsTab } from "./components/DocumentsTab"; @@ -139,6 +140,8 @@ export function ReadonlyBookingView({ const isCustoms = Boolean(booking.customsClearingEnabled); const canSelfRebook = !isCustoms; const isPendingConsolidation = status === "PENDING_CONSOLIDATION"; + // Prepaid clearance service fee gate — document upload stays locked until paid. + const isAwaitingClearanceFee = status === "AWAITING_CLEARANCE_PAYMENT"; const isClearance = [ "AWAITING_DOCUMENTS", "DOCUMENTS_UNDER_REVIEW", @@ -237,6 +240,28 @@ export function ReadonlyBookingView({
+ {isAwaitingClearanceFee && ( + + +
+ + Customs clearance service fee due + + + Pay the clearance service fee to unlock the clearance + document upload. Global Logistics starts working on your + shipment once the fee is settled. + +
+ +
+
+ )} + {isClearance && } = { + PAY_CLEARANCE: CreditCard, UPLOAD_DOCUMENTS: Upload, FIX_DOCUMENTS: AlertCircle, SCHEDULE_OPERATION: ArrowRight, @@ -56,6 +59,19 @@ export function BookingActionButton({ if (!isChangesRequested && !action) return null; + // The prepaid clearance service fee has its own payment flow (method modal + + // provider redirect) — delegate to the self-contained pay button. + if (action?.kind === "PAY_CLEARANCE") { + return ( + + ); + } + const Icon = action ? ICON_BY_KIND[action.kind] : PencilLine; const label = action ? action.label : "Update & resubmit"; // BOOK navigates to the booking form (cargo + day + window check) — the diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts b/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts index 1ebb047a6..2bdd976a9 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts @@ -7,6 +7,7 @@ import type { Freight } from "@edr/types"; * to operation. */ export type BookingActionKind = + | "PAY_CLEARANCE" // AWAITING_CLEARANCE_PAYMENT — pay the prepaid clearance service fee | "UPLOAD_DOCUMENTS" // AWAITING_DOCUMENTS — upload the required clearance docs | "FIX_DOCUMENTS" // DOCUMENTS_UNDER_REVIEW — some docs queried, re-upload them | "SCHEDULE_OPERATION" // CLEARANCE_READY (legacy with cargo) — pick a day and proceed @@ -23,6 +24,11 @@ export interface BookingNextAction { } const ACTION_BY_STATUS: Record = { + AWAITING_CLEARANCE_PAYMENT: { + kind: "PAY_CLEARANCE", + label: "Pay clearance fee", + title: "Pay the clearance service fee", + }, AWAITING_DOCUMENTS: { kind: "UPLOAD_DOCUMENTS", label: "Upload documents", diff --git a/apps/edr-freight-web/portal/src/pages/bookings/payments/PayClearanceFeeButton.tsx b/apps/edr-freight-web/portal/src/pages/bookings/payments/PayClearanceFeeButton.tsx new file mode 100644 index 000000000..02cf37937 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/payments/PayClearanceFeeButton.tsx @@ -0,0 +1,133 @@ +import { Button, type ButtonProps } from "@mantine/core"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { CreditCard } from "lucide-react"; +import { useState } from "react"; + +import { ModalSafeWrapper } from "@/components/customer-actions/ModalSafeWrapper"; +import { isPayable } from "@/pages/billing/invoice-ui"; +import { api } from "@/services/api"; +import { invoicesService } from "@/services/invoices.service"; +import { + paymentsService, + type PaymentMethod, +} from "@/services/payments.service"; +import { PaymentMethodModal } from "../BookingDetailPage/components/PaymentMethodModal"; + +/** + * Payment flow for the prepaid customs clearance service fee. The fee is its + * own `clearance`-source invoice — sourceId is the contract id (ONE_TIME, + * contract status AWAITING_CLEARANCE_PAYMENT) or the booking id (GENERAL + * shipment request, booking status AWAITING_CLEARANCE_PAYMENT). Paying it + * unlocks the clearance document upload; same modal + provider redirect as + * booking payment. + */ +export function useClearanceFeePayment(sourceId: string) { + const [modalOpen, setModalOpen] = useState(false); + + const { data: invoices = [] } = useQuery({ + queryKey: ["clearance-invoices", sourceId], + queryFn: () => invoicesService.listForSource("clearance", sourceId), + enabled: Boolean(sourceId), + }); + const payableInvoice = invoices.find((inv) => isPayable(inv.status)) ?? null; + + const mutation = useMutation({ + mutationFn: (method: PaymentMethod) => { + if (!payableInvoice) { + throw new Error( + "No payable clearance-fee invoice found yet. Please refresh or contact support.", + ); + } + return api.invoices.pay.call({ + id: payableInvoice.id, + payload: { method, platform: "web" }, + }); + }, + onSuccess: (data, method) => { + const redirectUrl = + data?.clientAction?.type === "REDIRECT" && data.clientAction.url + ? data.clientAction.url + : paymentsService.checkoutUrlForInvoice({ + invoiceId: payableInvoice!.id, + method, + }); + window.location.href = redirectUrl; + }, + }); + + const close = () => { + if (!mutation.isPending) { + setModalOpen(false); + mutation.reset(); + } + }; + + return { + invoice: payableInvoice, + modalOpen, + open: () => setModalOpen(true), + close, + processing: mutation.isPending, + error: mutation.isError + ? mutation.error instanceof Error + ? mutation.error.message + : "Could not start payment. Please try again." + : null, + confirm: (method: PaymentMethod) => mutation.mutate(method), + }; +} + +interface PayClearanceFeeButtonProps { + /** Contract id (ONE_TIME) or booking id (GENERAL shipment) the fee bills. */ + sourceId: string; + /** Fallback currency while the invoice is loading. */ + currency?: string; + label?: string; + size?: ButtonProps["size"]; + fullWidth?: boolean; +} + +/** Self-contained "Pay clearance fee" action — modal in place, no navigation. */ +export function PayClearanceFeeButton({ + sourceId, + currency, + label = "Pay clearance fee", + size = "xs", + fullWidth, +}: PayClearanceFeeButtonProps) { + const pay = useClearanceFeePayment(sourceId); + + return ( + + + + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx index 64c64ce26..1b5a29e2a 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx @@ -72,6 +72,7 @@ import { ContractClearancePanel } from "./ContractClearancePanel"; import { ContractClearanceWorkflowBanner } from "./ContractClearanceWorkflowBanner"; import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel"; import { InitiateBookingButton } from "@/components/customer-actions/ContractCustomerAction"; +import { PayClearanceFeeButton } from "@/pages/bookings/payments/PayClearanceFeeButton"; import { formatRateUnit } from "./new-contract-form/unit-rates"; import { getContractBookingAction } from "./contract-booking-action"; import { closedWindowMessage, hasOpenWindow } from "./booking-window"; @@ -396,6 +397,9 @@ export default function ContractDetailPage() { // clearance is finalized. const canUploadClearance = CLEARANCE_UPLOAD_STATUSES.includes(contract.status) && !clearanceFinalized; + // Prepaid clearance service fee gate (Path B) — the document step stays + // locked until the fee invoice settles. + const awaitingClearanceFee = contract.status === "AWAITING_CLEARANCE_PAYMENT"; return ( @@ -531,6 +535,13 @@ export default function ContractDetailPage() { Global Logistics is creating your booking )} + {awaitingClearanceFee && ( + + )} {canUploadClearance && (