Merge branch 'dev' into freight/nati-2

This commit is contained in:
Nathnael
2026-08-07 08:09:34 +00:00
79 changed files with 7273 additions and 802 deletions

View File

@@ -110,6 +110,7 @@ import { AiModule } from "./modules/ai/ai.module";
import { AuditModule } from "./modules/audit/audit.module";
import { LoggerMiddleware } from "./logger.middleware";
import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware";
import { PositionTypePermissionsCache } from "./common/position-type-permissions.cache";
if (!process.env.APPLICATION_NAME) {
process.env.APPLICATION_NAME = "freight";
@@ -273,6 +274,9 @@ if (!process.env.APPLICATION_NAME) {
ApprovedFirstLastMileDemoBookingsSeeder,
PaidImportExportMileDemoSeeder,
LoginAudienceMiddleware,
// Feeds position-TYPE grants to the synchronous permission checks — without
// it, staff whose permissions live on their position type resolve to none.
PositionTypePermissionsCache,
],
})
export class AppModule implements OnApplicationBootstrap {

View File

@@ -57,6 +57,18 @@ export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view);
export const BookingDocReviewAlert = () =>
BookingStaff(FREIGHT_PERMS.bookings.docReviewAlert);
/** Staff wagon-cancellation history list (admin side). */
export const WagonCancellationView = () =>
BookingStaff(FREIGHT_PERMS.bookings.wagonCancellationView);
/** Staff void of a customer's pending (fee-unpaid) wagon cancellation. */
export const WagonCancellationVoid = () =>
BookingStaff(FREIGHT_PERMS.bookings.wagonCancellationVoid);
/** Staff rebook of a customer's wagon-cancellation credit on their behalf. */
export const WagonCancellationRebook = () =>
BookingStaff(FREIGHT_PERMS.bookings.wagonCancellationRebook);
export const TrainSchedulingView = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.view);

View File

@@ -1,6 +1,9 @@
import {
assertCanApproveContractStep,
canEditContractStep,
collectPermissionKeys,
hasFreightPermission,
setPositionTypePermissionResolver,
} from './freight-permission.util';
import { FREIGHT_PERMS } from '../seed/freight-permissions.registry';
@@ -49,3 +52,72 @@ describe('canEditContractStep (strict per-step edit gate)', () => {
);
});
});
/**
* The GL lockout regression: positions created through the admin UI keep their
* grants on the position TYPE, and the JWT only ever snapshots DIRECT position
* permissions. Without the type resolver those staff resolved to zero
* permissions, so every gated route rejected them — which is what kept GL
* officers out of their own clearance detail pages.
*/
describe('collectPermissionKeys — position-type grants', () => {
const CLEARANCE = FREIGHT_PERMS.contracts.clearanceReview;
afterEach(() => {
setPositionTypePermissionResolver(() => []);
});
const glOfficer = {
roles: [],
permissions: [],
employee: {
position: {
permissions: [], // admin-created position carries NO direct grants
positionType: { key: 'commercial-global-logistics-(et)-officer' },
},
},
};
it('resolves permissions carried by the position type', () => {
setPositionTypePermissionResolver((key) =>
key === 'commercial-global-logistics-(et)-officer' ? [CLEARANCE] : [],
);
expect(collectPermissionKeys(glOfficer)).toContain(CLEARANCE);
expect(hasFreightPermission(glOfficer, CLEARANCE)).toBe(true);
});
it('handles the array-shaped employee payload too', () => {
setPositionTypePermissionResolver(() => [CLEARANCE]);
const arrayShaped = {
roles: [],
permissions: [],
employee: [
{
positions: [
{ permissions: [], positionType: { key: 'djibouti-gl-officer' } },
],
},
],
};
expect(hasFreightPermission(arrayShaped, CLEARANCE)).toBe(true);
});
it('still rejects when neither the position nor its type grants it', () => {
setPositionTypePermissionResolver(() => []);
expect(hasFreightPermission(glOfficer, CLEARANCE)).toBe(false);
});
it('keeps direct position permissions working with no resolver installed', () => {
const direct = {
roles: [],
permissions: [],
employee: { position: { permissions: [{ key: CLEARANCE }] } },
};
expect(hasFreightPermission(direct, CLEARANCE)).toBe(true);
});
});

View File

@@ -42,12 +42,41 @@ export function isFreightApprovalAdmin(user: MeLikeUser | null | undefined): boo
return isSuperAdmin(user) || isOrganizationAdmin(user);
}
/** Flat permission keys from JWT / session user (roles + position permissions). */
/**
* Permissions carried by a position TYPE rather than the position itself.
*
* The JWT snapshots only DIRECT position permissions, so type-level grants —
* which is where admin-created positions keep theirs — are absent from the
* token entirely. This resolver is installed at startup
* (see `PositionTypePermissionsCache`) so the synchronous permission checks
* below can still see them. Left as a no-op resolver until then, which
* degrades to the old position-only behaviour rather than throwing.
*/
let positionTypePermissionResolver: (positionTypeKey: string) => string[] = () =>
[];
export function setPositionTypePermissionResolver(
resolver: (positionTypeKey: string) => string[],
): void {
positionTypePermissionResolver = resolver;
}
/**
* Flat permission keys from JWT / session user: roles, position permissions,
* and the grants held by each position's TYPE.
*/
export function collectPermissionKeys(user: MeLikeUser | null | undefined): string[] {
if (!user) return [];
const keys = new Set<string>();
const addTypePermissions = (positionType: PositionTypeLike | null | undefined) => {
if (!positionType?.key) return;
for (const key of positionTypePermissionResolver(positionType.key)) {
keys.add(key);
}
};
for (const p of user.permissions ?? []) {
if (p.key) keys.add(p.key);
}
@@ -63,6 +92,7 @@ export function collectPermissionKeys(user: MeLikeUser | null | undefined): stri
for (const p of pos.permissions ?? []) {
if (p.key) keys.add(p.key);
}
addTypePermissions(pos.positionType);
}
}
return [...keys];
@@ -71,6 +101,7 @@ export function collectPermissionKeys(user: MeLikeUser | null | undefined): stri
for (const p of employee.position?.permissions ?? []) {
if (p.key) keys.add(p.key);
}
addTypePermissions(employee.position?.positionType);
for (const delegated of employee.delegatedPositions ?? []) {
for (const p of delegated.permissions ?? []) {
if (p.key) keys.add(p.key);

View File

@@ -0,0 +1,83 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { setPositionTypePermissionResolver } from './freight-permission.util';
/**
* Permissions granted to a position TYPE (`iam.position_type_permissions`).
*
* A position type is the platform's notion of a role, and positions created
* through the admin UI carry their grants there rather than on the position
* itself. The JWT only ever snapshots DIRECT position permissions, so those
* grants are invisible to `collectPermissionKeys` — staff on such a position
* resolve to zero permissions and every permission-gated route rejects them.
*
* The permission checks (`hasFreightPermission`, `FreightPermissionGuard`) are
* synchronous and sit on the request path, so the mapping is held in memory and
* refreshed periodically rather than queried per request. The dataset is tiny
* (tens of types, a few hundred rows), so a full reload is cheaper than any
* incremental scheme.
*/
@Injectable()
export class PositionTypePermissionsCache implements OnModuleInit {
private readonly logger = new Logger(PositionTypePermissionsCache.name);
/** position_type key → permission keys. Empty until the first load lands. */
private byPositionTypeKey = new Map<string, string[]>();
// ponytail: fixed 5-min refresh, no invalidation hook. A permission granted
// in the admin UI takes up to one interval to reach the guards. Wire the
// grant mutation to call `refresh()` if that lag ever matters.
private static readonly REFRESH_INTERVAL_MS = 5 * 60 * 1000;
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
async onModuleInit(): Promise<void> {
await this.refresh();
// Hand the lookup to the permission utils, whose checks are synchronous and
// therefore cannot query IAM themselves.
setPositionTypePermissionResolver((positionTypeKey) =>
this.get(positionTypeKey),
);
const timer = setInterval(() => {
void this.refresh();
}, PositionTypePermissionsCache.REFRESH_INTERVAL_MS);
// Never hold the process open for a cache refresh.
timer.unref?.();
}
/** Permission keys for a position-type key ([] when unknown/not loaded). */
get(positionTypeKey: string | undefined | null): string[] {
if (!positionTypeKey) return [];
return this.byPositionTypeKey.get(positionTypeKey) ?? [];
}
/** Reload the whole mapping. Failures keep the previous snapshot in place. */
async refresh(): Promise<void> {
try {
const rows: { position_type_key: string; permission_key: string }[] =
await this.dataSource.query(
`SELECT pt.key AS position_type_key, perm.key AS permission_key
FROM iam.position_type_permissions ptp
JOIN iam.position_types pt ON pt.id = ptp.position_type_id
JOIN iam.permissions perm ON perm.id = ptp.permission_id`,
);
const next = new Map<string, string[]>();
for (const row of rows) {
if (!row.position_type_key || !row.permission_key) continue;
const keys = next.get(row.position_type_key);
if (keys) keys.push(row.permission_key);
else next.set(row.position_type_key, [row.permission_key]);
}
this.byPositionTypeKey = next;
} catch (err) {
// iam schema unreachable — keep serving the previous snapshot rather than
// dropping every type-derived permission and locking staff out.
this.logger.warn(
`Position-type permission refresh failed: ${(err as Error).message}`,
);
}
}
}

View File

@@ -0,0 +1,65 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Partial wagon cancellation with rebooking credit.
*
* One row per cancellation cycle on a PAID booking: the customer asks to drop
* N wagons, pays a per-wagon cancellation fee (rates row
* rate_type = 'CANCELLATION_FEE', rate_unit = 'PER_WAGON'), and the dropped cargo becomes a
* rebookable credit. The credit is redeemed by creating a fresh booking
* through the normal under-contract create path (which re-checks contract
* validity and caps), immediately marked PAID — the freight was already paid
* on the original booking, only the fee is new money.
*
* cancelled_quantities carries what was cut, in the booking's own terms:
* `{ bulkTons }` for bulk, `{ bySize: { "20": 4, "40": 3 } }` for container.
* Container numbers are NOT stored here — they are recovered at rebook time
* from the unit rows the reduction soft-deleted (same hybrid pattern as
* RemainderPlacementService).
*/
export class BookingWagonCancellations3300000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.booking_wagon_cancellations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
booking_id uuid NOT NULL REFERENCES freight.bookings(id),
rebooked_booking_id uuid REFERENCES freight.bookings(id),
wagons_cancelled numeric(6,2) NOT NULL CHECK (wagons_cancelled > 0),
weight_tons numeric(12,3) NOT NULL DEFAULT 0,
cancelled_quantities jsonb NOT NULL,
credit_amount numeric(14,2) NOT NULL DEFAULT 0,
fee_rate_id uuid REFERENCES freight.rates(id),
fee_amount numeric(14,2) NOT NULL CHECK (fee_amount >= 0),
fee_currency varchar(8) NOT NULL DEFAULT 'ETB',
fee_invoice_id uuid REFERENCES freight.invoices(id),
fee_paid_at timestamptz,
status varchar(30) NOT NULL DEFAULT 'FEE_PENDING',
reason text,
requested_by_user_id uuid,
rebooked_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
)
`);
// One open (fee-unpaid) cancellation per booking — closes the double-click
// race without app-level locking.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_open_wagon_cancellation_per_booking
ON freight.booking_wagon_cancellations (booking_id)
WHERE status = 'FEE_PENDING' AND deleted_at IS NULL
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_bwc_booking
ON freight.booking_wagon_cancellations (booking_id)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_bwc_status
ON freight.booking_wagon_cancellations (status)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_wagon_cancellations`);
}
}

View File

@@ -35,10 +35,57 @@ export class FreightMeService {
}
}
/**
* Permissions granted to the position's TYPE (`iam.position_type_permissions`).
* A position type is the platform's notion of a role, and admin-created
* positions carry their grants there rather than on the position itself — but
* the JWT only ever snapshots direct position permissions. Without this, staff
* on such a position resolve to zero permissions and every permission-gated
* route rejects them (this is what locked GL officers out of their clearance
* detail pages). Resolved live from IAM, same as the position type above.
*/
private async lookupPositionTypePermissions(
positionId: string | undefined,
): Promise<string[]> {
if (!positionId) return [];
try {
const rows: { key: string }[] = await this.dataSource.query(
`SELECT DISTINCT perm.key
FROM iam.positions p
JOIN iam.position_type_permissions ptp
ON ptp.position_type_id = p.position_type_id
JOIN iam.permissions perm ON perm.id = ptp.permission_id
WHERE p.id = $1`,
[positionId],
);
return rows.map((r) => r.key).filter(Boolean);
} catch {
return []; // iam schema unreachable — degrade to position-only permissions
}
}
async getEnrichedProfile(user: TCurrentUser) {
const positionType = await this.lookupPositionType(
user.employee?.position?.id,
const positionId = user.employee?.position?.id;
const [positionType, positionTypePermissionKeys] = await Promise.all([
this.lookupPositionType(positionId),
this.lookupPositionTypePermissions(positionId),
]);
// Merge the type-level grants into the position's own permission list so
// BOTH consumers see them: `collectPermissionKeys` below, and the
// backoffice's `getPermissionKeys`, which walks this same nested array.
const positionPermissions = [
...(user.employee?.position?.permissions ?? []),
];
const seenPermissionKeys = new Set(
positionPermissions.map((p) => p?.key).filter(Boolean),
);
for (const key of positionTypePermissionKeys) {
if (!seenPermissionKeys.has(key)) {
seenPermissionKeys.add(key);
positionPermissions.push({ key } as (typeof positionPermissions)[number]);
}
}
const employee = user.employee
? [
@@ -56,7 +103,7 @@ export class FreightMeService {
name: user.employee.position.name,
isDelegate: user.employee.position.isDelegate,
parentPositionId: user.employee.position.parentPositionId,
permissions: user.employee.position.permissions ?? [],
permissions: positionPermissions,
positionType,
},
]
@@ -65,7 +112,15 @@ export class FreightMeService {
]
: [];
const permissionKeys = collectPermissionKeys(user);
// `collectPermissionKeys` reads the raw token (position-level only), so
// union the type-level grants in — the backoffice prefers this flat list
// over the nested array and would otherwise still see none of them.
const permissionKeys = [
...new Set([
...collectPermissionKeys(user),
...positionTypePermissionKeys,
]),
];
return {
id: user.id,

View File

@@ -20,6 +20,10 @@ import { FirstMileService } from "../first-mile/first-mile.service";
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
import { PriceLineItemDto } from "./dto/generate-price-response.dto";
import { BookingsRepository } from "./bookings.repository";
import {
BookingWagonCancellationService,
WAGON_CANCEL_FEE_INVOICE_TYPE,
} from "./booking-wagon-cancellation.service";
import { Booking } from "./entities/booking.entity";
/** Snapshot written onto `booking.pricingBreakdown` by the pricing service. */
@@ -58,6 +62,8 @@ export class BookingInvoiceService {
private readonly firstMile: FirstMileService,
@Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatch: BookingBatchService,
@Inject(forwardRef(() => BookingWagonCancellationService))
private readonly wagonCancellations: BookingWagonCancellationService,
) { }
/**
@@ -90,6 +96,21 @@ export class BookingInvoiceService {
return this.billing.generateInvoice(input);
}
/**
* Cancel the booking's open PREPAID invoice, if any — used when a
* changes-requested resubmit restates the cargo, so the re-priced booking can
* be re-invoiced. Throws when the invoice already has payments recorded
* (cargo must not change out from under recorded money).
*/
async cancelUnpaidInvoiceForBooking(bookingId: string): Promise<void> {
const existing = await this.billing.findPayable(
Freight.InvoiceSource.Booking,
bookingId,
"PREPAID",
);
if (existing) await this.billing.cancelInvoice(existing.id);
}
/**
* React to a booking invoice being paid — the settlement branch point. Per-type
* reactions live here (not in the payment process): each invoice type advances
@@ -108,6 +129,11 @@ export class BookingInvoiceService {
await this.bookingBatch.reviveOfferForInvoice(payload.invoiceId);
await this.advanceBookingOnPayment(payload.sourceId);
break;
case WAGON_CANCEL_FEE_INVOICE_TYPE:
// Partial wagon cancellation: the fee settled — reduce the booking and
// release the cancelled wagons (T2 of the cancellation cycle).
await this.wagonCancellations.onFeePaid(payload.invoiceId);
break;
default:
this.logger.warn(
`Unhandled booking invoice type "${payload.type}" paid (${payload.invoiceId})`,

View File

@@ -8,6 +8,9 @@ import {
Optional,
} from "@nestjs/common";
import { EventEmitter2, OnEvent } from "@nestjs/event-emitter";
import { DataSource } from "typeorm";
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import {
BookingBatchService,
@@ -56,11 +59,18 @@ export class BookingTransitionService {
private readonly bookingClearanceService: BookingClearanceService,
@Inject(forwardRef(() => ClearanceWorkflowService))
private readonly workflowService: ClearanceWorkflowService,
private readonly invoiceService: BookingInvoiceService,
// forwardRef: booking-invoice.service now pulls in the wagon-cancellation
// service, whose cross-module imports close a require cycle through this
// file — without it the class is undefined at decorator time.
@Inject(forwardRef(() => BookingInvoiceService))
private readonly invoiceService: BookingInvoiceService,
private readonly containerValidationService: ContainerValidationService,
private readonly notifier: BookingLifecycleNotifierService,
private readonly events: EventEmitter2,
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
// Optional + last so the hand-constructed service in *.spec.ts files keeps
// compiling; Nest injects it normally at runtime.
@Optional() private readonly dataSource?: DataSource,
) {}
private isPhasedCustoms(booking: Booking): boolean {
@@ -429,6 +439,20 @@ export class BookingTransitionService {
return fresh;
}
/**
* Customer self-service cancel, allowed only before payment — no fee.
* SELECTED_FOR_BATCH releases the wagon hold immediately; earlier statuses
* take the plain cancel path (open invoices expired, nothing reserved yet).
* Anything past payment falls through to cancel()'s status assertion.
*/
async customerCancel(bookingId: string, reason?: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
if (booking.status === "SELECTED_FOR_BATCH") {
return this.cancelHold(bookingId, reason);
}
return this.cancel(bookingId, reason ?? "Customer cancelled before payment");
}
async cancel(bookingId: string, reason: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [
@@ -978,6 +1002,15 @@ export class BookingTransitionService {
// booking through the space checks below AND is persisted so the accept /
// reserve path locks onto that train (pickExportSchedule honors it).
const requestedId = isExportTrain ? (requestedTrainScheduleId ?? null) : null;
// Export rail rides the exact train the customer picked — never an
// auto-assigned one. Both portal flows (clearance + contract completion)
// surface a picker, so a missing id is an invalid submission, not a
// legitimate "let the system choose".
if (isExportTrain && !requestedId) {
throw new BadRequestException(
"Select a train for the chosen shipment day.",
);
}
const scheduledBooking = {
...booking,
scheduledDate: date,
@@ -1244,6 +1277,12 @@ export class BookingTransitionService {
/** Flat list of physical container numbers on this booking (for the
* customer truck-assignment container picker). */
containerNumbers: string[];
/** The allocated train, when the booking is placed on a schedule. */
trainSchedule?: {
trainNumber: string | null;
reference: string | null;
scheduledDepartureDate: Date | null;
} | null;
}
> {
// This enrichment runs AFTER the transition has committed. A failure here
@@ -1296,6 +1335,32 @@ export class BookingTransitionService {
`enrichBookingResponse: batch-offer lookup failed for ${booking.id}: ${(err as Error).message}`,
);
}
// Allocated train: number + schedule reference for the detail headers
// (portal and backoffice). Degrades to null like every fragile field here.
let trainSchedule: {
trainNumber: string | null;
reference: string | null;
scheduledDepartureDate: Date | null;
} | null = null;
if (booking.trainScheduleId && this.dataSource) {
try {
const s = await this.dataSource.getRepository(TrainSchedule).findOne({
where: { id: booking.trainScheduleId },
});
if (s) {
trainSchedule = {
trainNumber: s.trainNumber ?? null,
reference: s.reference ?? null,
scheduledDepartureDate: s.scheduledDepartureDate ?? null,
};
}
} catch (err) {
this.logger.warn(
`enrichBookingResponse: train-schedule lookup failed for ${booking.id}: ${(err as Error).message}`,
);
}
}
// Physical container numbers entered at booking time (booking_container
// units), flattened for the customer truck-assignment container picker.
const containerNumbers = (booking.bookingContainers ?? [])
@@ -1310,6 +1375,7 @@ export class BookingTransitionService {
nextStep,
activeBatchOffer,
containerNumbers,
trainSchedule,
};
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,85 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, SelectQueryBuilder } from 'typeorm';
import { BookingWagonCancellation } from './entities/booking-wagon-cancellation.entity';
export interface WagonCancellationListFilter {
status?: string[];
/** Booking reference / company name search (staff list). */
search?: string;
companyId?: string;
bookingId?: string;
from?: Date;
to?: Date;
page?: number;
pageSize?: number;
}
@Injectable()
export class BookingWagonCancellationsRepository extends BaseRepository<BookingWagonCancellation> {
constructor(
@InjectRepository(BookingWagonCancellation)
repository: Repository<BookingWagonCancellation>,
) {
super(repository);
}
/** The one open (fee-unpaid) cancellation of a booking, if any. */
findOpenForBooking(bookingId: string): Promise<BookingWagonCancellation | null> {
return this.repository.findOne({
where: { bookingId, status: 'FEE_PENDING' },
});
}
findByFeeInvoiceId(feeInvoiceId: string): Promise<BookingWagonCancellation | null> {
return this.repository.findOne({ where: { feeInvoiceId } });
}
/** Paged history — staff see everything, customers are scoped by companyId. */
async list(
filter: WagonCancellationListFilter,
): Promise<{ items: BookingWagonCancellation[]; total: number }> {
const page = Math.max(1, filter.page ?? 1);
const pageSize = Math.min(100, Math.max(1, filter.pageSize ?? 10));
const qb = this.baseQuery();
if (filter.bookingId) {
qb.andWhere('(bwc.booking_id = :bookingId OR bwc.rebooked_booking_id = :bookingId)', {
bookingId: filter.bookingId,
});
}
if (filter.companyId) {
qb.andWhere('booking.company_id = :companyId', { companyId: filter.companyId });
}
if (filter.status?.length) {
qb.andWhere('bwc.status IN (:...statuses)', { statuses: filter.status });
}
if (filter.search) {
qb.andWhere('(booking.reference ILIKE :search OR company.name ILIKE :search)', {
search: `%${filter.search}%`,
});
}
if (filter.from) qb.andWhere('bwc.created_at >= :from', { from: filter.from });
if (filter.to) qb.andWhere('bwc.created_at <= :to', { to: filter.to });
// Property path (not raw column): skip/take builds a distinct-id subquery
// and the ORDER BY must resolve inside it.
const [items, total] = await qb
.orderBy('bwc.createdAt', 'DESC')
.skip((page - 1) * pageSize)
.take(pageSize)
.getManyAndCount();
return { items, total };
}
private baseQuery(): SelectQueryBuilder<BookingWagonCancellation> {
return this.repository
.createQueryBuilder('bwc')
.leftJoinAndSelect('bwc.booking', 'booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('bwc.rebookedBooking', 'rebookedBooking')
.leftJoinAndSelect('bwc.feeInvoice', 'feeInvoice');
}
}

View File

@@ -72,6 +72,12 @@ import { GenerateGrnDto } from './dto/generate-grn.dto';
import { ContainerReceiptService } from './container-receipt.service';
import { SignContractDto } from './dto/sign-contract.dto';
import { UpdateBookingDto } from './dto/update-booking.dto';
import { BookingWagonCancellationService } from './booking-wagon-cancellation.service';
import {
FilterWagonCancellationsDto,
RebookCancelledWagonsDto,
RequestWagonCancellationDto,
} from './dto/wagon-cancellation.dto';
import {
type AuthUserPayload,
resolveAuthUserId,
@@ -151,6 +157,7 @@ export class BookingsController {
private readonly firstMileService: FirstMileService,
private readonly lastMileService: LastMileService,
private readonly userTradeAccessService: UserTradeAccessService,
private readonly wagonCancellationService: BookingWagonCancellationService,
) {}
@Post()
@@ -507,6 +514,150 @@ export class BookingsController {
res.send(buffer);
}
@Get(':id/wagons')
@ApiOperation({
summary:
'Allocated wagons for a booking (JSON) — empty until the paid booking is placed on a train',
})
async wagonAllocations(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
return this.bookingsService.wagonAllocations(id);
}
// ── Partial wagon cancellation (paid bookings) ────────────────────────────
// Customer endpoints are ownership-scoped (no portal permission keys); the
// staff history/void/rebook variants are permission-gated below.
@Post(':id/wagon-cancellations/preview')
@ApiOperation({ summary: 'Preview the fee/credit of a partial wagon cancellation (no writes)' })
async previewWagonCancellation(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RequestWagonCancellationDto,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
return this.wagonCancellationService.previewCancellation(id, dto);
}
@Post(':id/wagon-cancellations')
@ApiOperation({
summary:
'Request a partial wagon cancellation on a PAID booking — opens the cancellation-fee invoice; wagons are released only once the fee settles',
})
async requestWagonCancellation(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RequestWagonCancellationDto,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
return this.wagonCancellationService.requestCancellation(id, dto, user?.id);
}
@Get(':id/wagon-cancellations')
@ApiOperation({ summary: 'Wagon-cancellation history of one booking (owner or staff)' })
async listBookingWagonCancellations(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
const staff =
hasFreightPermission(user, FREIGHT_PERMS.bookings.view) ||
hasFreightPermission(user, FREIGHT_PERMS.bookings.wagonCancellationView);
if (!staff) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
return this.wagonCancellationService.list({ bookingId: id, pageSize: 100 });
}
@Get('wagon-cancellations/my')
@ApiOperation({ summary: 'Wagon-cancellation history of the calling customer (paginated, filterable)' })
async listMyWagonCancellations(
@Query() filter: FilterWagonCancellationsDto,
@CurrentUser() user: TCurrentUser,
) {
const companyId = await this.bookingsService.resolveCustomerCompanyId(user?.id ?? '');
if (!companyId) throw new ForbiddenException('No customer company for this user.');
return this.wagonCancellationService.list({
companyId,
status: filter.statuses,
search: filter.search,
from: filter.from ? new Date(filter.from) : undefined,
to: filter.to ? new Date(filter.to) : undefined,
page: filter.page,
pageSize: filter.pageSize,
});
}
@Get('wagon-cancellations/history')
@WagonCancellationView()
@ApiOperation({ summary: 'All wagon cancellations (staff, paginated, filterable)' })
async listAllWagonCancellations(@Query() filter: FilterWagonCancellationsDto) {
return this.wagonCancellationService.list({
status: filter.statuses,
search: filter.search,
from: filter.from ? new Date(filter.from) : undefined,
to: filter.to ? new Date(filter.to) : undefined,
page: filter.page,
pageSize: filter.pageSize,
});
}
@Post('wagon-cancellations/:cancellationId/withdraw')
@ApiOperation({ summary: 'Withdraw a fee-pending wagon cancellation (owner, or staff with the void permission)' })
async withdrawWagonCancellation(
@Param('cancellationId', ParseUUIDPipe) cancellationId: string,
@CurrentUser() user: TCurrentUser,
) {
await this.assertWagonCancellationActor(
cancellationId,
user,
FREIGHT_PERMS.bookings.wagonCancellationVoid,
);
return this.wagonCancellationService.withdraw(cancellationId);
}
@Post('wagon-cancellations/:cancellationId/rebook')
@ApiOperation({
summary:
'Rebook a wagon-cancellation credit: pick a shipment day only — the new booking is created under the contract and marked PAID (freight already paid; contract must still be valid)',
})
async rebookWagonCancellation(
@Param('cancellationId', ParseUUIDPipe) cancellationId: string,
@Body() dto: RebookCancelledWagonsDto,
@CurrentUser() user: TCurrentUser,
) {
await this.assertWagonCancellationActor(
cancellationId,
user,
FREIGHT_PERMS.bookings.wagonCancellationRebook,
);
return this.wagonCancellationService.rebook(cancellationId, dto, user?.id);
}
/** Owner-or-staff gate shared by the per-cancellation actions. */
private async assertWagonCancellationActor(
cancellationId: string,
user: TCurrentUser,
staffPermission: string,
): Promise<void> {
if (hasFreightPermission(user, staffPermission)) return;
const row = await this.wagonCancellationService.findById(cancellationId);
const booking = await this.bookingsService.findById(row.bookingId);
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
@Get(':id/customer-trucks')
@MixedAudience([
FREIGHT_PERMS.bookings.view,
@@ -1382,6 +1533,19 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/customer-cancel")
@ApiOperation({
summary:
"Customer cancels their own booking before payment — no cancellation fee",
})
async customerCancel(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: RejectBookingDto,
) {
const booking = await this.transitionService.customerCancel(id, dto.reason);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/cancel-hold")
@PortalCustomer()
@ApiOperation({

View File

@@ -39,6 +39,9 @@ import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
import { BookingReviewNote } from './entities/booking-review-note.entity';
import { Booking } from './entities/booking.entity';
import { BookingWagonCancellation } from './entities/booking-wagon-cancellation.entity';
import { BookingWagonCancellationsRepository } from './booking-wagon-cancellations.repository';
import { BookingWagonCancellationService } from './booking-wagon-cancellation.service';
import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity';
import { CustomerTruckContainer } from './entities/customer-truck-container.entity';
import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository';
@@ -66,6 +69,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
BookingReviewNote,
BookingContractSignature,
BookingContainerAllocation,
BookingWagonCancellation,
CustomerTruckAssignment,
CustomerTruckContainer,
]),
@@ -110,6 +114,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
CustomerTruckAssignmentsRepository,
CustomerTruckService,
ContainerReceiptService,
BookingWagonCancellationsRepository,
BookingWagonCancellationService,
],
exports: [
BookingsService,
@@ -121,6 +127,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
ConsolidationService,
CustomerTruckService,
ContainerReceiptService,
BookingWagonCancellationService,
],
})
export class BookingsModule { }

View File

@@ -339,6 +339,64 @@ export class BookingsService {
};
}
/**
* Allocated wagons of a booking as JSON — the portal's "Wagons" tab. Same
* join chain as the carriage acceptance sheet, but structured (containers as
* an array per wagon, bulk load description when the wagon carries bulk).
* Empty array until the booking has been allocated onto a train.
*/
async wagonAllocations(bookingId: string): Promise<unknown[]> {
return this.dataSource.query(
`SELECT a.id AS "allocationId",
tsw.sequence_no AS "sequenceNo",
w.wagon_number AS "wagonNumber",
COALESCE(wt.name, wt.code) AS "wagonType",
wt.code AS "wagonTypeCode",
wt.tare_weight_tons AS "tareWeightTons",
tsw.capacity_tons AS "capacityTons",
tsw.length_meters AS "lengthMeters",
a.allocated_weight_tons AS "allocatedWeightTons",
a.load_type AS "loadType",
a.status AS "status",
s.train_number AS "trainNumber",
s.scheduled_departure_date AS "departureAt",
so.label AS "originStation",
sd.label AS "destinationStation",
bl.cargo_description AS "bulkCargoDescription",
bl.quantity AS "bulkQuantity",
COALESCE(
json_agg(
json_build_object(
'containerNumber', ci.container_number,
'sealNumber', ci.seal_number,
'positionOnWagon', ci.position_on_wagon,
'grossWeightTons', ci.gross_weight_tons
) ORDER BY ci.position_on_wagon, ci.container_number
) FILTER (WHERE ci.id IS NOT NULL),
'[]'
) AS "containers"
FROM freight.wagon_booking_allocations a
JOIN freight.train_set_wagons tsw
ON tsw.id = a.train_set_wagon_id AND tsw.deleted_at IS NULL
LEFT JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id
LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id
LEFT JOIN freight.train_schedules s
ON s.train_set_id = tsw.train_set_id AND s.deleted_at IS NULL
LEFT JOIN freight.yards so ON so.id = s.origin_station_id
LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id
LEFT JOIN freight.wagon_allocation_container_items ci
ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL
LEFT JOIN freight.wagon_allocation_bulk_loads bl
ON bl.wagon_booking_allocation_id = a.id AND bl.deleted_at IS NULL
WHERE a.booking_id = $1 AND a.deleted_at IS NULL
GROUP BY tsw.id, a.id, w.wagon_number, wt.name, wt.code, wt.tare_weight_tons,
s.train_number, s.scheduled_departure_date, so.label, sd.label,
bl.cargo_description, bl.quantity
ORDER BY tsw.sequence_no`,
[bookingId],
);
}
/**
* Split the booking amount across its wagons, proportional to allocated weight
* (equal shares when no weights are recorded). The last row absorbs the rounding
@@ -1425,7 +1483,46 @@ export class BookingsService {
tradeDirection,
);
}
if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate);
// Re-pinning the departure day on an edit (e.g. fixing a CHANGES_REQUESTED
// booking) must obey the same gate as creation: the route needs an OPEN
// departure on that EAT day that can carry the cargo. Skipped when the day
// didn't change, for general contracts (period-based, no pinned day) and
// for intercity (staff assign a passing train later).
if (dto.scheduledDate) {
const day = eatDay(new Date(dto.scheduledDate));
const dayChanged =
!existing.scheduledDate || eatDay(existing.scheduledDate) !== day;
if (
dayChanged &&
existing.bookingType !== 'GENERAL_CONTRACT' &&
tradeDirection !== 'DOMESTIC'
) {
const { hasDeparture, hasCompatible } =
await this.trainSchedulingService.checkDayCargoCompatibility(
originYardId,
destinationYardId,
day,
{
freightType: freightType as 'CONTAINER' | 'BULK',
cargoTypeId,
containerTypeIds: containers
.map((c) => c.containerTypeId)
.filter((cid): cid is string => Boolean(cid)),
},
);
if (!hasDeparture) {
throw new BadRequestException(
'No departures available on the selected day for this route',
);
}
if (!hasCompatible) {
throw new BadRequestException(
'No wagon on the selected day can carry this cargo type — please choose another day',
);
}
}
updates.scheduledDate = new Date(dto.scheduledDate);
}
if (dto.estimatedShipmentDate)
updates.estimatedShipmentDate = new Date(dto.estimatedShipmentDate);
if (dto.startDate) updates.startDate = new Date(dto.startDate);

View File

@@ -0,0 +1,112 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayNotEmpty,
IsArray,
IsDateString,
IsIn,
IsInt,
IsNumber,
IsOptional,
IsString,
IsUUID,
MaxLength,
Min,
ValidateNested,
} from 'class-validator';
import { WAGON_CANCELLATION_STATUSES } from '../entities/booking-wagon-cancellation.entity';
export class CancelContainerLineDto {
@ApiProperty({ description: 'Container size (ft) as stored on the booking line, e.g. "20", "40"' })
@IsString()
containerSize!: string;
@ApiProperty({ description: 'How many units of this size to cancel' })
@IsInt()
@Min(1)
quantity!: number;
}
export class RequestWagonCancellationDto {
@ApiPropertyOptional({
description:
'Cancel SPECIFIC allocated wagons: wagon_booking_allocation ids from GET /bookings/:id/wagons. ' +
'When set, wagons/containers are derived from the selected wagons and the other fields are ignored.',
type: [String],
})
@IsOptional()
@IsArray()
@ArrayNotEmpty()
@IsUUID('4', { each: true })
wagonAllocationIds?: string[];
@ApiPropertyOptional({
description: 'BULK bookings: number of wagons to cancel (tons derived proportionally)',
})
@IsOptional()
@IsNumber()
@Min(0.5)
wagons?: number;
@ApiPropertyOptional({
description: 'CONTAINER bookings: units to cancel per size (wagons derived per size)',
type: [CancelContainerLineDto],
})
@IsOptional()
@IsArray()
@ArrayNotEmpty()
@ValidateNested({ each: true })
@Type(() => CancelContainerLineDto)
containers?: CancelContainerLineDto[];
@ApiPropertyOptional({ description: 'Customer reason for the cancellation' })
@IsOptional()
@IsString()
@MaxLength(1000)
reason?: string;
}
export class RebookCancelledWagonsDto {
@ApiProperty({ description: 'Shipment day the credit is rebooked onto (ISO date)' })
@IsDateString()
scheduledDate!: string;
}
export class FilterWagonCancellationsDto {
@ApiPropertyOptional({ enum: WAGON_CANCELLATION_STATUSES, isArray: true })
@IsOptional()
@IsArray()
@IsIn(WAGON_CANCELLATION_STATUSES as readonly string[], { each: true })
statuses?: string[];
@ApiPropertyOptional({ description: 'Booking reference / company name search' })
@IsOptional()
@IsString()
@MaxLength(120)
search?: string;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
from?: string;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
to?: string;
@ApiPropertyOptional({ default: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@ApiPropertyOptional({ default: 10 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
pageSize?: number;
}

View File

@@ -0,0 +1,137 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Invoice } from '../../billing/entities/invoice.entity';
import { Rate } from '../../rule-engine/entities/rate.entity';
import { Booking } from './booking.entity';
export const WAGON_CANCELLATION_STATUSES = [
// Requested; fee invoice open; wagons still allocated to the customer.
'FEE_PENDING',
// Fee settled; booking reduced, wagons freed; credit waiting for a rebook.
'CREDIT_AVAILABLE',
// Credit redeemed into a new PAID booking (rebookedBookingId).
'REBOOKED',
// Customer/staff voided the request before paying the fee. Nothing changed.
'WITHDRAWN',
// Reserved for a future expiry policy; not set by code today.
'EXPIRED',
] as const;
export type WagonCancellationStatus = (typeof WAGON_CANCELLATION_STATUSES)[number];
/** Snapshot of one physical container unit cut by the cancellation. */
export interface CancelledUnitSnapshot {
containerSize: string;
containerNumber: string;
sealNumber?: string | null;
vgmTons: number;
isHazardous: boolean;
isReefer: boolean;
}
/** What the cancellation cut, in the booking's own quantity terms. */
export interface CancelledQuantities {
/** Bulk bookings: tons cut (PER_ITEM cargo: item count, matching cargoTotalWeightVgm). */
bulkTons?: number;
/** Container bookings: units cut per container size. */
bySize?: Record<string, number>;
/**
* Container bookings: the exact physical units cut. Snapshotted at request
* time when the customer picked specific wagons, otherwise at fee settlement
* (LIFO trim). The rebook reconstructs the new booking from THESE — never
* from a soft-deleted-row scan, which could pick up units dropped by an
* unrelated batch split on the same booking.
*/
units?: CancelledUnitSnapshot[];
/**
* Specific-wagon cancellation: the wagon_booking_allocation ids the customer
* picked in the Wagons tab. T2 releases exactly these (fallback to
* newest-first for any id that no longer exists, e.g. after a re-batch).
*/
allocationIds?: string[];
/**
* The wagon allocations were already released from the schedule at REQUEST
* time (policy: wagons free up immediately; the fee is still owed before the
* credit can be rebooked). Tells T2 to skip its release step so it never
* deletes wagons the batch engine re-assigned in between.
*/
releasedAtRequest?: boolean;
}
/**
* One partial-wagon-cancellation cycle on a PAID booking — the audit trail and
* the state machine. The credit itself is not a wallet balance: redeeming it
* creates a real booking through the under-contract create path and marks it
* PAID (see BookingWagonCancellationService).
*/
@Entity({ schema: 'freight', name: 'booking_wagon_cancellations' })
@Index(['bookingId'])
@Index(['status'])
export class BookingWagonCancellation extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@ManyToOne(() => Booking)
@JoinColumn({ name: 'booking_id' })
booking?: Booking;
@Column({ name: 'rebooked_booking_id', type: 'uuid', nullable: true })
rebookedBookingId?: string | null;
@ManyToOne(() => Booking, { nullable: true })
@JoinColumn({ name: 'rebooked_booking_id' })
rebookedBooking?: Booking | null;
@Column({ name: 'wagons_cancelled', type: 'numeric', precision: 6, scale: 2 })
wagonsCancelled!: number;
@Column({ name: 'weight_tons', type: 'numeric', precision: 12, scale: 3, default: 0 })
weightTons!: number;
@Column({ name: 'cancelled_quantities', type: 'jsonb' })
cancelledQuantities!: CancelledQuantities;
/**
* The freight value of the cancelled part at the ORIGINAL booking's price —
* informational (shown to the customer as "credit worth"); no refund is ever
* issued from it, the credit is redeemed by rebooking.
*/
@Column({ name: 'credit_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
creditAmount!: number;
@Column({ name: 'fee_rate_id', type: 'uuid', nullable: true })
feeRateId?: string | null;
@ManyToOne(() => Rate, { nullable: true })
@JoinColumn({ name: 'fee_rate_id' })
feeRate?: Rate | null;
@Column({ name: 'fee_amount', type: 'numeric', precision: 14, scale: 2 })
feeAmount!: number;
@Column({ name: 'fee_currency', type: 'varchar', length: 8, default: 'ETB' })
feeCurrency!: string;
@Column({ name: 'fee_invoice_id', type: 'uuid', nullable: true })
feeInvoiceId?: string | null;
@ManyToOne(() => Invoice, { nullable: true })
@JoinColumn({ name: 'fee_invoice_id' })
feeInvoice?: Invoice | null;
@Column({ name: 'fee_paid_at', type: 'timestamptz', nullable: true })
feePaidAt?: Date | null;
@Column({ name: 'status', type: 'varchar', length: 30, default: 'FEE_PENDING' })
status!: string;
@Column({ name: 'reason', type: 'text', nullable: true })
reason?: string | null;
@Column({ name: 'requested_by_user_id', type: 'uuid', nullable: true })
requestedByUserId?: string | null;
@Column({ name: 'rebooked_at', type: 'timestamptz', nullable: true })
rebookedAt?: Date | null;
}

View File

@@ -0,0 +1,115 @@
import { ContractBookingService } from './contract-booking.service';
/**
* OPERATION_CHANGES_REQUESTED resubmit with restated cargo. Operations can ask
* for the cargo itself to change, so a completion payload that restates
* containers must cancel the unpaid invoice, wipe the persisted cargo and
* re-run the fresh-completion path (re-persist, re-price, re-invoice). A
* payload without cargo keeps the day-only resubmit behavior.
*/
describe('ContractBookingService — changes-requested resubmit restating cargo', () => {
const CONTRACT = {
id: 'c-1',
reference: 'CTR-1',
contractKind: 'GENERAL',
freightType: 'CONTAINER',
tradeDirection: 'IMPORT',
customsClearingEnabled: false,
contractValidUntil: null,
cargoScope: [],
};
const bookingWithCargo = () => ({
id: 'b-1',
contractId: 'c-1',
reference: 'BKG-1',
status: 'OPERATION_CHANGES_REQUESTED',
bookingContainers: [{ containerSize: '20FT', quantity: 4 }],
cargoTotalWeightVgm: 80,
originYardId: 'y-o',
destinationYardId: 'y-d',
});
function makeService() {
const bookingsRepository = {
findByIdWithFiles: jest.fn().mockResolvedValue(bookingWithCargo()),
deleteContainers: jest.fn().mockResolvedValue(undefined),
update: jest.fn().mockResolvedValue(undefined),
};
const invoiceService = {
cancelUnpaidInvoiceForBooking: jest.fn().mockResolvedValue(undefined),
};
const trainSchedulingService = {
assertBookingWindowOpen: jest.fn().mockResolvedValue(undefined),
};
const contractsRepository = {
findByIdWithRelations: jest.fn().mockResolvedValue(CONTRACT),
};
const service = new ContractBookingService(
contractsRepository as never,
bookingsRepository as never,
{} as never, // bookingPricingService
{} as never, // consolidationService
{} as never, // containerTypesService
{} as never, // ruleEngineService
{} as never, // milestoneService
invoiceService as never,
{} as never, // bookingNotifier
{} as never, // dataSource
trainSchedulingService as never,
{} as never, // bookingBatchService
{} as never, // bookingTransitionService
);
return { service, bookingsRepository, invoiceService };
}
// Both paths dead-end into a downstream private assert we replace with a
// sentinel — which path threw tells us which branch the resubmit took.
const SENTINEL = new Error('reached-branch');
it('restated cargo cancels the invoice, wipes cargo and re-runs fresh completion', async () => {
const { service, bookingsRepository, invoiceService } = makeService();
// First gate inside the fresh-completion (!hasCargo) path.
jest
.spyOn(
service as never as { assertWithinQuantityCap: () => Promise<void> },
'assertWithinQuantityCap',
)
.mockRejectedValue(SENTINEL);
await expect(
service.completeUnderContract('c-1', 'b-1', {
scheduledDate: new Date().toISOString(),
containers: [{ containerSize: '20FT', quantity: 2 }],
} as never),
).rejects.toBe(SENTINEL);
expect(invoiceService.cancelUnpaidInvoiceForBooking).toHaveBeenCalledWith('b-1');
expect(bookingsRepository.deleteContainers).toHaveBeenCalledWith('b-1');
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', {
cargoTotalWeightVgm: 0,
});
});
it('a day-only resubmit keeps the persisted cargo and invoice untouched', async () => {
const { service, bookingsRepository, invoiceService } = makeService();
// First call inside the day-only (hasCargo) resubmit path.
jest
.spyOn(
service as never as {
assertPersistedContainersAvailable: () => Promise<void>;
},
'assertPersistedContainersAvailable',
)
.mockRejectedValue(SENTINEL);
await expect(
service.completeUnderContract('c-1', 'b-1', {
scheduledDate: new Date().toISOString(),
} as never),
).rejects.toBe(SENTINEL);
expect(invoiceService.cancelUnpaidInvoiceForBooking).not.toHaveBeenCalled();
expect(bookingsRepository.deleteContainers).not.toHaveBeenCalled();
});
});

View File

@@ -98,6 +98,9 @@ export class ContractBookingService {
private readonly containerTypesService: ContainerTypesService,
private readonly ruleEngineService: RuleEngineService,
private readonly milestoneService: ClearanceMilestoneService,
// forwardRef: part of the booking-invoice ⇄ wagon-cancellation ⇄ contracts
// require cycle (see BookingTransitionService).
@Inject(forwardRef(() => BookingInvoiceService))
private readonly invoiceService: BookingInvoiceService,
private readonly bookingNotifier: BookingLifecycleNotifierService,
private readonly dataSource: DataSource,
@@ -692,11 +695,30 @@ export class ContractBookingService {
});
const freightType = contract.freightType;
const hasCargo =
let hasCargo =
(booking.bookingContainers?.length ?? 0) > 0 ||
Number(booking.cargoTotalWeightVgm) > 0;
const warnings: string[] = [];
// Operations may return a booking asking for the CARGO to change (fewer or
// more containers), not just the day. A resubmit whose payload restates the
// cargo therefore starts the completion over: cancel the unpaid invoice
// first (it throws if money is already recorded — cargo must not change
// under a paid invoice), then wipe the persisted cargo so the fresh-
// completion path below re-persists, re-prices and re-invoices from the
// payload. A resubmit without cargo keeps today's day-only behavior.
const restatesCargo = Boolean(
dto.containers?.length || dto.bulkLines?.length,
);
if (hasCargo && restatesCargo) {
await this.invoiceService.cancelUnpaidInvoiceForBooking(booking.id);
await this.bookingsRepository.deleteContainers(booking.id);
await this.bookingsRepository.update(booking.id, {
cargoTotalWeightVgm: 0,
} as never);
hasCargo = false;
}
// EXPORT rides whole or not at all (no split concept): the chosen day must
// have a single open train that carries the whole booking. First completion
// sizes from the dto's cargo; a changes-requested resubmit (cargo already
@@ -1863,6 +1885,9 @@ export class ContractBookingService {
async validateShipment(
contractId: string,
dto: CreateBookingUnderContractDto,
// Completion/resubmit preview: the booking being completed must not clash
// with its own persisted containers.
excludeBookingId?: string,
): Promise<{
overweightLines: Array<{
containerTypeCode: string;
@@ -2024,6 +2049,7 @@ export class ContractBookingService {
originYardId: route?.originYardId,
destinationYardId: route?.destinationYardId,
},
excludeBookingId,
);
containerClashErrors = clashes.map(
(c) =>

View File

@@ -1145,8 +1145,11 @@ export class ContractsController {
validateShipment(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: CreateBookingUnderContractDto,
// Completion/resubmit preview: exclude this booking's own persisted
// containers from the same-train clash check.
@Query('bookingId') bookingId?: string,
) {
return this.contractBookingService.validateShipment(id, dto);
return this.contractBookingService.validateShipment(id, dto, bookingId);
}
@Get(':id/capacity')

View File

@@ -31,6 +31,19 @@ export function paymentDrainMs(): number {
);
}
/**
* ISO timestamp of the end of a pay window's drain tail, for client display
* (the "payment processing" countdown). Null in ⇒ null out.
*/
export function paymentDrainEndsAtIso(
deadline: Date | string | null | undefined,
): string | null {
if (deadline == null) return null;
const ms = new Date(deadline).getTime();
if (!Number.isFinite(ms)) return null;
return new Date(ms + paymentDrainMs()).toISOString();
}
/**
* A pay window AND its drain tail have closed.
*

View File

@@ -14,6 +14,7 @@ import { Server, Socket } from 'socket.io';
import { WsAuthService } from '../notification-inbox/ws-auth.service';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { paymentDrainEndsAtIso } from './booking-batch.constants';
/**
* Server → client push for booking-window state changes. Same handshake model
@@ -61,6 +62,7 @@ export class BookingWindowGateway implements OnGatewayConnection {
windowClosesAt: schedule.windowClosesAt?.toISOString() ?? null,
docReviewEndsAt: schedule.docReviewEndsAt?.toISOString() ?? null,
paymentPhaseEndsAt: schedule.paymentPhaseEndsAt?.toISOString() ?? null,
paymentDrainEndsAt: paymentDrainEndsAtIso(schedule.paymentPhaseEndsAt),
scheduledDepartureDate: schedule.scheduledDepartureDate?.toISOString() ?? null,
};
this.server.emit(BOOKING_WINDOW_WS_EVENTS.PHASE, payload);

View File

@@ -1,5 +1,6 @@
import {
DEFAULT_PAYMENT_DRAIN_MINUTES,
paymentDrainEndsAtIso,
paymentDrainMs,
payWindowLapsed,
} from "./booking-batch.constants";
@@ -64,4 +65,16 @@ describe("payWindowLapsed — pay-window drain tail", () => {
expect(paymentDrainMs()).toBe(DEFAULT_PAYMENT_DRAIN_MINUTES * MIN);
}
});
it("paymentDrainEndsAtIso reports deadline + drain, null/garbage-safe", () => {
expect(paymentDrainEndsAtIso(deadline)).toBe(
new Date(at(DEFAULT_PAYMENT_DRAIN_MINUTES * MIN)).toISOString(),
);
expect(paymentDrainEndsAtIso(deadline.toISOString())).toBe(
new Date(at(DEFAULT_PAYMENT_DRAIN_MINUTES * MIN)).toISOString(),
);
expect(paymentDrainEndsAtIso(null)).toBeNull();
expect(paymentDrainEndsAtIso(undefined)).toBeNull();
expect(paymentDrainEndsAtIso("not-a-date")).toBeNull();
});
});

View File

@@ -153,6 +153,7 @@ import {
DEFAULT_CONTAINER_WAGON_CAPACITY_TONS,
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
DEFAULT_CONTAINER_WAGON_TARE_TONS,
paymentDrainEndsAtIso,
} from './booking-batch.constants';
import { orderConsistWagons } from './consist-order.util';
import {
@@ -1546,23 +1547,10 @@ export class TrainSchedulingService {
}
: globalCfg;
// Staff cannot schedule inside the lead window — there must be room for a
// booking window before departure. IMPORT/DOMESTIC lead is in whole EAT
// days (lead 3, today 11th → first allowed departure is the 14th); EXPORT
// lead is in hours (24h = 1 day ahead). Checked against the schedule's OWN
// lead, so a custom lead is honoured rather than rejected by the global one.
const earliest = earliestSchedulableDeparture(direction, windowCfg, new Date());
if (departure.getTime() < earliest.getTime()) {
const detail =
direction === 'EXPORT'
? `at least ${windowCfg.exportBookingLeadHours} hour(s) ahead`
: `at least ${windowCfg.importWindowLeadDays} day(s) ahead`;
throw new BadRequestException(
`Departure ${departure.toISOString()} is inside the booking lead window; ` +
`${direction === 'EXPORT' ? 'export' : 'import'} trains must be scheduled ${detail} ` +
`(earliest ${earliest.toISOString()})`,
);
}
// Short-notice trains are allowed: a departure inside the booking lead
// window is NOT rejected — the window just opens immediately (opensAt is
// clamped to `now` below) instead of waiting out a lead that has already
// passed. Only `updateScheduleDate` still enforces the lead floor.
// Freeze the rule this schedule is born with. A later global-rules edit
// only re-derives NOT-YET-OPEN schedules (see restampPendingWindows); an
@@ -1577,6 +1565,11 @@ export class TrainSchedulingService {
...ruleSnapshot,
...computeImportWindowTimes(departure, windowCfg, new Date()),
};
// Inside-lead departure (e.g. a huge configured lead): the raw open lands
// in the past — clamp it to `now` so the window tick opens it immediately.
if (computedTimes.windowOpensAt.getTime() < Date.now()) {
computedTimes.windowOpensAt = new Date();
}
if (
computedTimes.windowOpensAt.getTime() >= computedTimes.windowClosesAt.getTime()
) {
@@ -6948,6 +6941,7 @@ export class TrainSchedulingService {
windowClosesAt: r.window_closes_at,
docReviewEndsAt: r.doc_review_ends_at,
paymentPhaseEndsAt: r.payment_phase_ends_at,
paymentDrainEndsAt: paymentDrainEndsAtIso(r.payment_phase_ends_at),
bookingWindowStatus: r.booking_window_status,
bookingCycleNo: r.booking_cycle_no,
departureDate: r.scheduled_departure_date,

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,7 @@ import {
Wallet,
LifeBuoy,
TrainFront,
XCircle,
} from "lucide-react";
import { useEffect } from "react";
import {
@@ -54,6 +55,7 @@ import BookingContractPage from "./pages/bookings/BookingContractPage";
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
import NewBookingPage from "./pages/bookings/NewBookingPage";
import WagonCancellationsPage from "./pages/bookings/WagonCancellationsPage";
import ContractRequestsPage from "./pages/contracts/ContractRequestsPage";
import ContractRequestDetailPage from "./pages/contracts/ContractRequestDetailPage";
import ContractViewPage from "./pages/contracts/ContractViewPage";
@@ -184,6 +186,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: <FileText />,
permission: FREIGHT_PERMS.bookings.view,
},
{
label: "Wagon cancellations",
href: "/dashboard/wagon-cancellations",
icon: <XCircle />,
permission: FREIGHT_PERMS.bookings.wagonCancellationView,
},
// Operations hub: per-shipment clearance-document review for services
// WITHOUT customs clearing (self-clearance) — bookings only.
{
@@ -901,6 +909,16 @@ const App = () => {
}
/>
<Route path="booking-requests/new" element={<RequirePermission permission={FREIGHT_PERMS.bookings.view}><NewBookingPage /></RequirePermission>} />
<Route
path="wagon-cancellations"
element={
<RequirePermission
permission={FREIGHT_PERMS.bookings.wagonCancellationView}
>
<WagonCancellationsPage />
</RequirePermission>
}
/>
<Route
path="booking-requests/:id"
element={

View File

@@ -23,7 +23,13 @@ interface AuthEmployeePosition {
permissions?: AuthPermission[];
/** Some IAM payloads nest the position record instead of flattening its key. */
position?: { id?: string; key?: string; name?: LocaleText };
positionType?: { id?: string; key?: string; name?: LocaleText } | null;
positionType?: {
id?: string;
key?: string;
name?: LocaleText;
/** Grants held by the TYPE — where admin-created positions keep theirs. */
permissions?: AuthPermission[];
} | null;
}
interface AuthEmployeeRecord {

View File

@@ -1,6 +1,6 @@
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import { Hash, Package, Ship, Weight, Clock } from "lucide-react";
import { Hash, Package, Ship, Weight, Clock, TrainFront } from "lucide-react";
import { Group, Stack, Text, Divider } from "@mantine/core";
import { cargoTonsAndItems } from "@/utils/cargoWeight";
@@ -50,6 +50,21 @@ export function BookingFactsCard({ booking }: BookingFactsCardProps) {
},
{ icon: Clock, label: "Last Updated", value: formatDate(booking.updatedAt) },
];
// Allocated train facts — only once the booking rides a schedule.
if (booking.trainSchedule?.trainNumber || booking.trainSchedule?.reference) {
facts.splice(1, 0, {
icon: TrainFront,
label: "Allocated Train",
value: [
booking.trainSchedule.trainNumber
? `Train ${booking.trainSchedule.trainNumber}`
: null,
booking.trainSchedule.reference ?? null,
]
.filter(Boolean)
.join(" · "),
});
}
return (
<SectionCard icon={Hash} title="Booking Details" accent="cyan">

View File

@@ -144,4 +144,10 @@ export interface BookingDetailView {
bookingContainers?: BookingContainerView[];
reviewNotes?: BookingReviewNoteView[];
files?: BookingFileView[];
/** The allocated train, present once the booking is placed on a schedule. */
trainSchedule?: {
trainNumber: string | null;
reference: string | null;
scheduledDepartureDate: string | null;
} | null;
}

View File

@@ -1,6 +1,6 @@
import { Alert, Button, Group, Paper, Stack, Text } from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { AlertTriangle, Send } from "lucide-react";
import { AlertTriangle, Pencil, Send } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router-dom";
import toast from "react-hot-toast";
@@ -16,6 +16,9 @@ export interface BookingChangesRequestedAlertProps {
scheduledDate?: string | null;
/** GL Ethiopia owns customs bookings, so only they get the resubmit control. */
canResubmit: boolean;
/** Completion-form route for editing the cargo before resubmitting —
* rendered only for resubmit-capable users when provided. */
editHref?: string;
onResubmitted?: () => void;
}
@@ -33,6 +36,7 @@ export function BookingChangesRequestedAlert({
note,
scheduledDate,
canResubmit,
editHref,
onResubmitted,
}: BookingChangesRequestedAlertProps) {
const [day, setDay] = useState<Date | null>(
@@ -122,6 +126,18 @@ export function BookingChangesRequestedAlert({
>
Resubmit to Operations
</Button>
{editHref ? (
<Button
component={Link}
to={editHref}
variant="default"
radius="md"
size="sm"
leftSection={<Pencil size={15} />}
>
Edit cargo & resubmit
</Button>
) : null}
</Group>
) : null}
</Stack>

View File

@@ -225,7 +225,13 @@ export function ExportClearanceStepper({
<Stepper.Step
label="Request transit assignee"
description="Ask GL Djibouti to name the officer handling this shipment"
// Description is the only part of a passed step that stays visible,
// so it carries the assigned officer's name for GL Ethiopia.
description={
clearance.transitAssignee?.name
? `Transit assignee: ${clearance.transitAssignee.name}`
: "Ask GL Djibouti to name the officer handling this shipment"
}
icon={
clearance.transitAssignee?.name ? (
<CheckCircle2 size={14} />

View File

@@ -961,7 +961,7 @@ export default function GlCreateBookingForm() {
// modal falls back to the contract unit-rate estimate while it loads.
const validateShipmentMutation = useMutation({
mutationFn: (dto: Freight.CreateBookingUnderContractDto) =>
contractsService.validateShipment(id ?? "", dto),
contractsService.validateShipment(id ?? "", dto, completeBookingId),
});
const validation = validateShipmentMutation.data ?? null;

View File

@@ -39,6 +39,8 @@ interface WindowRow {
windowClosesAt: string | null;
docReviewEndsAt: string | null;
paymentPhaseEndsAt: string | null;
/** End of the payment drain tail — pending payments may settle until then. */
paymentDrainEndsAt?: string | null;
bookingWindowStatus: string;
bookingCycleNo: number;
departureDate: string;
@@ -94,16 +96,29 @@ const COUNTDOWN_TEXT: Partial<
PRE_WINDOW: { label: "Opens in", expiredText: "Opening now…" },
OPEN: { label: "Closes in", expiredText: "Review starting…" },
DOC_REVIEW: { label: "Doc review ends in", expiredText: "Payment starting…" },
PAYMENT: { label: "Payment ends in", expiredText: "Closing…" },
PAYMENT: { label: "Payment ends in", expiredText: "Finalizing…" },
};
function phaseCountdown(
w: WindowRow,
): { label: string; deadline: string; expiredText: string } | null {
function phaseCountdown(w: WindowRow): {
label: string;
deadline: string;
expiredText: string;
graceDeadline?: string | null;
graceLabel?: string;
} | null {
const state = bookingWindowUiState(w);
const text = COUNTDOWN_TEXT[state.kind];
if (!state.countdownTo || !text) return null;
return { ...text, deadline: state.countdownTo };
// Once the pay deadline lapses, pending payments still settle during the
// drain tail — count it down as "processing" instead of a stale "closing".
const grace =
state.kind === "PAYMENT" && w.paymentDrainEndsAt
? {
graceDeadline: w.paymentDrainEndsAt,
graceLabel: "Processing payments — closes in",
}
: undefined;
return { ...text, deadline: state.countdownTo, ...grace };
}
/** Badge label + Mantine color per UI state — same state the countdown uses. */
@@ -225,6 +240,8 @@ function WindowCard({ w }: { w: WindowRow }) {
deadline={cd.deadline}
label={cd.label}
expiredText={cd.expiredText}
graceDeadline={cd.graceDeadline}
graceLabel={cd.graceLabel}
size="xs"
/>
</Box>

View File

@@ -357,7 +357,14 @@ export function PhasedClearanceActionPanel({
<Stepper.Step
label="Request transit assignee"
description="Ask GL Djibouti to name the officer handling this shipment"
// Once the flow moves past this step its content collapses — the
// description is the only slot that stays visible, so it carries
// the assigned officer's name for GL Ethiopia.
description={
clearance.transitAssignee?.name
? `Transit assignee: ${clearance.transitAssignee.name}`
: "Ask GL Djibouti to name the officer handling this shipment"
}
icon={
clearance.transitAssignee?.name ? (
<CheckCircle2 size={14} />

View File

@@ -30,6 +30,7 @@ interface WindowRow {
windowClosesAt: string | null;
docReviewEndsAt: string | null;
paymentPhaseEndsAt: string | null;
paymentDrainEndsAt?: string | null;
bookingWindowStatus: string;
bookingCycleNo: number;
departureDate: string;
@@ -55,6 +56,7 @@ function applyEvent<T extends WindowRow>(row: T, event: BookingWindowPhaseEvent)
windowClosesAt: event.windowClosesAt,
docReviewEndsAt: event.docReviewEndsAt,
paymentPhaseEndsAt: event.paymentPhaseEndsAt,
paymentDrainEndsAt: event.paymentDrainEndsAt,
departureDate: event.scheduledDepartureDate ?? row.departureDate,
};
}

View File

@@ -53,6 +53,10 @@ export const FREIGHT_PERMS = {
finalizeClearance: "edr_freight_app:bookings:finalize_clearance",
docReviewAlert: "edr_freight_app:bookings:doc_review_alert",
governmentExpedite: "edr_freight_app:bookings:government_expedite",
wagonCancellationView: "edr_freight_app:bookings:wagon_cancellation_view",
wagonCancellationVoid: "edr_freight_app:bookings:wagon_cancellation_void",
wagonCancellationRebook:
"edr_freight_app:bookings:wagon_cancellation_rebook",
},
contracts: {
view: "edr_freight_app:contracts:view",
@@ -382,6 +386,14 @@ export function getPermissionKeys(user: AuthUser | null | undefined): string[] {
for (const p of pos.permissions ?? []) {
if (p.key) keys.add(p.key);
}
// Positions created through the admin UI keep their grants on the
// position TYPE, not the position — miss these and such staff resolve to
// zero permissions and every gated route rejects them. `/api/me` folds
// them into the position's permission list, but older payloads may still
// carry them separately.
for (const p of pos.positionType?.permissions ?? []) {
if (p.key) keys.add(p.key);
}
}
}
return [...keys];
@@ -597,7 +609,9 @@ export function canViewScheduling(user: AuthUser | null | undefined): boolean {
}
/** Any train-scheduling write action (create / update / cancel / reschedule). */
export function canManageScheduling(user: AuthUser | null | undefined): boolean {
export function canManageScheduling(
user: AuthUser | null | undefined,
): boolean {
return (
hasPermission(user, FREIGHT_PERMS.trainScheduling.create) ||
hasPermission(user, FREIGHT_PERMS.trainScheduling.update) ||

View File

@@ -0,0 +1,420 @@
import {
Anchor,
Badge,
Box,
Button,
Card,
Group,
Modal,
Select,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { useDebouncedValue } from "@mantine/hooks";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Search, XCircle } from "lucide-react";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { Link } from "react-router-dom";
import { api } from "@/auth/http";
import { useAuth } from "@/auth/useAuth";
import { PageContainer, PageHeader } from "@/components/page";
import { toDayString } from "@/hooks/useListControls";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import {
DataTable,
DataTableFooter,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
type WagonCancellationStatus =
| "FEE_PENDING"
| "CREDIT_AVAILABLE"
| "REBOOKED"
| "WITHDRAWN"
| "EXPIRED";
interface WagonCancellation {
id: string;
bookingId: string;
rebookedBookingId?: string | null;
wagonsCancelled: number;
weightTons: number;
creditAmount: number;
feeAmount: number;
feeCurrency: string;
feeInvoiceId?: string | null;
feePaidAt?: string | null;
status: WagonCancellationStatus;
reason?: string | null;
rebookedAt?: string | null;
createdAt: string;
booking?: { id: string; reference: string; company?: { name: string } };
rebookedBooking?: { id: string; reference: string };
feeInvoice?: { invoiceNumber: string; status: string };
}
interface WagonCancellationListResponse {
items: WagonCancellation[];
total: number;
}
const STATUS_CHIP: Record<
WagonCancellationStatus,
{ label: string; color: string }
> = {
FEE_PENDING: { label: "Fee pending", color: "yellow" },
CREDIT_AVAILABLE: { label: "Credit available", color: "edr-green" },
REBOOKED: { label: "Rebooked", color: "indigo" },
WITHDRAWN: { label: "Withdrawn", color: "gray" },
EXPIRED: { label: "Expired", color: "red" },
};
const STATUS_FILTER_OPTIONS = (
Object.keys(STATUS_CHIP) as WagonCancellationStatus[]
).map((s) => ({ value: s, label: STATUS_CHIP[s].label }));
function StatusChip({ status }: { status: WagonCancellationStatus }) {
const chip = STATUS_CHIP[status] ?? { label: status, color: "gray" };
return (
<Badge
color={chip.color}
variant="light"
size="sm"
radius="md"
tt="uppercase"
fw={600}
style={{ fontSize: "0.7rem", letterSpacing: "0.05em" }}
>
{chip.label}
</Badge>
);
}
function formatDate(iso: string | null | undefined): string {
if (!iso) return "—";
const d = new Date(iso);
return Number.isNaN(d.getTime())
? "—"
: d.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
function formatAmount(amount: number, currency: string): string {
return `${currency} ${Number(amount).toLocaleString(undefined, {
minimumFractionDigits: 2,
})}`;
}
/**
* Staff view of partial wagon cancellations: every slice of capacity a
* customer gave back, its cancellation fee, and where the credit went
* (rebooked, still available, expired, or the request was voided).
*/
export default function WagonCancellationsPage() {
const { user } = useAuth();
const canVoid = hasPermission(
user,
FREIGHT_PERMS.bookings.wagonCancellationVoid,
);
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [status, setStatus] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 300);
const [from, setFrom] = useState<Date | null>(null);
const [to, setTo] = useState<Date | null>(null);
const [voiding, setVoiding] = useState<WagonCancellation | null>(null);
const resetPage = () =>
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
const filter = useMemo(
() => ({
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
...(status ? { statuses: status } : {}),
...(debouncedSearch.trim() ? { search: debouncedSearch.trim() } : {}),
...(from ? { from: toDayString(from) } : {}),
...(to ? { to: toDayString(to) } : {}),
}),
[pagination.pageIndex, pagination.pageSize, status, debouncedSearch, from, to],
);
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ["bookings", "wagon-cancellations", filter],
queryFn: async () => {
const res = await api.get<WagonCancellationListResponse>(
"/bookings/wagon-cancellations/history",
{ params: filter },
);
return res.data;
},
});
const rows = data?.items ?? [];
const total = data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const withdraw = useMutation({
mutationFn: (id: string) =>
api.post(`/bookings/wagon-cancellations/${id}/withdraw`),
});
const columns: ColumnDef<WagonCancellation>[] = [
{
id: "requested",
header: () => <span>Requested</span>,
cell: ({ row }) => (
<Text size="xs" c="dimmed">
{formatDate(row.original.createdAt)}
</Text>
),
},
{
id: "booking",
header: () => <span>Booking</span>,
cell: ({ row }) => (
<Anchor
component={Link}
to={`/dashboard/booking-requests/${row.original.bookingId}`}
size="sm"
fw={600}
>
{row.original.booking?.reference ?? row.original.bookingId}
</Anchor>
),
},
{
id: "company",
header: () => <span>Company</span>,
cell: ({ row }) => (
<Text size="sm">{row.original.booking?.company?.name ?? "—"}</Text>
),
},
{
id: "wagons",
header: () => <span>Wagons</span>,
cell: ({ row }) => <Text size="sm">{row.original.wagonsCancelled}</Text>,
},
{
id: "fee",
header: () => <span>Fee</span>,
cell: ({ row }) => (
<Text size="sm" style={{ fontVariantNumeric: "tabular-nums" }}>
{formatAmount(row.original.feeAmount, row.original.feeCurrency)}
</Text>
),
},
{
id: "credit",
header: () => <span>Credit</span>,
cell: ({ row }) => (
<Text size="sm" style={{ fontVariantNumeric: "tabular-nums" }}>
{formatAmount(row.original.creditAmount, row.original.feeCurrency)}
</Text>
),
},
{
id: "status",
header: () => <span>Status</span>,
cell: ({ row }) => <StatusChip status={row.original.status} />,
},
{
id: "rebookedAs",
header: () => <span>Rebooked as</span>,
cell: ({ row }) => {
const r = row.original;
if (!r.rebookedBookingId) return <Text size="sm"></Text>;
return (
<Anchor
component={Link}
to={`/dashboard/booking-requests/${r.rebookedBookingId}`}
size="sm"
>
{r.rebookedBooking?.reference ?? r.rebookedBookingId}
</Anchor>
);
},
},
{
id: "actions",
header: () => <span />,
cell: ({ row }) => {
const r = row.original;
if (r.status !== "FEE_PENDING" || !canVoid) return null;
return (
<Group justify="flex-end" wrap="nowrap">
<Button
size="xs"
radius="md"
variant="subtle"
color="red"
onClick={() => setVoiding(r)}
>
Void
</Button>
</Group>
);
},
},
];
return (
<PageContainer>
<Stack gap="lg">
<PageHeader
title="Wagon cancellations"
subtitle="Partial wagon cancellations — fees charged, credits held, and where each credit was rebooked"
breadcrumbs={[
{ label: "Bookings", href: "/dashboard/booking-requests" },
{ label: "Wagon cancellations" },
]}
/>
<Card withBorder radius="md" p="md">
<Stack gap="md">
<Group gap="sm" wrap="wrap">
<TextInput
placeholder="Search booking ref or company…"
leftSection={<Search size={15} />}
value={search}
onChange={(e) => {
setSearch(e.currentTarget.value);
resetPage();
}}
w={260}
radius="md"
/>
<Select
placeholder="Status"
data={STATUS_FILTER_OPTIONS}
value={status}
onChange={(v) => {
setStatus(v);
resetPage();
}}
clearable
w={190}
radius="md"
/>
<DateInput
placeholder="From"
value={from}
onChange={(v) => {
setFrom(v ? new Date(v) : null);
resetPage();
}}
maxDate={to ?? undefined}
clearable
radius="md"
style={{ minWidth: 140 }}
/>
<DateInput
placeholder="To"
value={to}
onChange={(v) => {
setTo(v ? new Date(v) : null);
resetPage();
}}
minDate={from ?? undefined}
clearable
radius="md"
style={{ minWidth: 140 }}
/>
<Button
variant="subtle"
radius="md"
onClick={() => {
setStatus(null);
setSearch("");
setFrom(null);
setTo(null);
resetPage();
}}
>
Clear
</Button>
</Group>
<Box style={{ overflowX: "auto" }} w="100%">
<DataTable
columns={columns}
data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
</Box>
</Stack>
</Card>
</Stack>
<Modal
opened={Boolean(voiding)}
onClose={() => setVoiding(null)}
radius="md"
title="Void this cancellation?"
>
{!voiding ? null : (
<Stack gap="sm">
<Text size="sm">
{voiding.booking?.reference ?? voiding.bookingId} ·{" "}
{voiding.wagonsCancelled} wagon(s) · fee{" "}
{formatAmount(voiding.feeAmount, voiding.feeCurrency)}
</Text>
<Text size="sm" c="dimmed">
The pending fee is dropped and the wagons stay on the booking.
Voiding can't be undone.
</Text>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
radius="md"
onClick={() => setVoiding(null)}
>
Keep it
</Button>
<Button
color="red"
radius="md"
leftSection={<XCircle size={15} />}
loading={withdraw.isPending}
onClick={async () => {
try {
await withdraw.mutateAsync(voiding.id);
toast.success("Cancellation voided");
setVoiding(null);
void refetch();
} catch {
// interceptor surfaces the reason
}
}}
>
Void
</Button>
</Group>
</Stack>
)}
</Modal>
</PageContainer>
);
}

View File

@@ -311,6 +311,7 @@ export default function ContractClearanceDetailPage() {
note={clearance.linkedBookingReviewNote}
scheduledDate={clearance.linkedBookingScheduledDate}
canResubmit={canResubmitBooking}
editHref={`/dashboard/contracts/${id}/bookings/${linkedBookingId}/complete?copyFrom=${linkedBookingId}`}
onResubmitted={() => {
void refetch();
void refetchContract();

View File

@@ -450,6 +450,35 @@ export default function ContractRequestDetailPage() {
description={statusMeta.description}
/>
{/* A contract resting in APPROVED means the automatic PDF generation on
final approval failed — on success it moves straight to
CONTRACT_READY. Offer the manual retry. */}
{contract.status === "APPROVED" ? (
<Alert
color="orange"
radius="md"
icon={<AlertTriangle size={18} />}
title="Contract document was not generated"
>
<Stack gap="sm" align="flex-start">
<Text size="sm">
All approvals are complete, but generating the contract PDF
failed. Retry the generation below.
</Text>
<Button
color="edr-green"
size="compact-sm"
radius="lg"
leftSection={<RefreshCw size={15} />}
loading={mutations.generateContract.isPending}
onClick={() => mutations.generateContract.mutate()}
>
Regenerate contract
</Button>
</Stack>
</Alert>
) : null}
{contract.status === "REJECTED" && contract.latestRejectionNote ? (
<Alert
color="red"

View File

@@ -921,10 +921,10 @@ export default function TrainScheduleV2DetailPage() {
<Title order={2} fw={700} style={{ color: "#0f172a" }}>
{schedule.route?.name ?? "Train schedule"}
</Title>
{schedule.trainNumber ? (
<Badge variant="light" color="#F2A516" radius="sm" style={{ fontWeight: 600 }}>
{schedule.trainNumber}
</Badge>
{schedule.train?.trainName ? (
<Text fw={700} style={{ color: "#0f172a" }}>
{schedule.train.trainName}
</Text>
) : null}
{schedule.train ? (
<Text size="xs" c="dimmed" ff="monospace">
@@ -932,6 +932,51 @@ export default function TrainScheduleV2DetailPage() {
</Text>
) : null}
</Group>
{/* Voyage (train) number and trade direction — the two things
operations identify a run by, so they read at a glance
rather than as small badges among the rest. */}
<Group gap="lg" align="center" wrap="wrap">
{schedule.trainNumber ? (
<Box>
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
Voyage No.
</Text>
<Text
ff="monospace"
fw={800}
lh={1.1}
style={{ fontSize: 32, color: "#0f172a" }}
>
{schedule.trainNumber}
</Text>
</Box>
) : null}
{schedule.direction ? (
<Box>
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
Direction
</Text>
<Text
fw={800}
lh={1.1}
tt="uppercase"
style={{
fontSize: 32,
letterSpacing: 0.5,
color:
schedule.direction === "IMPORT"
? "#2E5B96"
: schedule.direction === "EXPORT"
? "#0A6F4D"
: "#0f172a",
}}
>
{schedule.direction}
</Text>
</Box>
) : null}
</Group>
{(schedule.stops?.length ?? 0) >= 3 ||
(schedule.bookings ?? []).some(
(b) => b.tradeDirection === "DOMESTIC",

View File

@@ -673,8 +673,16 @@ export const contractsService = {
validateShipment: (
id: string,
payload: Freight.CreateBookingUnderContractDto,
// Completion/resubmit preview: exclude this booking's own containers from
// the same-train clash check.
excludeBookingId?: string,
) =>
postContract<ShipmentValidation>(C.VALIDATE_SHIPMENT(id), payload),
postContract<ShipmentValidation>(
excludeBookingId
? `${C.VALIDATE_SHIPMENT(id)}?bookingId=${excludeBookingId}`
: C.VALIDATE_SHIPMENT(id),
payload,
),
/** Remaining bookable quantity per cargo line (GENERAL draw-down cap). */
getCapacity: async (id: string): Promise<Freight.ContractCapacityLine[]> => {

View File

@@ -47,6 +47,7 @@ function applyEvent(
windowClosesAt: event.windowClosesAt,
docReviewEndsAt: event.docReviewEndsAt,
paymentPhaseEndsAt: event.paymentPhaseEndsAt,
paymentDrainEndsAt: event.paymentDrainEndsAt,
departureDate: event.scheduledDepartureDate ?? row.departureDate,
};
}

View File

@@ -68,16 +68,29 @@ const COUNTDOWN_TEXT: Partial<
PRE_WINDOW: { label: "Booking opens in", expiredText: "Booking opening now…" },
OPEN: { label: "Window closes in", expiredText: "Document review starting…" },
DOC_REVIEW: { label: "Document review ends in", expiredText: "Payment starting…" },
PAYMENT: { label: "Payment due in", expiredText: "Payment window closing…" },
PAYMENT: { label: "Payment due in", expiredText: "Finalizing payments…" },
};
function phaseCountdown(
w: MyBookingWindow,
): { label: string; deadline: string; expiredText: string } | null {
function phaseCountdown(w: MyBookingWindow): {
label: string;
deadline: string;
expiredText: string;
graceDeadline?: string | null;
graceLabel?: string;
} | null {
const state = bookingWindowUiState(w);
const text = COUNTDOWN_TEXT[state.kind];
if (!state.countdownTo || !text) return null;
return { ...text, deadline: state.countdownTo };
// Once the pay deadline lapses, pending payments still settle during the
// drain tail — count it down as "processing" instead of a stale "closing".
const grace =
state.kind === "PAYMENT" && w.paymentDrainEndsAt
? {
graceDeadline: w.paymentDrainEndsAt,
graceLabel: "Payment processing — closes in",
}
: undefined;
return { ...text, deadline: state.countdownTo, ...grace };
}
function Pill({
@@ -336,6 +349,8 @@ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({
deadline={cd.deadline}
label={cd.label}
expiredText={cd.expiredText}
graceDeadline={cd.graceDeadline}
graceLabel={cd.graceLabel}
size="xs"
/>
</Box>

View File

@@ -1,18 +1,30 @@
import {
Alert,
Box,
Button,
Group,
Modal,
Stack,
Text,
TextInput,
UnstyledButton,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { AlertCircle, Send, XCircle } from "lucide-react";
import {
AlertCircle,
CalendarDays,
ChevronRight,
Package,
Pencil,
Send,
XCircle,
} from "lucide-react";
import type { ReactNode } from "react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "@/services/api";
import { bookingsService } from "@/services/bookings.service";
import type { Freight } from "@edr/types";
import { PriceChangeModal } from "@/pages/bookings/resubmit/PriceChangeModal";
@@ -25,13 +37,55 @@ import { CompanyInfoCard } from "./components/CompanyInfoCard";
import { ContainersCard } from "./components/ContainersCard";
import { ContractInfoCard } from "./components/ContractInfoCard";
import { ActionRequiredBanner, MutationErrors } from "./components/Notices";
import { PageHeader } from "./components/PageHeader";
import { HeaderButton, PageHeader } from "./components/PageHeader";
import { EstimateCard } from "./components/pricing";
import { ScheduleCard } from "./components/ScheduleCard";
import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard";
import { StatusHero } from "./components/StatusHero";
import { SupportCard } from "./components/SupportCard";
/** Row linking straight to one section of the edit-booking form. */
function EditLink({
icon,
title,
description,
onClick,
}: {
icon: ReactNode;
title: string;
description: string;
onClick: () => void;
}) {
return (
<UnstyledButton
onClick={onClick}
p="sm"
style={{
border: "1px solid #E6ECF2",
borderRadius: 12,
width: "100%",
}}
>
<Group gap="sm" wrap="nowrap" align="flex-start">
<Box mt={2} c="#0A6F4D">
{icon}
</Box>
<Box style={{ flex: 1, minWidth: 0 }}>
<Text fz={13.5} fw={700} c="#10202F">
{title}
</Text>
<Text fz={12} c="#6B7C8E" mt={2}>
{description}
</Text>
</Box>
<Box mt={2} c="#9AA8B5">
<ChevronRight size={16} />
</Box>
</Group>
</UnstyledButton>
);
}
/**
* Detail-page view for a booking staff returned with CHANGES_REQUESTED.
*
@@ -64,8 +118,9 @@ export function ChangesRequestedView({
null) as Freight.PricingBreakdown | null;
const cancelMutation = useMutation({
// Customer-facing cancel endpoint — the plain /cancel route is staff-only.
mutationFn: (reason: string) =>
api.bookings.cancel.call({ id: booking.id, reason }),
bookingsService.customerCancel(booking.id, reason),
onSuccess: () => {
setCancelDialogOpen(false);
onBookingUpdated();
@@ -76,17 +131,21 @@ export function ChangesRequestedView({
<PageShell>
<PageHeader
booking={booking}
menuActions={{
onCancel: () => setCancelDialogOpen(true),
onSupport: () => navigate("/support"),
}}
actions={
<HeaderButton
red
icon={<XCircle size={16} />}
label="Cancel booking"
onClick={() => setCancelDialogOpen(true)}
/>
}
/>
<MutationErrors mutations={[...flow.mutations, cancelMutation]} />
<StatusHero booking={booking}>
{booking.latestChangeRequestNote ? (
<ActionRequiredBanner title="Review the requested changes, then resubmit.">
<ActionRequiredBanner title="Review the requested changes, update the booking if needed, then resubmit.">
{booking.latestChangeRequestNote}
</ActionRequiredBanner>
) : undefined}
@@ -101,6 +160,41 @@ export function ChangesRequestedView({
<ContractInfoCard booking={booking} />
<SectionCard>
<CardTitle>Fix your booking</CardTitle>
<Text fz="12.5px" c="#6B7C8E" mt={4} mb="md">
Staff asked for changes on this booking. Update whatever needs
fixing below, then resubmit for review the booking stays in
place, no need to start over.
</Text>
<Stack gap={8}>
<EditLink
icon={<Package size={16} />}
title="Cargo & containers"
description="Add or remove containers, change container type, quantity or VGM — or for bulk cargo, change the commodity and tonnage."
onClick={() =>
navigate(`/bookings/${booking.id}/edit?section=cargo`)
}
/>
<EditLink
icon={<CalendarDays size={16} />}
title="Schedule date"
description="Pick a different departure day — only days with an open schedule on your route can be selected."
onClick={() =>
navigate(`/bookings/${booking.id}/edit?section=schedule`)
}
/>
<EditLink
icon={<Pencil size={16} />}
title="Route, service & other details"
description="Change the origin or destination yard, service type, trucking options or notes."
onClick={() =>
navigate(`/bookings/${booking.id}/edit?section=service`)
}
/>
</Stack>
</SectionCard>
<SectionCard>
<Group justify="space-between" align="center" mb="md">
<CardTitle>Your documents</CardTitle>

View File

@@ -24,7 +24,10 @@ import { useNavigate } from "react-router-dom";
import { api } from "@/services/api";
import { downloadStoredFile } from "@/services/files.service";
import type { SubmitBookingResponse } from "@/services/bookings.service";
import {
bookingsService,
type SubmitBookingResponse,
} from "@/services/bookings.service";
import type { Freight } from "@edr/types";
import { REQUIRED_DOC_FIELDS } from "./constants";
@@ -116,8 +119,9 @@ export function DraftBookingView({
});
const cancelMutation = useMutation({
// Customer-facing cancel endpoint — the plain /cancel route is staff-only.
mutationFn: (reason: string) =>
api.bookings.cancel.call({ id: booking.id, reason }),
bookingsService.customerCancel(booking.id, reason),
onSuccess: () => {
setCancelDialogOpen(false);
onBookingUpdated();
@@ -157,17 +161,21 @@ export function DraftBookingView({
<PageHeader
booking={booking}
actions={
<HeaderButton
dark
icon={<Pencil size={16} />}
label="Continue editing"
onClick={() => navigate(`/bookings/${booking.id}/edit`)}
/>
<Group gap={8} wrap="nowrap">
<HeaderButton
dark
icon={<Pencil size={16} />}
label="Continue editing"
onClick={() => navigate(`/bookings/${booking.id}/edit`)}
/>
<HeaderButton
red
icon={<XCircle size={16} />}
label="Cancel"
onClick={() => setCancelDialogOpen(true)}
/>
</Group>
}
menuActions={{
onCancel: () => setCancelDialogOpen(true),
onSupport: () => navigate("/support"),
}}
/>
<MutationErrors

View File

@@ -1,15 +1,21 @@
import { Group, Tabs } from "@mantine/core";
import { Button, Group, Modal, Stack, Tabs, Text } from "@mantine/core";
import { useMutation } from "@tanstack/react-query";
import {
Clock,
CreditCard,
FileText,
LayoutGrid,
Package,
TrainFront,
Truck,
XCircle,
} from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import { useNavigate } from "react-router-dom";
import { useFileViewer } from "@/hooks/useFileViewer";
import { bookingsService } from "@/services/bookings.service";
import type { Freight } from "@edr/types";
import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton";
@@ -28,6 +34,7 @@ import { MileSummaryCard } from "./components/MileSummaryCard";
import { BodyGrid, PageShell } from "./components/layout";
import {
CancelledBanner,
ActionRequiredBanner,
ConsolidationPairedNotice,
ConsolidationWaitingBanner,
} from "./components/Notices";
@@ -41,10 +48,33 @@ import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard";
import { ShipmentTrackingCard } from "./components/ShipmentTrackingCard";
import { StatusHero } from "./components/StatusHero";
import { SupportCard } from "./components/SupportCard";
import { WagonCancellationCard } from "./components/WagonCancellationCard";
import { WagonsTab } from "./components/WagonsTab";
import { fmtDate, isNegative, priceTotal } from "./utils";
import { useScrollToHash } from "@/hooks/useScrollToHash";
import { useBookingPayment } from "@/pages/bookings/payments/useBookingPayment";
// Pre-payment statuses the customer may self-cancel from this view (free of
// charge). DRAFT / CHANGES_REQUESTED render their own views and drafts can
// simply be deleted; anything at or past payment must go through support.
const CUSTOMER_CANCELLABLE_STATUSES = [
"SUBMITTED",
"PRICE_CHANGED_PENDING_CONFIRM",
"PENDING_APPROVAL",
"CONTRACT_READY",
"OPERATION_REQUEST_PENDING",
"SELECTED_FOR_BATCH",
];
const cancelErrorMessage = (error: unknown) => {
const data = (
error as { response?: { data?: { message?: string | string[] } } }
)?.response?.data;
if (Array.isArray(data?.message)) return data.message.join(", ");
if (data?.message) return data.message;
return "Could not cancel the booking. Please try again.";
};
export function ReadonlyBookingView({
booking,
onBookingUpdated,
@@ -71,6 +101,23 @@ export function ReadonlyBookingView({
// and handles redirect vs CAC Bank OTP.
const pay = useBookingPayment(booking.id);
const [cancelOpen, setCancelOpen] = useState(false);
const cancelMutation = useMutation({
mutationFn: () => bookingsService.customerCancel(booking.id),
onSuccess: () => {
setCancelOpen(false);
toast.success(
"Your booking has been cancelled — no cancellation fee was charged.",
{ duration: 6000 },
);
onBookingUpdated?.();
},
onError: (e) => toast.error(cancelErrorMessage(e)),
});
const canCancel =
booking.paymentStatus !== "PAID" &&
CUSTOMER_CANCELLABLE_STATUSES.includes(status);
const pricing = booking.pricingBreakdown;
// A general contract is paid once it's FULLY_EXECUTED (signed) — it never
// enters batch selection. A one-time booking can only pay once it's been
@@ -117,6 +164,9 @@ export function ReadonlyBookingView({
"DOCUMENTS_UNDER_REVIEW",
"CLEARANCE_READY",
"OPERATION_REQUESTED",
// Operations returned the order — same card hosts the pick-a-new-day +
// resubmit flow.
"OPERATION_CHANGES_REQUESTED",
].includes(status);
// Paired: a consolidation partner was found and the booking resumed the normal
// flow. Surface the "partner found" reassurance only in the early stages,
@@ -124,13 +174,16 @@ export function ReadonlyBookingView({
const showPairedNotice =
!!booking.consolidationPartnerId &&
["SUBMITTED", "PENDING_APPROVAL", "CHANGES_REQUESTED"].includes(status);
// Wagons exist only after payment puts the booking on a train; before that
// the tab would always be an empty state, so it stays hidden.
const showWagonsTab = booking.paymentStatus === "PAID" && !isNegative(status);
return (
<PageShell>
<PageHeader
booking={booking}
actions={
(canApproveDelivery || (canPay && !showCountdown)) && (
(canApproveDelivery || (canPay && !showCountdown) || canCancel) && (
<Group gap={8} wrap="nowrap">
{canApproveDelivery && (
<ApproveDeliveryButton bookingId={booking.id} />
@@ -143,13 +196,17 @@ export function ReadonlyBookingView({
onClick={pay.open}
/>
)}
{canCancel && (
<HeaderButton
red
icon={<XCircle size={16} />}
label="Cancel booking"
onClick={() => setCancelOpen(true)}
/>
)}
</Group>
)
}
menuActions={{
onRebook: canSelfRebook ? onRebook : undefined,
onSupport: () => navigate("/support"),
}}
/>
{isNegative(status) ? (
@@ -182,7 +239,14 @@ export function ReadonlyBookingView({
priceLabel={pricing ? priceTotal(pricing) : undefined}
/>
) : (
<StatusHero booking={booking} />
<StatusHero booking={booking}>
{status === "OPERATION_CHANGES_REQUESTED" &&
booking.latestChangeRequestNote ? (
<ActionRequiredBanner title="Operations requested changes — pick a new shipment day and resubmit.">
{booking.latestChangeRequestNote}
</ActionRequiredBanner>
) : undefined}
</StatusHero>
)}
{showPairedNotice && <ConsolidationPairedNotice />}
@@ -210,6 +274,11 @@ export function ReadonlyBookingView({
<Tabs.Tab value="cargo" leftSection={<Package size={15} />}>
Cargo
</Tabs.Tab>
{showWagonsTab && (
<Tabs.Tab value="wagons" leftSection={<TrainFront size={15} />}>
Wagons
</Tabs.Tab>
)}
<Tabs.Tab value="logistics" leftSection={<Truck size={15} />}>
Logistics
</Tabs.Tab>
@@ -257,6 +326,11 @@ export function ReadonlyBookingView({
title="Consignment & Schedule"
consignment
/>
{/* Renders only on PAID + paid + contract-backed bookings. */}
<WagonCancellationCard
booking={booking}
onBookingUpdated={onBookingUpdated}
/>
<CompanyInfoCard booking={booking} />
<SupportCard />
</>
@@ -269,6 +343,20 @@ export function ReadonlyBookingView({
<CargoTab booking={booking} />
</Tabs.Panel>
{showWagonsTab && (
<Tabs.Panel value="wagons">
<WagonsTab
bookingId={booking.id}
cancellable={
booking.status === "PAID" &&
booking.paymentStatus === "PAID" &&
Boolean(booking.contractId)
}
onCancellationRequested={onBookingUpdated}
/>
</Tabs.Panel>
)}
<Tabs.Panel value="logistics">
<div className="flex flex-col gap-6">
<BodyGrid
@@ -313,6 +401,52 @@ export function ReadonlyBookingView({
bill={pay.bill}
onConfirm={pay.confirm}
/>
<Modal
opened={cancelOpen}
onClose={() => setCancelOpen(false)}
title={
<Text fw={800} fz={18} c="#10202F">
Cancel this booking?
</Text>
}
centered
radius={16}
>
<Stack gap="md">
<Text size="sm" c="#475569">
You&apos;re about to cancel booking{" "}
<Text span fw={700} c="#10202F">
{booking.reference}
</Text>
. Since you haven&apos;t paid yet,{" "}
<Text span fw={700}>
no cancellation fee
</Text>{" "}
will be charged
{status === "SELECTED_FOR_BATCH"
? ", and your reserved wagon space will be released immediately"
: ""}
. This cannot be undone.
</Text>
<Group justify="flex-end" gap={8}>
<Button
variant="default"
radius={10}
onClick={() => setCancelOpen(false)}
>
Keep booking
</Button>
<Button
color="red"
radius={10}
loading={cancelMutation.isPending}
onClick={() => cancelMutation.mutate()}
>
Cancel booking
</Button>
</Group>
</Stack>
</Modal>
{viewer}
</PageShell>
);

View File

@@ -35,6 +35,8 @@ export interface BookingContainerLineDetail {
isOverweight?: boolean;
overweightExcessTons?: number | string | null;
containerNumber?: string | null;
/** Size (ft) as stored on the line ("20"/"40") — the wagon-cancellation key. */
containerSize?: string | null;
containerType?: {
code: string;
label?: string | null;
@@ -75,6 +77,12 @@ export type BookingDetail = Freight.IBooking & {
reference: string;
status: string;
} | null;
/** The allocated train, present once the booking is placed on a schedule. */
trainSchedule?: {
trainNumber: string | null;
reference: string | null;
scheduledDepartureDate: string | null;
} | null;
};
/**

View File

@@ -51,7 +51,12 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
}
const summary =
status === "CLEARANCE_READY" ? (
status === "OPERATION_CHANGES_REQUESTED" ? (
<Alert color="yellow" radius="md" icon={<Clock size={18} />}>
Operations returned this order for changes. Update the booking details,
pick a new shipment day and resubmit.
</Alert>
) : status === "CLEARANCE_READY" ? (
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />}>
{`${
booking.customsClearingEnabled
@@ -103,9 +108,11 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
{summary}
<Text fz="12.5px" c="dimmed" mt="sm">
{isBookAction
? "Use “Book” to enter the cargo details and schedule your shipment."
: `Use “${action?.label ?? "the action button"}” to manage your ${docNoun}.`}
{status === "OPERATION_CHANGES_REQUESTED"
? "Use “Change booking” to update the details and pick a new shipment day."
: isBookAction
? "Use “Book” to enter the cargo details and schedule your shipment."
: `Use “${action?.label ?? "the action button"}” to manage your ${docNoun}.`}
</Text>
{!isBookAction && (

View File

@@ -1,14 +1,5 @@
import { ActionIcon, Button, Group, Menu, Stack, Text } from "@mantine/core";
import {
ArrowDownLeft,
ArrowUpRight,
Edit2,
FileText,
HelpCircle,
MoreHorizontal,
RefreshCw,
XCircle,
} from "lucide-react";
import { Button, Group, Stack, Text } from "@mantine/core";
import { ArrowDownLeft, ArrowUpRight } from "lucide-react";
import type { ReactNode } from "react";
import type { Freight } from "@edr/types";
@@ -20,22 +11,12 @@ import {
import { bookingSubtitle, isDraftLike, isNegative } from "../utils";
export interface PageHeaderMenuActions {
onViewContract?: () => void;
onCancel?: () => void;
onEdit?: () => void;
onSupport?: () => void;
onRebook?: () => void;
}
export function PageHeader({
booking,
actions,
menuActions,
}: {
booking: Freight.IBooking;
actions?: ReactNode;
menuActions?: PageHeaderMenuActions;
}) {
const status = booking.status as string;
const negative = isNegative(status);
@@ -47,8 +28,6 @@ export function PageHeader({
const pillText = negative ? "#A93226" : draft ? "#475569" : "#0A6F4D";
const isExport = booking.tradeDirection === "EXPORT";
const hasMenu = menuActions && Object.values(menuActions).some(Boolean);
return (
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Stack gap={8} miw={0}>
@@ -83,66 +62,6 @@ export function PageHeader({
<Group gap={8} wrap="nowrap" align="center">
{actions}
{hasMenu && (
<Menu shadow="md" radius={12} position="bottom-end">
<Menu.Target>
<ActionIcon
variant="default"
size={42}
radius={10}
aria-label="More options"
>
<MoreHorizontal size={18} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown miw={210}>
{menuActions!.onViewContract && (
<Menu.Item
leftSection={<FileText size={15} />}
onClick={menuActions!.onViewContract}
>
View contract
</Menu.Item>
)}
{menuActions!.onEdit && (
<Menu.Item
leftSection={<Edit2 size={15} />}
onClick={menuActions!.onEdit}
>
Edit
</Menu.Item>
)}
{menuActions!.onSupport && (
<Menu.Item
leftSection={<HelpCircle size={15} />}
onClick={menuActions!.onSupport}
>
Contact customer support
</Menu.Item>
)}
{menuActions!.onRebook && (
<Menu.Item
leftSection={<RefreshCw size={15} />}
onClick={menuActions!.onRebook}
>
Rebook similar schedule
</Menu.Item>
)}
{menuActions!.onCancel && (
<>
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<XCircle size={15} />}
onClick={menuActions!.onCancel}
>
Cancel booking
</Menu.Item>
</>
)}
</Menu.Dropdown>
</Menu>
)}
</Group>
</Group>
);
@@ -154,6 +73,7 @@ export function HeaderButton({
onClick,
dark,
green,
red,
disabled,
}: {
label: string;
@@ -161,6 +81,7 @@ export function HeaderButton({
onClick?: () => void;
dark?: boolean;
green?: boolean;
red?: boolean;
disabled?: boolean;
}) {
return (
@@ -169,14 +90,14 @@ export function HeaderButton({
disabled={disabled}
leftSection={icon}
radius={10}
variant={green || dark ? "filled" : "default"}
color={green ? "edr-green" : dark ? "#0C1A2B" : undefined}
variant={green || dark ? "filled" : red ? "outline" : "default"}
color={green ? "edr-green" : dark ? "#0C1A2B" : red ? "red" : undefined}
styles={{
root: { height: 42, paddingInline: 16 },
label: {
fontSize: 13,
fontWeight: 700,
color: green || dark ? "#fff" : "#10202F",
color: green || dark ? "#fff" : red ? undefined : "#10202F",
},
}}
>

View File

@@ -65,6 +65,12 @@ export function ScheduleCard({
const service = serviceTypeLabel(booking);
const equipmentReturn =
booking.equipmentReturn === "WITH_RETURN" ? "With return" : "Without return";
const schedule = booking.trainSchedule;
const trainLabel = schedule?.trainNumber
? `Train ${schedule.trainNumber}${schedule.reference ? ` · ${schedule.reference}` : ""}`
: schedule?.reference
? `Schedule ${schedule.reference}`
: "Track shipment";
const assignedTrain: Row = booking.trainScheduleId
? {
label: "Assigned train",
@@ -85,7 +91,7 @@ export function ScheduleCard({
fontSize: 13,
}}
>
<MapPin size={13} /> Track shipment
<MapPin size={13} /> {trainLabel}
</button>
),
}

View File

@@ -0,0 +1,496 @@
import {
Alert,
Box,
Button,
Group,
Modal,
NumberInput,
Stack,
Table,
Text,
Textarea,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { CheckCircle2, Clock, CreditCard, TrainTrack } from "lucide-react";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { Link, useNavigate } from "react-router-dom";
import type { Freight } from "@edr/types";
import { api } from "@/services/api";
import {
bookingsService,
type RequestWagonCancellationPayload,
type WagonCancellation,
type WagonCancellationPreview,
} from "@/services/bookings.service";
import { OperationDatePicker } from "@/pages/bookings/clearance";
import { useFeeInvoicePayment } from "@/pages/bookings/payments/useBookingPayment";
import type { BookingDetail } from "../booking-detail-types";
import { fmtDate } from "../utils";
import { CardTitle, SectionCard } from "./layout";
import { PaymentMethodModal } from "./PaymentMethodModal";
// Same pill treatment as WagonsTab's STATUS_TONES so the page reads as one.
const STATUS_TONES: Record<
WagonCancellation["status"],
{ bg: string; color: string; label: string }
> = {
FEE_PENDING: { bg: "#FFFBEB", color: "#92400E", label: "Fee pending" },
CREDIT_AVAILABLE: { bg: "#EAF1FE", color: "#1E40AF", label: "Credit available" },
REBOOKED: { bg: "#E8F5EF", color: "#0A6F4D", label: "Rebooked" },
WITHDRAWN: { bg: "#F1F4F7", color: "#475569", label: "Withdrawn" },
EXPIRED: { bg: "#FEF2F2", color: "#B91C1C", label: "Expired" },
};
function StatusPill({ status }: { status: WagonCancellation["status"] }) {
const tone = STATUS_TONES[status] ?? STATUS_TONES.WITHDRAWN;
return (
<Text
component="span"
fz={11}
fw={700}
px={9}
py={3}
style={{ borderRadius: 999, backgroundColor: tone.bg, color: tone.color }}
>
{tone.label}
</Text>
);
}
const fmtMoney = (amount: number | string, currency: string) =>
`${Number(amount).toLocaleString()} ${currency}`;
const apiErrorMessage = (error: unknown, fallback: string) => {
const data = (
error as { response?: { data?: { message?: string | string[] } } }
)?.response?.data;
if (Array.isArray(data?.message)) return data.message.join(", ");
if (data?.message) return data.message;
return fallback;
};
const th = { color: "#9AA8B5", fontSize: 11 } as const;
/**
* Partial wagon cancellation on a PAID contract booking: request a cut (fee
* previewed first), pay the cancellation fee, then rebook the freed credit
* onto another shipment day — plus the booking's cancellation history.
* Wagons leave the schedule at request time; the fee settles the credit.
*/
export function WagonCancellationCard({
booking,
onBookingUpdated,
}: {
booking: Freight.IBooking;
onBookingUpdated?: () => void;
}) {
const navigate = useNavigate();
const status = booking.status as string;
const eligible =
status === "PAID" &&
(booking.paymentStatus as string) === "PAID" &&
!!booking.contractId;
const isBulk = booking.freightType === "BULK";
const detail = booking as BookingDetail;
// The entity field the API serializes on the detail read; not on the DTO type.
const wagonsRequired = Number(
(booking as { wagonsRequired?: number | string | null }).wagonsRequired ?? 0,
);
// Live units per container size ("20"/"40"), summed across lines.
const containerLines = useMemo(() => {
const bySize = new Map<string, number>();
for (const line of detail.bookingContainers ?? []) {
const size =
line.containerSize ??
(line.containerType?.sizeFt != null
? String(line.containerType.sizeFt)
: null);
if (!size) continue;
bySize.set(size, (bySize.get(size) ?? 0) + Number(line.quantity ?? 0));
}
return [...bySize.entries()].map(([containerSize, quantity]) => ({
containerSize,
quantity,
}));
}, [detail.bookingContainers]);
const { data, refetch } = useQuery({
...api.bookings.listWagonCancellations.queryOptions({
input: { bookingId: booking.id },
}),
enabled: eligible,
});
// History includes rows where this booking is the rebooked TARGET — only
// rows this booking opened itself can be paid/withdrawn/rebooked from here.
const rows = data?.items ?? [];
const ownRows = rows.filter((r) => r.bookingId === booking.id);
const openRow = ownRows.find((r) => r.status === "FEE_PENDING");
const creditRow = ownRows.find((r) => r.status === "CREDIT_AVAILABLE");
const feePay = useFeeInvoicePayment(booking.id);
// ── Request modal state ──
const [modalOpen, setModalOpen] = useState(false);
const [wagons, setWagons] = useState<number | string>(1);
const [cancelBySize, setCancelBySize] = useState<Record<string, number>>({});
const [reason, setReason] = useState("");
const [preview, setPreview] = useState<WagonCancellationPreview | null>(null);
const closeModal = () => {
setModalOpen(false);
setWagons(1);
setCancelBySize({});
setReason("");
setPreview(null);
};
const requestPayload = (): RequestWagonCancellationPayload | null => {
const trimmedReason = reason.trim();
if (isBulk) {
const n = Number(wagons);
if (!n || n <= 0) return null;
return { wagons: n, ...(trimmedReason ? { reason: trimmedReason } : {}) };
}
const containers = containerLines
.map((l) => ({
containerSize: l.containerSize,
quantity: cancelBySize[l.containerSize] ?? 0,
}))
.filter((c) => c.quantity > 0);
if (!containers.length) return null;
return { containers, ...(trimmedReason ? { reason: trimmedReason } : {}) };
};
const payload = requestPayload();
const previewMutation = useMutation({
mutationFn: (body: RequestWagonCancellationPayload) =>
bookingsService.previewWagonCancellation(booking.id, body),
onSuccess: setPreview,
onError: (e) => {
setPreview(null);
toast.error(apiErrorMessage(e, "Could not calculate the fee. Please try again."));
},
});
const requestMutation = useMutation({
mutationFn: (body: RequestWagonCancellationPayload) =>
bookingsService.requestWagonCancellation(booking.id, body),
onSuccess: () => {
closeModal();
toast.success(
"Cancellation requested — pay the fee to release the wagons.",
{ duration: 6000 },
);
void refetch();
onBookingUpdated?.();
},
onError: (e) =>
toast.error(
apiErrorMessage(e, "Could not request the cancellation. Please try again."),
),
});
const withdrawMutation = useMutation({
mutationFn: () => bookingsService.withdrawWagonCancellation(openRow!.id),
onSuccess: () => {
toast.success("Cancellation withdrawn — the fee invoice was voided.");
void refetch();
onBookingUpdated?.();
},
onError: (e) =>
toast.error(
apiErrorMessage(e, "Could not withdraw the cancellation. Please try again."),
),
});
const [rebookDate, setRebookDate] = useState("");
const rebookMutation = useMutation({
mutationFn: () =>
bookingsService.rebookWagonCancellation(creditRow!.id, {
scheduledDate: rebookDate,
}),
onSuccess: ({ bookingId }) => {
toast.success("Wagons rebooked — taking you to the new booking.", {
duration: 6000,
});
navigate(`/bookings/${bookingId}`);
},
onError: (e) =>
toast.error(apiErrorMessage(e, "Could not rebook the wagons. Please try again.")),
});
if (!eligible) return null;
return (
<SectionCard>
<Group justify="space-between" align="center" mb="sm">
<CardTitle>Wagon Cancellation</CardTitle>
{!openRow && !creditRow && (
<Button
variant="default"
radius="md"
leftSection={<TrainTrack size={16} />}
onClick={() => setModalOpen(true)}
>
Cancel wagons
</Button>
)}
</Group>
{openRow ? (
<Stack gap="sm">
<Alert color="yellow" radius="md" icon={<Clock size={18} />}>
A cancellation of {Number(openRow.wagonsCancelled)} wagon(s) is
awaiting its fee of{" "}
<Text span fw={700}>
{fmtMoney(openRow.feeAmount, openRow.feeCurrency)}
</Text>
. The cancelled wagons have left the train. Pay the fee to unlock
the rebooking credit, or withdraw the request to get the wagons
back withdrawing works only while the train still has free space
for them.
</Alert>
<Group gap={8}>
<Button
color="edr-green"
radius="md"
leftSection={<CreditCard size={16} />}
onClick={feePay.open}
>
Pay cancellation fee
</Button>
<Button
variant="default"
radius="md"
loading={withdrawMutation.isPending}
onClick={() => withdrawMutation.mutate()}
>
Withdraw request
</Button>
</Group>
</Stack>
) : creditRow ? (
<Stack gap="sm">
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />}>
{Number(creditRow.wagonsCancelled)} wagon(s) were released a
credit of{" "}
<Text span fw={700}>
{fmtMoney(creditRow.creditAmount, booking.paymentCurrency)}
</Text>{" "}
is available. Pick a shipment day to rebook them as a new paid
booking (no further payment needed).
</Alert>
<OperationDatePicker
bookingId={booking.id}
value={rebookDate}
onChange={setRebookDate}
/>
<Group justify="flex-end">
<Button
color="edr-green"
radius="md"
disabled={!rebookDate}
loading={rebookMutation.isPending}
onClick={() => rebookMutation.mutate()}
>
Rebook wagons
</Button>
</Group>
</Stack>
) : (
<Text fz={13} c="#475569">
Need fewer wagons than you paid for? Cancel part of this booking for
a per-wagon fee the freed freight amount becomes a credit you can
rebook onto another shipment day.
</Text>
)}
{rows.length > 0 && (
<Box style={{ overflowX: "auto" }} mt="md">
<Table verticalSpacing={6} horizontalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th style={th}>Date</Table.Th>
<Table.Th style={th}>Wagons</Table.Th>
<Table.Th style={th}>Fee</Table.Th>
<Table.Th style={th}>Status</Table.Th>
<Table.Th style={th}>Rebooked as</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((r) => (
<Table.Tr key={r.id}>
<Table.Td>
<Text fz={12.5} c="#475569">
{fmtDate(r.createdAt)}
</Text>
</Table.Td>
<Table.Td>
<Text fz={12.5} fw={700} c="#10202F">
{Number(r.wagonsCancelled)}
</Text>
</Table.Td>
<Table.Td>
<Text fz={12.5} c="#475569">
{fmtMoney(r.feeAmount, r.feeCurrency)}
</Text>
</Table.Td>
<Table.Td>
<StatusPill status={r.status} />
</Table.Td>
<Table.Td>
{r.rebookedBookingId ? (
<Text
component={Link}
to={`/bookings/${r.rebookedBookingId}`}
fz={12.5}
fw={700}
c="#0A6F4D"
style={{ textDecoration: "underline" }}
>
{r.rebookedBooking?.reference ?? "View booking"}
</Text>
) : (
<Text fz={12.5} c="#9AA8B5">
</Text>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
)}
<PaymentMethodModal
opened={feePay.modalOpen}
onClose={feePay.close}
amountLabel={
openRow ? fmtMoney(openRow.feeAmount, openRow.feeCurrency) : undefined
}
currency={openRow?.feeCurrency}
processing={feePay.processing}
error={feePay.error}
otp={feePay.otp}
bill={feePay.bill}
onConfirm={feePay.confirm}
/>
<Modal
opened={modalOpen}
onClose={closeModal}
title={
<Text fw={800} fz={18} c="#10202F">
Cancel wagons
</Text>
}
centered
radius={16}
>
<Stack gap="md">
<Text size="sm" c="#475569">
Choose how much of booking{" "}
<Text span fw={700} c="#10202F">
{booking.reference}
</Text>{" "}
to cancel. A per-wagon fee applies; once it&apos;s paid the wagons
are released and the freed amount becomes a rebooking credit. At
least one wagon must remain to cancel everything, cancel the
whole booking instead.
</Text>
{isBulk ? (
<NumberInput
label="Wagons to cancel"
min={1}
max={wagonsRequired > 1 ? wagonsRequired - 1 : undefined}
allowDecimal={false}
value={wagons}
onChange={(v) => {
setWagons(v);
setPreview(null);
}}
/>
) : (
containerLines.map((line) => (
<NumberInput
key={line.containerSize}
label={`${line.containerSize}ft containers to cancel`}
description={`${line.quantity} on this booking`}
min={0}
max={line.quantity}
allowDecimal={false}
value={cancelBySize[line.containerSize] ?? 0}
onChange={(v) => {
setCancelBySize((prev) => ({
...prev,
[line.containerSize]: Number(v) || 0,
}));
setPreview(null);
}}
/>
))
)}
<Textarea
label="Reason (optional)"
placeholder="Why are these wagons no longer needed?"
autosize
minRows={2}
value={reason}
onChange={(e) => setReason(e.currentTarget.value)}
/>
{preview && (
<Alert color="blue" radius="md">
<Text fz={13}>
Cancelling{" "}
<Text span fw={700}>
{preview.wagons} wagon(s)
</Text>{" "}
(~{preview.weightTons} t) costs a fee of{" "}
<Text span fw={700}>
{fmtMoney(preview.feeAmount, preview.feeCurrency)}
</Text>{" "}
({fmtMoney(preview.feePerWagon, preview.feeCurrency)} per
wagon) and frees a rebooking credit of{" "}
<Text span fw={700}>
{fmtMoney(preview.creditAmount, booking.paymentCurrency)}
</Text>
.
</Text>
</Alert>
)}
<Group justify="flex-end" gap={8}>
<Button
variant="default"
radius={10}
disabled={!payload}
loading={previewMutation.isPending}
onClick={() => payload && previewMutation.mutate(payload)}
>
Calculate fee
</Button>
<Button
color="red"
radius={10}
disabled={!payload}
loading={requestMutation.isPending}
onClick={() => payload && requestMutation.mutate(payload)}
>
Request cancellation
</Button>
</Group>
</Stack>
</Modal>
</SectionCard>
);
}

View File

@@ -0,0 +1,792 @@
import {
Alert,
Box,
Button,
Checkbox,
Group,
Modal,
SimpleGrid,
Skeleton,
Stack,
Table,
Text,
Textarea,
Tooltip,
} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Container,
Gauge,
MapPin,
Package,
Route,
Scale,
TrainFront,
TrainTrack,
} from "lucide-react";
import { useState, type ReactNode } from "react";
import toast from "react-hot-toast";
import {
bookingsService,
type BookingWagonAllocation,
type WagonCancellation,
type WagonCancellationPreview,
} from "@/services/bookings.service";
import { api } from "@/services/api";
import { fmtDate, fmtWeight } from "../utils";
import { CardTitle, SectionCard } from "./layout";
const CANCEL_TONES: Record<
string,
{ bg: string; color: string; border: string; label: string; hint: string }
> = {
FEE_PENDING: {
bg: "#FFFBEB",
color: "#92400E",
border: "#FDE68A",
label: "Cancelled — fee unpaid",
hint: "These wagons left the train. Pay the cancellation fee to turn them into a rebooking credit, or withdraw to get them back (needs free space).",
},
CREDIT_AVAILABLE: {
bg: "#E6F7F2",
color: "#0A6F4D",
border: "#B7E6D6",
label: "Cancelled — credit ready",
hint: "Fee paid. Pick a new shipment day in the wagon cancellation card to rebook these — no new freight charge.",
},
REBOOKED: {
bg: "#E8F5EF",
color: "#0A6F4D",
border: "#B7E6D6",
label: "Rebooked",
hint: "These wagons ride again on the rebooked shipment.",
},
};
/** Cancelled wagons + their cargo, categorized by cancellation state. */
function CancelledWagonsSection({ rows }: { rows: WagonCancellation[] }) {
const visible = rows.filter((r) => CANCEL_TONES[r.status]);
if (!visible.length) return null;
return (
<SectionCard>
<CardTitle>Cancelled wagons</CardTitle>
<Stack gap="sm" mt="sm">
{visible.map((r) => {
const tone = CANCEL_TONES[r.status];
const units = r.cancelledQuantities.units ?? [];
return (
<Box
key={r.id}
p={12}
style={{
borderRadius: 10,
backgroundColor: tone.bg,
border: `1px solid ${tone.border}`,
}}
>
<Group justify="space-between" align="center" wrap="wrap" gap={6}>
<Text fz={13.5} fw={800} style={{ color: tone.color }}>
{Number(r.wagonsCancelled)} wagon(s) {tone.label}
</Text>
<Text fz={12} c="#6B7C8E">
{fmtDate(r.createdAt)}
{r.rebookedBooking
? ` · new booking ${r.rebookedBooking.reference}`
: ""}
</Text>
</Group>
{units.length > 0 && (
<Group gap={6} mt={6} wrap="wrap">
{units.map((u) => (
<Text
key={u.containerNumber}
component="span"
fz={11.5}
fw={700}
px={8}
py={2}
style={{
borderRadius: 6,
backgroundColor: "white",
border: `1px solid ${tone.border}`,
color: tone.color,
fontFamily: "monospace",
}}
>
{u.containerNumber} · {u.containerSize}ft
</Text>
))}
</Group>
)}
{!units.length && r.cancelledQuantities.bulkTons != null && (
<Text fz={12.5} mt={4} style={{ color: tone.color }}>
{Number(r.cancelledQuantities.bulkTons).toLocaleString()} tons of
bulk cargo
</Text>
)}
<Text fz={12} c="#6B7C8E" mt={6}>
{tone.hint}
</Text>
</Box>
);
})}
</Stack>
</SectionCard>
);
}
const apiErrorMessage = (error: unknown, fallback: string) => {
const data = (
error as { response?: { data?: { message?: string | string[] } } }
)?.response?.data;
if (Array.isArray(data?.message)) return data.message.join(", ");
if (data?.message) return data.message;
return fallback;
};
// Mirrors CargoTab's local Flag/StatTile look so the two tabs read as one page.
const STATUS_TONES: Record<
BookingWagonAllocation["status"],
{ bg: string; color: string; label: string }
> = {
PLANNED: { bg: "#F1F4F7", color: "#475569", label: "Planned" },
RESERVED: { bg: "#FFFBEB", color: "#92400E", label: "Reserved" },
LOADED: { bg: "#E8F5EF", color: "#0A6F4D", label: "Loaded" },
DEPARTED: { bg: "#EAF1FE", color: "#1E40AF", label: "Departed" },
};
function StatusPill({ status }: { status: BookingWagonAllocation["status"] }) {
const tone = STATUS_TONES[status] ?? STATUS_TONES.PLANNED;
return (
<Text
component="span"
fz={11}
fw={700}
px={9}
py={3}
style={{ borderRadius: 999, backgroundColor: tone.bg, color: tone.color }}
>
{tone.label}
</Text>
);
}
function StatTile({
icon,
label,
value,
sub,
}: {
icon: ReactNode;
label: string;
value: string;
sub?: string;
}) {
return (
<Box
p={14}
style={{
borderRadius: 12,
border: "1px solid #E6ECF2",
backgroundColor: "#FAFCFE",
}}
>
<Group gap={6} align="center" mb={6} c="#6B7C8E">
{icon}
<Text fz="11px" fw={700} tt="uppercase" style={{ letterSpacing: "0.05em" }}>
{label}
</Text>
</Group>
<Text fz={18} fw={800} c="#10202F" truncate>
{value}
</Text>
{sub && (
<Text fz={12} c="#9AA8B5" mt={2}>
{sub}
</Text>
)}
</Box>
);
}
/** Little consist strip: locomotive + one box per wagon, in marshalling order. */
function ConsistStrip({ wagons }: { wagons: BookingWagonAllocation[] }) {
return (
<Box style={{ overflowX: "auto" }} pb={4}>
<Group gap={5} wrap="nowrap" align="flex-end">
<Box
px={10}
py={8}
style={{
borderRadius: "10px 4px 4px 10px",
backgroundColor: "#10202F",
color: "white",
display: "flex",
alignItems: "center",
gap: 6,
flexShrink: 0,
}}
>
<TrainFront size={16} />
<Text fz={11} fw={800}>
LOCO
</Text>
</Box>
{wagons.map((w) => (
<Tooltip
key={w.sequenceNo}
label={`${w.wagonNumber ?? "Unassigned"} · ${w.wagonType ?? "—"} · ${
STATUS_TONES[w.status]?.label ?? w.status
}`}
withArrow
>
<Box
px={10}
py={8}
ta="center"
style={{
borderRadius: 6,
border: "1.5px solid #C9D6E2",
backgroundColor: STATUS_TONES[w.status]?.bg ?? "#F1F4F7",
flexShrink: 0,
minWidth: 64,
cursor: "default",
}}
>
<Text fz={10} fw={700} c="#6B7C8E">
W{w.sequenceNo}
</Text>
<Text fz={11.5} fw={800} c="#10202F" style={{ fontFamily: "monospace" }}>
{w.wagonNumber ?? "—"}
</Text>
</Box>
</Tooltip>
))}
</Group>
</Box>
);
}
function LoadBar({ allocated, capacity }: { allocated: number; capacity: number }) {
const pct = capacity > 0 ? Math.min(100, Math.round((allocated / capacity) * 100)) : 0;
return (
<Box>
<Group justify="space-between" mb={4}>
<Text fz={11.5} fw={700} c="#6B7C8E">
Load
</Text>
<Text fz={11.5} fw={800} c="#10202F">
{fmtWeight(allocated)}
{capacity > 0 ? ` / ${fmtWeight(capacity)} · ${pct}%` : ""}
</Text>
</Group>
<Box style={{ height: 6, borderRadius: 999, backgroundColor: "#EDF2F7" }}>
<Box
style={{
height: 6,
width: `${pct}%`,
borderRadius: 999,
backgroundColor: pct >= 95 ? "#B45309" : "#0A6F4D",
transition: "width 300ms ease",
}}
/>
</Box>
</Box>
);
}
const th = { color: "#9AA8B5", fontSize: 11 } as const;
function WagonCard({
wagon,
selectable,
selected,
onToggle,
}: {
wagon: BookingWagonAllocation;
selectable?: boolean;
selected?: boolean;
onToggle?: () => void;
}) {
const allocated = Number(wagon.allocatedWeightTons || 0);
const capacity = Number(wagon.capacityTons || 0);
const containers = wagon.containers ?? [];
return (
<SectionCard
style={
selected
? { outline: "2px solid #B45309", outlineOffset: -2, borderRadius: 16 }
: undefined
}
>
<Group justify="space-between" align="flex-start" wrap="nowrap" mb="sm">
<Group gap={10} align="center" wrap="nowrap">
{selectable && (
<Checkbox
checked={!!selected}
onChange={onToggle}
color="orange"
aria-label={`Select wagon ${wagon.sequenceNo} for cancellation`}
/>
)}
<Box
style={{
width: 40,
height: 40,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
borderRadius: 10,
backgroundColor: "#10202F",
color: "white",
flexShrink: 0,
}}
>
<Text fz={9} fw={700} c="#9AA8B5" lh={1}>
WAGON
</Text>
<Text fz={15} fw={800} lh={1.2}>
{wagon.sequenceNo}
</Text>
</Box>
<Box>
<Text fz={16} fw={800} c="#10202F" style={{ fontFamily: "monospace" }}>
{wagon.wagonNumber ?? "Not yet assigned"}
</Text>
<Text fz={12} c="#9AA8B5">
{wagon.wagonType ?? "Wagon type pending"}
{wagon.wagonTypeCode && wagon.wagonType !== wagon.wagonTypeCode
? ` · ${wagon.wagonTypeCode}`
: ""}
</Text>
</Box>
</Group>
<StatusPill status={wagon.status} />
</Group>
<LoadBar allocated={allocated} capacity={capacity} />
<Group gap={16} mt="sm" mb={containers.length || wagon.loadType === "BULK" ? "sm" : 0}>
{Number(wagon.tareWeightTons) > 0 && (
<Group gap={5}>
<Scale size={12} color="#9AA8B5" />
<Text fz={12} c="#475569">
Tare {fmtWeight(Number(wagon.tareWeightTons))}
</Text>
</Group>
)}
{Number(wagon.lengthMeters) > 0 && (
<Group gap={5}>
<Route size={12} color="#9AA8B5" />
<Text fz={12} c="#475569">
{Number(wagon.lengthMeters)} m
</Text>
</Group>
)}
<Group gap={5}>
{wagon.loadType === "BULK" ? (
<Package size={12} color="#9AA8B5" />
) : (
<Container size={12} color="#9AA8B5" />
)}
<Text fz={12} c="#475569">
{wagon.loadType === "BULK" ? "Bulk load" : "Container load"}
</Text>
</Group>
</Group>
{wagon.loadType === "BULK" && (wagon.bulkCargoDescription || wagon.bulkQuantity) && (
<Box
p={10}
style={{ borderRadius: 10, backgroundColor: "#FAFCFE", border: "1px solid #EDF2F7" }}
>
<Text fz={12.5} fw={700} c="#10202F">
{wagon.bulkCargoDescription ?? "Bulk cargo"}
</Text>
{Number(wagon.bulkQuantity) > 0 && (
<Text fz={12} c="#9AA8B5">
Quantity: {Number(wagon.bulkQuantity).toLocaleString()}
</Text>
)}
</Box>
)}
{containers.length > 0 && (
<Box style={{ overflowX: "auto" }}>
<Table verticalSpacing={6} horizontalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th style={th}>Container no.</Table.Th>
<Table.Th style={th}>Seal no.</Table.Th>
<Table.Th style={th}>Gross wt.</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{containers.map((c, i) => (
<Table.Tr key={c.containerNumber ?? i}>
<Table.Td>
<Text fz={12.5} fw={700} c="#10202F" style={{ fontFamily: "monospace" }}>
{c.containerNumber ?? "—"}
</Text>
</Table.Td>
<Table.Td>
<Text fz={12.5} c="#475569">
{c.sealNumber ?? "—"}
</Text>
</Table.Td>
<Table.Td>
<Text fz={12.5} c="#475569">
{Number(c.grossWeightTons) > 0
? fmtWeight(Number(c.grossWeightTons))
: "—"}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
)}
</SectionCard>
);
}
/**
* "Wagons" tab: the customer's view of their allocated wagons once the paid
* booking has been placed on a train — consist strip in marshalling order,
* per-wagon load/containers, and the train's route summary.
*/
export function WagonsTab({
bookingId,
cancellable,
onCancellationRequested,
}: {
bookingId: string;
/** PAID contract booking — specific wagons may be selected for cancellation. */
cancellable?: boolean;
onCancellationRequested?: () => void;
}) {
const queryClient = useQueryClient();
const { data: wagons, isLoading } = useQuery({
queryKey: ["booking-wagons", bookingId],
queryFn: () => bookingsService.getWagons(bookingId),
enabled: !!bookingId,
});
// Cancellation history: feeds the "Cancelled wagons" section and blocks a
// second request while one is awaiting its fee.
const { data: history } = useQuery({
...api.bookings.listWagonCancellations.queryOptions({ input: { bookingId } }),
enabled: !!bookingId,
});
const ownCancellations = (history?.items ?? []).filter(
(r) => r.bookingId === bookingId,
);
const hasOpenCancellation = ownCancellations.some(
(r) => r.status === "FEE_PENDING",
);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [confirmOpen, setConfirmOpen] = useState(false);
const [reason, setReason] = useState("");
const [preview, setPreview] = useState<WagonCancellationPreview | null>(null);
const toggle = (allocationId: string) =>
setSelected((prev) => {
const next = new Set(prev);
if (next.has(allocationId)) next.delete(allocationId);
else next.add(allocationId);
return next;
});
const previewMutation = useMutation({
mutationFn: () =>
bookingsService.previewWagonCancellation(bookingId, {
wagonAllocationIds: [...selected],
}),
onSuccess: setPreview,
onError: (e) => {
setPreview(null);
toast.error(apiErrorMessage(e, "Could not calculate the fee. Please try again."));
},
});
const requestMutation = useMutation({
mutationFn: () =>
bookingsService.requestWagonCancellation(bookingId, {
wagonAllocationIds: [...selected],
...(reason.trim() ? { reason: reason.trim() } : {}),
}),
onSuccess: () => {
setConfirmOpen(false);
setSelected(new Set());
setReason("");
setPreview(null);
toast.success(
"Cancellation requested — pay the fee in the wagon cancellation card to release these wagons.",
{ duration: 7000 },
);
void queryClient.invalidateQueries({ queryKey: ["booking-wagons", bookingId] });
void queryClient.invalidateQueries({
queryKey: api.bookings.listWagonCancellations.queryKey({ bookingId }),
});
onCancellationRequested?.();
},
onError: (e) =>
toast.error(apiErrorMessage(e, "Could not request the cancellation. Please try again.")),
});
const openConfirm = () => {
setPreview(null);
setConfirmOpen(true);
previewMutation.mutate();
};
if (isLoading) {
return (
<div className="flex flex-col gap-6" style={{ maxWidth: 980 }}>
<Skeleton height={140} radius={16} />
<SimpleGrid cols={{ base: 1, md: 2 }} spacing={24}>
<Skeleton height={220} radius={16} />
<Skeleton height={220} radius={16} />
</SimpleGrid>
</div>
);
}
if (!wagons?.length) {
return (
<div className="flex flex-col gap-6" style={{ maxWidth: 980 }}>
<CancelledWagonsSection rows={ownCancellations} />
<SectionCard>
<Group gap={12} align="center">
<Box
style={{
width: 44,
height: 44,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 12,
backgroundColor: "#F1F4F7",
color: "#6B7C8E",
}}
>
<TrainTrack size={22} />
</Box>
<Box>
<Text fz={15} fw={800} c="#10202F">
No wagons allocated yet
</Text>
<Text fz={13} c="#9AA8B5">
Your wagons will appear here once the shipment is placed on a
train after payment.
</Text>
</Box>
</Group>
</SectionCard>
</div>
);
}
const canSelect = !!cancellable && !hasOpenCancellation;
const first = wagons[0];
const totalAllocated = wagons.reduce(
(s, w) => s + Number(w.allocatedWeightTons || 0),
0,
);
const totalCapacity = wagons.reduce((s, w) => s + Number(w.capacityTons || 0), 0);
const containerCount = wagons.reduce((s, w) => s + (w.containers?.length ?? 0), 0);
const utilization =
totalCapacity > 0 ? Math.round((totalAllocated / totalCapacity) * 100) : null;
return (
<div className="flex flex-col gap-6" style={{ maxWidth: 980 }}>
<SectionCard>
<Group justify="space-between" align="flex-start" mb="md" wrap="wrap">
<Group gap={10} align="center">
<Box
style={{
width: 36,
height: 36,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 10,
backgroundColor: "#E8F5EF",
color: "#0A6F4D",
}}
>
<TrainFront size={18} />
</Box>
<Box>
<Text fz={15} fw={800} c="#10202F">
{first.trainNumber ? `Train ${first.trainNumber}` : "Your train"}
</Text>
<Group gap={5} align="center">
<MapPin size={11} color="#9AA8B5" />
<Text fz={12} c="#9AA8B5">
{first.originStation ?? "—"} {first.destinationStation ?? "—"}
{first.departureAt ? ` · departs ${fmtDate(first.departureAt)}` : ""}
</Text>
</Group>
</Box>
</Group>
<CardTitle>Your wagons on this train</CardTitle>
</Group>
<ConsistStrip wagons={wagons} />
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing={10} mt="md">
<StatTile
icon={<TrainTrack size={13} />}
label="Wagons"
value={`${wagons.length}`}
sub="allocated to you"
/>
<StatTile
icon={<Scale size={13} />}
label="Allocated weight"
value={fmtWeight(totalAllocated)}
/>
<StatTile
icon={<Container size={13} />}
label="Containers"
value={containerCount ? `${containerCount}` : "—"}
sub={containerCount ? "loaded on wagons" : undefined}
/>
<StatTile
icon={<Gauge size={13} />}
label="Utilization"
value={utilization != null ? `${utilization}%` : "—"}
sub="of wagon capacity"
/>
</SimpleGrid>
</SectionCard>
{canSelect && (
<SectionCard>
<Group justify="space-between" align="center" wrap="wrap" gap="sm">
<Box>
<Text fz={14} fw={800} c="#10202F">
Cancel specific wagons
</Text>
<Text fz={12.5} c="#9AA8B5">
Tick the wagons you want to cancel. They leave this train
immediately; after you pay the per-wagon cancellation fee, the
freight you paid for them becomes a credit you can rebook on
another day.
</Text>
</Box>
<Button
color="orange"
disabled={selected.size === 0 || selected.size >= wagons.length}
onClick={openConfirm}
>
Cancel selected ({selected.size})
</Button>
</Group>
{selected.size >= wagons.length && selected.size > 0 && (
<Text fz={12} c="#B3362C" mt={6}>
You cannot cancel every wagon here to cancel the whole booking,
use the booking cancellation instead.
</Text>
)}
</SectionCard>
)}
{cancellable && hasOpenCancellation && (
<Alert color="yellow" variant="light">
A wagon cancellation is already awaiting its fee pay or withdraw it
in the wagon cancellation card before requesting another.
</Alert>
)}
<CancelledWagonsSection rows={ownCancellations} />
<SimpleGrid cols={{ base: 1, md: 2 }} spacing={24}>
{wagons.map((w) => (
<WagonCard
key={w.allocationId ?? w.sequenceNo}
wagon={w}
selectable={
canSelect &&
!!w.allocationId &&
(w.status === "PLANNED" || w.status === "RESERVED")
}
selected={!!w.allocationId && selected.has(w.allocationId)}
onToggle={() => w.allocationId && toggle(w.allocationId)}
/>
))}
</SimpleGrid>
<Modal
opened={confirmOpen}
onClose={() => setConfirmOpen(false)}
title="Cancel selected wagons"
centered
>
<Stack gap="sm">
<Text fz={13.5} c="#475569">
You are cancelling <b>{selected.size}</b> wagon(s). They stay
allocated to you until the cancellation fee is paid; after that the
paid freight for them becomes a credit you can rebook on another
day while your contract is valid.
</Text>
{previewMutation.isPending && <Skeleton height={64} radius={10} />}
{preview && (
<Box
p={12}
style={{
borderRadius: 10,
backgroundColor: "#FFFBEB",
border: "1px solid #FDE68A",
}}
>
<Group justify="space-between">
<Text fz={13} c="#92400E">
Cancellation fee ({preview.wagons} × {Number(preview.feePerWagon).toLocaleString()})
</Text>
<Text fz={14} fw={800} c="#92400E">
{Number(preview.feeAmount).toLocaleString()} {preview.feeCurrency}
</Text>
</Group>
<Group justify="space-between" mt={4}>
<Text fz={13} c="#0A6F4D">
Rebooking credit kept
</Text>
<Text fz={14} fw={800} c="#0A6F4D">
{Number(preview.creditAmount).toLocaleString()}
</Text>
</Group>
</Box>
)}
<Textarea
label="Reason (optional)"
placeholder="Why are you cancelling these wagons?"
value={reason}
onChange={(e) => setReason(e.currentTarget.value)}
autosize
minRows={2}
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setConfirmOpen(false)}>
Keep wagons
</Button>
<Button
color="orange"
loading={requestMutation.isPending}
disabled={!preview}
onClick={() => requestMutation.mutate()}
>
Request cancellation
</Button>
</Group>
</Stack>
</Modal>
</div>
);
}

View File

@@ -85,7 +85,12 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
return (
<Stack gap={0}>
{isReady ? (
{status === "OPERATION_CHANGES_REQUESTED" ? (
<Alert color="yellow" radius="md" icon={<Clock size={18} />} mb="md">
Operations returned this order for changes. Review their note, pick a
new shipment day below and resubmit.
</Alert>
) : isReady ? (
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />} mb="md">
{needsCompletion
? "Clearance is finalized. Complete your booking now — enter the cargo details and pick a shipment day inside an open booking window."

View File

@@ -89,6 +89,22 @@ function actionByStatus(booking: ActionBooking): BookingNextAction | null {
label: "Schedule & proceed",
title: "Schedule your shipment",
};
case "OPERATION_CHANGES_REQUESTED":
// Contract bookings reopen the full completion form (cargo + shipment
// day, prefilled from the booking) — same page as the initial booking.
// Contract-less bookings keep the in-place day-picker modal.
return booking.contractId
? {
kind: "BOOK",
label: "Change booking",
title: "Change your booking",
to: `/contracts/${booking.contractId}/bookings/${booking.id}/complete`,
}
: {
kind: "SCHEDULE_OPERATION",
label: "Choose day & resubmit",
title: "Resubmit your shipment",
};
default:
return null;
}

View File

@@ -98,7 +98,10 @@ export function useClearanceFlow(booking: Freight.IBooking) {
[clearance],
);
const isReady = status === "CLEARANCE_READY";
// OPERATION_CHANGES_REQUESTED re-opens the same pick-a-day flow: the
// customer resubmits via the same clearance/proceed endpoint.
const isReady =
status === "CLEARANCE_READY" || status === "OPERATION_CHANGES_REQUESTED";
// Bare initiated instance: created with no cargo and no price; completion
// (cargo + shipment day + window check) happens on the full booking form.
const isBareInstance =

View File

@@ -3,18 +3,22 @@ import { useState } from "react";
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
import { type PaymentMethod } from "@/services/payments.service";
import { invoicesService } from "@/services/invoices.service";
import { invoicesService, type PortalInvoice } from "@/services/invoices.service";
import { isPayable } from "@/pages/billing/invoice-ui";
/** Fee invoice opened by a partial wagon cancellation (see WagonCancellationCard). */
export const WAGON_CANCEL_FEE_INVOICE_TYPE = "WAGON_CANCEL_FEE";
/**
* Shared payment flow for a single booking: opens the method modal, fires
* POST /billing/my-invoices/:id/pay for the booking's currently payable
* invoice, and redirects the browser to the provider (or, for CAC Bank, an
* OTP debit with no redirect, collects the SMS'd code in the modal). Reused by
* the booking detail page, the booking list, and the home page so "Pay now"
* behaves identically everywhere.
* Core of the booking payment flows: resolves the booking's invoices (shared
* query/key with BookingPaymentPanel, so they share that cache), picks the one
* matching `match`, and charges it through the ownership-checked portal route —
* redirect vs CAC Bank OTP handled by `useInvoicePayment`.
*/
export function useBookingPayment(bookingId: string) {
function useBookingInvoicePayment(
bookingId: string,
match: (invoice: PortalInvoice) => boolean,
) {
const [modalOpen, setModalOpen] = useState(false);
const [noInvoice, setNoInvoice] = useState(false);
@@ -22,7 +26,7 @@ export function useBookingPayment(bookingId: string) {
queryKey: ["booking-invoices", bookingId],
queryFn: () => invoicesService.listForSource("booking", bookingId),
});
const payableInvoiceId = invoices.find((inv) => isPayable(inv.status))?.id;
const payableInvoiceId = invoices.find(match)?.id;
const flow = useInvoicePayment();
@@ -56,3 +60,28 @@ export function useBookingPayment(bookingId: string) {
},
};
}
/**
* Shared payment flow for a single booking: opens the method modal, fires
* POST /billing/my-invoices/:id/pay for the booking's currently payable
* invoice, and redirects the browser to the provider (or, for CAC Bank, an
* OTP debit with no redirect, collects the SMS'd code in the modal). Reused by
* the booking detail page, the booking list, and the home page so "Pay now"
* behaves identically everywhere.
*/
export function useBookingPayment(bookingId: string) {
return useBookingInvoicePayment(bookingId, (inv) => isPayable(inv.status));
}
/**
* Same flow, but targets the booking's payable wagon-cancellation FEE invoice
* (type WAGON_CANCEL_FEE) — the freight invoice is already paid on these
* bookings, so the generic "first payable" pick would work today, but pinning
* the type keeps the two buttons from ever racing over the same invoice.
*/
export function useFeeInvoicePayment(bookingId: string) {
return useBookingInvoicePayment(
bookingId,
(inv) => inv.type === WAGON_CANCEL_FEE_INVOICE_TYPE && isPayable(inv.status),
);
}

View File

@@ -104,16 +104,29 @@ const COUNTDOWN_TEXT: Partial<
PRE_WINDOW: { label: "Booking opens in", expiredText: "Booking opening now…" },
OPEN: { label: "Window closes in", expiredText: "Document review starting…" },
DOC_REVIEW: { label: "Document review ends in", expiredText: "Payment starting…" },
PAYMENT: { label: "Payment due in", expiredText: "Payment window closing…" },
PAYMENT: { label: "Payment due in", expiredText: "Finalizing payments…" },
};
function phaseCountdown(
w: MyBookingWindow,
): { label: string; deadline: string; expiredText: string } | null {
function phaseCountdown(w: MyBookingWindow): {
label: string;
deadline: string;
expiredText: string;
graceDeadline?: string | null;
graceLabel?: string;
} | null {
const state = bookingWindowUiState(w);
const text = COUNTDOWN_TEXT[state.kind];
if (!state.countdownTo || !text) return null;
return { ...text, deadline: state.countdownTo };
// Once the pay deadline lapses, pending payments still settle during the
// drain tail — count it down as "processing" instead of a stale "closing".
const grace =
state.kind === "PAYMENT" && w.paymentDrainEndsAt
? {
graceDeadline: w.paymentDrainEndsAt,
graceLabel: "Payment processing — closes in",
}
: undefined;
return { ...text, deadline: state.countdownTo, ...grace };
}
/**
@@ -226,6 +239,8 @@ function WindowCard({ w }: { w: MyBookingWindow }) {
deadline={cd.deadline}
label={cd.label}
expiredText={cd.expiredText}
graceDeadline={cd.graceDeadline}
graceLabel={cd.graceLabel}
size="xs"
/>
</Box>

View File

@@ -38,6 +38,7 @@ import {
CheckCircle2,
ChevronLeft,
FileDown,
FileText,
FileUp,
Flame,
MapPin,
@@ -59,6 +60,7 @@ import {
contractsService,
type ShipmentValidation,
} from "@/services/contracts.service";
import { downloadStoredFile } from "@/services/files.service";
import {
SelectField,
StepCard,
@@ -245,6 +247,127 @@ function bulkUnitOfMeasure(
return hasPerItem ? "PER_ITEM" : "PER_TON";
}
/**
* Prefill for a changes-requested resubmit: the booking's persisted cargo,
* currency and route become the form's starting values so the customer edits
* what exists instead of retyping it. The shipment day is deliberately left
* empty — a new day must be picked.
*/
function mapBookingToShipmentValues(
booking: Freight.IBooking,
contract: Freight.IContract,
): Partial<ShipmentFormInputValues> {
const b = booking as unknown as {
cargoFreeText?: string | null;
contractRouteId?: string | null;
cargoTotalWeightVgm?: number | string | null;
bulkTotalWeightTons?: number | string | null;
bulkHazardousQuantity?: number | string | null;
bulkReeferQuantity?: number | string | null;
bookingContainers?: Array<{
quantity?: number;
hazardousQuantity?: number | string | null;
reeferQuantity?: number | string | null;
returnQuantity?: number | string | null;
containerType?: { sizeFt?: number | null } | null;
units?: Array<{
containerNumber?: string;
sealNumber?: string | null;
vgmTons?: number | string;
isHazardous?: boolean;
isReefer?: boolean;
isReturn?: boolean;
}>;
}>;
};
const values: Partial<ShipmentFormInputValues> = {
paymentCurrency: booking.paymentCurrency === "ETB" ? "ETB" : "USD",
withReturn: booking.equipmentReturn === "WITH_RETURN",
cargoDescription: b.cargoFreeText ?? "",
...(b.contractRouteId ? { contractRouteId: b.contractRouteId } : {}),
};
if (contract.freightType === "CONTAINER") {
const rows = b.bookingContainers ?? [];
const lineFor = (size: "20ft" | "40ft") => {
const bc = rows.find(
(r) => (r.containerType?.sizeFt === 40 ? "40ft" : "20ft") === size,
);
return {
containerSize: size,
quantity: String(bc?.quantity ?? 0),
hazardousQuantity: String(Number(bc?.hazardousQuantity ?? 0)),
reeferQuantity: String(Number(bc?.reeferQuantity ?? 0)),
returnQuantity: String(Number(bc?.returnQuantity ?? 0)),
units: (bc?.units ?? []).map((u) => ({
containerNumber: u.containerNumber ?? "",
sealNumber: u.sealNumber ?? "",
vgmTons: String(Number(u.vgmTons ?? 0)),
isHazardous: Boolean(u.isHazardous),
isReefer: Boolean(u.isReefer),
isReturn: Boolean(u.isReturn),
})),
};
};
const sizes = (contract.cargoScope ?? [])
.map((s) => s.containerSize)
.filter((s): s is "20ft" | "40ft" => s === "20ft" || s === "40ft");
values.containers = (sizes.length ? sizes : (["20ft", "40ft"] as const)).map(lineFor);
} else {
const perItem = bulkUnitOfMeasure(contract) === "PER_ITEM";
const amount = Number(b.cargoTotalWeightVgm ?? 0);
if (perItem) {
values.itemCount = amount ? String(amount) : "";
values.cargoWeightTons =
b.bulkTotalWeightTons != null
? String(Number(b.bulkTotalWeightTons))
: "";
} else {
values.cargoWeightTons = amount ? String(amount) : "";
}
values.bulkHazardousQuantity = String(Number(b.bulkHazardousQuantity ?? 0));
values.bulkReeferQuantity = String(Number(b.bulkReeferQuantity ?? 0));
}
return values;
}
/** Read-only list of the booking's already-uploaded documents (resubmit view). */
function UploadedDocumentsCard({ booking }: { booking: Freight.IBooking }) {
const files =
(booking as unknown as { files?: Array<{ id: string; name: string }> })
.files ?? [];
if (!files.length) return null;
return (
<Paper withBorder radius="lg" p="lg">
<Group gap={8} mb={4}>
<FileText size={16} />
<Text fw={700} fz="sm">
Your uploaded documents
</Text>
</Group>
<Text fz={12.5} c="dimmed" mb="sm">
These stay attached to the booking no need to upload them again.
</Text>
<Stack gap={6}>
{files.map((f) => (
<Group key={f.id} justify="space-between" wrap="nowrap">
<Text fz={13} truncate>
{f.name}
</Text>
<ActionIcon
variant="default"
radius="md"
onClick={() => void downloadStoredFile(f.id, f.name)}
aria-label={`Download ${f.name}`}
>
<FileDown size={15} />
</ActionIcon>
</Group>
))}
</Stack>
</Paper>
);
}
function NewShipmentBookingForm({
contract,
contractId,
@@ -284,6 +407,10 @@ function NewShipmentBookingForm({
unitOfMeasure: bulkUnitOfMeasure(contract),
// Intercity rides a passing train staff pick later — no date to choose.
requiresDate: contract.tradeDirection !== "DOMESTIC",
// Export completion locks onto a specific train — the pick is required
// (mirrors the ScheduleStep picker's visibility).
requiresTrain:
contract.tradeDirection === "EXPORT" && Boolean(completeBookingId),
}),
),
mode: "onChange",
@@ -302,6 +429,34 @@ function NewShipmentBookingForm({
: 0;
const hasOdd20ft = ft20Total % 2 === 1;
// COMPLETION mode: fetch the booking — a changes-requested resubmit prefills
// the form from it and shows the operations note + uploaded documents.
const { data: completeBooking } = useQuery(
api.bookings.get.queryOptions({
input: { id: completeBookingId! },
enabled: Boolean(completeBookingId),
}),
);
const isResubmit = Boolean(
completeBooking &&
["OPERATION_CHANGES_REQUESTED", "EXPIRED"].includes(
completeBooking.status as string,
) &&
(((completeBooking as unknown as { bookingContainers?: unknown[] })
.bookingContainers?.length ?? 0) > 0 ||
Number(completeBooking.cargoTotalWeightVgm ?? 0) > 0),
);
const prefilledRef = useRef(false);
useEffect(() => {
if (!isResubmit || prefilledRef.current || !completeBooking) return;
prefilledRef.current = true;
form.reset({
...form.getValues(),
...mapBookingToShipmentValues(completeBooking, contract),
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isResubmit]);
const submitMutation = useMutation({
mutationFn: (dto: Freight.CreateBookingUnderContractDto) =>
completeBookingId
@@ -328,7 +483,12 @@ function NewShipmentBookingForm({
// price modal opens so re-reviewing after an edit re-checks.
const validateMutation = useMutation({
mutationFn: (dto: Freight.CreateBookingUnderContractDto) =>
api.contracts.validateShipment.call({ id: contractId, dto }),
api.contracts.validateShipment.call({
id: contractId,
dto,
// Resubmit preview must not clash with this booking's own containers.
excludeBookingId: completeBookingId,
}),
});
function buildDto(
@@ -484,12 +644,16 @@ function NewShipmentBookingForm({
style={{ letterSpacing: "-0.01em" }}
>
{completeBookingId
? "Complete Your Booking"
? isResubmit
? "Change Your Booking"
: "Complete Your Booking"
: "New Shipment Booking"}
</Title>
<Text size="sm" c="edr-muted" mt={4}>
{completeBookingId
? `Clearance is finalized — enter the cargo details and shipment day to complete your booking under contract ${contract.reference}.`
? isResubmit
? `Update the details below and pick a new shipment day, then resubmit your booking under contract ${contract.reference}.`
: `Clearance is finalized — enter the cargo details and shipment day to complete your booking under contract ${contract.reference}.`
: `Book a shipment against contract ${contract.reference}.`}
</Text>
</Box>
@@ -531,6 +695,16 @@ function NewShipmentBookingForm({
{/* Single-step form — all sections on one page. */}
<Stack gap="lg" className="mx-auto max-w-4xl">
{isResubmit && completeBooking?.latestChangeRequestNote && (
<Alert
color="yellow"
icon={<AlertCircle size={16} />}
radius="md"
title="Operations requested changes"
>
<Text size="sm">{completeBooking.latestChangeRequestNote}</Text>
</Alert>
)}
<RouteStep form={form} contract={contract} routes={routes} />
<CargoStep form={form} contract={contract} />
{/* Legacy contracts only — WITH_RETURN contracts capture per-line
@@ -543,6 +717,9 @@ function NewShipmentBookingForm({
routes={routes}
completeBookingId={completeBookingId ?? null}
/>
{isResubmit && completeBooking && (
<UploadedDocumentsCard booking={completeBooking} />
)}
{/* Notes are captured when the booking is initiated — completing
a bare booking does not re-ask for them. */}
{!completeBookingId && <NotesSection form={form} />}
@@ -590,7 +767,7 @@ function NewShipmentBookingForm({
onClick={handleReview}
disabled={hasOdd20ft}
>
Review price &amp; book
{isResubmit ? "Change booking" : "Review price & book"}
</Button>
</Box>
</Tooltip>
@@ -1179,12 +1356,23 @@ function ScheduleStep({
</Text>
)}
{isExportPick && scheduledDate ? (
<ExportTrainPicker
options={exportTrainsQuery.data ?? []}
loading={exportTrainsQuery.isLoading}
value={selectedTrainId ?? ""}
onChange={(id) => form.setValue("trainScheduleId", id)}
/>
<>
<ExportTrainPicker
options={exportTrainsQuery.data ?? []}
loading={exportTrainsQuery.isLoading}
value={selectedTrainId ?? ""}
onChange={(id) =>
form.setValue("trainScheduleId", id, {
shouldValidate: true,
})
}
/>
{form.formState.errors.trainScheduleId?.message && (
<Text fz="xs" c="red" mt={6}>
{String(form.formState.errors.trainScheduleId.message)}
</Text>
)}
</>
) : null}
</Box>
)}

View File

@@ -30,6 +30,11 @@ export interface ShipmentValidationContext {
* staff pick later, so no shipment day is chosen. Defaults to true.
*/
requiresDate?: boolean;
/**
* EXPORT rail completion: the shipment must ride a specific train the
* customer picks for the chosen day. Defaults to false.
*/
requiresTrain?: boolean;
}
// ISO 6346: 3-letter owner code + category id (U/J/Z) + 6-digit serial + check digit.
@@ -102,6 +107,20 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) {
});
}
// Train is only pickable once a day is chosen — the day error covers the
// no-date case, so don't stack a second error on an invisible field.
if (
ctx.requiresTrain &&
data.scheduledDate.trim() &&
!data.trainScheduleId.trim()
) {
refineCtx.addIssue({
code: "custom",
path: ["trainScheduleId"],
message: "Select a train for your shipment day.",
});
}
// No default currency — the customer must pick one before submitting.
if (!data.paymentCurrency) {
refineCtx.addIssue({

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { createShipmentFormSchema, initialShipmentFormValues } from "./schema";
const schema = createShipmentFormSchema({
isContainer: false,
isHazardous: false,
isReefer: false,
requiresTrain: true,
});
const values = (over: Record<string, unknown> = {}) => ({
...initialShipmentFormValues,
cargoWeightTons: "10",
paymentCurrency: "USD",
scheduledDate: "2026-08-10",
...over,
});
const trainIssue = (input: Record<string, unknown>) => {
const result = schema.safeParse(input);
return result.success
? undefined
: result.error.issues.find((i) => i.path[0] === "trainScheduleId");
};
describe("requiresTrain", () => {
it("rejects a dated export completion without a train pick", () => {
expect(trainIssue(values())?.message).toMatch(/select a train/i);
});
it("passes once a train is picked", () => {
expect(trainIssue(values({ trainScheduleId: "sched-1" }))).toBeUndefined();
});
it("stays silent while no date is chosen (day error covers it)", () => {
expect(trainIssue(values({ scheduledDate: "" }))).toBeUndefined();
});
it("is off by default (non-completion flows)", () => {
const plain = createShipmentFormSchema({
isContainer: false,
isHazardous: false,
isReefer: false,
});
const result = plain.safeParse(values());
expect(
result.success ||
result.error.issues.every((i) => i.path[0] !== "trainScheduleId"),
).toBe(true);
});
});

View File

@@ -15,6 +15,8 @@ import {
GeneratePriceResponse,
type MyBookingWindow,
SubmitBookingResponse,
type WagonCancellationListFilter,
type WagonCancellationListResponse,
} from "./bookings.service";
import {
contractsService,
@@ -483,6 +485,22 @@ export const api = {
bookingsService.getExportTrains(bookingId, date, cargo),
),
listWagonCancellations: endpoint<
{ bookingId: string },
WagonCancellationListResponse
>("bookings", "listWagonCancellations", ({ bookingId }) =>
bookingsService.listWagonCancellations(bookingId),
),
listMyWagonCancellations: endpoint<
WagonCancellationListFilter | void,
WagonCancellationListResponse
>(
"bookings",
"listMyWagonCancellations",
bookingsService.listMyWagonCancellations,
),
getMyBookingWindows: endpoint<void, MyBookingWindow[]>(
"train-scheduling",
"myBookingWindows",
@@ -610,10 +628,14 @@ export const api = {
),
validateShipment: endpoint<
{ id: string; dto: Freight.CreateBookingUnderContractDto },
{
id: string;
dto: Freight.CreateBookingUnderContractDto;
excludeBookingId?: string;
},
ShipmentValidation
>("contracts", "validateShipment", ({ id, dto }) =>
contractsService.validateShipment(id, dto),
>("contracts", "validateShipment", ({ id, dto, excludeBookingId }) =>
contractsService.validateShipment(id, dto, excludeBookingId),
),
getContractMilestones: endpoint<

View File

@@ -91,6 +91,8 @@ export interface MyBookingWindow {
windowClosesAt: string | null;
docReviewEndsAt: string | null;
paymentPhaseEndsAt: string | null;
/** End of the payment drain tail — pending payments may settle until then. */
paymentDrainEndsAt: string | null;
bookingWindowStatus: string;
bookingCycleNo: number;
departureDate: string;
@@ -193,6 +195,116 @@ export interface BookingListFilter {
sortOrder?: "ASC" | "DESC";
}
export interface BookingWagonContainer {
containerNumber: string | null;
sealNumber: string | null;
positionOnWagon: number | null;
grossWeightTons: string | null;
}
/** One allocated wagon of a booking, as returned by GET /bookings/:id/wagons. */
export interface BookingWagonAllocation {
/** wagon_booking_allocations id — the handle for cancelling this specific wagon. */
allocationId: string;
sequenceNo: number;
wagonNumber: string | null;
wagonType: string | null;
wagonTypeCode: string | null;
tareWeightTons: string | null;
capacityTons: string | null;
lengthMeters: string | null;
allocatedWeightTons: string | null;
loadType: "CONTAINER" | "BULK";
status: "PLANNED" | "RESERVED" | "LOADED" | "DEPARTED";
trainNumber: string | null;
departureAt: string | null;
originStation: string | null;
destinationStation: string | null;
bulkCargoDescription: string | null;
bulkQuantity: string | null;
containers: BookingWagonContainer[];
}
// ── Partial wagon cancellation (paid bookings) ──────────────────────────────
export type WagonCancellationStatus =
| "FEE_PENDING"
| "CREDIT_AVAILABLE"
| "REBOOKED"
| "WITHDRAWN"
| "EXPIRED";
/** One partial-cancellation ledger row of a paid booking. */
export interface WagonCancellation {
id: string;
bookingId: string;
rebookedBookingId?: string | null;
wagonsCancelled: number;
weightTons: number;
/** What was cut: bulk tons, or container units per size (ft). */
cancelledQuantities: {
bulkTons?: number;
bySize?: Record<string, number>;
/** Exact physical containers leaving with the cancelled wagons. */
units?: Array<{
containerSize: string;
containerNumber: string;
sealNumber?: string | null;
vgmTons: number;
isHazardous: boolean;
isReefer: boolean;
}>;
/** Wagons already left the schedule when the request was made. */
releasedAtRequest?: boolean;
};
/** Rebooking credit — the cancelled share of the original freight price. */
creditAmount: number;
feeAmount: number;
feeCurrency: string;
feeInvoiceId?: string | null;
feePaidAt?: string | null;
status: WagonCancellationStatus;
reason?: string | null;
rebookedAt?: string | null;
createdAt: string;
booking?: { id: string; reference: string } | null;
rebookedBooking?: { id: string; reference: string } | null;
}
/** Fee/credit preview of a partial wagon cancellation (no writes). */
export interface WagonCancellationPreview {
wagons: number;
weightTons: number;
feePerWagon: number;
feeAmount: number;
feeCurrency: string;
creditAmount: number;
}
export interface RequestWagonCancellationPayload {
/** Cancel SPECIFIC wagons: allocationIds from getWagons. Overrides the fields below. */
wagonAllocationIds?: string[];
/** BULK bookings: number of wagons to cancel (tons derived proportionally). */
wagons?: number;
/** CONTAINER bookings: units to cancel per size ("20"/"40", as stored on the line). */
containers?: Array<{ containerSize: string; quantity: number }>;
reason?: string;
}
export interface WagonCancellationListFilter {
statuses?: string[];
search?: string;
from?: string;
to?: string;
page?: number;
pageSize?: number;
}
export interface WagonCancellationListResponse {
items: WagonCancellation[];
total: number;
}
export const bookingsService = {
list: async (
filter: BookingListFilter | void = {},
@@ -315,6 +427,16 @@ export const bookingsService = {
return data.data;
},
customerCancel: async (
id: string,
reason?: string,
): Promise<Freight.IBooking> => {
const { data } = await client.post(`/api/bookings/${id}/customer-cancel`, {
reason,
});
return data.data;
},
reject: async (id: string, reason?: string): Promise<Freight.IBooking> => {
const { data } = await client.post(`/api/bookings/${id}/reject`, { reason });
return data.data;
@@ -548,6 +670,82 @@ export const bookingsService = {
return data.data as Freight.DayAvailabilityResponse;
},
/**
* Allocated wagons for a paid booking (empty until placed on a train).
* One row per wagon with its containers / bulk load.
*/
getWagons: async (bookingId: string): Promise<BookingWagonAllocation[]> => {
const { data } = await client.get(`/api/bookings/${bookingId}/wagons`);
return (data.data ?? data) as BookingWagonAllocation[];
},
// ── Partial wagon cancellation ──
/** Fee/credit preview for the confirm dialog — same math as the request, no writes. */
previewWagonCancellation: async (
id: string,
payload: RequestWagonCancellationPayload,
): Promise<WagonCancellationPreview> => {
const { data } = await client.post(
`/api/bookings/${id}/wagon-cancellations/preview`,
payload,
);
return data.data ?? data;
},
/** Open a cancellation: issues the fee invoice; wagons release once the fee settles. */
requestWagonCancellation: async (
id: string,
payload: RequestWagonCancellationPayload,
): Promise<WagonCancellation> => {
const { data } = await client.post(
`/api/bookings/${id}/wagon-cancellations`,
payload,
);
return data.data ?? data;
},
/** Cancellation history of one booking (as source and as rebooked target). */
listWagonCancellations: async (
bookingId: string,
): Promise<WagonCancellationListResponse> => {
const { data } = await client.get(
`/api/bookings/${bookingId}/wagon-cancellations`,
);
return data.data ?? data;
},
/** The signed-in customer's wagon cancellations (paginated, filterable). */
listMyWagonCancellations: async (
filter: WagonCancellationListFilter | void = {},
): Promise<WagonCancellationListResponse> => {
const { data } = await client.get("/api/bookings/wagon-cancellations/my", {
params: filter,
});
return data.data ?? data;
},
/** Void a FEE_PENDING request — the fee invoice is cancelled, nothing was released. */
withdrawWagonCancellation: async (
cancellationId: string,
): Promise<WagonCancellation> => {
const { data } = await client.post(
`/api/bookings/wagon-cancellations/${cancellationId}/withdraw`,
);
return data.data ?? data;
},
/** Rebook a CREDIT_AVAILABLE cancellation onto a shipment day → new PAID booking. */
rebookWagonCancellation: async (
cancellationId: string,
payload: { scheduledDate: string },
): Promise<{ cancellation: WagonCancellation; bookingId: string }> => {
const { data } = await client.post(
`/api/bookings/wagon-cancellations/${cancellationId}/rebook`,
payload,
);
return data.data ?? data;
},
/**
* Upcoming/open booking windows on the signed-in customer's active-contract
* lanes (import booking-day windows + export 24h pre-departure windows).

View File

@@ -400,8 +400,13 @@ export const contractsService = {
validateShipment: async (
id: string,
dto: Freight.CreateBookingUnderContractDto,
// Completion/resubmit: exclude this booking's own containers from the
// same-train clash check.
excludeBookingId?: string,
): Promise<ShipmentValidation> => {
const { data } = await client.post(C.VALIDATE_SHIPMENT(id), dto);
const { data } = await client.post(C.VALIDATE_SHIPMENT(id), dto, {
params: excludeBookingId ? { bookingId: excludeBookingId } : undefined,
});
return data.data ?? data;
},

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -86,5 +86,5 @@ export default {
},
},
plugins: [],
};
};

View File

@@ -48,35 +48,23 @@ export function defaultPaymentReason(
: "Freight invoice";
}
/**
* CBE reads Response_Description back to the payer at the counter or in the USSD prompt, so it
* has to name the thing they are actually holding — a passenger booking or a freight invoice —
* rather than our internal "bill" abstraction (plan §6.6).
*/
function subjectOf(referenceType: PaymentReferenceType): string {
return referenceType === PaymentReferenceType.BOOKING ? "booking" : "invoice";
}
export function reasonToDescription(
reason: string | null | undefined,
referenceType: PaymentReferenceType,
): string {
const subject = subjectOf(referenceType);
/** Short descriptions per CBE integration request — CBE's channel renders them as-is. */
export function reasonToDescription(reason: string | null | undefined): string {
switch (reason) {
case "ALREADY_PAID":
return `This ${subject} has already been paid.`;
return "Already paid";
case "CANCELLED":
return `This ${subject} has been cancelled.`;
return "Cancelled";
case "REFUNDED":
return `This ${subject} has been refunded.`;
return "Refunded";
case "EXPIRED":
return `This ${subject} has expired and can no longer be paid.`;
return "Expired";
// A bill reference we issued whose order has since vanished from the domain app. Same
// wording as an unknown Bill_Id — from the teller's side it is the same situation.
case "NOT_FOUND":
return "Bill not found.";
return "Bill not found";
default:
return `This ${subject} is no longer payable.`;
return "Not payable";
}
}
@@ -135,7 +123,7 @@ export class BillResolverService {
}`,
);
// TRANSIENT so CBE may retry the same End_To_End_Txn_Id once we recover (plan R5).
throw new CbeBillError("Service temporarily unavailable.", "TRANSIENT");
throw new CbeBillError("Service unavailable", "TRANSIENT");
}
}
}

View File

@@ -22,7 +22,7 @@ import { CbePaymentResponseDto } from "./dto/cbe-payment-response.dto";
* CBE Unified Bill Payment — the INBOUND surface CBE core banking calls (docs/cbe/). We are
* the biller: CBE authenticates against /cbe/oauth/token with credentials we issued, then
* presents the bearer token on /cbe/query and /cbe/payment. Business failures answer HTTP 200
* with Response_Code "3"; only authentication answers 401 (plan D6/D7).
* with Response_Code "2"; only authentication answers 401 (plan D6/D7).
*/
@ApiTags("CBE Unified Bill (inbound)")
@Controller("cbe")

View File

@@ -59,7 +59,7 @@ function localReason(intent: PaymentIntent): BillNotPayableReason {
/**
* Orchestration for CBE's three inbound calls (docs/cbe/CBE_IMPLEMENTATION_PLAN.md Phase 3).
* Business failures return HTTP 200 + Response_Code "3" envelopes (never throw past the
* Business failures return HTTP 200 + Response_Code "2" envelopes (never throw past the
* controller); the exception filter only catches auth, validation, and the unexpected.
*/
@Injectable()
@@ -130,7 +130,7 @@ export class CbeBillService {
const billQuery = await this.billResolver.billQuery(intent);
if (!billQuery.stillPayable) {
throw new CbeBillError(
reasonToDescription(billQuery.reason, intent.referenceType),
reasonToDescription(billQuery.reason),
"BUSINESS",
);
}
@@ -184,11 +184,15 @@ export class CbeBillService {
);
if (prior) {
if (prior.tradeStatus === "SUCCESS") {
// Replay the stored body verbatim. Never re-settle.
return prior.responsePayload as unknown as CbePaymentResponseDto;
// Per CBE integration request: a settled End_To_End_Txn_Id never replays the stored
// success — every repeat answers "Already paid". Money moved exactly once (the first
// call); this only changes what a duplicate hears back. NOTE this diverges from the
// original §6.5 replay design: if CBE retries because our SUCCESS response was lost
// in transit, it now sees FAILED for a debit we kept — reconcile such cases manually.
return mapPaymentFailure(dto, "Already paid");
}
if (prior.tradeStatus === "PENDING") {
return mapPaymentFailure(dto, "Payment is being processed.");
return mapPaymentFailure(dto, "Payment in progress");
}
if (prior.failureClass === "BUSINESS") {
// Final — retrying cannot change the answer. Same End_To_End_Txn_Id was already
@@ -196,7 +200,7 @@ export class CbeBillService {
// echoing the original reason, which no longer describes this request.
return mapPaymentFailure(
dto,
`End_To_End_Txn_Id ${dto.End_To_End_Txn_Id} was already processed and failed: ${prior.responseDescription ?? "unknown reason"}.`,
`Already processed: ${prior.responseDescription ?? "failed"}`,
);
}
// FAILED + TRANSIENT: allowed retry — fall through and re-run the settlement.
@@ -209,7 +213,7 @@ export class CbeBillService {
if (settled) {
return mapPaymentFailure(
dto,
`Invalid transaction reference number ${dto.Cbe_Txn_Ref}.`,
"Duplicate transaction ref",
);
}
@@ -237,7 +241,7 @@ export class CbeBillService {
} catch (err) {
if ((err as { code?: string }).code === PG_UNIQUE_VIOLATION) {
// Concurrent duplicate of the same attempt lost the insert race.
return mapPaymentFailure(dto, "Payment is being processed.");
return mapPaymentFailure(dto, "Payment in progress");
}
throw err;
}
@@ -248,7 +252,7 @@ export class CbeBillService {
intent = await this.resolveIntent(dto.Bill_Id);
if (dto.Currency && dto.Currency !== intent.currency) {
throw new CbeBillError("Payment currency does not match.", "BUSINESS");
throw new CbeBillError("Currency mismatch", "BUSINESS");
}
// Re-run bill-query — fresh, never cached. Last legitimate point for a synchronous
@@ -256,7 +260,7 @@ export class CbeBillService {
const billQuery = await this.billResolver.billQuery(intent);
if (!billQuery.stillPayable) {
throw new CbeBillError(
reasonToDescription(billQuery.reason, intent.referenceType),
reasonToDescription(billQuery.reason),
"BUSINESS",
);
}
@@ -267,7 +271,7 @@ export class CbeBillService {
Math.abs(amount - intent.amountMinor) >
intent.amountMinor * AMOUNT_TOLERANCE
) {
throw new CbeBillError("Payment amount does not match.", "BUSINESS");
throw new CbeBillError("Amount mismatch", "BUSINESS");
}
const paidAt = new Date(dto.Timestamp);
@@ -325,10 +329,10 @@ export class CbeBillService {
private assertIntentPayable(intent: PaymentIntent): void {
if (intent.status === ProviderPaymentStatus.REQUIRES_ACTION) return;
if (intent.status === ProviderPaymentStatus.PROCESSING) {
throw new CbeBillError("Payment is being processed.", "BUSINESS");
throw new CbeBillError("Payment in progress", "BUSINESS");
}
throw new CbeBillError(
reasonToDescription(localReason(intent), intent.referenceType),
reasonToDescription(localReason(intent)),
"BUSINESS",
);
}
@@ -336,11 +340,11 @@ export class CbeBillService {
/** Check digit first (cheap reject), then the unique bill_reference lookup. */
private async resolveIntent(billId: string): Promise<PaymentIntent> {
if (!this.billReferenceService.isValid(billId)) {
throw new CbeBillError("Bill not found.", "BUSINESS");
throw new CbeBillError("Bill not found", "BUSINESS");
}
const intent = await this.intentsRepository.findByBillReference(billId);
if (!intent || intent.provider !== ProviderMethod.CBE_BILL) {
throw new CbeBillError("Bill not found.", "BUSINESS");
throw new CbeBillError("Bill not found", "BUSINESS");
}
return intent;
}

View File

@@ -40,7 +40,7 @@ export class CbeExceptionFilter implements ExceptionFilter {
response.status(HttpStatus.SERVICE_UNAVAILABLE).json({
Status: "FAILED",
Response_Code: "9",
Response_Description: "Service temporarily unavailable.",
Response_Description: "Service unavailable",
});
return;
}
@@ -54,7 +54,7 @@ export class CbeExceptionFilter implements ExceptionFilter {
: exception.message;
response.status(HttpStatus.OK).json({
Status: "FAILED",
Response_Code: "3",
Response_Code: "2",
Response_Description: message || "Invalid request",
});
return;
@@ -65,8 +65,8 @@ export class CbeExceptionFilter implements ExceptionFilter {
);
response.status(HttpStatus.OK).json({
Status: "FAILED",
Response_Code: "3",
Response_Description: "Internal server error.",
Response_Code: "2",
Response_Description: "Internal error",
});
}
}

View File

@@ -16,8 +16,8 @@ export class CbeBillError extends Error {
}
/**
* Every failure maps to Response_Code "3" — the AAFDA spec (§2.10, §3.10) defines only
* 0 (success), 1 (auth), 3 (business); only the description is specific (plan §6.6).
* Every failure maps to Response_Code "2" (per current CBE integration requirement; the
* original AAFDA plan used 3); only the description is specific (plan §6.6).
*/
export function toCbeFailure(err: unknown): {
description: string;
@@ -26,5 +26,5 @@ export function toCbeFailure(err: unknown): {
if (err instanceof CbeBillError) {
return { description: err.message, failureClass: err.failureClass };
}
return { description: "Internal server error.", failureClass: "TRANSIENT" };
return { description: "Internal error", failureClass: "TRANSIENT" };
}

View File

@@ -27,7 +27,7 @@ export function mapPaymentFailure(
Cbe_Txn_Ref: request.Cbe_Txn_Ref,
Destination_Txn_Ref: "",
Status: "FAILED",
Response_Code: "3",
Response_Code: "2",
Response_Description: description,
Additional_Fields: [],
};

View File

@@ -48,7 +48,7 @@ export function mapQueryFailure(
Transaction_Type: "",
Timestamp: new Date().toISOString(),
Status: "FAILED",
Response_Code: "3",
Response_Code: "2",
Response_Description: description,
Additional_Fields: [],
};