implement update train details feature: add DTO, service method, and UI modal for editing train name and run numbers

This commit is contained in:
Marshal
2026-07-15 20:14:39 +00:00
parent d7d9db9c3a
commit fc44eee25e
44 changed files with 970 additions and 14 deletions

View File

@@ -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<void> {
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<void> {
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;
`);
}
}

View File

@@ -1,5 +1,6 @@
import { import {
BadRequestException, BadRequestException,
ConflictException,
forwardRef, forwardRef,
Inject, Inject,
Injectable, Injectable,
@@ -714,6 +715,11 @@ export class BookingTransitionService {
files: Express.Multer.File[], files: Express.Multer.File[],
): Promise<Booking> { ): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId); 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, [ assertBookingStatus(booking, [
"AWAITING_DOCUMENTS", "AWAITING_DOCUMENTS",
"DOCUMENTS_UNDER_REVIEW", "DOCUMENTS_UNDER_REVIEW",

View File

@@ -46,6 +46,7 @@ export const BOOKING_STATUSES = [
'CONTRACT_ACTIVE', 'CONTRACT_ACTIVE',
'CONTRACT_CLOSED', 'CONTRACT_CLOSED',
// Post counter-sign document-clearance gate (GL workflow). // Post counter-sign document-clearance gate (GL workflow).
'AWAITING_CLEARANCE_PAYMENT', // clearance fee invoiced, unpaid — docs locked
'AWAITING_DOCUMENTS', 'AWAITING_DOCUMENTS',
'DOCUMENTS_UNDER_REVIEW', 'DOCUMENTS_UNDER_REVIEW',
'CLEARANCE_READY', 'CLEARANCE_READY',
@@ -521,6 +522,10 @@ export class Booking extends BaseEntity {
@Column({ name: 'clearance_current_phase', type: 'varchar', length: 40, nullable: true }) @Column({ name: 'clearance_current_phase', type: 'varchar', length: 40, nullable: true })
clearanceCurrentPhase?: string | null; 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 }) @Column({ name: 'duty_required', type: 'boolean', nullable: true })
dutyRequired?: boolean | null; dutyRequired?: boolean | null;

View File

@@ -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<boolean> {
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<Invoice> {
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<Invoice> {
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<void> {
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<void> {
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<void> {
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);
}
}
}

View File

@@ -26,6 +26,7 @@ describe('ContractBookingService — quantity-cap completion', () => {
{} as never, // milestoneService {} as never, // milestoneService
{} as never, // workflowService {} as never, // workflowService
{} as never, // invoiceService {} as never, // invoiceService
{} as never, // clearanceFeeService
{} as never, // dataSource {} as never, // dataSource
{} as never, // trainSchedulingService {} as never, // trainSchedulingService
{} as never, // bookingBatchService {} as never, // bookingBatchService

View File

@@ -57,6 +57,7 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
milestoneService as never, milestoneService as never,
{} as never, // workflowService {} as never, // workflowService
invoiceService as never, invoiceService as never,
{} as never, // clearanceFeeService
{} as never, // dataSource {} as never, // dataSource
{} as never, // trainSchedulingService {} as never, // trainSchedulingService
{} as never, // bookingBatchService {} as never, // bookingBatchService

View File

@@ -35,6 +35,7 @@ import { hasFreightPermission } from '../../common/freight-permission.util';
import { Contract } from './entities/contract.entity'; import { Contract } from './entities/contract.entity';
import { ContractRoute } from './entities/contract-route.entity'; import { ContractRoute } from './entities/contract-route.entity';
import { ContractsRepository } from './contracts.repository'; import { ContractsRepository } from './contracts.repository';
import { ClearanceFeeService } from './clearance-fee.service';
import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ClearanceWorkflowService } from './clearance-workflow.service'; import { ClearanceWorkflowService } from './clearance-workflow.service';
import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto'; import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto';
@@ -78,6 +79,7 @@ export class ContractBookingService {
private readonly milestoneService: ClearanceMilestoneService, private readonly milestoneService: ClearanceMilestoneService,
private readonly workflowService: ClearanceWorkflowService, private readonly workflowService: ClearanceWorkflowService,
private readonly invoiceService: BookingInvoiceService, private readonly invoiceService: BookingInvoiceService,
private readonly clearanceFeeService: ClearanceFeeService,
private readonly dataSource: DataSource, private readonly dataSource: DataSource,
@Inject(forwardRef(() => TrainSchedulingService)) @Inject(forwardRef(() => TrainSchedulingService))
private readonly trainSchedulingService: TrainSchedulingService, private readonly trainSchedulingService: TrainSchedulingService,
@@ -492,6 +494,11 @@ export class ContractBookingService {
const route = await this.resolveRoute(contract, opts.contractRouteId); 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( const booking = await insertWithGeneratedReference(
() => this.generateReference(), () => this.generateReference(),
(reference) => (reference) =>
@@ -501,7 +508,7 @@ export class ContractBookingService {
companyProfileId: contract.companyProfileId ?? null, companyProfileId: contract.companyProfileId ?? null,
isGovernment: contract.isGovernment, isGovernment: contract.isGovernment,
governmentInstitution: contract.governmentInstitution ?? null, governmentInstitution: contract.governmentInstitution ?? null,
status: 'AWAITING_DOCUMENTS', status: feeGate ? 'AWAITING_CLEARANCE_PAYMENT' : 'AWAITING_DOCUMENTS',
bookingType: 'ONE_TIME', bookingType: 'ONE_TIME',
contractId: contract.id, contractId: contract.id,
contractRouteId: route?.id ?? null, contractRouteId: route?.id ?? null,
@@ -540,6 +547,10 @@ export class ContractBookingService {
contract.tradeDirection, contract.tradeDirection,
); );
if (feeGate) {
await this.clearanceFeeService.issueForBooking(booking, contract);
}
return (await this.bookingsRepository.findByIdWithFiles(booking.id)) ?? booking; return (await this.bookingsRepository.findByIdWithFiles(booking.id)) ?? booking;
} }

View File

@@ -489,6 +489,11 @@ export class ContractClearanceService {
files: Express.Multer.File[], files: Express.Multer.File[],
): Promise<Contract> { ): Promise<Contract> {
const contract = await this.contractsService.findById(contractId); 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 ( if (
contract.status !== 'AWAITING_CLEARANCE_DOCUMENTS' && contract.status !== 'AWAITING_CLEARANCE_DOCUMENTS' &&
contract.status !== 'CLEARANCE_UNDER_REVIEW' contract.status !== 'CLEARANCE_UNDER_REVIEW'

View File

@@ -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 ────────────────────────── // ── Clearance milestones needing customer action ──────────────────────────
/** GL advised duty & tax on the contract cycle — customer pays + uploads slip. */ /** GL advised duty & tax on the contract cycle — customer pays + uploads slip. */

View File

@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common'; import { Injectable, UnprocessableEntityException } from '@nestjs/common';
import { RatesService } from '../rule-engine/services/rates.service'; import { RatesService } from '../rule-engine/services/rates.service';
import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { ContainerTypesService } from '../rule-engine/services/container-types.service';
@@ -15,6 +15,11 @@ export interface ContractUnitRateLineItem {
containerSize?: string | null; containerSize?: string | null;
conditionalOn?: string | null; conditionalOn?: string | null;
cargoTypeCode?: 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). */ /** 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 { return {
displayMode: 'UNIT_RATES', displayMode: 'UNIT_RATES',
currency, currency,
@@ -229,6 +259,7 @@ export class ContractPricingService {
containerSize: line.containerSize ?? null, containerSize: line.containerSize ?? null,
isSurcharge: !!line.conditionalOn, isSurcharge: !!line.conditionalOn,
conditionalOn: line.conditionalOn ?? null, conditionalOn: line.conditionalOn ?? null,
isClearance: !!line.isClearance,
}); });
} }
} }

View File

@@ -24,6 +24,7 @@ import { SignaturesService } from '../signatures/signatures.service';
import { OtpService } from '../otp/otp.service'; import { OtpService } from '../otp/otp.service';
import { ContractTemplatesService } from '../contract-templates/contract-templates.service'; import { ContractTemplatesService } from '../contract-templates/contract-templates.service';
import { ContractPricingService } from './contract-pricing.service'; import { ContractPricingService } from './contract-pricing.service';
import { ClearanceFeeService } from './clearance-fee.service';
import { ContractNotifierService } from './contract-notifier.service'; import { ContractNotifierService } from './contract-notifier.service';
import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ContractsRepository } from './contracts.repository'; import { ContractsRepository } from './contracts.repository';
@@ -89,6 +90,7 @@ export class ContractTransitionService {
private readonly otpService: OtpService, private readonly otpService: OtpService,
private readonly notifier: ContractNotifierService, private readonly notifier: ContractNotifierService,
private readonly contractTemplates: ContractTemplatesService, private readonly contractTemplates: ContractTemplatesService,
private readonly clearanceFeeService: ClearanceFeeService,
) {} ) {}
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */ /** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
@@ -866,8 +868,17 @@ export class ContractTransitionService {
const cycleNumber = (contract.clearanceCycleNumber ?? 0) + 1; const cycleNumber = (contract.clearanceCycleNumber ?? 0) + 1;
const cycle = await this.contractsRepository.openCycle(contractId, cycleNumber); const cycle = await this.contractsRepository.openCycle(contractId, cycleNumber);
await this.milestoneService.seedPreBookingMilestones(contract, cycle.id); await this.milestoneService.seedPreBookingMilestones(contract, cycle.id);
updates.status = 'AWAITING_CLEARANCE_DOCUMENTS'; // Path B prepay gate: the customs clearance service fee is invoiced here
updates.clearanceStatus = 'AWAITING_DOCUMENTS'; // 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; updates.clearanceCycleNumber = cycleNumber;
} else { } else {
// No contract-level clearance gate — DOMESTIC, or any GENERAL contract // No contract-level clearance gate — DOMESTIC, or any GENERAL contract

View File

@@ -22,6 +22,7 @@ import { ContractsController } from './contracts.controller';
import { ContractsService } from './contracts.service'; import { ContractsService } from './contracts.service';
import { ContractsRepository } from './contracts.repository'; import { ContractsRepository } from './contracts.repository';
import { ContractPricingService } from './contract-pricing.service'; import { ContractPricingService } from './contract-pricing.service';
import { ClearanceFeeService } from './clearance-fee.service';
import { ContractNotifierService } from './contract-notifier.service'; import { ContractNotifierService } from './contract-notifier.service';
import { ContractTransitionService } from './contract-transition.service'; import { ContractTransitionService } from './contract-transition.service';
import { ContractClearanceService } from './contract-clearance.service'; import { ContractClearanceService } from './contract-clearance.service';
@@ -103,6 +104,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
ContractsService, ContractsService,
ContractsRepository, ContractsRepository,
ContractPricingService, ContractPricingService,
ClearanceFeeService,
ContractNotifierService, ContractNotifierService,
ContractTransitionService, ContractTransitionService,
ContractClearanceService, ContractClearanceService,

View File

@@ -44,4 +44,11 @@ export class ContractRateSnapshot extends BaseEntity {
/** is_hazardous | is_reefer when this is a conditional surcharge. */ /** is_hazardous | is_reefer when this is a conditional surcharge. */
@Column({ name: 'conditional_on', type: 'varchar', length: 32, nullable: true }) @Column({ name: 'conditional_on', type: 'varchar', length: 32, nullable: true })
conditionalOn?: string | null; 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;
} }

View File

@@ -25,6 +25,7 @@ export const CONTRACT_STATUSES = [
'SIGNED_CUSTOMER', 'SIGNED_CUSTOMER',
'FULLY_EXECUTED', 'FULLY_EXECUTED',
'CONTRACT_ACTIVE', 'CONTRACT_ACTIVE',
'AWAITING_CLEARANCE_PAYMENT', // Path B — clearance fee invoiced, unpaid
'AWAITING_CLEARANCE_DOCUMENTS', 'AWAITING_CLEARANCE_DOCUMENTS',
'CLEARANCE_UNDER_REVIEW', 'CLEARANCE_UNDER_REVIEW',
'CLEARANCE_READY_FOR_BOOKING', 'CLEARANCE_READY_FOR_BOOKING',
@@ -84,6 +85,7 @@ export type ContractKindValue = (typeof CONTRACT_KINDS)[number];
export const CONTRACT_CLEARANCE_STATUSES = [ export const CONTRACT_CLEARANCE_STATUSES = [
'NOT_APPLICABLE', 'NOT_APPLICABLE',
'AWAITING_PAYMENT', // Path B — clearance service fee must be paid first
'AWAITING_DOCUMENTS', 'AWAITING_DOCUMENTS',
'DOCUMENTS_UNDER_REVIEW', 'DOCUMENTS_UNDER_REVIEW',
'CLEARANCE_READY_FOR_BOOKING', // Path B — GL may create the booking '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 }) @Column({ name: 'clearance_cycle_number', type: 'int', default: 0 })
clearanceCycleNumber!: number; 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 }) @Column({ name: 'pricing_breakdown', type: 'jsonb', nullable: true })
pricingBreakdown?: Record<string, unknown> | null; pricingBreakdown?: Record<string, unknown> | null;

View File

@@ -37,6 +37,8 @@ export function deriveRateType(input: {
return 'DEMURRAGE'; return 'DEMURRAGE';
case 'PIL_EXTRA_FEE': case 'PIL_EXTRA_FEE':
return 'PIL_EXTRA_FEE'; return 'PIL_EXTRA_FEE';
case 'CUSTOMS_CLEARANCE':
return 'CUSTOMS_CLEARANCE';
} }
} }

View File

@@ -30,6 +30,9 @@ export function allowedRateUnits(input: {
return ['PER_CONTAINER', 'PER_TON']; return ['PER_CONTAINER', 'PER_TON'];
case 'CANCELLATION': case 'CANCELLATION':
return ['FLAT', 'PER_INVOICE']; return ['FLAT', 'PER_INVOICE'];
case 'CUSTOMS_CLEARANCE':
// Flat per clearance (ONE_TIME contract) / per shipment request (GENERAL).
return ['FLAT'];
case 'CONSOLIDATION': case 'CONSOLIDATION':
return ['PER_CONTAINER', 'FLAT']; return ['PER_CONTAINER', 'FLAT'];
case 'SHIPPING_LINE': case 'SHIPPING_LINE':

View File

@@ -21,6 +21,7 @@ export const RATE_TYPES = [
'HAZARD_SURCHARGE', 'HAZARD_SURCHARGE',
'REEFER_SURCHARGE', 'REEFER_SURCHARGE',
'PIL_EXTRA_FEE', 'PIL_EXTRA_FEE',
'CUSTOMS_CLEARANCE',
] as const; ] as const;
export type RateType = typeof RATE_TYPES[number]; export type RateType = typeof RATE_TYPES[number];
@@ -75,6 +76,9 @@ export const RATE_TRIGGERS = [
'CANCELLATION', 'CANCELLATION',
'DEMURRAGE', 'DEMURRAGE',
'PIL_EXTRA_FEE', '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; ] as const;
export type RateTrigger = typeof RATE_TRIGGERS[number]; export type RateTrigger = typeof RATE_TRIGGERS[number];

View File

@@ -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;
}

View File

@@ -45,7 +45,7 @@ export class Train extends BaseEntity {
trainNumber?: string; trainNumber?: string;
@Column({ name: 'train_name', type: 'varchar', length: 100, nullable: true }) @Column({ name: 'train_name', type: 'varchar', length: 100, nullable: true })
trainName?: string; trainName?: string | null;
@Column({ name: 'route_id', type: 'uuid', nullable: true }) @Column({ name: 'route_id', type: 'uuid', nullable: true })
routeId?: string; routeId?: string;

View File

@@ -19,6 +19,7 @@ import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto';
import { BuildTrainDto } from './dto/build-train.dto'; import { BuildTrainDto } from './dto/build-train.dto';
import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto'; import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.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 { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
import { UpdateTrainYardDto } from './dto/update-train-yard.dto'; import { UpdateTrainYardDto } from './dto/update-train-yard.dto';
import { TrainBuilderService } from './train-builder.service'; import { TrainBuilderService } from './train-builder.service';
@@ -59,6 +60,18 @@ export class TrainBuilderController {
return this.trainBuilderService.setLocomotives(id, dto); 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') @Patch(':id/yard')
@FleetManage() @FleetManage()
@ApiOperation({ @ApiOperation({

View File

@@ -17,6 +17,7 @@ import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto';
import { BuildTrainDto } from './dto/build-train.dto'; import { BuildTrainDto } from './dto/build-train.dto';
import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto'; import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.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 { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
import { TrainLocomotive } from './entities/train-locomotive.entity'; import { TrainLocomotive } from './entities/train-locomotive.entity';
import { Train } from './entities/train.entity'; import { Train } from './entities/train.entity';
@@ -331,6 +332,53 @@ export class TrainBuilderService {
return this.getComposition(id); 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<Train> = {};
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 * 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 * coupled locomotive and wagon follows to the new yard (so their current

View File

@@ -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 (
<Modal
opened={Boolean(train)}
onClose={onClose}
title={
<Group gap={8}>
<Pencil size={16} />
<Text fw={700}>Edit train {train?.code ?? ""}</Text>
</Group>
}
centered
size="md"
radius="lg"
>
<Stack gap="md">
<TextInput
label="Train name"
placeholder="Optional display name"
value={name}
onChange={(e) => setName(e.currentTarget.value)}
maxLength={100}
radius="md"
/>
<Group grow>
<TextInput
label="Import train no."
placeholder="e.g. 8002"
value={importNo}
onChange={(e) => setImportNo(e.currentTarget.value)}
maxLength={20}
radius="md"
/>
<TextInput
label="Export train no."
placeholder="e.g. 8001"
value={exportNo}
onChange={(e) => setExportNo(e.currentTarget.value)}
maxLength={20}
radius="md"
/>
</Group>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button
color="edr-green"
loading={update.isPending}
onClick={handleSave}
>
Save
</Button>
</Group>
</Stack>
</Modal>
);
};
export default EditTrainDetailsModal;

View File

@@ -285,7 +285,12 @@ export const BOOKING_LIST_TABS = [
{ {
key: "clearance", key: "clearance",
label: "Clearance", label: "Clearance",
statuses: ["AWAITING_DOCUMENTS", "DOCUMENTS_UNDER_REVIEW", "CLEARANCE_READY"], statuses: [
"AWAITING_CLEARANCE_PAYMENT",
"AWAITING_DOCUMENTS",
"DOCUMENTS_UNDER_REVIEW",
"CLEARANCE_READY",
],
}, },
{ {
key: "payment", key: "payment",

View File

@@ -51,6 +51,10 @@ export const CONTRACT_STATUS_STYLES: Record<string, StatusStyle> = {
label: "Active", label: "Active",
color: "bg-[color:var(--freight-brand-muted)] text-[color:var(--freight-brand)] border-[color:var(--freight-brand-border)]", 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: { AWAITING_CLEARANCE_DOCUMENTS: {
label: "Awaiting Documents", label: "Awaiting Documents",
color: "bg-amber-50 text-amber-700 border-amber-200", color: "bg-amber-50 text-amber-700 border-amber-200",
@@ -118,6 +122,7 @@ export const CONTRACT_STATUS_COLOR: Record<string, string> = {
SIGNED_CUSTOMER: "cyan", SIGNED_CUSTOMER: "cyan",
FULLY_EXECUTED: "indigo", FULLY_EXECUTED: "indigo",
CONTRACT_ACTIVE: "edr-green", CONTRACT_ACTIVE: "edr-green",
AWAITING_CLEARANCE_PAYMENT: "orange",
AWAITING_CLEARANCE_DOCUMENTS: "yellow", AWAITING_CLEARANCE_DOCUMENTS: "yellow",
CLEARANCE_UNDER_REVIEW: "yellow", CLEARANCE_UNDER_REVIEW: "yellow",
CLEARANCE_READY_FOR_BOOKING: "edr-green", CLEARANCE_READY_FOR_BOOKING: "edr-green",
@@ -207,6 +212,13 @@ export const CONTRACT_STATUS_META: Record<string, StatusMeta> = {
color: "text-[color:var(--freight-brand)]", color: "text-[color:var(--freight-brand)]",
stage: 3, 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: { AWAITING_CLEARANCE_DOCUMENTS: {
title: "Awaiting Documents", title: "Awaiting Documents",
description: "Customer is uploading pre-booking clearance documents.", description: "Customer is uploading pre-booking clearance documents.",

View File

@@ -141,6 +141,7 @@ const RATE_TRIGGERS = [
{ label: "Cancellation", value: "CANCELLATION" }, { label: "Cancellation", value: "CANCELLATION" },
{ label: "Demurrage", value: "DEMURRAGE" }, { label: "Demurrage", value: "DEMURRAGE" },
{ label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" }, { 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 }); 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"]; return ["PER_CONTAINER", "PER_TON"];
case "CANCELLATION": case "CANCELLATION":
return ["FLAT", "PER_INVOICE"]; return ["FLAT", "PER_INVOICE"];
case "CUSTOMS_CLEARANCE":
// Flat per clearance (ONE_TIME) / per shipment request (GENERAL).
return ["FLAT"];
case "CONSOLIDATION": case "CONSOLIDATION":
case "SHIPPING_LINE": case "SHIPPING_LINE":
case "PIL_EXTRA_FEE": case "PIL_EXTRA_FEE":

View File

@@ -1,6 +1,7 @@
import type { ColumnDef } from "@edr/ui-common"; import type { ColumnDef } from "@edr/ui-common";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common"; import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
import { import {
ActionIcon,
Badge, Badge,
Box, Box,
Button, Button,
@@ -15,6 +16,7 @@ import { useDebouncedValue } from "@mantine/hooks";
import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { import {
Hammer, Hammer,
Pencil,
Ruler, Ruler,
Search, Search,
Train as TrainIcon, Train as TrainIcon,
@@ -27,6 +29,7 @@ import { useNavigate } from "react-router-dom";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import BuildTrainModal from "@/components/trainBuilder/BuildTrainModal"; import BuildTrainModal from "@/components/trainBuilder/BuildTrainModal";
import EditTrainDetailsModal from "@/components/trainBuilder/EditTrainDetailsModal";
import { import {
directionColor, directionColor,
directionRowStyle, directionRowStyle,
@@ -52,6 +55,7 @@ export default function TrainBuilderListPage() {
const [statusFilter, setStatusFilter] = useState<"ALL" | BuiltTrainStatus>("ALL"); const [statusFilter, setStatusFilter] = useState<"ALL" | BuiltTrainStatus>("ALL");
const [yardFilter, setYardFilter] = useState("ALL"); const [yardFilter, setYardFilter] = useState("ALL");
const [buildOpen, setBuildOpen] = useState(false); const [buildOpen, setBuildOpen] = useState(false);
const [editTarget, setEditTarget] = useState<BuiltTrainSummary | null>(null);
const resetPage = useCallback(() => { const resetPage = useCallback(() => {
setPagination((prev) => setPagination((prev) =>
@@ -239,6 +243,26 @@ export default function TrainBuilderListPage() {
</Badge> </Badge>
), ),
}, },
{
id: "actions",
header: "",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<ActionIcon
variant="subtle"
color="gray"
aria-label={`Edit train ${row.original.code}`}
title="Edit name & train numbers"
onClick={(e) => {
// Row click navigates to the detail page — keep the edit local.
e.stopPropagation();
setEditTarget(row.original);
}}
>
<Pencil size={15} />
</ActionIcon>
),
},
]; ];
}, []); }, []);
@@ -363,6 +387,8 @@ export default function TrainBuilderListPage() {
onClose={() => setBuildOpen(false)} onClose={() => setBuildOpen(false)}
onBuilt={(composition) => navigate(`/dashboard/train-builder/${composition.id}`)} onBuilt={(composition) => navigate(`/dashboard/train-builder/${composition.id}`)}
/> />
<EditTrainDetailsModal train={editTarget} onClose={() => setEditTarget(null)} />
</PageContainer> </PageContainer>
); );
} }

View File

@@ -191,6 +191,7 @@ import {
type BuiltTrainListResponse, type BuiltTrainListResponse,
type ScheduleConsist, type ScheduleConsist,
type TrainComposition, type TrainComposition,
type UpdateTrainDetailsPayload,
} from "./trainBuilder.service"; } from "./trainBuilder.service";
import { trainSchedulingService } from "./trainScheduling.service"; import { trainSchedulingService } from "./trainScheduling.service";
import { wagonTypesService, type WagonType } from "./wagon-types.service"; import { wagonTypesService, type WagonType } from "./wagon-types.service";
@@ -1842,6 +1843,18 @@ export const api = {
() => TRAIN_BUILDER_INVALIDATIONS, () => 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>( assignWagons: endpoint<{ id: string; wagonIds: string[] }, TrainComposition>(
"train-builder", "train-builder",
"assignWagons", "assignWagons",

View File

@@ -140,6 +140,14 @@ export interface BuildTrainPayload {
notes?: string; 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. */ /** Built train annotated for the schedule-creation picker. */
export interface AvailableTrain { export interface AvailableTrain {
id: string; id: string;
@@ -241,6 +249,9 @@ export const trainBuilderService = {
build: (payload: BuildTrainPayload) => apiClient.post<TrainComposition>(BASE, payload), build: (payload: BuildTrainPayload) => apiClient.post<TrainComposition>(BASE, payload),
setLocomotives: (id: string, locomotiveIds: string[]) => setLocomotives: (id: string, locomotiveIds: string[]) =>
apiClient.put<TrainComposition>(`${BASE}/${id}/locomotives`, { locomotiveIds }), apiClient.put<TrainComposition>(`${BASE}/${id}/locomotives`, { locomotiveIds }),
/** Edit the train's name and fixed import/export run numbers. */
updateDetails: (id: string, payload: UpdateTrainDetailsPayload) =>
apiClient.patch<TrainComposition>(`${BASE}/${id}/details`, payload),
/** Relocate the train — coupled locomotives and wagons move with it. */ /** Relocate the train — coupled locomotives and wagons move with it. */
setYard: (id: string, currentYardId: string) => setYard: (id: string, currentYardId: string) =>
apiClient.patch<TrainComposition>(`${BASE}/${id}/yard`, { currentYardId }), apiClient.patch<TrainComposition>(`${BASE}/${id}/yard`, { currentYardId }),

View File

@@ -28,6 +28,7 @@ export const BOOKING_STATUSES = [
"CONTRACT_ACTIVE", "CONTRACT_ACTIVE",
"CONTRACT_CLOSED", "CONTRACT_CLOSED",
// Post counter-sign document-clearance gate. // Post counter-sign document-clearance gate.
"AWAITING_CLEARANCE_PAYMENT",
"AWAITING_DOCUMENTS", "AWAITING_DOCUMENTS",
"DOCUMENTS_UNDER_REVIEW", "DOCUMENTS_UNDER_REVIEW",
"CLEARANCE_READY", "CLEARANCE_READY",

View File

@@ -14,6 +14,7 @@ import { useNavigate } from "react-router-dom";
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
import { PayClearanceFeeButton } from "@/pages/bookings/payments/PayClearanceFeeButton";
import { PayNowButton } from "@/pages/bookings/payments/PayNowButton"; import { PayNowButton } from "@/pages/bookings/payments/PayNowButton";
import { api } from "@/services/api"; import { api } from "@/services/api";
import { ContractClearanceAction } from "./ContractClearanceAction"; import { ContractClearanceAction } from "./ContractClearanceAction";
@@ -69,6 +70,17 @@ export function ContractCustomerAction({
); );
} }
if (action.type === "pay-clearance") {
return (
<PayClearanceFeeButton
sourceId={action.contractId}
currency={contract.paymentCurrency}
label={action.label}
size={size}
/>
);
}
if (action.type === "initiate") { if (action.type === "initiate") {
return ( return (
<InitiateBookingButton <InitiateBookingButton

View File

@@ -80,6 +80,14 @@ export type ContractCustomerAction =
label: string; label: string;
primary: boolean; primary: boolean;
icon: LucideIcon; icon: LucideIcon;
}
| {
/** Prepaid customs clearance service fee (contract-level, ONE_TIME Path B). */
type: "pay-clearance";
contractId: string;
label: string;
primary: boolean;
icon: LucideIcon;
}; };
function findPayableBookingForContract( function findPayableBookingForContract(
@@ -137,6 +145,17 @@ export function deriveContractCustomerAction(
}; };
} }
// Prepaid clearance service fee gate — must settle before document upload.
if (contract.status === "AWAITING_CLEARANCE_PAYMENT") {
return {
type: "pay-clearance",
contractId: id,
label: "Pay clearance fee",
primary: true,
icon: CreditCard,
};
}
const payable = findPayableBookingForContract(id, bookings); const payable = findPayableBookingForContract(id, bookings);
if (payable) { if (payable) {
return { return {

View File

@@ -6,7 +6,7 @@ import { contractNeedsClearanceAction } from "@/components/customer-actions/deri
export interface ActionItem { export interface ActionItem {
id: string; id: string;
/** What the customer must do — drives the icon, label and modal. */ /** What the customer must do — drives the icon, label and modal. */
kind: "clearance" | "duty" | "sign" | "book" | "pay"; kind: "clearance" | "duty" | "sign" | "book" | "pay" | "clearance-fee";
/** The contract/booking reference for display. */ /** The contract/booking reference for display. */
reference: string; reference: string;
/** Short human description of the action. */ /** Short human description of the action. */
@@ -40,6 +40,18 @@ export function deriveActionItems(
}); });
continue; continue;
} }
// Prepaid clearance service fee (Path B) — blocks the document step.
if (c.status === "AWAITING_CLEARANCE_PAYMENT") {
items.push({
id: `clearance-fee-${c.id}`,
kind: "clearance-fee",
reference: c.reference,
description: "Clearance service fee due — pay to unlock document upload",
targetId: c.id,
urgent: true,
});
continue;
}
const clr = contractNeedsClearanceAction(c); const clr = contractNeedsClearanceAction(c);
if (clr.show) { if (clr.show) {
items.push({ items.push({
@@ -70,6 +82,19 @@ export function deriveActionItems(
} }
for (const b of bookings) { for (const b of bookings) {
// Per-shipment clearance service fee (GENERAL + customs shipment request).
if (b.status === "AWAITING_CLEARANCE_PAYMENT") {
items.push({
id: `clearance-fee-${b.id}`,
kind: "clearance-fee",
reference: b.reference,
description:
"Clearance service fee due for this shipment — pay to unlock document upload",
targetId: b.id,
urgent: true,
});
continue;
}
const isGeneral = b.bookingType === "GENERAL_CONTRACT"; const isGeneral = b.bookingType === "GENERAL_CONTRACT";
const canPay = const canPay =
b.paymentStatus !== "PAID" && b.paymentStatus !== "PAID" &&

View File

@@ -39,6 +39,7 @@ const KIND_META: Record<
sign: { icon: FileSignature, label: "Sign", color: "blue" }, sign: { icon: FileSignature, label: "Sign", color: "blue" },
book: { icon: PackagePlus, label: "Book", color: "violet" }, book: { icon: PackagePlus, label: "Book", color: "violet" },
pay: { icon: CreditCard, label: "Payment", color: "orange" }, pay: { icon: CreditCard, label: "Payment", color: "orange" },
"clearance-fee": { icon: CreditCard, label: "Clearance fee", color: "orange" },
}; };
export interface ActionNeededSectionProps { export interface ActionNeededSectionProps {
@@ -137,9 +138,14 @@ export function ActionNeededSection({
// Billing is invoice-centric — resolve the booking's currently payable // Billing is invoice-centric — resolve the booking's currently payable
// invoice before paying it (mirrors ReadonlyBookingView). // invoice before paying it (mirrors ReadonlyBookingView).
// A "pay" item settles the booking invoice; a "clearance-fee" item settles the
// prepaid clearance-fee invoice (source `clearance`, keyed by contract or
// booking id depending on where the gate sits).
const payItemSource = payItem?.kind === "clearance-fee" ? "clearance" : "booking";
const { data: payItemInvoices = [] } = useQuery({ const { data: payItemInvoices = [] } = useQuery({
queryKey: ["booking-invoices", payItem?.targetId], queryKey: [`${payItemSource}-invoices`, payItem?.targetId],
queryFn: () => invoicesService.listForSource("booking", payItem!.targetId), queryFn: () =>
invoicesService.listForSource(payItemSource, payItem!.targetId),
enabled: payItem !== null, enabled: payItem !== null,
}); });
const payableInvoiceId = payItemInvoices.find((inv) => const payableInvoiceId = payItemInvoices.find((inv) =>
@@ -182,6 +188,7 @@ export function ActionNeededSection({
navigate(`/contracts/${item.targetId}`); navigate(`/contracts/${item.targetId}`);
break; break;
case "pay": case "pay":
case "clearance-fee":
setPayItem(item); setPayItem(item);
break; break;
case "sign": case "sign":
@@ -277,7 +284,9 @@ export function ActionNeededSection({
> >
{item.kind === "pay" {item.kind === "pay"
? "Pay now" ? "Pay now"
: item.kind === "duty" : item.kind === "clearance-fee"
? "Pay clearance fee"
: item.kind === "duty"
? "Pay duty & upload slip" ? "Pay duty & upload slip"
: item.kind === "sign" : item.kind === "sign"
? "Sign" ? "Sign"

View File

@@ -165,6 +165,19 @@ export const STATUS_CONFIG: Record<string, StageConfig> = {
badgeDot: "edr-green.5", badgeDot: "edr-green.5",
action: { label: "View", kind: "outline" }, 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: { AWAITING_DOCUMENTS: {
stage: 3, stage: 3,
icon: FileUp, icon: FileUp,

View File

@@ -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 { useMutation, useQuery } from "@tanstack/react-query";
import { CreditCard, FileText, LayoutGrid } from "lucide-react"; import { CreditCard, FileText, LayoutGrid } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
@@ -12,6 +12,7 @@ import { isPayable } from "@/pages/billing/invoice-ui";
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton"; import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton";
import { PayClearanceFeeButton } from "../payments/PayClearanceFeeButton";
import { ActivityCard } from "./components/ActivityCard"; import { ActivityCard } from "./components/ActivityCard";
import { ClearanceCard } from "./components/ClearanceCard"; import { ClearanceCard } from "./components/ClearanceCard";
import { DocumentsTab } from "./components/DocumentsTab"; import { DocumentsTab } from "./components/DocumentsTab";
@@ -139,6 +140,8 @@ export function ReadonlyBookingView({
const isCustoms = Boolean(booking.customsClearingEnabled); const isCustoms = Boolean(booking.customsClearingEnabled);
const canSelfRebook = !isCustoms; const canSelfRebook = !isCustoms;
const isPendingConsolidation = status === "PENDING_CONSOLIDATION"; const isPendingConsolidation = status === "PENDING_CONSOLIDATION";
// Prepaid clearance service fee gate — document upload stays locked until paid.
const isAwaitingClearanceFee = status === "AWAITING_CLEARANCE_PAYMENT";
const isClearance = [ const isClearance = [
"AWAITING_DOCUMENTS", "AWAITING_DOCUMENTS",
"DOCUMENTS_UNDER_REVIEW", "DOCUMENTS_UNDER_REVIEW",
@@ -237,6 +240,28 @@ export function ReadonlyBookingView({
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<ContractCard booking={booking} /> <ContractCard booking={booking} />
{isAwaitingClearanceFee && (
<Paper withBorder radius="lg" p="lg" style={{ borderColor: "#FDE68A", background: "#FFFBEB" }}>
<Group justify="space-between" align="center" wrap="wrap" gap="md">
<div>
<Text fw={700} fz={15} c="#92400E">
Customs clearance service fee due
</Text>
<Text fz={13} c="#B45309" mt={4}>
Pay the clearance service fee to unlock the clearance
document upload. Global Logistics starts working on your
shipment once the fee is settled.
</Text>
</div>
<PayClearanceFeeButton
sourceId={booking.id}
currency={booking.paymentCurrency}
size="md"
/>
</Group>
</Paper>
)}
{isClearance && <ClearanceCard booking={booking} />} {isClearance && <ClearanceCard booking={booking} />}
<BodyGrid <BodyGrid

View File

@@ -3,6 +3,7 @@ import { useDisclosure } from "@mantine/hooks";
import { import {
AlertCircle, AlertCircle,
ArrowRight, ArrowRight,
CreditCard,
PackagePlus, PackagePlus,
PencilLine, PencilLine,
Upload, Upload,
@@ -11,6 +12,7 @@ import { useNavigate } from "react-router-dom";
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
import { PayClearanceFeeButton } from "@/pages/bookings/payments/PayClearanceFeeButton";
import { ResubmitBookingModal } from "@/pages/bookings/resubmit/ResubmitBookingModal"; import { ResubmitBookingModal } from "@/pages/bookings/resubmit/ResubmitBookingModal";
import { BookingActionModal } from "./BookingActionModal"; import { BookingActionModal } from "./BookingActionModal";
@@ -23,6 +25,7 @@ const ICON_BY_KIND: Record<
BookingActionKind, BookingActionKind,
typeof Upload typeof Upload
> = { > = {
PAY_CLEARANCE: CreditCard,
UPLOAD_DOCUMENTS: Upload, UPLOAD_DOCUMENTS: Upload,
FIX_DOCUMENTS: AlertCircle, FIX_DOCUMENTS: AlertCircle,
SCHEDULE_OPERATION: ArrowRight, SCHEDULE_OPERATION: ArrowRight,
@@ -56,6 +59,19 @@ export function BookingActionButton({
if (!isChangesRequested && !action) return null; 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 (
<PayClearanceFeeButton
sourceId={booking.id}
currency={booking.paymentCurrency}
label={action.label}
size={size}
/>
);
}
const Icon = action ? ICON_BY_KIND[action.kind] : PencilLine; const Icon = action ? ICON_BY_KIND[action.kind] : PencilLine;
const label = action ? action.label : "Update & resubmit"; const label = action ? action.label : "Update & resubmit";
// BOOK navigates to the booking form (cargo + day + window check) — the // BOOK navigates to the booking form (cargo + day + window check) — the

View File

@@ -7,6 +7,7 @@ import type { Freight } from "@edr/types";
* to operation. * to operation.
*/ */
export type BookingActionKind = export type BookingActionKind =
| "PAY_CLEARANCE" // AWAITING_CLEARANCE_PAYMENT — pay the prepaid clearance service fee
| "UPLOAD_DOCUMENTS" // AWAITING_DOCUMENTS — upload the required clearance docs | "UPLOAD_DOCUMENTS" // AWAITING_DOCUMENTS — upload the required clearance docs
| "FIX_DOCUMENTS" // DOCUMENTS_UNDER_REVIEW — some docs queried, re-upload them | "FIX_DOCUMENTS" // DOCUMENTS_UNDER_REVIEW — some docs queried, re-upload them
| "SCHEDULE_OPERATION" // CLEARANCE_READY (legacy with cargo) — pick a day and proceed | "SCHEDULE_OPERATION" // CLEARANCE_READY (legacy with cargo) — pick a day and proceed
@@ -23,6 +24,11 @@ export interface BookingNextAction {
} }
const ACTION_BY_STATUS: Record<string, BookingNextAction> = { const ACTION_BY_STATUS: Record<string, BookingNextAction> = {
AWAITING_CLEARANCE_PAYMENT: {
kind: "PAY_CLEARANCE",
label: "Pay clearance fee",
title: "Pay the clearance service fee",
},
AWAITING_DOCUMENTS: { AWAITING_DOCUMENTS: {
kind: "UPLOAD_DOCUMENTS", kind: "UPLOAD_DOCUMENTS",
label: "Upload documents", label: "Upload documents",

View File

@@ -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 (
<ModalSafeWrapper>
<Button
size={size}
radius="md"
fw={700}
fz={13}
color="edr-green"
fullWidth={fullWidth}
leftSection={<CreditCard size={14} />}
onClick={(e) => {
e.stopPropagation();
pay.open();
}}
>
{label}
</Button>
<PaymentMethodModal
opened={pay.modalOpen}
onClose={pay.close}
amountLabel={
pay.invoice
? `${Number(pay.invoice.totalAmount).toLocaleString()} ${pay.invoice.currency}`
: undefined
}
currency={pay.invoice?.currency ?? currency}
processing={pay.processing}
error={pay.error}
onConfirm={pay.confirm}
/>
</ModalSafeWrapper>
);
}

View File

@@ -72,6 +72,7 @@ import { ContractClearancePanel } from "./ContractClearancePanel";
import { ContractClearanceWorkflowBanner } from "./ContractClearanceWorkflowBanner"; import { ContractClearanceWorkflowBanner } from "./ContractClearanceWorkflowBanner";
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel"; import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
import { InitiateBookingButton } from "@/components/customer-actions/ContractCustomerAction"; import { InitiateBookingButton } from "@/components/customer-actions/ContractCustomerAction";
import { PayClearanceFeeButton } from "@/pages/bookings/payments/PayClearanceFeeButton";
import { formatRateUnit } from "./new-contract-form/unit-rates"; import { formatRateUnit } from "./new-contract-form/unit-rates";
import { getContractBookingAction } from "./contract-booking-action"; import { getContractBookingAction } from "./contract-booking-action";
import { closedWindowMessage, hasOpenWindow } from "./booking-window"; import { closedWindowMessage, hasOpenWindow } from "./booking-window";
@@ -396,6 +397,9 @@ export default function ContractDetailPage() {
// clearance is finalized. // clearance is finalized.
const canUploadClearance = const canUploadClearance =
CLEARANCE_UPLOAD_STATUSES.includes(contract.status) && !clearanceFinalized; 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 ( return (
<Box style={{ padding: "28px 32px 40px" }}> <Box style={{ padding: "28px 32px 40px" }}>
@@ -531,6 +535,13 @@ export default function ContractDetailPage() {
Global Logistics is creating your booking Global Logistics is creating your booking
</Badge> </Badge>
)} )}
{awaitingClearanceFee && (
<PayClearanceFeeButton
sourceId={contract.id}
currency={contract.paymentCurrency}
size="md"
/>
)}
{canUploadClearance && ( {canUploadClearance && (
<Button <Button
color="edr-green" color="edr-green"
@@ -875,6 +886,12 @@ export default function ContractDetailPage() {
{item.containerSize} {item.containerSize}
</Text> </Text>
)} )}
{item.isClearance && (
<Text fz={12} c="orange.7" fw={600}>
Paid in advance, before clearance excluded from
shipment invoices
</Text>
)}
</Box> </Box>
<Text fz={14} fw={700} style={{ color: GREEN }}> <Text fz={14} fw={700} style={{ color: GREEN }}>
{(item.unitPrice ?? 0).toLocaleString()} {pricing.currency}{" "} {(item.unitPrice ?? 0).toLocaleString()} {pricing.currency}{" "}

View File

@@ -1005,6 +1005,12 @@ export default function NewContractPage({
{item.containerSize} {item.containerSize}
</Text> </Text>
)} )}
{item.isClearance && (
<Text size="xs" c="orange.7" fw={600}>
Paid in advance, before clearance not part of your
shipment booking invoice
</Text>
)}
</Box> </Box>
<Text <Text
size="sm" size="sm"

View File

@@ -51,10 +51,11 @@ export default function NewShipmentRequestPage() {
contractsService.submitBookingRequest(id!, dto), contractsService.submitBookingRequest(id!, dto),
onSuccess: (request) => { onSuccess: (request) => {
// Clearance-first flow: the request auto-initiates a booking instance — // Clearance-first flow: the request auto-initiates a booking instance —
// send the customer straight to it to upload clearance documents. // send the customer straight to it. The clearance service fee is due
// first; document upload unlocks once it settles.
if (request.createdBookingId) { if (request.createdBookingId) {
toast.success( toast.success(
"Shipment initiated — upload your clearance documents to start the review.", "Shipment initiated — pay the clearance service fee to unlock the document upload.",
); );
navigate(`/bookings/${request.createdBookingId}`); navigate(`/bookings/${request.createdBookingId}`);
} else { } else {

View File

@@ -125,6 +125,10 @@ export const CONTRACT_STATUS_CONFIG: Record<
FULLY_EXECUTED: { label: "Fully Executed", ...TONE.success }, FULLY_EXECUTED: { label: "Fully Executed", ...TONE.success },
CONTRACT_ACTIVE: { label: "Active", ...TONE.success }, CONTRACT_ACTIVE: { label: "Active", ...TONE.success },
// ── Path B pre-booking clearance (contract-level) ── // ── Path B pre-booking clearance (contract-level) ──
AWAITING_CLEARANCE_PAYMENT: {
label: "Clearance Fee Due",
...TONE.warning,
},
AWAITING_CLEARANCE_DOCUMENTS: { AWAITING_CLEARANCE_DOCUMENTS: {
label: "Upload Clearance Docs", label: "Upload Clearance Docs",
...TONE.warning, ...TONE.warning,

View File

@@ -42,6 +42,7 @@ export const CONTRACT_STATUSES = [
"FULLY_EXECUTED", // ONE_TIME "FULLY_EXECUTED", // ONE_TIME
"CONTRACT_ACTIVE", // GENERAL "CONTRACT_ACTIVE", // GENERAL
// customs clearance execution (Path B, pre-booking) // customs clearance execution (Path B, pre-booking)
"AWAITING_CLEARANCE_PAYMENT", // clearance service fee invoiced, unpaid
"AWAITING_CLEARANCE_DOCUMENTS", "AWAITING_CLEARANCE_DOCUMENTS",
"CLEARANCE_UNDER_REVIEW", "CLEARANCE_UNDER_REVIEW",
"CLEARANCE_READY_FOR_BOOKING", "CLEARANCE_READY_FOR_BOOKING",
@@ -67,6 +68,7 @@ export type ContractStatus = (typeof CONTRACT_STATUSES)[number];
*/ */
export const CONTRACT_CLEARANCE_STATUSES = [ export const CONTRACT_CLEARANCE_STATUSES = [
"NOT_APPLICABLE", "NOT_APPLICABLE",
"AWAITING_PAYMENT", // Path B — clearance service fee must be paid first
"AWAITING_DOCUMENTS", "AWAITING_DOCUMENTS",
"DOCUMENTS_UNDER_REVIEW", "DOCUMENTS_UNDER_REVIEW",
"CLEARANCE_READY_FOR_BOOKING", // Path B — GL may create the booking "CLEARANCE_READY_FOR_BOOKING", // Path B — GL may create the booking
@@ -102,6 +104,11 @@ export interface ContractUnitRateLineItem {
/** "is_hazardous" | "is_reefer" when this is a conditional surcharge. */ /** "is_hazardous" | "is_reefer" when this is a conditional surcharge. */
conditionalOn?: string | null; conditionalOn?: string | null;
cargoTypeCode?: string | null; cargoTypeCode?: string | null;
/**
* True for the customs clearance service fee — billed separately in advance
* (before document upload), never part of shipment booking totals.
*/
isClearance?: boolean;
} }
/** Contract `pricing_breakdown` shape — unit rates, no totals. */ /** Contract `pricing_breakdown` shape — unit rates, no totals. */

View File

@@ -101,6 +101,8 @@ export enum BookingStatus {
PendingConsolidation = "PENDING_CONSOLIDATION", PendingConsolidation = "PENDING_CONSOLIDATION",
Consolidated = "CONSOLIDATED", Consolidated = "CONSOLIDATED",
// Post counter-sign document-clearance gate (GL workflow). // Post counter-sign document-clearance gate (GL workflow).
/** Clearance service fee invoiced; docs + GL work locked until paid. */
AwaitingClearancePayment = "AWAITING_CLEARANCE_PAYMENT",
AwaitingDocuments = "AWAITING_DOCUMENTS", AwaitingDocuments = "AWAITING_DOCUMENTS",
DocumentsUnderReview = "DOCUMENTS_UNDER_REVIEW", DocumentsUnderReview = "DOCUMENTS_UNDER_REVIEW",
ClearanceReady = "CLEARANCE_READY", ClearanceReady = "CLEARANCE_READY",
@@ -173,7 +175,9 @@ export enum InvoiceSource {
Warehouse = "warehouse", Warehouse = "warehouse",
Demurrage = "demurrage", Demurrage = "demurrage",
FirstMile = "firstmile", FirstMile = "firstmile",
LastMile = "lastmile" LastMile = "lastmile",
/** Customs clearance service fee, prepaid before clearance work begins. */
Clearance = "clearance"
} }
export enum SchedulingStatus { export enum SchedulingStatus {