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/controllers/priority-configs.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts index 99eaabf3f..6424f013e 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Patch, Post, Query, } from '@nestjs/common'; @@ -24,6 +25,23 @@ export class PriorityConfigsController { return this.service.findAll(query); } + // Static route — must stay above `:id` (Express matches in declaration order). + @Get('next-range') + @RuleEngineView('priority-configs') + @ApiOperation({ + summary: + "Where the next contiguous range for a type (and currency) must start, plus the type's ceiling", + }) + nextRange( + @Query('type') type: 'WAGON' | 'CURRENCY' | 'CUSTOMS', + @Query('currency') currency?: string, + ) { + if (!['WAGON', 'CURRENCY', 'CUSTOMS'].includes(type)) { + throw new BadRequestException('type must be WAGON, CURRENCY, or CUSTOMS'); + } + return this.service.nextRange(type, currency ?? null); + } + @Get(':id') @RuleEngineView('priority-configs') @ApiOperation({ summary: 'Get a priority config by ID' }) 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/rule-engine/services/priority-configs.range.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.range.spec.ts new file mode 100644 index 000000000..53cb7ed83 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.range.spec.ts @@ -0,0 +1,229 @@ +import { BadRequestException } from '@nestjs/common'; + +import { PriorityConfig } from '../entities/priority-config.entity'; +import { PriorityConfigsService } from './priority-configs.service'; + +/** + * Contiguous-range rules for priority configs: per type (per currency for + * CURRENCY), ranges run 1..cap with no gaps and no overlaps; the next range + * must start at the lowest uncovered wagon count. Caps: WAGON 50, + * CURRENCY 35, CUSTOMS 15. + */ +describe('PriorityConfigsService range validation', () => { + const rule = ( + type: PriorityConfig['type'], + min: number, + max: number, + currency: string | null = null, + id = `${type}-${min}-${max}-${currency ?? 'none'}`, + ): PriorityConfig => + ({ + id, + type, + label: `${min}-${max}`, + currency, + minWagonCount: min, + maxWagonCount: max, + }) as PriorityConfig; + + const serviceWith = (rules: PriorityConfig[]): PriorityConfigsService => { + const repository = { + findAll: jest.fn(async ({ where }: { where: { type: string } }) => + rules.filter((r) => r.type === where.type), + ), + findById: jest.fn(async (id: string) => + rules.find((r) => r.id === id) ?? null, + ), + }; + return new PriorityConfigsService( + repository as never, + undefined as never, // DisplayOrderService — unused by range validation + ); + }; + + const attempt = ( + svc: PriorityConfigsService, + input: Partial[0]>, + ) => + svc.assertNoRangeCollision({ + type: 'WAGON', + minWagonCount: 1, + maxWagonCount: 5, + ...input, + }); + + it('accepts the first WAGON range starting at 1', async () => { + await expect( + attempt(serviceWith([]), { minWagonCount: 1, maxWagonCount: 5 }), + ).resolves.toBeUndefined(); + }); + + it('rejects a first range that does not start at 1', async () => { + await expect( + attempt(serviceWith([]), { minWagonCount: 3, maxWagonCount: 5 }), + ).rejects.toThrow(BadRequestException); + }); + + it('rejects an exact duplicate (1–5 vs 1–5)', async () => { + await expect( + attempt(serviceWith([rule('WAGON', 1, 5)]), { + minWagonCount: 1, + maxWagonCount: 5, + }), + ).rejects.toThrow(/must start at 6/); + }); + + it('rejects a partial overlap (4–7 after 1–5)', async () => { + await expect( + attempt(serviceWith([rule('WAGON', 1, 5)]), { + minWagonCount: 4, + maxWagonCount: 7, + }), + ).rejects.toThrow(/must start at 6/); + }); + + it('rejects a gap (8–9 after 1–5) — next range must start at 6', async () => { + await expect( + attempt(serviceWith([rule('WAGON', 1, 5)]), { + minWagonCount: 8, + maxWagonCount: 9, + }), + ).rejects.toThrow(/must start at 6/); + }); + + it('accepts the contiguous continuation (6–10 after 1–5)', async () => { + await expect( + attempt(serviceWith([rule('WAGON', 1, 5)]), { + minWagonCount: 6, + maxWagonCount: 10, + }), + ).resolves.toBeUndefined(); + }); + + it('after deleting a middle rule, the next range must fill the lowest gap', async () => { + // Chain was 1–5, 6–10, 11–20; 6–10 deleted → next must start at 6. + const svc = serviceWith([rule('WAGON', 1, 5), rule('WAGON', 11, 20)]); + await expect( + attempt(svc, { minWagonCount: 21, maxWagonCount: 25 }), + ).rejects.toThrow(/must start at 6/); + await expect( + attempt(svc, { minWagonCount: 6, maxWagonCount: 10 }), + ).resolves.toBeUndefined(); + }); + + it('rejects a gap-fill that overruns into the next rule (6–15 into 11–20)', async () => { + const svc = serviceWith([rule('WAGON', 1, 5), rule('WAGON', 11, 20)]); + await expect( + attempt(svc, { minWagonCount: 6, maxWagonCount: 15 }), + ).rejects.toThrow(/overlaps existing rule/); + }); + + it('enforces the per-type ceilings (WAGON 50, CURRENCY 35, CUSTOMS 15)', async () => { + await expect( + attempt(serviceWith([]), { minWagonCount: 1, maxWagonCount: 51 }), + ).rejects.toThrow(/may not exceed 50/); + await expect( + attempt(serviceWith([]), { + type: 'CURRENCY', + currency: 'USD', + minWagonCount: 1, + maxWagonCount: 36, + }), + ).rejects.toThrow(/may not exceed 35/); + await expect( + attempt(serviceWith([]), { + type: 'CUSTOMS', + minWagonCount: 1, + maxWagonCount: 16, + }), + ).rejects.toThrow(/may not exceed 15/); + }); + + it('rejects any new rule once the chain covers the full range', async () => { + await expect( + attempt(serviceWith([rule('WAGON', 1, 50)]), { + minWagonCount: 51, + maxWagonCount: 51, + }), + ).rejects.toThrow(/may not exceed 50/); + await expect( + attempt(serviceWith([rule('CUSTOMS', 1, 15)]), { + type: 'CUSTOMS', + minWagonCount: 1, + maxWagonCount: 1, + }), + ).rejects.toThrow(/already cover the full 1–15 range/); + }); + + it('tracks CURRENCY chains per currency — USD and ETB are independent', async () => { + const svc = serviceWith([rule('CURRENCY', 1, 5, 'USD')]); + // ETB has no rules yet → starts at 1. + await expect( + attempt(svc, { + type: 'CURRENCY', + currency: 'ETB', + minWagonCount: 1, + maxWagonCount: 5, + }), + ).resolves.toBeUndefined(); + // USD must continue at 6. + await expect( + attempt(svc, { + type: 'CURRENCY', + currency: 'USD', + minWagonCount: 1, + maxWagonCount: 5, + }), + ).rejects.toThrow(/must start at 6/); + }); + + it('excludes the rule being edited from its own contiguity check', async () => { + const existing = rule('WAGON', 6, 10, null, 'editing-me'); + const svc = serviceWith([rule('WAGON', 1, 5), existing]); + // Re-saving 6–10 (e.g. changing points) keeps min 6 — allowed. + await expect( + attempt(svc, { + minWagonCount: 6, + maxWagonCount: 12, + excludeId: 'editing-me', + }), + ).resolves.toBeUndefined(); + }); + + it('lets an upper rule keep its start while a lower gap exists', async () => { + // Chain 1–5, [gap 6–10], 11–20: editing 11–20 keeps min 11 — a lower gap + // must not block editing an upper rule's points or max. + const upper = rule('WAGON', 11, 20, null, 'upper'); + const svc = serviceWith([rule('WAGON', 1, 5), upper]); + await expect( + attempt(svc, { + minWagonCount: 11, + maxWagonCount: 25, + excludeId: 'upper', + }), + ).resolves.toBeUndefined(); + // But it cannot RELOCATE to an arbitrary start — only keep 11 or fill 6. + await expect( + attempt(svc, { + minWagonCount: 30, + maxWagonCount: 35, + excludeId: 'upper', + }), + ).rejects.toThrow(/must start at 6/); + }); + + it('reports the next-range prefill for the form', async () => { + const svc = serviceWith([rule('WAGON', 1, 5), rule('WAGON', 11, 20)]); + await expect(svc.nextRange('WAGON')).resolves.toEqual({ + nextMin: 6, + maxCap: 50, + }); + await expect( + serviceWith([rule('CUSTOMS', 1, 15)]).nextRange('CUSTOMS'), + ).resolves.toEqual({ nextMin: null, maxCap: 15 }); + await expect(serviceWith([]).nextRange('CURRENCY', 'USD')).resolves.toEqual({ + nextMin: 1, + maxCap: 35, + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts index 9aaf06985..ff3711e42 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts @@ -10,6 +10,31 @@ import { } from '../interfaces/priority-configs.repository.interface'; import { DisplayOrderService } from './display-order.service'; +/** Hard ceiling of each type's wagon-count chain (1..cap, contiguous). */ +export const RANGE_CAPS: Record<'WAGON' | 'CURRENCY' | 'CUSTOMS', number> = { + WAGON: 50, + CURRENCY: 35, + CUSTOMS: 15, +}; + +/** + * Lowest wagon count ≥ 1 not covered by any of `rules` — where the next range + * must start. Null when the chain is already complete up to the type's cap. + */ +function nextRangeStart( + rules: Pick[], +): number | null { + const cap = rules.length ? RANGE_CAPS[rules[0].type] : null; + const sorted = [...rules].sort((a, b) => a.minWagonCount - b.minWagonCount); + let next = 1; + for (const r of sorted) { + if (r.minWagonCount > next) break; // gap before this rule — fill it + next = Math.max(next, r.maxWagonCount + 1); + } + if (cap != null && next > cap) return null; + return next; +} + @Injectable() export class PriorityConfigsService { constructor( @@ -73,10 +98,13 @@ export class PriorityConfigsService { } /** - * No two rules of the same type (and, for CURRENCY rules, the same currency) - * may cover overlapping wagon-count ranges — a booking must match at most one - * rule per type. Rejects an exact duplicate (1–5 vs 1–5) and any partial - * overlap (1–5 vs 4–7). Ranges are inclusive on both ends. + * Range rules per type (and, for CURRENCY rules, per currency): + * - ranges never overlap — a booking matches at most one rule per type; + * - ranges are contiguous from 1: a new range must START at the lowest + * wagon count not yet covered (after 1–5 the next is 6–…; deleting a + * middle rule opens a gap and the next create must fill it first); + * - each type has a hard ceiling: WAGON 50, CURRENCY 35, CUSTOMS 15. + * Ranges are inclusive on both ends. */ async assertNoRangeCollision(input: { type: 'WAGON' | 'CURRENCY' | 'CUSTOMS'; @@ -90,13 +118,49 @@ export class PriorityConfigsService { 'Min wagon count cannot be greater than max wagon count', ); } - const siblings = await this.repository.findAll({ - where: { type: input.type }, - }); - const clash = siblings.find( + const cap = RANGE_CAPS[input.type]; + if (input.maxWagonCount > cap) { + throw new BadRequestException( + `${input.type} ranges may not exceed ${cap} — ` + + `${input.minWagonCount}–${input.maxWagonCount} goes past the ceiling.`, + ); + } + + const siblings = ( + await this.repository.findAll({ where: { type: input.type } }) + ).filter( (s) => s.id !== input.excludeId && - (input.type !== 'CURRENCY' || (s.currency ?? null) === (input.currency ?? null)) && + (input.type !== 'CURRENCY' || + (s.currency ?? null) === (input.currency ?? null)), + ); + + const expectedStart = nextRangeStart(siblings); + // An edited rule may always KEEP its current start (so a gap lower in the + // chain never blocks editing an upper rule's points/max) — or move down to + // fill that lowest gap. + const currentStart = input.excludeId + ? (await this.repository.findById(input.excludeId))?.minWagonCount ?? null + : null; + if (expectedStart == null && currentStart == null) { + throw new BadRequestException( + `${input.type} rules already cover the full 1–${cap} range — ` + + 'delete or shrink an existing rule first.', + ); + } + if ( + input.minWagonCount !== expectedStart && + input.minWagonCount !== currentStart + ) { + throw new BadRequestException( + `The next ${input.type} range must start at ${expectedStart} ` + + `(ranges are contiguous — no gaps, no overlaps). ` + + `You entered ${input.minWagonCount}–${input.maxWagonCount}.`, + ); + } + + const clash = siblings.find( + (s) => input.minWagonCount <= s.maxWagonCount && input.maxWagonCount >= s.minWagonCount, ); @@ -109,6 +173,24 @@ export class PriorityConfigsService { } } + /** + * Where the next range for a type/currency must start, and the type's + * ceiling — feeds the create form so the min field is auto-filled and + * locked. `nextMin` is null when the chain already covers 1..cap. + */ + async nextRange( + type: 'WAGON' | 'CURRENCY' | 'CUSTOMS', + currency?: string | null, + ): Promise<{ nextMin: number | null; maxCap: number }> { + const siblings = ( + await this.repository.findAll({ where: { type } }) + ).filter( + (s) => + type !== 'CURRENCY' || (s.currency ?? null) === (currency ?? null), + ); + return { nextMin: nextRangeStart(siblings), maxCap: RANGE_CAPS[type] }; + } + async remove(id: string): Promise { await this.findById(id); await this.repository.softDelete(id); 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/contracts/ContractActionsToolbar.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx index a7187638e..0aeb624b3 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx @@ -4,12 +4,12 @@ import { useQuery } from "@tanstack/react-query"; import { Button, Modal, Stack, Text, Textarea } from "@mantine/core"; import { Check, + FileCheck, FilePen, FileSignature, MessageSquareWarning, RefreshCw, ShieldCheck, - Sparkles, XCircle, Zap, } from "lucide-react"; @@ -180,7 +180,7 @@ export function ContractActionsToolbar({ documentGenerated ? ( ) : ( - + ) } loading={mutations.generateContract.isPending} @@ -196,7 +196,7 @@ export function ContractActionsToolbar({ fullWidth variant="light" color="orange" - leftSection={} + leftSection={} loading={mutations.generateContract.isPending} onClick={() => mutations.generateContract.mutate()} > diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx index 044c729ee..eed6c9e3a 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from "react"; -import { Check, ShieldCheck, X } from "lucide-react"; +import { AlertTriangle, Check, FileCheck, ShieldCheck, X } from "lucide-react"; import { Stack, Group, @@ -31,6 +31,7 @@ export function ContractApprovalStepsCard({ const [confirmOpen, setConfirmOpen] = useState(false); const [pendingStep, setPendingStep] = useState(null); + const [needsGenerateOpen, setNeedsGenerateOpen] = useState(false); const [rejectOpen, setRejectOpen] = useState(false); const [rejectStepRow, setRejectStepRow] = useState(null); @@ -47,7 +48,17 @@ export function ContractApprovalStepsCard({ const nextPending = steps.find((s) => s.status === "PENDING"); const summary = formatContractApprovalProgress(contract.status, steps); + // Approvers must review the GENERATED contract document before approving. If + // it has not been generated yet, block the approval and tell staff to generate + // it first (via "Generate contract" in Staff actions) — mirrors the server + // guard so the user sees a clear reason, not a generic failure toast. + const documentGenerated = Boolean(contract.contractGeneratedAt); + const openApprove = (step: Freight.IContractApprovalStep) => { + if (contract.status === "PENDING_APPROVAL" && !documentGenerated) { + setNeedsGenerateOpen(true); + return; + } setPendingStep(step); setConfirmOpen(true); }; @@ -181,6 +192,47 @@ export function ContractApprovalStepsCard({ + setNeedsGenerateOpen(false)} + title={ + + + Generate the contract first + + } + radius="md" + centered + > + + + The contract document for{" "} + + {contract.reference} + {" "} + has not been generated yet. Approvers must review the generated + document before it can be approved. + + + Use{" "} + + Generate contract + {" "} + in the Staff actions panel — edit the articles first if needed — then + return here to approve. + + + + + + + = {}; for (const field of visibleFields) { - const raw = values[field.name]; + // Derived fields always submit their computed value — never stale state. + const raw = field.computeValue + ? (field.computeValue(values) ?? "") + : values[field.name]; if (field.type === "multiselect") { // Always the full replacement list — the API syncs the relation to it. payload[field.name] = Array.isArray(raw) ? raw : []; @@ -348,6 +351,7 @@ const RuleEngineFormDialog = ({ } const isNumber = field.type === "number"; + const computed = field.computeValue ? field.computeValue(values) : undefined; return ( { const next = e.currentTarget.value; if (isNumber && next.trim().startsWith("-")) return; 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/hooks/rule-engine/useRuleEngine.ts b/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts index b3a6c49fd..5ae7bac72 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts @@ -246,10 +246,13 @@ export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => { /** * Priority-rule approval workflow. Every create/update/delete of a priority * config is SUBMITTED as a change request; an approver applies or rejects it. - * Error toasts surface the backend message so range-collision rejections - * ("1–5 overlaps existing rule …") reach the user verbatim. + * Backend messages (range collision, gap, ceiling) surface verbatim — through + * `onErrorMessage` (the page shows them in a modal) or a toast as fallback. */ -export const usePriorityRuleWorkflow = (enabled: boolean) => { +export const usePriorityRuleWorkflow = ( + enabled: boolean, + onErrorMessage?: (message: string) => void, +) => { const qc = useQueryClient(); const backendMessage = (err: unknown, fallback: string) => { @@ -259,6 +262,12 @@ export const usePriorityRuleWorkflow = (enabled: boolean) => { return msg || fallback; }; + const showError = (err: unknown, fallback: string) => { + const message = backendMessage(err, fallback); + if (onErrorMessage) onErrorMessage(message); + else toast.error(message); + }; + const pending = useQuery({ queryKey: QUERY_KEYS.RULE_ENGINE.priorityRuleChanges, queryFn: () => ruleEngineService.listPriorityRuleChanges("PENDING"), @@ -270,6 +279,10 @@ export const usePriorityRuleWorkflow = (enabled: boolean) => { queryKey: QUERY_KEYS.RULE_ENGINE.priorityRuleChanges, }); await invalidateRuleEngineList(qc, "priority-configs"); + // The full order-list backs the auto-filled min field — keep it fresh too. + await qc.invalidateQueries({ + queryKey: QUERY_KEYS.RULE_ENGINE.orderList("priority-configs"), + }); }; const submit = useMutation({ @@ -279,7 +292,7 @@ export const usePriorityRuleWorkflow = (enabled: boolean) => { toast.success("Change submitted for approval — the team has been notified"); await invalidate(); }, - onError: (err) => toast.error(backendMessage(err, "Failed to submit change")), + onError: (err) => showError(err, "Failed to submit change"), }); const approve = useMutation({ @@ -289,7 +302,7 @@ export const usePriorityRuleWorkflow = (enabled: boolean) => { toast.success("Change approved and applied"); await invalidate(); }, - onError: (err) => toast.error(backendMessage(err, "Failed to approve change")), + onError: (err) => showError(err, "Failed to approve change"), }); const reject = useMutation({ @@ -299,7 +312,7 @@ export const usePriorityRuleWorkflow = (enabled: boolean) => { toast.success("Change rejected"); await invalidate(); }, - onError: (err) => toast.error(backendMessage(err, "Failed to reject change")), + onError: (err) => showError(err, "Failed to reject change"), }); return { pending, submit, approve, reject }; diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx index 95c4c22ac..af8b65a50 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx @@ -19,6 +19,7 @@ import { Navigate, useLocation, useParams } from "react-router-dom"; import { PageContainer, PageHeader } from "@/components/page"; import ManageRuleEngineOrderDialog from "@/components/ruleEngine/ManageRuleEngineOrderDialog"; import PriorityRuleApprovalsSection from "@/pages/ruleEngine/PriorityRuleApprovalsSection"; +import { nextPriorityRangeStart } from "@/pages/ruleEngine/priorityRuleRange"; import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid"; import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog"; import RuleEngineOrderControls from "@/components/ruleEngine/RuleEngineOrderControls"; @@ -145,10 +146,13 @@ const RuleEngineResourcePage = () => { ); // Priority rules never mutate directly: changes are filed for approval and a - // pending queue renders above the table. + // pending queue renders above the table. Validation errors (range collision, + // gap, ceiling) surface in a modal so the text is impossible to miss. const isPriorityRules = config?.slug === "priority-configs"; + const [priorityError, setPriorityError] = useState(null); const priorityWorkflow = usePriorityRuleWorkflow( Boolean(isPriorityRules && canView), + setPriorityError, ); const editingId = editing?.id ? String(editing.id) : undefined; @@ -178,9 +182,36 @@ const RuleEngineResourcePage = () => { const { data: wagonTypeOptions, isLoading: wagonTypeOptionsLoading } = useWagonTypeOptions(usesWagonTypeField); + // Full rule list backing the auto-filled "min wagon count": the next range + // always continues the chain for the selected type (per currency), so the + // form needs every existing rule, not the current page. + const { data: allPriorityRules } = useRuleEngineOrderList( + config?.slug ?? DEFAULT_CONFIGURATION_SLUG, + Boolean(isPriorityRules && formOpen), + config?.orderConfig?.field, + ); + const formFields = useMemo(() => { if (!config) return []; return config.formFields.map((field) => { + if (isPriorityRules && field.name === "minWagonCount") { + return { + ...field, + // Editing keeps the rule's own start (a lower gap never forces it to + // move); creating always continues the chain / fills the lowest gap. + computeValue: (values: Record) => + editing?.minWagonCount != null + ? Number(editing.minWagonCount) + : nextPriorityRangeStart( + allPriorityRules ?? [], + String(values.type ?? ""), + !values.currency || values.currency === RULE_ENGINE_SELECT_NONE + ? null + : String(values.currency), + editingId, + ), + }; + } if (config.slug === "cargo-types" && field.name === "parentGroupId") { return { ...field, @@ -226,7 +257,7 @@ const RuleEngineResourcePage = () => { } return field; }); - }, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions, wagonTypeOptions]); + }, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions, wagonTypeOptions, isPriorityRules, allPriorityRules, editing, editingId]); const rows = data?.items ?? []; const meta = data?.meta; @@ -452,6 +483,25 @@ const RuleEngineResourcePage = () => { /> ) : null} + setPriorityError(null)} + title="Cannot save priority rule" + centered + size="md" + > + + + {priorityError} + + + + + + + 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 bc17fb47a..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 @@ -61,6 +61,13 @@ export interface FormFieldDef { * relation list (`wagonTypeIds` read from `record.wagonTypes`). */ getInitialValue?: (record: Record) => unknown; + /** + * Fully derived field: its value is computed from the live form values on + * every render and the input is locked. Used for the priority-rule min + * wagon count, which always continues the previous range for the selected + * type. Return null/undefined to leave the field empty (e.g. chain full). + */ + computeValue?: (values: Record) => number | string | null; } export interface RuleEngineOrderConfig { @@ -134,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 }); @@ -155,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": @@ -356,8 +367,21 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ placeholder: "Select a currency", hideWhen: { field: "type", equals: ["WAGON", "CUSTOMS"] }, }, - { name: "minWagonCount", label: "Min wagon count", type: "number", required: true }, - { name: "maxWagonCount", label: "Max wagon count", type: "number", required: true }, + { + name: "minWagonCount", + label: "Min wagon count", + type: "number", + required: true, + disabled: true, + description: "Auto-filled — continues the previous range for the selected type", + }, + { + name: "maxWagonCount", + label: "Max wagon count", + type: "number", + required: true, + description: "Ceiling per type: WAGON 50 · CURRENCY 35 · CUSTOMS 15", + }, { name: "scorePoints", label: "Score points", type: "number", required: true }, { name: "isActive", label: "Active", type: "boolean" }, ], diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/priorityRuleRange.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/priorityRuleRange.ts new file mode 100644 index 000000000..f387fc8dc --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/priorityRuleRange.ts @@ -0,0 +1,60 @@ +/** + * Client mirror of the backend's contiguous-range rules for priority configs + * (see PriorityConfigsService.assertNoRangeCollision): ranges per type — per + * currency for CURRENCY — run 1..cap with no gaps and no overlaps, so the next + * range always starts at the lowest uncovered wagon count. The backend + * re-validates on submit AND on approval; this only drives the form prefill. + */ + +export type PriorityRuleType = "WAGON" | "CURRENCY" | "CUSTOMS"; + +/** Hard ceiling of each type's chain — keep in sync with the API's RANGE_CAPS. */ +export const PRIORITY_RANGE_CAPS: Record = { + WAGON: 50, + CURRENCY: 35, + CUSTOMS: 15, +}; + +export interface PriorityRangeRule { + id?: unknown; + type?: unknown; + currency?: unknown; + minWagonCount?: unknown; + maxWagonCount?: unknown; +} + +/** + * Where the next range for `type` (+`currency`) must start, excluding + * `excludeId` (the rule being edited). Null when the chain already covers + * 1..cap — no further rule fits. + */ +export function nextPriorityRangeStart( + rules: PriorityRangeRule[], + type: string, + currency: string | null | undefined, + excludeId?: string, +): number | null { + const cap = PRIORITY_RANGE_CAPS[type as PriorityRuleType]; + if (!cap) return null; + + const scoped = rules + .filter( + (r) => + String(r.type ?? "") === type && + (excludeId === undefined || String(r.id ?? "") !== excludeId) && + (type !== "CURRENCY" || + String(r.currency ?? "") === String(currency ?? "")), + ) + .map((r) => ({ + min: Number(r.minWagonCount ?? 0), + max: Number(r.maxWagonCount ?? 0), + })) + .sort((a, b) => a.min - b.min); + + let next = 1; + for (const r of scoped) { + if (r.min > next) break; // gap before this rule — fill it first + next = Math.max(next, r.max + 1); + } + return next > cap ? null : next; +} 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 && (