Merge pull request #1044 from Tria-plc/cbe-integration

Cbe integration
This commit is contained in:
Abubeker Yasin
2026-07-31 14:50:21 +03:00
committed by GitHub
149 changed files with 6924 additions and 1281 deletions

View File

@@ -32,6 +32,7 @@ import { ConsignmentsModule } from "./modules/consignments/consignments.module";
// import { TrainsModule } from "./modules/trains/trains.module";
import { LocomotivesModule } from "./modules/locomotives/locomotives.module";
import { TruckTypesModule } from "./modules/truck-types/truck-types.module";
import { TransitAgentsModule } from "./modules/transit-agents/transit-agents.module";
import { WagonTypesModule } from "./modules/wagon-types/wagon-types.module";
import { TrainSetsModule } from "./modules/train-sets/train-sets.module";
import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module";
@@ -175,6 +176,7 @@ import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middlewar
ConsignmentsModule,
LocomotivesModule,
TruckTypesModule,
TransitAgentsModule,
WagonTypesModule,
TrainSetsModule,
TrainSchedulesModule,

View File

@@ -0,0 +1,42 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* GL Ethiopia ↔ GL Djibouti document exchange. The documents are ordinary
* `freight.files` rows (resource `gl_exchange`), so they only need the metadata
* a free-form upload has and a catalog-driven one does not: the uploader's own
* title, who uploaded it (the only user allowed to change it afterwards) and
* whether the customer may see it in the portal.
*/
export class AddGlExchangeDocumentFields3030000000000
implements MigrationInterface
{
name = 'AddGlExchangeDocumentFields3030000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.files
ADD COLUMN IF NOT EXISTS title varchar(300),
ADD COLUMN IF NOT EXISTS visible_to_customer boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS uploaded_by_user_id uuid,
ADD COLUMN IF NOT EXISTS uploaded_by_name varchar(200);`,
);
// Every read of a thread is "all files of one resource" — index the pair.
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS idx_files_resource_lookup
ON freight.files (resource, resource_id);`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX IF EXISTS freight.idx_files_resource_lookup;`,
);
await queryRunner.query(
`ALTER TABLE freight.files
DROP COLUMN IF EXISTS title,
DROP COLUMN IF EXISTS visible_to_customer,
DROP COLUMN IF EXISTS uploaded_by_user_id,
DROP COLUMN IF EXISTS uploaded_by_name;`,
);
}
}

View File

@@ -0,0 +1,29 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddTransitAgents3040000000000 implements MigrationInterface {
name = "AddTransitAgents3040000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.transit_agents (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name varchar(150) NOT NULL,
valid_from date NOT NULL,
valid_to date NOT NULL,
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS ix_transit_agents_is_active
ON freight.transit_agents (is_active)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.transit_agents`);
}
}

View File

@@ -0,0 +1,15 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddCbeBillPaymentMethod3050000000000 implements MigrationInterface {
name = "AddCbeBillPaymentMethod3050000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
// CBE Unified Bill Payment (docs/cbe/CBE_IMPLEMENTATION_PLAN.md §4.3) — lowercase-hyphen
// per the local convention (see 2460000000000-AddCacBankPaymentMethod).
await queryRunner.query(`ALTER TYPE freight.payments_method_enum ADD VALUE IF NOT EXISTS 'cbe-bill';`);
}
public async down(_queryRunner: QueryRunner): Promise<void> {
// PostgreSQL does not support removing enum values directly.
}
}

View File

@@ -995,6 +995,7 @@ export class BillingService {
): Promise<InitiateResponseDto> {
const invoice = await this.dataSource.getRepository(Invoice).findOne({
where: { id: invoiceId, status: In(OPEN_STATUSES) },
relations: { company: true },
});
if (!invoice) {
throw new NotFoundException(
@@ -1023,6 +1024,10 @@ export class BillingService {
method: opts.method ?? "TELEBIRR",
platform: opts.platform,
payerAccount: opts.payerAccount,
// CBE_BILL: payer identity + the invoice's own due date as the bill expiry
// (docs/cbe/CBE_IMPLEMENTATION_PLAN.md §6.4).
payerName: invoice.company?.name,
expiresAt: invoice.dueAt?.toISOString(),
returnUrl: opts.returnUrl,
failureUrl: opts.failureUrl,
});
@@ -1033,8 +1038,9 @@ export class BillingService {
.update({ id: invoice.id }, { paymentId: result.intentId });
// Settlement is driven by the payment API (webhook/outbox → payment.succeeded);
// billing must not simulate it. Kept commented for local demos only.
if (!result.immediateSuccess) {
// billing must not simulate it. Kept for local demos only. NEVER for CBE_BILL —
// its bill must stay open until CBE actually settles it via /cbe/payment.
if (!result.immediateSuccess && opts.method !== "CBE_BILL") {
await this.payment.handlePaymentEvent({
eventType: "payment.succeeded",
eventId: `demo-${result.intentId}`,
@@ -1079,4 +1085,52 @@ export class BillingService {
paidAt,
});
}
/**
* CBE bill-query (docs/cbe/CBE_IMPLEMENTATION_PLAN.md Phase 4): live still-payable check for
* the invoice behind a payment reference. `referenceId` is the gateway intent's referenceId,
* i.e. the invoice `sourceId`. Read-only; called while a CBE teller/app is waiting.
*/
async billQuery(referenceId: string): Promise<{
stillPayable: boolean;
payerName?: string | null;
currentAmountMinor?: number | null;
currency?: string | null;
reason?: string | null;
}> {
const repo = this.dataSource.getRepository(Invoice);
const open = await repo.findOne({
where: { sourceId: referenceId, status: In(OPEN_STATUSES) },
relations: { company: true },
order: { issuedAt: "DESC" },
});
if (open) {
const balance = Math.round(Number(open.balanceAmount ?? open.totalAmount));
const expired = open.dueAt && open.dueAt.getTime() < Date.now();
return {
stillPayable: balance > 0 && !expired,
payerName: open.company?.name ?? null,
currentAmountMinor: balance,
currency: open.currency,
reason: expired ? "EXPIRED" : balance > 0 ? null : "ALREADY_PAID",
};
}
const latest = await repo.findOne({
where: { sourceId: referenceId },
relations: { company: true },
order: { createdAt: "DESC" },
});
return {
stillPayable: false,
payerName: latest?.company?.name ?? null,
currentAmountMinor: latest ? Math.round(Number(latest.totalAmount)) : null,
currency: latest?.currency ?? null,
reason:
latest?.status === Freight.InvoiceStatus.Paid
? "ALREADY_PAID"
: "CANCELLED",
};
}
}

View File

@@ -317,11 +317,11 @@ export class BookingLifecycleNotifierService {
});
}
/** GL raised the final (post-offload) invoice — customer pays + uploads slip. */
/** GL raised the final (post-offload) invoice — customer approves, pays, uploads slip. */
finalInvoiceCreated(b: Booking, amount: number, currency: string): void {
const msg =
`A final invoice of ${amount} ${currency} has been issued for booking ${b.reference}. ` +
`Please pay and upload the payment slip from the portal.`;
`A final invoice of ${amount} ${currency} has been raised for booking ${b.reference}. ` +
`Please review and approve it in the portal, then pay and upload the payment slip.`;
void this.notifyContact(b, msg, 'FINAL INVOICE');
this.inApp(b, 'Final invoice issued', msg, {
type: NotificationType.INVOICE_ISSUED,
@@ -361,6 +361,15 @@ export class BookingLifecycleNotifierService {
);
}
/** Customer approved the GL Djibouti final invoice — payment slip can follow. */
finalInvoiceApprovedToStaff(b: Booking): void {
this.inAppStaff(
b,
'Final invoice approved',
`The customer approved the final invoice for booking ${this.ref(b)} — awaiting payment slip.`,
);
}
/** Customer signed the booking contract. */
customerSignedToStaff(b: Booking): void {
this.inAppStaff(
@@ -392,16 +401,27 @@ export class BookingLifecycleNotifierService {
);
}
/**
* The customer disputed the advised duty & tax. This goes to STAFF, not the
* customer: GL Ethiopia is the one who has to re-advise, and the clearance
* page is where they do it.
*/
dutyDisputed(b: Booking, note: string): void {
/** GL Ethiopia sent a draft customs declaration — the customer must accept or request a change. */
draftDeclarationReady(b: Booking, price: number, currency: string): void {
const msg =
`The customer disputed the duty & tax advised on booking ${this.ref(b)}: ` +
`"${note}". Review and re-advise the amount on the clearance page.`;
this.inAppStaff(b, `Duty disputed on ${this.ref(b)}`, msg, {
`A draft customs declaration for booking ${b.reference} is ready for your review — ` +
`estimated price ${price} ${currency}. Please accept it or request a change from the portal.`;
void this.notifyContact(b, msg, 'DRAFT DECLARATION READY');
this.inApp(b, 'Draft declaration ready for review', msg, {
type: NotificationType.DOCUMENT_ACTION,
});
}
/**
* The customer asked for a change on the draft declaration. This goes to
* STAFF, not the customer: GL Ethiopia is the one who has to send a
* corrected draft, and the clearance page is where they do it.
*/
draftDeclarationChangeRequested(b: Booking, note: string): void {
const msg =
`The customer requested a change to the draft declaration on booking ${this.ref(b)}: ` +
`"${note}". Send a corrected draft from the clearance page.`;
this.inAppStaff(b, `Draft declaration change requested — ${this.ref(b)}`, msg, {
type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/bookings/${b.id}/clearance`,
});

View File

@@ -841,13 +841,13 @@ export class BookingsController {
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@ApiOperation({
summary:
'GL Djibouti names the transit officer (free text) — unblocks the customs declaration; calling again reassigns',
'GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns',
})
async assignBookingTransitAssignee(
@Param('id', ParseUUIDPipe) id: string,
@Body('assignee') assignee: string,
@Body('transitAgentId', ParseUUIDPipe) transitAgentId: string,
) {
const booking = await this.bookingClearanceService.assignTransitAssignee(id, assignee);
const booking = await this.bookingClearanceService.assignTransitAssignee(id, transitAgentId);
return this.transitionService.enrichBookingResponse(booking);
}
@@ -900,17 +900,52 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/duty/dispute')
@Post(':id/clearance/draft-declaration')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({
summary:
'Customer disputes the advised duty/tax with a reason — reopens the step so GL Ethiopia can re-advise (repeatable)',
'GL ET sends a draft customs declaration (multi-file) with an estimated price for the customer to review',
})
async disputeBookingDuty(
async uploadBookingDraftDeclaration(
@Param('id', ParseUUIDPipe) id: string,
@Body('price') priceRaw: string,
@Body('currency') currency: string | undefined,
@UploadedFiles() files: Express.Multer.File[],
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingClearanceService.uploadDraftDeclaration(
id,
files ?? [],
Number(priceRaw),
currency ?? 'ETB',
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/draft-declaration/accept')
@ApiOperation({
summary:
'Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia',
})
async acceptBookingDraftDeclaration(@Param('id', ParseUUIDPipe) id: string) {
const booking = await this.bookingClearanceService.acceptDraftDeclaration(id);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/draft-declaration/change')
@ApiOperation({
summary:
'Customer requests a change to the draft customs declaration with a reason — GL Ethiopia sends a corrected draft (repeatable)',
})
async requestBookingDraftDeclarationChange(
@Param('id', ParseUUIDPipe) id: string,
@Body('note') note: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingClearanceService.disputeDuty(
const booking = await this.bookingClearanceService.requestDraftDeclarationChange(
id,
note,
resolveAuthUserId(user),

View File

@@ -7,10 +7,10 @@ export const REVIEW_NOTE_TYPES = [
'REJECTION',
'STAFF_NOTE',
/**
* The customer disputed the advised duty & tax and asked GL Ethiopia to
* correct it. One row per round — the advice/dispute loop can repeat.
* The customer asked GL Ethiopia to correct the draft customs declaration
* (price/files). One row per round — the draft/change-request loop can repeat.
*/
'DUTY_DISPUTE',
'DRAFT_DECL_CHANGE_REQUEST',
] as const;
export type ReviewNoteType = (typeof REVIEW_NOTE_TYPES)[number];

View File

@@ -104,6 +104,12 @@ function makeService(overrides?: {
transitAssigneeRequested: jest.fn(),
transitAssigneeAssigned: jest.fn(),
} as never, // notifier
{ listVisibleToCustomer: jest.fn().mockResolvedValue([]) } as never, // GL exchange
{
getAssignable: jest
.fn()
.mockResolvedValue({ id: 'ta-1', name: 'Ahmed Bourhan' }),
} as never, // transit agents
);
return {

View File

@@ -1,10 +1,13 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import {
ContractDocPhase,
isDraftDeclarationFileCode,
type ClearanceFinalInvoiceSummary,
type ClearanceOffloadState,
type ClearanceSecondDuty,
type ClearanceT1State,
type ClearanceTrainState,
type GlExchangeDocument,
} from '@edr/types';
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
@@ -23,8 +26,10 @@ import { assertDoCollectionDates } from './contract-clearance.util';
import { ClearanceWorkflowService } from './clearance-workflow.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { GlOperationsService } from './gl-operations.service';
import { GlExchangeService } from './gl-exchange.service';
import { TransitAgentsService } from '../transit-agents/transit-agents.service';
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util';
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDraftDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util';
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
@@ -89,10 +94,22 @@ export interface BookingClearanceView {
noticeFile?: { id: string; name: string; url: string } | null;
} | null;
/**
* The customer's open objection to the advised duty. Present only until GL
* re-advises; `rounds` counts how many times it has been sent back.
* Import only: the draft customs declaration GL Ethiopia sends before filing
* the real one. Present once a draft has been uploaded, regardless of
* accept state — `accepted` tells the caller which.
*/
dutyDispute?: {
draftDeclaration?: {
price: number;
currency: string;
files: Array<{ id: string; name: string; url: string }>;
accepted: boolean;
} | null;
/**
* The customer's open change request on the current draft declaration.
* Present only until GL sends a corrected draft; `rounds` counts how many
* times it has been sent back.
*/
draftDeclarationChangeRequest?: {
note: string;
raisedAt: string;
rounds: number;
@@ -107,6 +124,8 @@ export interface BookingClearanceView {
t1Closed?: boolean;
t1ClosedAt?: string | null;
offloaded?: boolean;
/** Offload stats for this booking (what came off the train, and where). */
offload?: ClearanceOffloadState | null;
/** GL Djibouti post-offload final invoice (export). */
finalInvoice?: ClearanceFinalInvoiceSummary | null;
/** Customs risk level assigned by GL ET (import; visible to the customer). */
@@ -117,6 +136,8 @@ export interface BookingClearanceView {
/** Post-arrival additional duty/tax round (import). */
secondDuty?: ClearanceSecondDuty | null;
importReleaseGranted?: boolean;
/** GL-shared documents this booking's uploader marked visible to the customer. */
exchangeDocuments?: GlExchangeDocument[];
}
@Injectable()
@@ -131,6 +152,8 @@ export class BookingClearanceService {
private readonly dropdownSettingsService: DropdownSettingsService,
private readonly glOperationsService: GlOperationsService,
private readonly notifier: BookingLifecycleNotifierService,
private readonly glExchangeService: GlExchangeService,
private readonly transitAgentsService: TransitAgentsService,
) {}
private async assertPhasedCustoms(booking: Booking): Promise<void> {
@@ -231,7 +254,11 @@ export class BookingClearanceService {
booking.tradeDirection ?? 'IMPORT',
);
const dutyAdvice = this.buildDutyAdvice(files, milestones);
const dutyDispute = await this.buildDutyDispute(bookingId, milestones);
const draftDeclaration = this.buildDraftDeclaration(files, milestones);
const draftDeclarationChangeRequest = await this.buildDraftDeclarationChangeRequest(
bookingId,
milestones,
);
const workflowFiles = buildWorkflowFiles(
files,
booking.tradeDirection ?? 'IMPORT',
@@ -259,6 +286,12 @@ export class BookingClearanceService {
const t1ClosedMilestone = bookingMilestone('T1_CLOSED');
const riskMilestone = bookingMilestone('RISK_ASSIGNED');
const secondDuty = this.glOperationsService.secondDutyState(milestones, files);
// GL↔GL exchange documents shared with the customer. The two desks may work
// the thread on the booking (per-booking customs) or on its contract
// (pre-booking clearance), so the customer's view spans both.
const exchangeDocuments = await this.glExchangeService.listVisibleToCustomer(
[bookingId, booking.contractId ?? ''],
);
return {
bookingId,
@@ -301,8 +334,10 @@ export class BookingClearanceService {
: null,
},
dutyAdvice,
dutyDispute,
draftDeclaration,
draftDeclarationChangeRequest,
workflowFiles,
exchangeDocuments,
t1,
train,
gatepassGranted: gatepass.granted,
@@ -313,6 +348,7 @@ export class BookingClearanceService {
? t1ClosedMilestone.triggeredAt.toISOString()
: null,
offloaded: bookingMilestone('OFFLOADED')?.status === 'COMPLETED',
offload: await this.glOperationsService.offloadState(bookingId, milestones),
finalInvoice,
riskLevel:
riskMilestone?.status === 'COMPLETED'
@@ -359,13 +395,37 @@ export class BookingClearanceService {
};
}
private async buildDutyDispute(
private buildDraftDeclaration(
files: Array<{ code?: string | null; id: string; name: string; url: string }>,
milestones: ClearanceMilestone[],
): BookingClearanceView['draftDeclaration'] {
const uploaded = milestones.find(
(m) => m.milestoneCode === 'DRAFT_DECLARATION_UPLOADED' && m.status === 'COMPLETED',
);
if (!uploaded?.metadata) return null;
const price = uploaded.metadata.draftDeclarationPrice;
const currency = uploaded.metadata.draftDeclarationCurrency;
if (typeof price !== 'number' || typeof currency !== 'string') return null;
const draftFiles = files
.filter((f) => f.code && isDraftDeclarationFileCode(f.code))
.sort((a, b) => (a.code ?? '').localeCompare(b.code ?? ''))
.map((f) => ({ id: f.id, name: f.name, url: f.url }));
const accepted =
milestones.find((m) => m.milestoneCode === 'DRAFT_DECLARATION_ACCEPTED')?.status ===
'COMPLETED';
return { price, currency, files: draftFiles, accepted };
}
private async buildDraftDeclarationChangeRequest(
bookingId: string,
milestones: ClearanceMilestone[],
): Promise<BookingClearanceView['dutyDispute']> {
const advised = milestones.find((m) => m.milestoneCode === 'DUTY_TAXES_ADVISED');
if (!advised || advised.status === 'COMPLETED') return null;
const notes = await this.bookingsRepository.findReviewNotes(bookingId, 'DUTY_DISPUTE');
): Promise<BookingClearanceView['draftDeclarationChangeRequest']> {
const uploaded = milestones.find((m) => m.milestoneCode === 'DRAFT_DECLARATION_UPLOADED');
if (!uploaded || uploaded.status === 'COMPLETED') return null;
const notes = await this.bookingsRepository.findReviewNotes(
bookingId,
'DRAFT_DECL_CHANGE_REQUEST',
);
const latest = notes[0];
if (!latest) return null;
return {
@@ -426,28 +486,27 @@ export class BookingClearanceService {
}
/**
* GL Djibouti names the transit officer — free text, because the person is not
* a platform user. Answering unblocks the declaration for Ethiopia. A later
* call overwrites the name (reassignment) and re-notifies.
* GL Djibouti picks the transit officer from the admin-managed roster —
* rejected unless the agent is active and inside its validity window.
* Answering unblocks the declaration for Ethiopia. A later call overwrites
* the name (reassignment) and re-notifies.
*/
async assignTransitAssignee(bookingId: string, assignee: string): Promise<Booking> {
async assignTransitAssignee(bookingId: string, transitAgentId: string): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (!assignee?.trim()) {
throw new BadRequestException('Name the officer who will handle the transit.');
}
if (!booking.transitAssigneeRequestedAt) {
throw new BadRequestException(
'GL Ethiopia has not requested a transit assignee for this shipment yet.',
);
}
const agent = await this.transitAgentsService.getAssignable(transitAgentId);
const previous = booking.transitAssigneeName ?? null;
await this.bookingsRepository.update(bookingId, {
transitAssigneeName: assignee.trim(),
transitAssigneeName: agent.name,
transitAssigneeAssignedAt: new Date(),
} as never);
this.notifier.transitAssigneeAssigned(booking, assignee.trim(), previous);
this.notifier.transitAssigneeAssigned(booking, agent.name, previous);
return this.bookingsService.findById(bookingId);
}
@@ -499,12 +558,6 @@ export class BookingClearanceService {
: ContractDocPhase.CustomerDuty,
} as never);
// Export: the declaration is the last GL ET pre-operation action — release
// immediately so the customer can proceed without a separate confirm click.
if (tradeDirection === 'EXPORT') {
await this.workflowService.onExportReleasedForBooking(bookingId, userId);
}
return this.bookingsService.findById(bookingId);
}
@@ -562,61 +615,120 @@ export class BookingClearanceService {
}
/**
* The customer disagrees with the advised duty & tax on this booking and asks
* GL Ethiopia to correct it. Nothing is paid; the advice milestone reopens so
* the Duty & tax step becomes actionable again on the GL clearance page, with
* the customer's message shown beside it. GL re-advises (same endpoint as the
* first time), which closes the dispute — the loop may run as many rounds as
* it takes.
* GL Ethiopia sends a draft customs declaration (estimated price + files) for
* the customer to review before the real declaration is filed. Repeatable —
* each call replaces the previous draft's files/price and re-arms the step,
* which is what a re-send after a change request needs.
*/
async disputeDuty(
async uploadDraftDeclaration(
bookingId: string,
files: Express.Multer.File[],
price: number,
currency: string,
userId?: string,
): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (booking.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Draft declaration applies only to import bookings.');
}
if (files.length === 0) {
throw new BadRequestException('No draft declaration documents uploaded');
}
if (!Number.isFinite(price) || price < 0) {
throw new BadRequestException('A valid estimated price is required.');
}
// Backfills the two new milestone rows for bookings seeded before this step
// existed — a blind complete() 404s on a booking with no such row yet.
await this.milestoneService.ensureBookingMilestones(bookingId, 'IMPORT');
await this.workflowService.assertPriorCompleteForBooking(
bookingId,
'IMPORT',
'DRAFT_DECLARATION_UPLOADED',
);
await persistDraftDeclarationUploads(this.filesService, bookingId, 'bookings', files);
await this.milestoneService.completeWithMetadataForBooking(
bookingId,
'DRAFT_DECLARATION_UPLOADED',
{ draftDeclarationPrice: price, draftDeclarationCurrency: currency },
userId,
);
await this.bookingsRepository.update(bookingId, {
clearanceCurrentPhase: ContractDocPhase.GlEtOutput,
} as never);
const updated = await this.bookingsService.findById(bookingId);
this.notifier.draftDeclarationReady(updated, price, currency);
return updated;
}
/**
* The customer accepts the draft declaration — GL Ethiopia may now file the
* real customs declaration.
*/
async acceptDraftDeclaration(bookingId: string): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (booking.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Draft declaration applies only to import bookings.');
}
const milestones = await this.workflowService.listMilestonesForBooking(bookingId);
const uploaded = milestones.find((m) => m.milestoneCode === 'DRAFT_DECLARATION_UPLOADED');
if (uploaded?.status !== 'COMPLETED') {
throw new BadRequestException('There is no draft declaration to accept yet.');
}
await this.workflowService.completeMilestoneForBooking(bookingId, 'DRAFT_DECLARATION_ACCEPTED');
return this.bookingsService.findById(bookingId);
}
/**
* The customer sends the draft declaration back with a reason. Nothing is
* filed; the upload milestone reopens so the step becomes actionable again
* for GL Ethiopia, with the customer's message shown beside it. GL re-sends
* (same endpoint as the first time), which closes the request — the loop may
* run as many rounds as it takes.
*/
async requestDraftDeclarationChange(
bookingId: string,
note: string,
userId?: string,
): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (booking.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Duty applies only to import bookings.');
throw new BadRequestException('Draft declaration applies only to import bookings.');
}
if (!note?.trim()) {
throw new BadRequestException(
'Say what is wrong with the advised amount so GL can correct it.',
'Say what needs to change so GL can correct the draft.',
);
}
if (!booking.dutyRequired) {
throw new BadRequestException('Duty/tax is not required for this clearance.');
}
const milestones = await this.workflowService.listMilestonesForBooking(bookingId);
const byCode = new Map(milestones.map((m) => [m.milestoneCode, m]));
if (byCode.get('DUTY_TAXES_ADVISED')?.status !== 'COMPLETED') {
throw new BadRequestException(
'There is no advised duty amount to dispute yet.',
);
if (byCode.get('DRAFT_DECLARATION_UPLOADED')?.status !== 'COMPLETED') {
throw new BadRequestException('There is no draft declaration to request a change on yet.');
}
// Once the slip is in, the money is paid — a dispute then is a refund
// conversation, not a re-advice.
if (byCode.get('DUTY_TAX_PAID')?.status === 'COMPLETED') {
if (byCode.get('DRAFT_DECLARATION_ACCEPTED')?.status === 'COMPLETED') {
throw new BadRequestException(
'The duty payment slip has already been submitted — contact GL Ethiopia directly.',
'The draft declaration has already been accepted — contact GL Ethiopia directly.',
);
}
await this.bookingsRepository.createReviewNote(
bookingId,
note.trim(),
'DUTY_DISPUTE',
'DRAFT_DECL_CHANGE_REQUEST',
userId,
);
// Back to GL: reopening the milestone is what re-arms the Duty & tax step
// (the stepper picks its active step from milestone completion).
await this.milestoneService.reopenForBooking(bookingId, 'DUTY_TAXES_ADVISED');
// Back to GL: reopening the milestone is what re-arms the step (the
// stepper picks its active step from milestone completion).
await this.milestoneService.reopenForBooking(bookingId, 'DRAFT_DECLARATION_UPLOADED');
await this.bookingsRepository.update(bookingId, {
clearanceCurrentPhase: ContractDocPhase.GlEtOutput,
} as never);
const updated = await this.bookingsService.findById(bookingId);
this.notifier.dutyDisputed(updated, note.trim());
this.notifier.draftDeclarationChangeRequested(updated, note.trim());
return updated;
}
@@ -824,6 +936,10 @@ export class BookingClearanceService {
'RELEASE_ORDER_SECURED',
userId,
);
// Release Order is now the last GL DJ pre-operation action (it follows the
// declaration) — release immediately so booking creation unlocks without a
// separate confirm click.
await this.workflowService.onExportReleasedForBooking(bookingId, userId);
return { booking: await this.bookingsService.findById(bookingId), hold: false };
}

View File

@@ -1,162 +0,0 @@
import { BadRequestException } from '@nestjs/common';
import { BookingClearanceService } from './booking-clearance.service';
import type { Booking } from '../bookings/entities/booking.entity';
/**
* The duty advice → dispute → re-advice loop, at the booking level. GL
* Ethiopia advises an amount; the customer either pays it or sends it back
* with a reason. Sending it back reopens the advice milestone — that is what
* puts the Duty & tax step back in GL's hands — and the round can repeat
* until the amount is agreed.
*/
describe('BookingClearanceService — duty dispute', () => {
const booking = (over: Partial<Booking> = {}): Booking =>
({
id: 'bk-1',
reference: 'BKG-2026-00042',
tradeDirection: 'IMPORT',
customsClearingEnabled: true,
contractId: 'ctr-1',
dutyRequired: true,
...over,
}) as Booking;
const milestone = (code: string, status: string) =>
({ milestoneCode: code, status }) as never;
let repo: {
createReviewNote: jest.Mock;
findReviewNotes: jest.Mock;
update: jest.Mock;
};
let bookingsService: { findById: jest.Mock };
let workflowService: { listMilestonesForBooking: jest.Mock };
let milestoneService: { reopenForBooking: jest.Mock };
let notifier: { dutyDisputed: jest.Mock };
let service: BookingClearanceService;
const build = (milestones: unknown[]) => {
workflowService.listMilestonesForBooking.mockResolvedValue(milestones);
};
beforeEach(() => {
repo = {
createReviewNote: jest.fn().mockResolvedValue(undefined),
findReviewNotes: jest.fn().mockResolvedValue([]),
update: jest.fn().mockResolvedValue(undefined),
};
bookingsService = { findById: jest.fn().mockResolvedValue(booking()) };
workflowService = { listMilestonesForBooking: jest.fn().mockResolvedValue([]) };
milestoneService = { reopenForBooking: jest.fn().mockResolvedValue(undefined) };
notifier = { dutyDisputed: jest.fn() };
service = new BookingClearanceService(
repo as never,
bookingsService as never,
{} as never, // filesService
{} as never, // fileUploadSettingsService
workflowService as never,
milestoneService as never,
{} as never, // dropdownSettingsService
{} as never, // glOperationsService
notifier as never,
);
build([
milestone('DUTY_TAXES_ADVISED', 'COMPLETED'),
milestone('DUTY_TAX_PAID', 'PENDING'),
]);
});
it('records the objection and hands the step back to GL', async () => {
await service.disputeDuty('bk-1', ' Declared value is wrong ', 'user-1');
expect(repo.createReviewNote).toHaveBeenCalledWith(
'bk-1',
'Declared value is wrong',
'DUTY_DISPUTE',
'user-1',
);
// Reopening the advice milestone is what re-arms the Duty & tax step.
expect(milestoneService.reopenForBooking).toHaveBeenCalledWith(
'bk-1',
'DUTY_TAXES_ADVISED',
);
expect(repo.update).toHaveBeenCalledWith('bk-1', {
clearanceCurrentPhase: 'GL_ET_OUTPUT',
});
});
it('tells GL Ethiopia, not the customer', async () => {
await service.disputeDuty('bk-1', 'Too high', 'user-1');
expect(notifier.dutyDisputed).toHaveBeenCalledWith(
expect.objectContaining({ id: 'bk-1' }),
'Too high',
);
});
it('requires a reason — GL cannot correct an unexplained objection', async () => {
await expect(service.disputeDuty('bk-1', ' ')).rejects.toBeInstanceOf(
BadRequestException,
);
expect(milestoneService.reopenForBooking).not.toHaveBeenCalled();
});
it('refuses when nothing has been advised yet', async () => {
build([milestone('DUTY_TAXES_ADVISED', 'PENDING')]);
await expect(service.disputeDuty('bk-1', 'Too high')).rejects.toThrow(
/no advised duty amount/i,
);
});
it('refuses once the payment slip is in — that is a refund, not a re-advice', async () => {
build([
milestone('DUTY_TAXES_ADVISED', 'COMPLETED'),
milestone('DUTY_TAX_PAID', 'COMPLETED'),
]);
await expect(service.disputeDuty('bk-1', 'Too high')).rejects.toThrow(
/already been submitted/i,
);
});
it('refuses when duty was never required for this clearance', async () => {
bookingsService.findById.mockResolvedValue(booking({ dutyRequired: false }));
await expect(service.disputeDuty('bk-1', 'Too high')).rejects.toThrow(
/not required/i,
);
});
describe('the view', () => {
const buildDispute = (milestones: unknown[]) =>
(
service as unknown as {
buildDutyDispute: (id: string, m: unknown[]) => Promise<unknown>;
}
).buildDutyDispute('bk-1', milestones);
it('shows the objection while GL still owes a corrected advice', async () => {
repo.findReviewNotes.mockResolvedValue([
{ note: 'Second look please', createdAt: new Date('2026-07-20T09:00:00Z') },
{ note: 'First objection', createdAt: new Date('2026-07-18T09:00:00Z') },
]);
const dispute = await buildDispute([
milestone('DUTY_TAXES_ADVISED', 'PENDING'),
]);
expect(dispute).toMatchObject({ note: 'Second look please', rounds: 2 });
});
it('clears itself once GL re-advises', async () => {
repo.findReviewNotes.mockResolvedValue([
{ note: 'First objection', createdAt: new Date('2026-07-18T09:00:00Z') },
]);
const dispute = await buildDispute([
milestone('DUTY_TAXES_ADVISED', 'COMPLETED'),
]);
expect(dispute).toBeNull();
});
});
});

View File

@@ -18,6 +18,8 @@ const IMPORT_DEFS: Record<string, Omit<MilestoneDef, 'code'>> = {
IMPORT_DOCS_UPLOADED: { label: 'Import Documents Uploaded', ownerRegion: 'CUST', triggeredByDoc: false },
PENDING_DOCUMENT_REVIEW: { label: 'Pending Document Review', ownerRegion: 'ET', triggeredByDoc: true },
DOCUMENTS_APPROVED: { label: 'Documents Approved', ownerRegion: 'ET', triggeredByDoc: false },
DRAFT_DECLARATION_UPLOADED: { label: 'Draft Declaration Sent', ownerRegion: 'ET', triggeredByDoc: true },
DRAFT_DECLARATION_ACCEPTED: { label: 'Draft Declaration Accepted', ownerRegion: 'CUST', triggeredByDoc: false },
UNDER_CUSTOMS_CLEARANCE: { label: 'Under Customs Clearance', ownerRegion: 'ET', triggeredByDoc: false },
DECLARED: { label: 'Declared', ownerRegion: 'ET', triggeredByDoc: true },
DUTY_TAXES_ADVISED: { label: 'Duty and Taxes Advised', ownerRegion: 'ET', triggeredByDoc: false },

View File

@@ -353,10 +353,10 @@ export class ClearanceWorkflowService {
if (!isDone('DOCUMENTS_APPROVED')) return ContractDocPhase.GlEtReview;
if (tradeDirection === 'EXPORT') {
if (!isDone('DECLARED')) return ContractDocPhase.GlEtOutput;
if (!isDone('RELEASE_ORDER_SECURED')) {
return ContractDocPhase.GlDjCollection;
}
if (!isDone('DECLARED')) return ContractDocPhase.GlEtOutput;
if (!isDone(EXPORT_BOUNDARY)) return ContractDocPhase.GlEtPostClearance;
return ContractDocPhase.GlEtPostClearance;
}
@@ -450,13 +450,6 @@ export class ClearanceWorkflowService {
: 'Proceed to request operation';
if (tradeDirection === 'EXPORT') {
if (!isDone('RELEASE_ORDER_SECURED')) {
return {
actor: 'GL_DJ',
action: 'Upload Release Order and vessel departure date',
milestoneCode: 'RELEASE_ORDER_SECURED',
};
}
if (!isDone('DECLARED')) {
return {
actor: 'GL_ET',
@@ -464,6 +457,13 @@ export class ClearanceWorkflowService {
milestoneCode: 'DECLARED',
};
}
if (!isDone('RELEASE_ORDER_SECURED')) {
return {
actor: 'GL_DJ',
action: 'Upload Release Order and vessel departure date',
milestoneCode: 'RELEASE_ORDER_SECURED',
};
}
if (!isDone(EXPORT_BOUNDARY)) {
return {
actor: 'GL_ET',

View File

@@ -7,6 +7,7 @@ import {
import {
ContractDocPhase,
type ClearanceFinalInvoiceSummary,
type ClearanceOffloadState,
type ClearanceSecondDuty,
type ClearanceT1State,
type ClearanceTrainState,
@@ -26,6 +27,7 @@ import { ClearanceWorkflowService } from './clearance-workflow.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ContractNotifierService } from './contract-notifier.service';
import { GlOperationsService } from './gl-operations.service';
import { TransitAgentsService } from '../transit-agents/transit-agents.service';
import {
ClearanceMilestone,
type RiskAssignmentRecord,
@@ -140,6 +142,8 @@ export interface ContractClearanceView {
t1Closed?: boolean;
t1ClosedAt?: string | null;
offloaded?: boolean;
/** Offload stats for the linked booking (null until one exists). */
offload?: ClearanceOffloadState | null;
/** GL Djibouti post-offload final invoice (export). */
finalInvoice?: ClearanceFinalInvoiceSummary | null;
/** Customs risk level assigned by GL ET (import; visible to the customer). */
@@ -165,6 +169,7 @@ export class ContractClearanceService {
private readonly dropdownSettingsService: DropdownSettingsService,
private readonly glOperationsService: GlOperationsService,
private readonly notifier: ContractNotifierService,
private readonly transitAgentsService: TransitAgentsService,
) {}
private isPhasedCustoms(contract: Contract): boolean {
@@ -433,6 +438,9 @@ export class ContractClearanceService {
? t1ClosedMilestone.triggeredAt.toISOString()
: null,
offloaded: bookingMilestone('OFFLOADED')?.status === 'COMPLETED',
offload: cycle?.bookingId
? await this.glOperationsService.offloadState(cycle.bookingId, bookingMilestones)
: null,
finalInvoice,
riskLevel:
riskMilestone?.status === 'COMPLETED'
@@ -1117,20 +1125,18 @@ export class ContractClearanceService {
}
/**
* GL Djibouti names the transit officer — free text, because the person is
* not a platform user. Answering unblocks the declaration for Ethiopia. A
* later call overwrites the name (reassignment) and re-notifies.
* GL Djibouti picks the transit officer from the admin-managed roster —
* rejected unless the agent is active and inside its validity window.
* Answering unblocks the declaration for Ethiopia. A later call overwrites
* the name (reassignment) and re-notifies.
*/
async assignTransitAssignee(
contractId: string,
assignee: string,
transitAgentId: string,
userId?: string,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
this.assertPhasedCustoms(contract);
if (!assignee?.trim()) {
throw new BadRequestException('Name the officer who will handle the transit.');
}
const cycle = await this.contractsRepository.currentCycle(contractId);
if (!cycle) throw new BadRequestException('No clearance cycle found');
if (!cycle.transitAssigneeRequestedAt) {
@@ -1138,16 +1144,17 @@ export class ContractClearanceService {
'GL Ethiopia has not requested a transit assignee for this clearance yet.',
);
}
const agent = await this.transitAgentsService.getAssignable(transitAgentId);
const previous = cycle.transitAssigneeName ?? null;
await this.contractsRepository.updateCycle(cycle.id, {
transitAssigneeName: assignee.trim(),
transitAssigneeName: agent.name,
transitAssigneeAssignedAt: new Date(),
transitAssigneeAssignedByUserId: userId ?? null,
});
const updated = await this.contractsService.findById(contractId);
this.notifier.transitAssigneeAssigned(updated, assignee.trim(), previous);
this.notifier.transitAssigneeAssigned(updated, agent.name, previous);
return updated;
}
@@ -1186,6 +1193,15 @@ export class ContractClearanceService {
const contract = await this.contractsService.findById(contractId);
this.assertPhasedCustoms(contract);
await this.ensureDeclarationPrerequisites(contractId, contract);
// The draft-declaration accept/change-request loop only exists on the
// booking-scoped clearance page (portal customers never see contract-scoped
// clearance) — skip it here so it can never block the ONE_TIME pre-booking
// flow, which has no UI to complete it. Contracts seeded before this step
// existed have no such row to skip — ignore, `assertPriorComplete` below
// already tolerates a missing milestone as "not required".
await this.workflowService
.skipMilestones(contractId, ['DRAFT_DECLARATION_UPLOADED', 'DRAFT_DECLARATION_ACCEPTED'])
.catch(() => undefined);
await this.workflowService.assertPriorComplete(
contractId,
contract.tradeDirection,
@@ -1215,12 +1231,6 @@ export class ContractClearanceService {
});
}
// Export: the declaration is the last GL ET pre-booking action — release
// immediately so booking creation unlocks without a separate confirm click.
if (contract.tradeDirection === 'EXPORT') {
await this.workflowService.onExportReleased(contractId, userId);
}
return this.contractsService.findById(contractId);
}
@@ -1561,6 +1571,10 @@ export class ContractClearanceService {
currentPhase: ContractDocPhase.GlEtOutput,
});
await this.workflowService.completeMilestone(contractId, 'RELEASE_ORDER_SECURED', userId);
// Release Order is now the last GL DJ pre-booking action (it follows the
// declaration) — release immediately so booking creation unlocks without a
// separate confirm click.
await this.workflowService.onExportReleased(contractId, userId);
return { contract: await this.contractsService.findById(contractId), hold: false };
}

View File

@@ -62,6 +62,7 @@ describe('ContractClearanceService — duty dispute', () => {
{} as never, // dropdownSettingsService
{} as never, // glOperationsService
notifier as never,
{} as never, // transitAgentsService
);
build([
milestone('DUTY_TAXES_ADVISED', 'COMPLETED'),

View File

@@ -837,16 +837,16 @@ export class ContractsController {
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@ApiOperation({
summary:
'GL Djibouti names the transit officer (free text) — unblocks the customs declaration; calling again reassigns',
'GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns',
})
assignTransitAssignee(
@Param('id', ParseUUIDPipe) id: string,
@Body('assignee') assignee: string,
@Body('transitAgentId', ParseUUIDPipe) transitAgentId: string,
@CurrentUser() user: AuthUserPayload,
) {
return this.clearanceService.assignTransitAssignee(
id,
assignee,
transitAgentId,
resolveAuthUserId(user),
);
}
@@ -1294,6 +1294,20 @@ export class ContractsController {
);
}
@Post('bookings/:bookingId/final-invoice/approve')
@ApiOperation({
summary: 'Customer approves the drafted final invoice — unlocks the payment slip',
})
approveFinalInvoice(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@CurrentUser() user: AuthUserPayload,
) {
return this.glOperationsService.approveFinalInvoice(
bookingId,
resolveAuthUserId(user),
);
}
@Post('bookings/:bookingId/final-invoice-slip')
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')

View File

@@ -17,6 +17,7 @@ import { NotificationInboxModule } from '../notification-inbox/notification-inbo
import { BookingsModule } from '../bookings/bookings.module';
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
import { ContractTemplatesModule } from '../contract-templates/contract-templates.module';
import { TransitAgentsModule } from '../transit-agents/transit-agents.module';
import { ContractsController } from './contracts.controller';
import { ContractsService } from './contracts.service';
@@ -31,6 +32,8 @@ import { ClearanceWorkflowService } from './clearance-workflow.service';
import { ContractBookingService } from './contract-booking.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { GlOperationsService } from './gl-operations.service';
import { GlExchangeController } from './gl-exchange.controller';
import { GlExchangeService } from './gl-exchange.service';
import { BookingRequestService } from './booking-request.service';
import { BookingRequestRepository } from './booking-request.repository';
@@ -89,6 +92,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
// Provides the admin-editable contract document templates consumed by
// ContractDocumentViewModelBuilder when rendering contract PDFs.
ContractTemplatesModule,
TransitAgentsModule,
// BookingsModule provides BookingsRepository/BookingPricingService used by the
// contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3).
forwardRef(() => BookingsModule),
@@ -102,7 +106,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
config.get<ExchangeOptions>('app.cbeExchange') ?? {},
}),
],
controllers: [ContractsController],
controllers: [ContractsController, GlExchangeController],
providers: [
ContractsService,
ContractsRepository,
@@ -117,6 +121,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
ContractBookingService,
ClearanceMilestoneService,
GlOperationsService,
GlExchangeService,
BookingRequestService,
BookingRequestRepository,
// Contract PDF providers (template resolution + render + PDF) — stateless
@@ -136,6 +141,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
BookingClearanceService,
ContractBookingService,
ClearanceMilestoneService,
GlExchangeService,
],
})
export class ContractsModule {}

View File

@@ -46,6 +46,9 @@ export interface MilestoneMetadata {
declarationSerial?: string;
/** When the gate pass was physically granted (GL DJ captures the time). */
gatepassAt?: string;
/** DRAFT_DECLARATION_UPLOADED → the estimated price GL sent the customer. */
draftDeclarationPrice?: number;
draftDeclarationCurrency?: string;
}
/**

View File

@@ -0,0 +1,102 @@
import { BadRequestException } from '@nestjs/common';
import { Freight } from '@edr/types';
import { GlOperationsService } from './gl-operations.service';
import { Booking } from '../bookings/entities/booking.entity';
/**
* The GL Djibouti final invoice lands as a DRAFT: the customer must approve it
* (which issues it) before a payment slip is accepted.
*/
describe('GlOperationsService — final invoice approval', () => {
const invoice = (status: Freight.InvoiceStatus, issuedAt: Date | null = null) => ({
id: 'inv-1',
invoiceNumber: 'INV-1',
status,
totalAmount: 1500,
currency: 'ETB',
issuedAt,
paidAt: null,
});
let billingService: { findInvoice: jest.Mock; updateStatus: jest.Mock };
let filesService: { findByResource: jest.Mock; upsertByCode: jest.Mock };
let notifier: { finalInvoiceApprovedToStaff: jest.Mock; dutySlipUploadedToStaff: jest.Mock };
let service: GlOperationsService;
beforeEach(() => {
billingService = {
findInvoice: jest.fn(),
updateStatus: jest.fn().mockResolvedValue(undefined),
};
filesService = {
findByResource: jest.fn().mockResolvedValue([]),
upsertByCode: jest.fn().mockResolvedValue(undefined),
};
notifier = {
finalInvoiceApprovedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
};
const dataSource = {
getRepository: (entity: unknown) =>
entity === Booking
? { findOne: jest.fn().mockResolvedValue({ id: 'bk-1', reference: 'BKG-1' }) }
: { findOne: jest.fn().mockResolvedValue({ description: 'Post-offload charges' }) },
};
service = new GlOperationsService(
dataSource as never,
filesService as never,
{} as never, // milestoneService
billingService as never,
notifier as never,
);
});
it('issues the draft on customer approval and reports approvedAt', async () => {
const issued = new Date('2026-07-28T09:00:00.000Z');
billingService.findInvoice
.mockResolvedValueOnce(invoice(Freight.InvoiceStatus.Draft))
.mockResolvedValueOnce(invoice(Freight.InvoiceStatus.Issued, issued));
const summary = await service.approveFinalInvoice('bk-1', 'user-1');
expect(billingService.updateStatus).toHaveBeenCalledWith(
'inv-1',
Freight.InvoiceStatus.Issued,
);
expect(notifier.finalInvoiceApprovedToStaff).toHaveBeenCalled();
expect(summary.approvedAt).toBe(issued.toISOString());
});
it('is a no-op when the invoice was already approved', async () => {
billingService.findInvoice.mockResolvedValue(
invoice(Freight.InvoiceStatus.Issued, new Date()),
);
await service.approveFinalInvoice('bk-1');
expect(billingService.updateStatus).not.toHaveBeenCalled();
});
it('refuses a payment slip while the invoice is still a draft', async () => {
billingService.findInvoice.mockResolvedValue(invoice(Freight.InvoiceStatus.Draft));
await expect(
service.uploadFinalInvoiceSlip('bk-1', { originalname: 'slip.pdf' } as never),
).rejects.toThrow(BadRequestException);
expect(filesService.upsertByCode).not.toHaveBeenCalled();
});
it('accepts the payment slip once approved', async () => {
billingService.findInvoice.mockResolvedValue(
invoice(Freight.InvoiceStatus.Issued, new Date()),
);
await service.uploadFinalInvoiceSlip('bk-1', { originalname: 'slip.pdf' } as never);
expect(filesService.upsertByCode).toHaveBeenCalledWith(
expect.objectContaining({ code: 'final_invoice_slip' }),
);
});
});

View File

@@ -0,0 +1,133 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
ParseUUIDPipe,
Patch,
Post,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
import { actorLabel } from '../warehouses/current-actor.util';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { hasFreightPermission } from '../../common/freight-permission.util';
import { resolveAuthUserId } from '../../common/resolve-auth-user-id';
import {
GlExchangeService,
type GlExchangeActor,
type GlExchangeSide,
} from './gl-exchange.service';
/** Either GL desk may read and post; ownership decides who may edit. */
const GL_EXCHANGE_PERMS = [
FREIGHT_PERMS.contracts.clearanceEtActions,
FREIGHT_PERMS.contracts.clearanceDjActions,
];
/** Multipart bodies arrive as strings — "true"/"1" mean checked. */
const asBool = (raw: string | boolean | undefined): boolean =>
raw === true || raw === 'true' || raw === '1';
@ApiTags('gl-exchange')
@ApiBearerAuth()
@Controller('gl-exchange')
export class GlExchangeController {
constructor(private readonly exchangeService: GlExchangeService) {}
@Get(':entityId')
@BookingStaff(GL_EXCHANGE_PERMS)
@ApiOperation({
summary: 'GL ET ↔ GL DJ shared documents for a booking or contract',
})
list(
@Param('entityId', ParseUUIDPipe) entityId: string,
@CurrentUser() user: TCurrentUser,
) {
return this.exchangeService.list(entityId, resolveAuthUserId(user));
}
@Post(':entityId')
@BookingStaff(GL_EXCHANGE_PERMS)
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Share a document with the other GL desk' })
upload(
@Param('entityId', ParseUUIDPipe) entityId: string,
@UploadedFile() file: Express.Multer.File | undefined,
@Body('title') title: string,
@Body('visibleToCustomer') visibleToCustomer: string | undefined,
@CurrentUser() user: TCurrentUser,
) {
return this.exchangeService.upload(
entityId,
file,
{ title, visibleToCustomer: asBool(visibleToCustomer) },
this.actor(user),
);
}
@Patch('documents/:documentId')
@BookingStaff(GL_EXCHANGE_PERMS)
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
@ApiOperation({
summary: 'Uploader edits a shared document (title, visibility, file)',
})
update(
@Param('documentId', ParseUUIDPipe) documentId: string,
@UploadedFile() file: Express.Multer.File | undefined,
@Body('title') title: string | undefined,
@Body('visibleToCustomer') visibleToCustomer: string | undefined,
@CurrentUser() user: TCurrentUser,
) {
return this.exchangeService.update(
documentId,
{
title,
visibleToCustomer:
visibleToCustomer == null ? undefined : asBool(visibleToCustomer),
},
file,
resolveAuthUserId(user),
);
}
@Delete('documents/:documentId')
@BookingStaff(GL_EXCHANGE_PERMS)
@HttpCode(204)
@ApiOperation({ summary: 'Uploader removes a shared document' })
async remove(
@Param('documentId', ParseUUIDPipe) documentId: string,
@CurrentUser() user: TCurrentUser,
) {
await this.exchangeService.remove(documentId, resolveAuthUserId(user));
}
/**
* Which desk is posting. A user holding only the Djibouti actions permission
* is Djibouti; everyone else (GL Ethiopia, and super admins who hold both)
* posts as Ethiopia.
*/
private actor(user: TCurrentUser): GlExchangeActor {
const side: GlExchangeSide =
!hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) &&
hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions)
? 'DJ'
: 'ET';
return {
userId: resolveAuthUserId(user),
name: actorLabel(user) ?? null,
side,
};
}
}

View File

@@ -0,0 +1,198 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type { Freight } from '@edr/types';
import { FilesService } from '../files/files.service';
import type { FileRecord } from '../files/entities/file.entity';
/**
* `files.resource` of the GL Ethiopia ↔ GL Djibouti document exchange. The
* thread is keyed by the entity the two desks are working on — a booking id on
* the per-booking clearance pages, a contract id on the pre-booking ones — so
* both desks opening the same record see the same documents.
*/
export const GL_EXCHANGE_RESOURCE = 'gl_exchange';
export type GlExchangeSide = 'ET' | 'DJ';
export interface GlExchangeActor {
userId: string;
name?: string | null;
side: GlExchangeSide;
}
export interface GlExchangeUploadInput {
title: string;
visibleToCustomer: boolean;
}
/**
* Free-form document exchange between the two Global Logistics desks. Anything
* either side needs the other to have (scans, correspondence, corrected forms)
* lands here under a title they choose, instead of a fixed clearance slot.
*
* Rules, all enforced here rather than in the UI:
* - both desks read every document in a thread, whoever uploaded it;
* - only the uploader may retitle, replace or remove one;
* - the customer sees only what its uploader marked visible.
*/
@Injectable()
export class GlExchangeService {
constructor(private readonly filesService: FilesService) {}
/** Every document on one thread, newest first, from a GL desk's view. */
async list(
entityId: string,
viewerId: string,
): Promise<Freight.GlExchangeDocument[]> {
const records = await this.filesService.findByResource(
entityId,
GL_EXCHANGE_RESOURCE,
);
return this.sort(records.map((r) => this.toDto(r, viewerId)));
}
/**
* The customer-facing slice across several threads (a booking and the
* contract it belongs to). Never exposes internal documents, and never marks
* anything editable — the customer is not a GL desk.
*/
async listVisibleToCustomer(
entityIds: string[],
): Promise<Freight.GlExchangeDocument[]> {
const ids = [...new Set(entityIds.filter(Boolean))];
if (ids.length === 0) return [];
const grouped = await this.filesService.findByResourceIdsGrouped(
ids,
GL_EXCHANGE_RESOURCE,
);
const visible = [...grouped.values()]
.flat()
.filter((r) => r.visibleToCustomer);
return this.sort(visible.map((r) => this.toDto(r, null)));
}
async upload(
entityId: string,
file: Express.Multer.File | undefined,
input: GlExchangeUploadInput,
actor: GlExchangeActor,
): Promise<Freight.GlExchangeDocument> {
const title = input.title?.trim();
if (!title) throw new BadRequestException('A document title is required.');
if (!file) throw new BadRequestException('A file is required.');
const record = await this.filesService.upload({
resourceId: entityId,
resource: GL_EXCHANGE_RESOURCE,
// No fixed slot exists for these — `code` carries the uploading desk, so
// a document's origin survives even if the uploader leaves the org.
code: actor.side,
file,
title,
visibleToCustomer: input.visibleToCustomer,
uploadedByUserId: actor.userId,
uploadedByName: actor.name ?? null,
});
return this.toDto(record, actor.userId);
}
/**
* Retitle, re-share or replace a document. Uploader only — the other desk
* reads it but never edits it. A replacement file supersedes the old record
* (soft-deleted, bytes kept) and carries its metadata forward.
*/
async update(
documentId: string,
patch: { title?: string; visibleToCustomer?: boolean },
file: Express.Multer.File | undefined,
actorId: string,
): Promise<Freight.GlExchangeDocument> {
const record = await this.assertUploader(documentId, actorId);
const title = patch.title?.trim();
if (patch.title != null && !title) {
throw new BadRequestException('A document title is required.');
}
if (file) {
const replacement = await this.filesService.upload({
resourceId: record.resourceId,
resource: GL_EXCHANGE_RESOURCE,
code: record.code,
file,
title: title ?? record.title,
visibleToCustomer: patch.visibleToCustomer ?? record.visibleToCustomer,
uploadedByUserId: record.uploadedByUserId,
uploadedByName: record.uploadedByName,
});
await this.filesService.remove(record.id);
return this.toDto(replacement, actorId);
}
const updated = await this.filesService.updateMeta(record.id, {
...(title ? { title } : {}),
...(patch.visibleToCustomer != null
? { visibleToCustomer: patch.visibleToCustomer }
: {}),
});
return this.toDto(updated, actorId);
}
/** Uploader-only removal (soft delete — the stored bytes are kept). */
async remove(documentId: string, actorId: string): Promise<void> {
const record = await this.assertUploader(documentId, actorId);
await this.filesService.remove(record.id);
}
private async assertUploader(
documentId: string,
actorId: string,
): Promise<FileRecord> {
const record = await this.filesService.findById(documentId);
if (record.resource !== GL_EXCHANGE_RESOURCE) {
throw new NotFoundException(`Exchange document ${documentId} not found`);
}
if (record.uploadedByUserId !== actorId) {
throw new ForbiddenException(
'Only the person who uploaded this document can change it.',
);
}
return record;
}
private sort(
docs: Freight.GlExchangeDocument[],
): Freight.GlExchangeDocument[] {
return docs.sort((a, b) => b.uploadedAt.localeCompare(a.uploadedAt));
}
private toDto(
record: FileRecord,
viewerId: string | null,
): Freight.GlExchangeDocument {
return {
id: record.id,
entityId: record.resourceId,
// Pre-title rows (none in practice) fall back to the filename so a list
// never renders a blank row.
title: record.title ?? record.name,
side: record.code === 'DJ' ? 'DJ' : 'ET',
visibleToCustomer: record.visibleToCustomer,
uploadedById: record.uploadedByUserId,
uploadedByName: record.uploadedByName,
uploadedAt: record.createdAt.toISOString(),
file: {
id: record.id,
name: record.name,
url: record.url,
size: record.size,
mimeType: record.mimeType,
},
canEdit: viewerId != null && record.uploadedByUserId === viewerId,
};
}
}

View File

@@ -282,6 +282,85 @@ export class GlOperationsService {
};
}
/**
* Offload facts for a booking, read-only: what came off the train at its
* destination (containers, wagons, tonnes) and where the goods went. Sourced
* from the booking's warehouse-inventory row — written by the auto-unload
* that runs on train arrival for both directions.
*/
async offloadState(
bookingId: string,
milestones: Array<{ milestoneCode: string; status: string; triggeredAt?: Date | null }>,
): Promise<Freight.ClearanceOffloadState> {
const [row]: Array<{
destination: string | null;
containers: number;
wagons: number;
bookedWeight: string | null;
inventoryStatus: string | null;
unloadedAt: Date | null;
grnNumber: string | null;
offloadedWeight: string | null;
warehouse: string | null;
warehouseYard: string | null;
zone: string | null;
}> = await this.dataSource.query(
`SELECT COALESCE(dy.label, dy.code) AS "destination",
(SELECT COUNT(*)::int
FROM freight.booking_container bc
JOIN freight.booking_container_units bcu
ON bcu.booking_container_id = bc.id AND bcu.deleted_at IS NULL
WHERE bc.booking_id = b.id AND bc.deleted_at IS NULL) AS "containers",
(SELECT COUNT(*)::int
FROM freight.wagon_booking_allocations wba
WHERE wba.booking_id = b.id AND wba.deleted_at IS NULL) AS "wagons",
b.cargo_total_weight_vgm AS "bookedWeight",
inv.status AS "inventoryStatus",
inv.unloaded_at AS "unloadedAt",
inv.grn_number AS "grnNumber",
inv.weight AS "offloadedWeight",
wh.name AS "warehouse",
wy.name AS "warehouseYard",
wz.name AS "zone"
FROM freight.bookings b
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
LEFT JOIN LATERAL (
SELECT i.*
FROM freight.warehouse_inventory i
WHERE i.booking_id = b.id AND i.deleted_at IS NULL
ORDER BY i.unloaded_at DESC NULLS LAST, i.created_at DESC
LIMIT 1
) inv ON TRUE
LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id
LEFT JOIN freight.warehouse_yards wy ON wy.id = inv.yard_id
LEFT JOIN freight.warehouse_zones wz ON wz.id = inv.zone_id
WHERE b.id = $1 AND b.deleted_at IS NULL`,
[bookingId],
);
const milestone = milestones.find((m) => m.milestoneCode === 'OFFLOADED');
const offloadedAt =
milestone?.status === 'COMPLETED' && milestone.triggeredAt
? new Date(milestone.triggeredAt).toISOString()
: (row?.unloadedAt ? new Date(row.unloadedAt).toISOString() : null);
// The warehouse records the real offloaded tonnage; before it does, the
// booked VGM is the best number we have.
const weight = Number(row?.offloadedWeight ?? 0) || Number(row?.bookedWeight ?? 0);
const location = [row?.warehouse, row?.warehouseYard, row?.zone].filter(Boolean).join(' ');
return {
offloaded: milestone?.status === 'COMPLETED' || Boolean(row?.unloadedAt),
offloadedAt,
destination: row?.destination ?? null,
containers: row?.containers ?? 0,
wagons: row?.wagons ?? 0,
weightTons: weight || null,
grnNumber: row?.grnNumber ?? null,
location: location || null,
inventoryStatus: row?.inventoryStatus ?? null,
};
}
/**
* GL Djibouti uploads T1 transport documents (multi-file) once the gate pass
* is secured on the train schedule (which itself follows wagon allocation).
@@ -381,8 +460,9 @@ export class GlOperationsService {
/**
* GL Djibouti raises the post-offload final invoice (export): manual amount +
* attached invoice document. The customer pays offline and attaches a slip;
* GL (ET or DJ) then confirms to settle it.
* attached invoice document. It is issued as a DRAFT the customer must approve
* first; only then do they pay offline and attach a slip, and GL (ET or DJ)
* confirms to settle it.
*/
async createFinalInvoice(
bookingId: string,
@@ -445,7 +525,8 @@ export class GlOperationsService {
amount: input.amount,
},
],
status: Freight.InvoiceStatus.Issued,
// DRAFT until the customer approves it — approveFinalInvoice issues it.
status: Freight.InvoiceStatus.Draft,
});
await this.filesService.upsertByCode({
@@ -467,6 +548,40 @@ export class GlOperationsService {
return summary;
}
/**
* Customer approves the drafted final invoice — issues it, which is what
* unlocks the payment slip upload. Idempotent: approving twice is a no-op.
*/
async approveFinalInvoice(
bookingId: string,
userId?: string,
): Promise<Freight.ClearanceFinalInvoiceSummary> {
const booking = await this.getBooking(bookingId);
const invoice = await this.billingService.findInvoice(
Freight.InvoiceSource.Booking,
bookingId,
GL_FINAL_INVOICE_TYPE,
);
if (!invoice) {
throw new BadRequestException('No final invoice has been raised for this shipment.');
}
if (
invoice.status === Freight.InvoiceStatus.Cancelled ||
invoice.status === Freight.InvoiceStatus.Expired
) {
throw new BadRequestException('The final invoice is no longer payable.');
}
if (invoice.status === Freight.InvoiceStatus.Draft) {
await this.billingService.updateStatus(invoice.id, Freight.InvoiceStatus.Issued);
this.notifier.finalInvoiceApprovedToStaff(booking);
}
void userId;
const summary = await this.finalInvoiceSummary(bookingId);
if (!summary) throw new NotFoundException('Final invoice not found.');
return summary;
}
/** Customer attaches the payment slip for the final invoice. */
async uploadFinalInvoiceSlip(
bookingId: string,
@@ -483,6 +598,11 @@ export class GlOperationsService {
if (!invoice) {
throw new BadRequestException('No final invoice has been issued for this shipment.');
}
if (invoice.status === Freight.InvoiceStatus.Draft) {
throw new BadRequestException(
'Approve the final invoice before attaching a payment slip.',
);
}
if (invoice.status === Freight.InvoiceStatus.Paid) {
throw new BadRequestException('The final invoice is already paid.');
}
@@ -688,6 +808,8 @@ export class GlOperationsService {
description: line?.description ?? null,
invoiceFile: toRef('final_invoice'),
slipFile: toRef('final_invoice_slip'),
// Issuing IS the customer approval (createFinalInvoice leaves it DRAFT).
approvedAt: invoice.issuedAt ? new Date(invoice.issuedAt).toISOString() : null,
confirmedAt: invoice.paidAt ? new Date(invoice.paidAt).toISOString() : null,
};
}

View File

@@ -2,7 +2,9 @@ import { BadRequestException } from '@nestjs/common';
import {
catalogEntriesForTradeDirection,
declarationFileLabel,
draftDeclarationFileLabel,
isDeclarationFileCode,
isDraftDeclarationFileCode,
isImportTransitPermitFileCode,
isExportTransportFileCode,
isT1TransportFileCode,
@@ -72,6 +74,52 @@ export async function persistDeclarationUploads(
);
}
/** Require at least one draft declaration file in the upload batch. */
export function assertDraftDeclarationFiles(files: Express.Multer.File[]): void {
if (files.length === 0) {
throw new BadRequestException('No draft declaration documents uploaded');
}
}
/** Assign stable `draft_declaration_*` codes so multi-file uploads always pass validation. */
export function normalizeDraftDeclarationFieldNames(
files: Express.Multer.File[],
): Express.Multer.File[] {
return files.map((file, index) => ({
...file,
fieldname: `draft_declaration_${index}`,
}));
}
/** Replace all draft declaration files on a resource with a new multi-file upload batch. */
export async function persistDraftDeclarationUploads(
store: DeclarationFileStore,
resourceId: string,
resource: string,
files: Express.Multer.File[],
): Promise<void> {
const normalized = normalizeDraftDeclarationFieldNames(files);
assertDraftDeclarationFiles(normalized);
const existing = await store.findByResource(resourceId, resource);
await Promise.all(
existing
.filter((f) => f.code && isDraftDeclarationFileCode(f.code))
.map((f) => store.deleteByCode(resourceId, resource, f.code!)),
);
await Promise.all(
normalized.map((file, index) =>
store.upload({
resourceId,
resource,
code: `draft_declaration_${index}`,
file,
}),
),
);
}
/** Require at least one transit permit file in the upload batch. */
export function assertTransitPermitFiles(files: Express.Multer.File[]): void {
if (files.length === 0) {
@@ -341,6 +389,22 @@ export function buildWorkflowFiles(
});
});
const extraDraftDeclarations = files
.filter((f) => f.code && isDraftDeclarationFileCode(f.code) && !included.has(f.code))
.sort((a, b) => (a.code ?? '').localeCompare(b.code ?? ''));
extraDraftDeclarations.forEach((file, index) => {
if (!file.code) return;
included.add(file.code);
out.push({
code: file.code,
label: draftDeclarationFileLabel(index),
uploadedBy: 'gl_et',
category: 'draft_declaration',
file: { id: file.id, name: file.name, url: file.url },
});
});
if (tradeDirection === 'IMPORT') {
const extraTransit = files
.filter((f) => f.code && isImportTransitPermitFileCode(f.code) && !included.has(f.code))

View File

@@ -26,6 +26,7 @@ describe('ContractClearanceService — transit assignee', () => {
transitAssigneeRequested: jest.Mock;
transitAssigneeAssigned: jest.Mock;
};
let transitAgentsService: { getAssignable: jest.Mock };
let service: ContractClearanceService;
const cycle = (over: Record<string, unknown> = {}) => ({
@@ -45,6 +46,9 @@ describe('ContractClearanceService — transit assignee', () => {
transitAssigneeRequested: jest.fn(),
transitAssigneeAssigned: jest.fn(),
};
transitAgentsService = {
getAssignable: jest.fn().mockResolvedValue({ id: 'agent-1', name: 'Ahmed Bourhan' }),
};
service = new ContractClearanceService(
repo as never,
contractsService as never,
@@ -56,6 +60,7 @@ describe('ContractClearanceService — transit assignee', () => {
{} as never,
{} as never,
notifier as never,
transitAgentsService as never,
);
});
@@ -79,8 +84,9 @@ describe('ContractClearanceService — transit assignee', () => {
cycle({ transitAssigneeRequestedAt: new Date() }),
);
await service.assignTransitAssignee('ctr-1', ' Ahmed Bourhan ', 'dj-1');
await service.assignTransitAssignee('ctr-1', 'agent-1', 'dj-1');
expect(transitAgentsService.getAssignable).toHaveBeenCalledWith('agent-1');
const patch = repo.updateCycle.mock.calls[0][1];
expect(patch.transitAssigneeName).toBe('Ahmed Bourhan');
expect(patch.transitAssigneeAssignedByUserId).toBe('dj-1');
@@ -98,8 +104,12 @@ describe('ContractClearanceService — transit assignee', () => {
transitAssigneeName: 'Ahmed Bourhan',
}),
);
transitAgentsService.getAssignable.mockResolvedValue({
id: 'agent-2',
name: 'Fatouma Ali',
});
await service.assignTransitAssignee('ctr-1', 'Fatouma Ali', 'dj-1');
await service.assignTransitAssignee('ctr-1', 'agent-2', 'dj-1');
expect(notifier.transitAssigneeAssigned).toHaveBeenCalledWith(
expect.anything(),
@@ -108,19 +118,23 @@ describe('ContractClearanceService — transit assignee', () => {
);
});
it('refuses an empty name', async () => {
it('refuses a suspended or out-of-window agent', async () => {
repo.currentCycle.mockResolvedValue(
cycle({ transitAssigneeRequestedAt: new Date() }),
);
transitAgentsService.getAssignable.mockRejectedValue(
new BadRequestException('suspended'),
);
await expect(
service.assignTransitAssignee('ctr-1', ' ', 'dj-1'),
service.assignTransitAssignee('ctr-1', 'agent-1', 'dj-1'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('refuses before Ethiopia has asked', async () => {
await expect(
service.assignTransitAssignee('ctr-1', 'Ahmed Bourhan', 'dj-1'),
service.assignTransitAssignee('ctr-1', 'agent-1', 'dj-1'),
).rejects.toThrow(/not requested/i);
expect(transitAgentsService.getAssignable).not.toHaveBeenCalled();
});
});

View File

@@ -65,4 +65,29 @@ export class FileRecord extends BaseEntity {
/** Why the file was replaced — shown on the document's version history. */
@Column({ name: "replace_reason", type: "text", nullable: true })
replaceReason!: string | null;
/**
* Free-text label chosen by the uploader, when the document has no fixed slot
* (`code`) to name it — the GL Ethiopia ↔ GL Djibouti exchange. Null for every
* catalog-driven upload, whose label comes from its code.
*/
@Column({ name: "title", type: "varchar", length: 300, nullable: true })
title!: string | null;
/** Uploader's choice to share the document with the customer's portal. */
@Column({ name: "visible_to_customer", type: "boolean", default: false })
visibleToCustomer!: boolean;
/** Who uploaded it — the only user allowed to edit or remove it afterwards. */
@Column({ name: "uploaded_by_user_id", type: "uuid", nullable: true })
uploadedByUserId!: string | null;
/** Uploader's display name, resolved once so lists need no IAM lookup. */
@Column({
name: "uploaded_by_name",
type: "varchar",
length: 200,
nullable: true,
})
uploadedByName!: string | null;
}

View File

@@ -15,6 +15,11 @@ export interface CreateFileInput {
resource: string;
code: string;
file: Express.Multer.File;
/** Optional metadata for free-form uploads (GL exchange) — see FileRecord. */
title?: string | null;
visibleToCustomer?: boolean;
uploadedByUserId?: string | null;
uploadedByName?: string | null;
}
/**
@@ -101,9 +106,27 @@ export class FilesService {
url,
size: file.size,
mimeType: file.mimetype,
title: input.title ?? null,
visibleToCustomer: input.visibleToCustomer ?? false,
uploadedByUserId: input.uploadedByUserId ?? null,
uploadedByName: input.uploadedByName ?? null,
});
}
/**
* Edit the uploader-authored metadata of a stored file (title, customer
* visibility). Bytes are untouched — callers replacing content upload a new
* record instead.
*/
async updateMeta(
id: string,
patch: { title?: string; visibleToCustomer?: boolean },
): Promise<FileRecord> {
const updated = await this.filesRepository.update(id, patch);
if (!updated) throw new NotFoundException(`File ${id} not found`);
return updated;
}
/**
* Replace the file stored under a resource + code (e.g. contract PDF). The
* previous version is retired, not destroyed — pass `replacedBy` to record who

View File

@@ -327,6 +327,8 @@ export class LastMileService {
*/
async arrivalTrucksForBooking(bookingId: string): Promise<
Array<{
/** The last-mile leg this truck belongs to — lets a caller chain straight into truck-detention-preview without a separate lookup. */
lastMileId: string;
vehicleId: string;
truckPlateNumber: string | null;
trailerPlateNumber: string | null;
@@ -359,6 +361,7 @@ export class LastMileService {
: [];
const out: Array<{
lastMileId: string;
vehicleId: string;
truckPlateNumber: string | null;
trailerPlateNumber: string | null;
@@ -386,6 +389,7 @@ export class LastMileService {
}
}
out.push({
lastMileId: lm.id,
vehicleId: vehicle.id,
truckPlateNumber: vehicle.powerPlateNo || vehicle.plateNumber || null,
trailerPlateNumber: vehicle.trailerPlateNo || null,

View File

@@ -4,7 +4,7 @@ import { PaymentRefundEntity } from "./payment-refund.entity";
/** Invoice source that owns the intent ('booking', 'demurrage', …) — caller-supplied. */
type PaymentType = string
type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr" | "waafi" | "card" | "dmoney" | "cac-bank"
type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr" | "waafi" | "card" | "dmoney" | "cac-bank" | "cbe-bill"
type Currency = "ETB" | "USD"
export type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded"
@@ -22,7 +22,7 @@ export class PaymentEntity extends BaseEntity {
@Column({ type: "varchar", length: 40, nullable: true, name: "reference_type" })
referenceType?: string;
@Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr", "waafi", "card", "dmoney", "cac-bank"] })
@Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr", "waafi", "card", "dmoney", "cac-bank", "cbe-bill"] })
method!: PaymentMethod
@Column({ type: "enum", enum: ["ETB", "USD"] })

View File

@@ -1,30 +1,42 @@
import {
Body,
Controller,
forwardRef,
HttpCode,
HttpStatus,
Inject,
Logger,
Post,
UseGuards,
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { Public } from "@edr/api-common";
import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payment.dto";
import {
PaymentEventDto,
MarkPaidResponseDto,
BillQueryRequestDto,
BillQueryResponseDto,
} from "./internal-payment.dto";
import { PaymentService } from "./payment.service";
import { BillingService } from "../billing/billing.service";
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
/**
* Consumer side of the payment microservice's outbox relay.
* WARNING: currently unauthenticated — anyone who can reach the API can mark
* payments as paid. Re-add ServiceAuthGuard before exposing beyond a trusted network.
* Consumer side of the payment microservice's outbox relay. Only the payment service may
* call this (shared service token — restored per docs/cbe/CBE_IMPLEMENTATION_PLAN.md R8).
* Idempotent by design — the relay delivers at-least-once, so duplicates must be harmless.
* Becomes a queue consumer via PaymentEventsConsumer when RabbitMQ is available;
* this HTTP endpoint remains as a transport-agnostic fallback.
*/
@ApiTags("Internal Payments")
@Public()
@UseGuards(ServiceAuthGuard)
@Controller("internal/payments")
export class InternalPaymentController {
private readonly logger = new Logger(InternalPaymentController.name);
constructor(private readonly paymentService: PaymentService) { }
constructor(
private readonly paymentService: PaymentService,
@Inject(forwardRef(() => BillingService))
private readonly billingService: BillingService,
) { }
@Post("mark-paid")
@HttpCode(HttpStatus.OK)
@@ -36,4 +48,16 @@ export class InternalPaymentController {
this.logger.log(`Marking payment ${event} as PAID`);
return this.paymentService.handlePaymentEvent(event);
}
@Post("bill-query")
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary:
"Live still-payable check + payer name for a CBE bill (called while CBE is on the line)",
})
async billQuery(
@Body() request: BillQueryRequestDto,
): Promise<BillQueryResponseDto> {
return this.billingService.billQuery(request.referenceId);
}
}

View File

@@ -51,3 +51,24 @@ export class MarkPaidResponseDto {
@ApiPropertyOptional() alreadyFinalized?: boolean;
@ApiPropertyOptional() reason?: string;
}
/**
* CBE bill-query hop (docs/cbe/CBE_IMPLEMENTATION_PLAN.md Phase 4): the payment service asks
* "is this invoice still payable, by whom, for how much" while a CBE channel is on the line.
*/
export class BillQueryRequestDto {
@ApiProperty({ enum: PaymentReferenceType })
@IsEnum(PaymentReferenceType)
referenceType!: PaymentReferenceType;
@ApiProperty() @IsString() referenceId!: string;
}
export class BillQueryResponseDto {
@ApiProperty() stillPayable!: boolean;
@ApiPropertyOptional() payerName?: string | null;
@ApiPropertyOptional() currentAmountMinor?: number | null;
@ApiPropertyOptional() currency?: string | null;
/** When stillPayable=false: "CANCELLED" | "ALREADY_PAID" | "EXPIRED". */
@ApiPropertyOptional() reason?: string | null;
}

View File

@@ -51,6 +51,10 @@ export interface InitiateIntentInput {
payerAccount?: string;
returnUrl?: string;
failureUrl?: string;
/** CBE_BILL: payer full name snapshot (feeds CBE's mandatory Full_Name). */
payerName?: string;
/** CBE_BILL: intent expiry, ISO-8601 — the invoice due date, never a session TTL. */
expiresAt?: string;
}
export interface InitiateIntentResult {
@@ -79,6 +83,7 @@ const PROVIDER_TO_METHOD: Record<string, PaymentEntity["method"]> = {
CARD: "card",
DMONEY: "dmoney",
CAC_BANK: "cac-bank",
CBE_BILL: "cbe-bill",
};
/**
@@ -190,8 +195,13 @@ export class PaymentService {
*/
async initiate(input: InitiateIntentInput): Promise<InitiateIntentResult> {
try {
const isCbeBill = input.method === ProviderMethod.CBE_BILL;
// CBE settles ETB only (docs/cbe/CBE_IMPLEMENTATION_PLAN.md D8).
if (isCbeBill && input.currency?.toUpperCase() !== "ETB") {
throw new BadRequestException(
"CBE bill payment is only available for ETB invoices",
);
}
const snapshot = await this.paymentClient.initiate({
service: PaymentServiceEnum.FREIGHT,
@@ -199,11 +209,15 @@ export class PaymentService {
referenceId: input.referenceId,
orderRef: input.orderRef,
// amountMinor: input.amountMinor,
amountMinor:1,
// CBE_BILL must carry the REAL amount: /cbe/payment verifies what the customer was
// debited against the intent amount, so the 1-birr dev shortcut would break it.
amountMinor: isCbeBill ? input.amountMinor : 1,
currency: input.currency,
provider: input.method as ProviderMethod,
platform: input.platform,
payerAccount: input.payerAccount,
payerName: input.payerName,
expiresAt: input.expiresAt,
returnUrl:
input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success",
failureUrl:

View File

@@ -60,8 +60,10 @@ export class RefundDto {
}
export class ClientActionDto {
@ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP"] })
type!: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP";
@ApiProperty({
enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP", "SHOW_BILL_REFERENCE"],
})
type!: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP" | "SHOW_BILL_REFERENCE";
@ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" })
url?: string;
@@ -80,6 +82,17 @@ export class ClientActionDto {
@ApiPropertyOptional({ description: "Set when type=COLLECT_OTP" })
message?: string;
@ApiPropertyOptional({
description: "Set when type=SHOW_BILL_REFERENCE (CBE bill payment)",
})
billReference?: string;
@ApiPropertyOptional({ description: "Set when type=SHOW_BILL_REFERENCE" })
instructions?: string;
@ApiPropertyOptional({ description: "Set when type=SHOW_BILL_REFERENCE" })
expiresAt?: string;
}
export class InitiateResponseDto {

View File

@@ -32,8 +32,11 @@ import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository';
import { BookingNotifierService } from './booking-notifier.service';
import { TrainSchedulingService } from './train-scheduling.service';
import { eatDay } from './batch-window.util';
import {
TrainSchedulingService,
effectiveWindowConfig,
} from './train-scheduling.service';
import { eatDay, listConfigBookingWindows } from './batch-window.util';
import {
BATCH_BOARD_STATUSES,
BatchBoardQueryDto,
@@ -167,6 +170,13 @@ export type BookingAllocationStatus =
| "FAILED";
export interface BatchBoardBookingDetail extends BatchBoardBooking {
/**
* 0-based booking-window cycle this booking entered the pool in (derived from
* `fullyExecutedAt` against the schedule's window cycles). Ranking compares
* bookings within a cycle only — an earlier cycle always boards before a later
* one regardless of score. Null while the contract is still pending.
*/
windowCycleNo: number | null;
fullyExecutedAt: string | null;
selectedForBatchAt: string | null;
allocationStatus: BookingAllocationStatus;
@@ -1321,10 +1331,12 @@ export class BookingBatchService implements OnModuleInit {
}
}
const cycleOf = await this.windowCycleIndexer(s);
const items: BatchBoardBookingDetail[] = bookings.map((b) => {
const need = this.needFor(b, wagonDims);
const alloc = allocationByBooking.get(b.id);
return {
windowCycleNo: b.fullyExecutedAt ? cycleOf(b.fullyExecutedAt) : null,
id: b.id,
reference: b.reference ?? b.id.slice(0, 8),
company: b.isGovernment
@@ -1632,7 +1644,7 @@ export class BookingBatchService implements OnModuleInit {
// Same bulk re-score as fillRouteDayInternal — the legacy per-schedule fill
// must rank bulk bookings by their wagon-derived priority too.
await this.recomputeBulkPriorities(pool, wagonDims);
this.resortPoolByPriority(pool);
this.resortPoolByPriority(pool, await this.windowCycleIndexer(schedule));
const units = this.groupConsolidatedPool(pool);
let armed = false;
let preempted = false;
@@ -1847,6 +1859,9 @@ export class BookingBatchService implements OnModuleInit {
armed: boolean;
changed: boolean;
}> = [];
// The day group shares one booking window (route+day grouping), so any
// member's window grid stands for the pool's cycle derivation.
let cycleSchedule: TrainSchedule | null = null;
for (const id of scheduleIds) {
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(id);
@@ -1857,6 +1872,7 @@ export class BookingBatchService implements OnModuleInit {
);
continue;
}
cycleSchedule ??= schedule;
const limits = await this.capacityLimits(locomotive);
await this.syncScheduleMaxWagons(schedule, locomotive);
const budget = await this.remainingBudget(schedule, limits, wagonDims);
@@ -1877,7 +1893,10 @@ export class BookingBatchService implements OnModuleInit {
// BULK bookings only get their real (wagon-derived) priority score now, at
// batch time — stamp it and re-rank before the fill consumes the pool.
await this.recomputeBulkPriorities(pool, wagonDims);
this.resortPoolByPriority(pool);
this.resortPoolByPriority(
pool,
cycleSchedule ? await this.windowCycleIndexer(cycleSchedule) : undefined,
);
// Consolidated partners collapse into one atomic unit (both-or-neither); a
// consolidated booking whose partner isn't ready this cycle is skipped.
const units = this.groupConsolidatedPool(pool);
@@ -3257,11 +3276,66 @@ export class BookingBatchService implements OnModuleInit {
}
}
/** Restore the batch pool ordering (mirrors findBatchPool's ORDER BY) after scores changed. */
private resortPoolByPriority(pool: Booking[]): void {
/**
* Maps a booking's pool-entry time (`fullyExecutedAt`) to the 0-based
* booking-window cycle it arrived in: the last window whose open is at/before
* the timestamp (a timestamp in the doc-review/payment gap belongs to the
* cycle that just closed). The cycle grid comes from the schedule's frozen
* window-rule snapshot — the exact windows the cycle engine runs.
*/
private async windowCycleIndexer(
schedule: TrainSchedule,
): Promise<(ts: Date | null | undefined) => number> {
if (!schedule.scheduledDepartureDate) return () => 0;
let starts: number[];
try {
const liveCfg = await this.trainSchedulingService.getWindowConfig();
const cfg = effectiveWindowConfig(schedule, liveCfg);
const windows = listConfigBookingWindows(
schedule.direction,
schedule.scheduledDepartureDate,
{
...cfg,
reopenGapMinutes:
schedule.ruleReopenDelayMinutes ??
cfg.docReviewMinutes + cfg.paymentWindowMinutes,
},
);
starts = windows.map((w) => w.start.getTime());
} catch (err) {
// A failed cycle derivation must never block the batch — fall back to one
// flat cycle (pure priority order, the old behaviour).
this.logger.warn(
`Window-cycle derivation failed for schedule ${schedule.id}: ` +
`${(err as Error).message}`,
);
return () => 0;
}
return (ts) => {
if (!ts) return 0;
const ms = ts.getTime();
let idx = 0;
for (let i = 0; i < starts.length; i += 1) {
if (ms >= starts[i]) idx = i;
}
return idx;
};
}
/**
* Rank the batch pool: government first, then WINDOW CYCLE (bookings compete
* only within the cycle they arrived in — an earlier cycle's booking always
* outranks a later cycle's, whatever the scores), then priority score, then
* oldest. `cycleOf` comes from {@link windowCycleIndexer}.
*/
private resortPoolByPriority(
pool: Booking[],
cycleOf: (ts: Date | null | undefined) => number = () => 0,
): void {
pool.sort(
(a, b) =>
Number(b.isGovernment) - Number(a.isGovernment) ||
cycleOf(a.fullyExecutedAt) - cycleOf(b.fullyExecutedAt) ||
Number(b.priorityScore ?? 0) - Number(a.priorityScore ?? 0) ||
(a.fullyExecutedAt?.getTime() ?? Infinity) -
(b.fullyExecutedAt?.getTime() ?? Infinity) ||

View File

@@ -2754,11 +2754,13 @@ export class TrainSchedulingService {
allocations: (wagon.allocations ?? []).map((allocation) => ({
bookingId: allocation.bookingId,
bookingReference: allocation.booking?.reference ?? null,
booking: allocation.booking,
loadType: allocation.loadType ?? null,
allocatedWeightTons: Number(allocation.allocatedWeightTons) || 0,
containerNumbers: (allocation.containerItems ?? [])
.map((item) => item.containerNumber)
.filter(Boolean),
containerItems: allocation.containerItems ?? [],
})),
})),
operation: await this.getImportDjiboutiOperation(schedule.id),
@@ -2839,6 +2841,7 @@ export class TrainSchedulingService {
return allocations.map((allocation) => {
const booking = allocation.booking ?? bookingById.get(allocation.bookingId);
const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType;
const companyName = (booking as unknown as { company?: { name?: string } } | undefined)?.company?.name ?? '-';
const containerItems = allocation.containerItems ?? [];
const firstContainer = containerItems[0];
const containerNumbers = containerItems.map((item) => item.containerNumber).filter(Boolean).join(', ');
@@ -2847,6 +2850,7 @@ export class TrainSchedulingService {
return `<tr>
${wagonCells}
<td>${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)}</td>
<td>${esc(companyName)}</td>
<td>${esc(containerNumbers || firstContainer?.containerNumber)}</td>
<td>${esc(chassisNumbers)}</td>
<td>${esc(sealNumbers)}</td>
@@ -2861,6 +2865,18 @@ export class TrainSchedulingService {
0,
);
// Container count summary (40ft, 20ft)
let count40ft = 0, count20ft = 0;
wagons.forEach((wagon) => {
(wagon.allocations ?? []).forEach((allocation) => {
(allocation.containerItems ?? []).forEach((item) => {
const size = item.bookingContainer?.containerSize;
if (size?.includes('40')) count40ft++;
else if (size?.includes('20')) count20ft++;
});
});
});
return `<!doctype html>
<html>
<head>
@@ -2910,6 +2926,9 @@ export class TrainSchedulingService {
<div class="tile"><span>Departure station</span><strong>${esc(schedule.originStation?.label ?? schedule.originStation?.code)}</strong></div>
<div class="tile"><span>Arrival station</span><strong>${esc(schedule.destinationStation?.label ?? schedule.destinationStation?.code)}</strong></div>
<div class="tile"><span>Total loaded weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div>
<div class="tile"><span>Containers 40ft</span><strong>${esc(count40ft)}</strong></div>
<div class="tile"><span>Containers 20ft</span><strong>${esc(count20ft)}</strong></div>
<div class="tile"><span>Total containers</span><strong>${esc(count40ft + count20ft)}</strong></div>
<div class="tile"><span>Prepared person</span><strong>${esc(schedule.preparedByUserId)}</strong></div>
<div class="tile"><span>Check person</span><strong>${esc(schedule.checkedByUserId)}</strong></div>
<div class="tile"><span>Wagons</span><strong>${esc(wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}</strong></div>
@@ -2928,6 +2947,7 @@ export class TrainSchedulingService {
<th class="num">Tare Weight</th>
<th class="num">Load Capacity</th>
<th>Cargo Type</th>
<th>Company</th>
<th>Container No</th>
<th>Chassis No</th>
<th>Seal No</th>
@@ -3010,6 +3030,19 @@ export class TrainSchedulingService {
0,
);
const emptyWagons = loadList.wagons.filter((wagon) => wagon.allocations.length === 0).length;
// Container count summary (40ft, 20ft)
let count40ft = 0, count20ft = 0;
loadList.wagons.forEach((wagon) => {
wagon.allocations.forEach((allocation) => {
(allocation.containerItems ?? []).forEach((item) => {
const size = item.bookingContainer?.containerSize;
if (size?.includes('40')) count40ft++;
else if (size?.includes('20')) count20ft++;
});
});
});
const allocationRows = loadList.wagons
.flatMap((wagon) => {
const wagonCells = `<td>${esc(wagon.sequenceNo)}</td>
@@ -3025,13 +3058,17 @@ export class TrainSchedulingService {
];
}
return wagon.allocations.map(
(allocation) => `<tr>
(allocation) => {
const companyName = (allocation.booking as unknown as { company?: { name?: string } } | undefined)?.company?.name ?? '-';
return `<tr>
${wagonCells}
<td>${esc(allocation.bookingReference ?? allocation.bookingId)}</td>
<td>${esc(companyName)}</td>
<td>${esc(allocation.loadType)}</td>
<td>${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')}</td>
<td class="num">${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))}</td>
</tr>`,
</tr>`;
},
);
})
.join('');
@@ -3096,6 +3133,9 @@ export class TrainSchedulingService {
<div class="tile"><span>Wagons</span><strong>${esc(loadList.wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}</strong></div>
<div class="tile"><span>Allocations</span><strong>${esc(totalAllocations)}</strong></div>
<div class="tile"><span>Total weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div>
<div class="tile"><span>Containers 40ft</span><strong>${esc(count40ft)}</strong></div>
<div class="tile"><span>Containers 20ft</span><strong>${esc(count20ft)}</strong></div>
<div class="tile"><span>Total containers</span><strong>${esc(count40ft + count20ft)}</strong></div>
<div class="tile"><span>Gatepass granted</span><strong>${esc(date(loadList.operation.gatepassGrantedAt))}</strong></div>
</div>
@@ -3115,6 +3155,7 @@ export class TrainSchedulingService {
<th>Seq</th>
<th>Wagon</th>
<th>Booking</th>
<th>Company</th>
<th>Load</th>
<th>Container numbers</th>
<th class="num">Weight T</th>

View File

@@ -0,0 +1,31 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsBoolean, IsDateString, IsOptional, IsString, MaxLength } from 'class-validator';
const toBoolean = ({ value }: { value: unknown }) => {
if (typeof value === 'boolean') return value;
if (value === 'true') return true;
if (value === 'false') return false;
return value;
};
export class CreateTransitAgentDto {
@ApiProperty({ maxLength: 150, example: 'Ahmed Bourhan' })
@IsString()
@MaxLength(150)
name!: string;
@ApiProperty({ example: '2026-01-01' })
@IsDateString()
validFrom!: string;
@ApiProperty({ example: '2026-12-31' })
@IsDateString()
validTo!: string;
@ApiPropertyOptional({ default: true })
@IsOptional()
@Transform(toBoolean)
@IsBoolean()
isActive?: boolean;
}

View File

@@ -0,0 +1,5 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateTransitAgentDto } from './create-transit-agent.dto';
export class UpdateTransitAgentDto extends PartialType(CreateTransitAgentDto) {}

View File

@@ -0,0 +1,24 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
/**
* Djibouti transit officer GL Djibouti may assign against a shipment's
* transit-assignee handshake. Admin-managed so the roster and each officer's
* validity window arrive without a code change; `isActive` is the manual
* suspend/reactivate switch, independent of the validity window.
*/
@Entity({ schema: 'freight', name: 'transit_agents' })
@Index(['isActive'])
export class TransitAgent extends BaseEntity {
@Column({ name: 'name', type: 'varchar', length: 150 })
name!: string;
@Column({ name: 'valid_from', type: 'date' })
validFrom!: string;
@Column({ name: 'valid_to', type: 'date' })
validTo!: string;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
}

View File

@@ -0,0 +1,87 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
RuleEngineCreate,
RuleEngineDelete,
RuleEngineUpdate,
RuleEngineView,
} from '../../common/rule-engine-guards';
import { CreateTransitAgentDto } from './dto/create-transit-agent.dto';
import { UpdateTransitAgentDto } from './dto/update-transit-agent.dto';
import { TransitAgentsService } from './transit-agents.service';
@ApiTags('transit-agents')
@Controller('transit-agents')
@ApiBearerAuth()
export class TransitAgentsController {
constructor(private readonly transitAgentsService: TransitAgentsService) {}
@Get()
@RuleEngineView('transit-agents')
@ApiOperation({ summary: 'List transit agents' })
findAll(@Query() query: Record<string, string | undefined>) {
return this.transitAgentsService.findAll({
isActive:
query.isActive === 'all'
? undefined
: query.isActive !== undefined
? query.isActive === 'true'
: undefined,
page: query.page ? parseInt(query.page, 10) : undefined,
pageSize: query.pageSize ? parseInt(query.pageSize, 10) : undefined,
sortBy: query.sortBy,
sortOrder: query.sortOrder,
});
}
/** Active + currently valid officers — the transit-assignee assignment dropdown. */
@Get('assignable')
@RuleEngineView('transit-agents')
@ApiOperation({ summary: 'List transit agents assignable right now (active and in-window)' })
findAssignable() {
return this.transitAgentsService.findAssignable();
}
@Get(':id')
@RuleEngineView('transit-agents')
@ApiOperation({ summary: 'Get a transit agent by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.transitAgentsService.findById(id);
}
@Post()
@RuleEngineCreate('transit-agents')
@ApiOperation({ summary: 'Create a transit agent' })
create(@Body() dto: CreateTransitAgentDto) {
return this.transitAgentsService.create(dto);
}
@Patch(':id')
@RuleEngineUpdate('transit-agents')
@ApiOperation({ summary: 'Update a transit agent' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateTransitAgentDto) {
return this.transitAgentsService.update(id, dto);
}
@Delete(':id')
@RuleEngineDelete('transit-agents')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a transit agent' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.transitAgentsService.remove(id);
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TransitAgent } from './entities/transit-agent.entity';
import { TransitAgentsController } from './transit-agents.controller';
import { TransitAgentsRepository } from './transit-agents.repository';
import { TransitAgentsService } from './transit-agents.service';
@Module({
imports: [TypeOrmModule.forFeature([TransitAgent])],
controllers: [TransitAgentsController],
providers: [TransitAgentsRepository, TransitAgentsService],
exports: [TransitAgentsRepository, TransitAgentsService],
})
export class TransitAgentsModule {}

View File

@@ -0,0 +1,28 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm';
import { TransitAgent } from './entities/transit-agent.entity';
@Injectable()
export class TransitAgentsRepository extends BaseRepository<TransitAgent> {
constructor(
@InjectRepository(TransitAgent)
repository: Repository<TransitAgent>,
) {
super(repository);
}
/** Active AND currently inside its validity window (today's date, server-side). */
findAssignable(today: string): Promise<TransitAgent[]> {
return this.repository.find({
where: {
isActive: true,
validFrom: LessThanOrEqual(today),
validTo: MoreThanOrEqual(today),
},
order: { name: 'ASC' },
});
}
}

View File

@@ -0,0 +1,138 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { FindOptionsOrder } from 'typeorm';
import { CreateTransitAgentDto } from './dto/create-transit-agent.dto';
import { UpdateTransitAgentDto } from './dto/update-transit-agent.dto';
import { TransitAgent } from './entities/transit-agent.entity';
import { TransitAgentsRepository } from './transit-agents.repository';
export type TransitAgentValidityStatus = 'VALID' | 'NOT_STARTED' | 'EXPIRED';
export type TransitAgentView = TransitAgent & {
validityStatus: TransitAgentValidityStatus;
};
type TransitAgentListFilter = {
isActive?: boolean;
page?: number;
pageSize?: number;
sortBy?: string;
sortOrder?: string;
};
/** Today as `yyyy-MM-dd`, matching the `date`-typed validity columns. */
function todayISODate(): string {
return new Date().toISOString().slice(0, 10);
}
function validityStatus(agent: Pick<TransitAgent, 'validFrom' | 'validTo'>): TransitAgentValidityStatus {
const today = todayISODate();
if (today < agent.validFrom) return 'NOT_STARTED';
if (today > agent.validTo) return 'EXPIRED';
return 'VALID';
}
function withValidityStatus(agent: TransitAgent): TransitAgentView {
return { ...agent, validityStatus: validityStatus(agent) };
}
@Injectable()
export class TransitAgentsService {
constructor(private readonly transitAgentsRepository: TransitAgentsRepository) {}
async findAll(filter: TransitAgentListFilter = {}): Promise<{
data: TransitAgentView[];
meta: { total: number; page: number; pageSize: number; totalPages: number };
}> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 500;
const sortBy = ['name', 'validFrom', 'validTo', 'isActive'].includes(filter.sortBy ?? '')
? (filter.sortBy as keyof TransitAgent)
: 'name';
const sortOrder = filter.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
const [data, total] = await this.transitAgentsRepository.findAndCount({
where: filter.isActive === undefined ? {} : { isActive: filter.isActive },
order: { [sortBy]: sortOrder } as FindOptionsOrder<TransitAgent>,
skip: (page - 1) * pageSize,
take: pageSize,
});
return {
data: data.map(withValidityStatus),
meta: {
total,
page,
pageSize,
totalPages: Math.max(1, Math.ceil(total / pageSize)),
},
};
}
/** Active and currently inside its validity window — the DJ assignment dropdown. */
async findAssignable(): Promise<TransitAgent[]> {
return this.transitAgentsRepository.findAssignable(todayISODate());
}
async findById(id: string): Promise<TransitAgentView> {
const agent = await this.transitAgentsRepository.findById(id);
if (!agent) {
throw new NotFoundException(`Transit agent ${id} not found`);
}
return withValidityStatus(agent);
}
/** Used by the assignment flow — rejects a suspended or out-of-window officer. */
async getAssignable(id: string): Promise<TransitAgent> {
const agent = await this.transitAgentsRepository.findById(id);
if (!agent) {
throw new BadRequestException('Selected transit officer was not found.');
}
if (!agent.isActive) {
throw new BadRequestException(`${agent.name} is suspended — pick another transit officer.`);
}
if (validityStatus(agent) !== 'VALID') {
throw new BadRequestException(
`${agent.name}'s validity window has expired — pick another transit officer or extend their dates.`,
);
}
return agent;
}
async create(dto: CreateTransitAgentDto): Promise<TransitAgentView> {
if (dto.validTo < dto.validFrom) {
throw new BadRequestException('Valid-to date must be on or after valid-from date.');
}
const agent = await this.transitAgentsRepository.create({
name: dto.name.trim(),
validFrom: dto.validFrom,
validTo: dto.validTo,
isActive: dto.isActive ?? true,
});
return withValidityStatus(agent);
}
async update(id: string, dto: UpdateTransitAgentDto): Promise<TransitAgentView> {
const current = await this.findById(id);
const nextValidFrom = dto.validFrom ?? current.validFrom;
const nextValidTo = dto.validTo ?? current.validTo;
if (nextValidTo < nextValidFrom) {
throw new BadRequestException('Valid-to date must be on or after valid-from date.');
}
const updated = await this.transitAgentsRepository.update(id, {
...dto,
...(dto.name ? { name: dto.name.trim() } : {}),
});
if (!updated) {
throw new NotFoundException(`Transit agent ${id} not found`);
}
return withValidityStatus(updated);
}
async remove(id: string): Promise<void> {
await this.findById(id);
await this.transitAgentsRepository.softDelete(id);
}
}

View File

@@ -3679,6 +3679,7 @@ export class WarehouseInventoryService {
contractId: string | null;
hasLastMile: boolean;
handoverSigned: boolean;
inspectionStatus: string | null;
}>
> {
const rows: Array<{
@@ -3696,6 +3697,7 @@ export class WarehouseInventoryService {
contractId: string | null;
hasLastMile: boolean;
delivered: boolean;
inspectionStatus: string | null;
}> = await this.dataSource.query(
`SELECT bcu.container_number AS "containerNumber",
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods,
@@ -3710,7 +3712,8 @@ export class WarehouseInventoryService {
b.reference AS "bookingReference",
b.contract_id AS "contractId",
(b.last_mile_delivery_address IS NOT NULL) AS "hasLastMile",
COALESCE(inv.status = 'DELIVERED', false) AS delivered
COALESCE(inv.status = 'DELIVERED', false) AS delivered,
inv.inspection_status AS "inspectionStatus"
FROM freight.booking_container_units bcu
JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
@@ -3762,6 +3765,7 @@ export class WarehouseInventoryService {
bookingReference: r.bookingReference,
contractId: r.contractId,
hasLastMile: r.hasLastMile,
inspectionStatus: r.inspectionStatus,
handoverSigned,
}));
}

View File

@@ -164,20 +164,25 @@ export class EdrOrgSeeder {
manager: EntityManager,
applicationId: string,
) {
const permissionRepository = manager.getRepository(Permission);
// Upsert by key so reruns are idempotent; applicationId ties every
// permission to the EDR Freight application (also backfills rows that
// were previously seeded without the relation).
await permissionRepository.upsert(
EDR_FREIGHT_PERMISSIONS.map((permission) => ({
id: permission.id,
key: permission.key,
name: { ...permission.name },
applicationId,
})),
{ conflictPaths: { key: true } },
);
// iam.permissions has TWO unique columns (PK id, UQ key) but ON CONFLICT
// can only target one. Seeding a hand-minted id that some older/retired key
// already owns in an environment slips past ON CONFLICT (key) and dies on
// the PK. The key is the identity every consumer resolves by (positions
// seeder maps key -> id at runtime), so ids are left to the column default
// and never sent — no id can collide.
await manager
.createQueryBuilder()
.insert()
.into(Permission)
.values(
EDR_FREIGHT_PERMISSIONS.map((permission) => ({
key: permission.key,
name: { ...permission.name },
applicationId,
})),
)
.orUpdate(["name", "application_id"], ["key"])
.execute();
this.logger.log(
`Ensured ${EDR_FREIGHT_PERMISSIONS.length} permissions on application '${EDR_FREIGHT_APPLICATION.key}'`,

View File

@@ -0,0 +1,13 @@
import { EDR_FREIGHT_PERMISSIONS } from './edr-freight.seed';
describe('EDR_FREIGHT_PERMISSIONS', () => {
// The seeder inserts the whole catalog in one ON CONFLICT (key) DO UPDATE
// statement — a duplicated key there is a Postgres 21000 at boot, not a
// silent no-op.
it('has no duplicate keys', () => {
const keys = EDR_FREIGHT_PERMISSIONS.map((permission) => permission.key);
const duplicates = [...new Set(keys.filter((key, i) => keys.indexOf(key) !== i))];
expect(duplicates).toEqual([]);
});
});

View File

@@ -22,6 +22,7 @@ export const RULE_ENGINE_RESOURCE_SLUGS = [
// Keep new slugs at the END: ruleEngineCrudId derives ids from list index,
// so a mid-list insert would shift ids already seeded for later slugs.
'truck-types',
'transit-agents',
] as const;
export type RuleEngineResourceSlug = (typeof RULE_ENGINE_RESOURCE_SLUGS)[number];
@@ -108,8 +109,12 @@ export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [
perm('a3000001-0001-4000-8000-00000000001b', 'edr_freight_app:contracts:suspend', 'Suspend / resume a signed contract'),
];
// Existing per-slug view ids are kept as-is: position-type grants reference
// them by id, so re-minting would orphan those rows.
// Historical ids. EdrOrgSeeder no longer sends them — it upserts on `key` and
// lets the column default mint the uuid — so these are kept only as a record of
// which ids each environment already holds. A hand-picked id must still never
// be recycled from a retired key: `edr_freight_app:rule_engine:truck_types:manage`
// owned …001b, and reusing it for transit-agents crashed boot with a PK 23505
// on every environment that still had the retired row.
const RULE_ENGINE_VIEW_IDS: Record<RuleEngineResourceSlug, string> = {
'cargo-types': 'b2000001-0001-4000-8000-000000000001',
'container-types': 'b2000001-0001-4000-8000-000000000003',
@@ -123,6 +128,7 @@ const RULE_ENGINE_VIEW_IDS: Record<RuleEngineResourceSlug, string> = {
rates: 'b2000001-0001-4000-8000-000000000011',
'approval-rules': 'b2000001-0001-4000-8000-000000000013',
'yard-distances': 'b2000001-0001-4000-8000-000000000018',
'transit-agents': 'b2000003-0001-4000-8000-000000000001',
};
// CRUD replaces the retired coarse `:manage`. New ids live in a fresh block
@@ -136,7 +142,7 @@ const ruleEngineCrudId = (
): string => {
const n =
RULE_ENGINE_RESOURCE_SLUGS.indexOf(slug) * 3 +
RULE_ENGINE_CRUD_ACTIONS.indexOf(action) +
RULE_ENGINE_CRUD_ACTIONS.indexOf(action) +
1; // 1..36
return `b2000002-0001-4000-8000-${n.toString(16).padStart(12, '0')}`;
};

View File

@@ -0,0 +1,301 @@
import { useMemo, useState } from "react";
import { useQueries, useQuery } from "@tanstack/react-query";
import { Badge, Button, Center, Group, Loader, SimpleGrid, Stack, Table, Text } from "@mantine/core";
import { Coins, Truck } from "lucide-react";
import { api } from "@/services/api";
import { warehouseService } from "@/services/warehouse.service";
import { lastMileService } from "@/services/last-mile.service";
import { FeePreviewModal } from "@/components/warehouses/FeePreviewModal";
import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal";
import { SectionCard } from "./SectionCard";
import { MetricTile } from "./MetricTile";
const money = (amount: number, currency: string) =>
`${Number(amount).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`;
const fmt = (iso: string | null | undefined) => (iso ? new Date(iso).toLocaleString() : "—");
function inspectionLabel(status: string | null | undefined): { text: string; color: string } {
if (!status) return { text: "Pending", color: "gray" };
if (status === "PASSED") return { text: "Passed", color: "edr-green" };
if (status === "FAILED") return { text: "Failed", color: "red" };
return { text: status, color: "gray" };
}
interface TruckRow {
key: string;
plate: string;
driver: string | null;
truckType: string | null;
containers: string[];
warehouseArrived: string | null;
warehouseDeparted: string | null;
destinationArrived: string | null;
returned: string | null;
detentionOpen: boolean;
detentionDays: number | null;
detentionAmount: number | null;
hasDetentionRule: boolean;
inspection: { text: string; color: string };
}
/**
* Every truck tied to a booking's last mile — EDR-dispatched or customer
* self-haul (a booking only ever uses one), each with its own warehouse-gate
* and destination-detention clocks, plus the booking's cargo-side cost totals
* (storage/demurrage/double handling — billed per row internally, always
* shown here as one booking-level total). Detention stays EDR-only; customer
* self-haul rows show "—" since EDR only bills detention on its own fleet.
*/
export function BookingTrucksPanel({ bookingId }: { bookingId: string }) {
const [feeModalOpen, setFeeModalOpen] = useState(false);
const [detentionModalOpen, setDetentionModalOpen] = useState(false);
const inventoryQuery = useQuery(
api.warehouses.listInventory.queryOptions({ input: { filter: { bookingId } } }),
);
const inventoryItems = inventoryQuery.data ?? [];
const latestInventory = inventoryItems[0] ?? null;
const edrTrucksQuery = useQuery({
queryKey: ["booking-edr-trucks", bookingId],
queryFn: () => warehouseService.getLastMileTrucks(bookingId),
});
const edrTrucks = edrTrucksQuery.data ?? [];
const customerTrucksQuery = useQuery({
queryKey: ["booking-customer-trucks", bookingId],
queryFn: () => warehouseService.getCustomerTrucks(bookingId),
enabled: edrTrucksQuery.isSuccess && edrTrucks.length === 0,
});
const customerTrucks = customerTrucksQuery.data ?? [];
const mode: "EDR" | "CUSTOMER" | "NONE" =
edrTrucks.length > 0 ? "EDR" : customerTrucks.length > 0 ? "CUSTOMER" : "NONE";
const containerItemsQuery = useQuery({
queryKey: ["booking-container-items-for-trucks", bookingId],
queryFn: () => warehouseService.getContainerItems(bookingId),
});
const inspectionByContainer = new Map(
(containerItemsQuery.data ?? []).map((c) => [c.containerNumber, c.inspectionStatus]),
);
const lastMileId = edrTrucks[0]?.lastMileId ?? null;
const detentionPreviewQuery = useQuery({
queryKey: ["truck-detention-preview-for-trucks-tab", lastMileId],
queryFn: () => lastMileService.truckDetentionPreview(lastMileId as string).then((r) => r.data),
enabled: Boolean(lastMileId),
});
const detentionPreview = detentionPreviewQuery.data;
const detentionByVehicle = new Map(
(detentionPreview?.groups ?? []).map((g) => [g.vehicleId ?? "", g]),
);
const lastMileRecordQuery = useQuery({
queryKey: ["last-mile-record-for-trucks-tab", lastMileId],
queryFn: () => lastMileService.getById(lastMileId as string).then((r) => r.data),
enabled: Boolean(lastMileId),
});
// Booking-level cost strip: same per-row fee preview the accrual dashboard
// and FeePreviewModal already use, summed across every inventory row on
// this booking rather than duplicated per row.
const feeQueries = useQueries({
queries: inventoryItems.map((item) =>
api.warehouses.feePreview.queryOptions({ input: { inventoryId: item.id, billingCurrency: "USD" } }),
),
});
const allFees = feeQueries.flatMap((q) => q.data ?? []);
const feeCurrency = allFees[0]?.currency ?? "USD";
const sumByType = (type: string) =>
allFees.filter((f) => f.ruleType === type).reduce((sum, f) => sum + Number(f.amount || 0), 0);
const rows: TruckRow[] = useMemo(() => {
if (mode === "EDR") {
return edrTrucks.map((t) => {
const g = detentionByVehicle.get(t.vehicleId);
return {
key: t.vehicleId,
plate: [t.truckPlateNumber, t.trailerPlateNumber].filter(Boolean).join(" + ") || "—",
driver: t.driverName,
truckType: t.truckType,
containers: t.containerNumber ? [t.containerNumber] : [],
warehouseArrived: t.arrivedAt,
warehouseDeparted: t.departedAt,
destinationArrived: g?.startDate ?? null,
returned: g?.endIsOpen ? null : g?.endDate ?? null,
detentionOpen: Boolean(g?.endIsOpen),
detentionDays: g?.chargeableDays ?? null,
detentionAmount: g?.amount ?? null,
hasDetentionRule: Boolean(g?.ruleId),
inspection: inspectionLabel(t.containerNumber ? inspectionByContainer.get(t.containerNumber) : undefined),
};
});
}
if (mode === "CUSTOMER") {
return customerTrucks.map((t) => {
const containers = (t.containers ?? []).map((c) => c.containerNumber);
const statuses = new Set(containers.map((cn) => inspectionByContainer.get(cn) ?? null));
const inspection =
containers.length === 0
? inspectionLabel(undefined)
: statuses.size > 1
? { text: "Mixed", color: "yellow" }
: inspectionLabel([...statuses][0]);
return {
key: t.id,
plate: t.plateNumber,
driver: t.driverName,
truckType: t.truckType,
containers,
warehouseArrived: t.arrivedAt ?? null,
warehouseDeparted: t.departedAt ?? null,
destinationArrived: null,
returned: null,
detentionOpen: false,
detentionDays: null,
detentionAmount: null,
hasDetentionRule: false,
inspection,
};
});
}
return [];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode, edrTrucks, customerTrucks, inspectionByContainer, detentionByVehicle]);
if (inventoryQuery.isLoading || edrTrucksQuery.isLoading) {
return (
<Center py={60}>
<Group gap={10}>
<Loader color="edr-green" />
<Text c="dimmed">Loading trucks</Text>
</Group>
</Center>
);
}
return (
<Stack gap="lg">
<SectionCard
icon={Coins}
title="Cargo costs"
subtitle="Storage, demurrage & double handling — booking total"
accent="teal"
extra={
latestInventory && (
<Button size="xs" variant="light" onClick={() => setFeeModalOpen(true)}>
View breakdown
</Button>
)
}
>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="sm">
<MetricTile label="Storage" value={money(sumByType("STORAGE_FEE"), feeCurrency)} />
<MetricTile label="Demurrage" value={money(sumByType("DEMURRAGE_FEE"), feeCurrency)} />
<MetricTile label="Double handling" value={money(sumByType("DOUBLE_HANDLING_FEE"), feeCurrency)} />
</SimpleGrid>
</SectionCard>
<SectionCard
icon={Truck}
title="Trucks"
subtitle={
mode === "EDR" ? "EDR Last Mile" : mode === "CUSTOMER" ? "Customer Self-Haul" : undefined
}
accent="grape"
extra={
mode === "EDR" && (
<Button size="xs" variant="light" onClick={() => setDetentionModalOpen(true)}>
Detention times
</Button>
)
}
>
{rows.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="md">
No trucks assigned to this booking's last mile yet.
</Text>
) : (
<Table.ScrollContainer minWidth={1000}>
<Table verticalSpacing="xs" fz="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Plate</Table.Th>
<Table.Th>Driver</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Container(s)</Table.Th>
<Table.Th>Wh. arrived</Table.Th>
<Table.Th>Wh. departed</Table.Th>
<Table.Th>Dest. arrived</Table.Th>
<Table.Th>Returned</Table.Th>
<Table.Th>Detention</Table.Th>
<Table.Th>Inspection</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((r) => (
<Table.Tr key={r.key}>
<Table.Td>{r.plate}</Table.Td>
<Table.Td>{r.driver ?? "—"}</Table.Td>
<Table.Td>{r.truckType ?? "—"}</Table.Td>
<Table.Td>{r.containers.length ? r.containers.join(", ") : "—"}</Table.Td>
<Table.Td>{fmt(r.warehouseArrived)}</Table.Td>
<Table.Td>{fmt(r.warehouseDeparted)}</Table.Td>
<Table.Td>{fmt(r.destinationArrived)}</Table.Td>
<Table.Td>
{r.detentionOpen ? (
<Badge size="xs" color="orange" variant="light">
still out
</Badge>
) : (
fmt(r.returned)
)}
</Table.Td>
<Table.Td>
{mode !== "EDR" || r.detentionDays == null ? (
"—"
) : (
<>
{r.detentionDays}d · {money(r.detentionAmount ?? 0, detentionPreview?.currency ?? "USD")}
{!r.hasDetentionRule && (
<Text span size="xs" c="red">
{" "}
· no rule
</Text>
)}
</>
)}
</Table.Td>
<Table.Td>
<Badge size="xs" variant="light" color={r.inspection.color}>
{r.inspection.text}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
</SectionCard>
<FeePreviewModal
opened={feeModalOpen}
onClose={() => setFeeModalOpen(false)}
inventoryId={latestInventory?.id ?? null}
/>
{mode === "EDR" && (
<TruckDetentionModal
opened={detentionModalOpen}
onClose={() => setDetentionModalOpen(false)}
record={lastMileRecordQuery.data ?? null}
/>
)}
</Stack>
);
}

View File

@@ -2,6 +2,7 @@ export * from "./booking-detail.styles";
export * from "./SectionCard";
export * from "./ClearanceReviewSection";
export * from "./BookingDocumentsPanel";
export * from "./BookingTrucksPanel";
export * from "./ContractOrdersPanel";
export * from "./MetricTile";
export * from "./BookingDetailToolbar";

View File

@@ -1,12 +1,15 @@
import type { ReactNode } from "react";
import { Badge, Stack, Tabs, Text } from "@mantine/core";
import { AlertTriangle, FileText, ShieldAlert } from "lucide-react";
import { AlertTriangle, FileText, Share2, ShieldAlert } from "lucide-react";
import type { Freight } from "@edr/types";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { AssignRiskCard } from "@/components/contracts/gl-actions/AssignRiskCard";
import { IncidentReportCard } from "@/components/contracts/gl-actions/IncidentReportCard";
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
import { GlExchangePanel } from "@/components/contracts/GlExchangePanel";
export interface ClearanceOpsTabsProps {
bookingId: string | undefined;
@@ -17,6 +20,12 @@ export interface ClearanceOpsTabsProps {
/** Phased customs workflow files — enables the Uploaded documents tab. */
workflowFiles?: Freight.ClearanceWorkflowFile[];
showWorkflowFilesTab?: boolean;
/**
* Booking or contract id whose GL Ethiopia ↔ GL Djibouti document exchange
* belongs on this page. Undefined hides the tab; it is also hidden from staff
* who hold neither desk's clearance-actions permission.
*/
exchangeEntityId?: string;
tradeDirection?: string;
onViewFile?: (file: { name: string; url: string }) => void;
onDownloadFile?: (file: { id: string; name: string }) => void;
@@ -40,6 +49,7 @@ export function ClearanceOpsTabs({
clearanceTab,
workflowFiles = [],
showWorkflowFilesTab = false,
exchangeEntityId,
tradeDirection = "IMPORT",
onViewFile,
onDownloadFile,
@@ -53,7 +63,12 @@ export function ClearanceOpsTabs({
return true;
}).length;
const showDocuments = showWorkflowFilesTab && Boolean(onViewFile);
const hasTabs = (showOpsTabs && hasOps) || showDocuments;
const { user } = useAuth();
const showExchange =
Boolean(exchangeEntityId) &&
(hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) ||
hasPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions));
const hasTabs = (showOpsTabs && hasOps) || showDocuments || showExchange;
if (!hasTabs) {
return <>{clearanceTab}</>;
@@ -78,6 +93,11 @@ export function ClearanceOpsTabs({
Uploaded documents
</Tabs.Tab>
) : null}
{showExchange ? (
<Tabs.Tab value="exchange" leftSection={<Share2 size={14} />}>
Document exchange
</Tabs.Tab>
) : null}
{showOpsTabs && riskMs ? (
<Tabs.Tab value="risk" leftSection={<ShieldAlert size={14} />}>
Risk assignment
@@ -103,6 +123,12 @@ export function ClearanceOpsTabs({
</Tabs.Panel>
) : null}
{showExchange ? (
<Tabs.Panel value="exchange">
<GlExchangePanel entityId={exchangeEntityId!} />
</Tabs.Panel>
) : null}
{showOpsTabs && riskMs && bookingId ? (
<Tabs.Panel value="risk">
<SectionCard icon={ShieldAlert} title="Customs risk" accent="edr-green">

View File

@@ -21,12 +21,14 @@ const CATEGORY_LABELS: Record<
string
> = {
declaration: "Declaration",
draft_declaration: "Draft declaration",
duty: "Duty & taxes",
transit: "Transit",
djibouti: "Djibouti",
};
const CATEGORY_ORDER: Freight.ClearanceWorkflowFileCategory[] = [
"draft_declaration",
"declaration",
"duty",
"transit",

View File

@@ -99,16 +99,20 @@ export function ContractMilestonesTimeline({
});
}
// Every acted approval step, not just hazardous ones — this is the one
// place the approval-time record shows up in the page's main content
// (the sidebar's ContractApprovalStepsCard has the same times, but only
// there, and only while the chain is still actionable).
for (const step of contract.approvalSteps ?? []) {
if (!(step.requiredRole in HAZARDOUS_APPROVAL_ROLE_PERMISSION)) continue;
if (!step.actedAt) continue;
const hazard = step.requiredRole in HAZARDOUS_APPROVAL_ROLE_PERMISSION;
items.push({
key: `hazard-${step.id}`,
key: `step-${step.id}`,
at: step.actedAt,
title: CONTRACT_APPROVAL_ROLE_LABELS[step.requiredRole] ?? step.requiredRole,
detail: step.status === "REJECTED" ? "Rejected" : "Approved",
color: step.status === "REJECTED" ? "red" : "orange",
icon: Flame,
title: `${CONTRACT_APPROVAL_ROLE_LABELS[step.requiredRole] ?? step.requiredRole} ${step.status === "REJECTED" ? "rejected" : "approved"}`,
detail: step.note ?? undefined,
color: step.status === "REJECTED" ? "red" : hazard ? "orange" : "edr-green",
icon: hazard ? Flame : ShieldCheck,
});
}

View File

@@ -1,9 +1,10 @@
import { Badge, Group } from "@mantine/core";
import { Repeat } from "lucide-react";
import { Building2, Repeat, UserRound } from "lucide-react";
import {
CONTRACT_STATUS_COLOR,
CONTRACT_STATUS_STYLES,
contractCourt,
} from "@/features/contracts/contract-status.config";
interface ContractStatusBadgeProps {
@@ -69,3 +70,42 @@ export function ContractStatusBadge({
</Group>
);
}
/** Whose court the contract sits in: customer, EDR, or nobody ("—"). */
export function ContractCourtBadge({ status }: { status: string }) {
const court = contractCourt(status);
if (!court) {
return (
<span className="text-sm text-muted-foreground" title="No party is awaited">
</span>
);
}
const isCustomer = court === "customer";
return (
<Badge
color={isCustomer ? "orange" : "edr-green"}
variant="light"
size="sm"
radius="md"
tt="uppercase"
fw={600}
leftSection={
isCustomer ? <UserRound size={12} /> : <Building2 size={12} />
}
title={
isCustomer
? "Waiting on the customer to act"
: "Waiting on EDR staff to act"
}
style={{
fontSize: "0.7rem",
letterSpacing: "0.05em",
display: "inline-flex",
whiteSpace: "nowrap",
}}
>
{isCustomer ? "With customer" : "With EDR"}
</Badge>
);
}

View File

@@ -20,6 +20,7 @@ import {
CheckCircle2,
FileText,
PackageCheck,
PackageOpen,
Receipt,
Ship,
Train,
@@ -31,6 +32,7 @@ import toast from "react-hot-toast";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
import { TransitAssigneePanel } from "@/components/contracts/TransitAssigneePanel";
import {
TransitPermitMultiUpload,
type TransitPermitUploadedRow,
@@ -41,9 +43,11 @@ import {
} from "@/components/contracts/PhasedUploadedFileRow";
import {
DeclarationStep,
OffloadStep,
StepStatus,
isBookingMilestoneDone,
isMilestoneDone,
offloadSummary,
type ClearanceViewLike,
type MilestoneRow,
} from "@/components/contracts/PhasedClearanceActionPanel";
@@ -59,7 +63,8 @@ function todayISODate(): string {
/**
* Export customs flow, ordered per the stakeholder process:
* customer docs → RO (DJ) → declaration (ET, auto-releases) → create booking (ET)
* customer docs → transit assignee (DJ names officer) → declaration (ET,
* releases the export) → RO (DJ, auto-releases) → create booking (ET)
* → payment + wagons → transport document / T1 (ET) → train to Djibouti
* → accept T1 (DJ, one button after arrival) → gate pass (DJ)
* → final invoice (DJ) + customer slip + GL confirm.
@@ -71,9 +76,10 @@ export function computeExportActiveStep(
): number {
const released = Boolean(clearance.bookingReady || clearance.operationReady);
if (!isMilestoneDone(clearance.milestones, "DOCUMENTS_APPROVED")) return 0;
if (!isMilestoneDone(clearance.milestones, "RELEASE_ORDER_SECURED")) return 1;
if (!isMilestoneDone(clearance.milestones, "DECLARED") || !released) return 2;
if (!bookingCreated) return 3;
if (!clearance.transitAssignee?.name) return 1;
if (!isMilestoneDone(clearance.milestones, "DECLARED")) return 2;
if (!isMilestoneDone(clearance.milestones, "RELEASE_ORDER_SECURED") || !released) return 3;
if (!bookingCreated) return 4;
if (
!isBookingMilestoneDone(bookingMilestones, "FREIGHT_PAYMENT_SETTLED") ||
!(
@@ -81,14 +87,17 @@ export function computeExportActiveStep(
clearance.train?.wagonAllocated
)
) {
return 4;
return 5;
}
if (!isBookingMilestoneDone(bookingMilestones, "EXPORT_TRANSPORT_ISSUED")) return 5;
if (!clearance.train?.arrivedAt) return 6;
if (!clearance.t1Closed) return 7;
if (!clearance.gatepassGranted) return 8;
if (clearance.finalInvoice?.status !== "PAID") return 9;
return 10;
if (!isBookingMilestoneDone(bookingMilestones, "EXPORT_TRANSPORT_ISSUED")) return 6;
if (!clearance.train?.arrivedAt) return 7;
if (!clearance.t1Closed) return 8;
if (!clearance.gatepassGranted) return 9;
// Step 10 is the read-only Offload step. It never gates the flow: the final
// invoice may be raised on a secured gate pass alone, so parking the stepper
// there would hide the invoice actions whenever operations lag on the offload.
if (clearance.finalInvoice?.status !== "PAID") return 11;
return 12;
}
export function exportTransitFilesFromWorkflow(
@@ -168,6 +177,7 @@ export function ExportClearanceStepper({
isBookingMilestoneDone(bookingMilestones, "WAGON_ALLOCATED") ||
Boolean(clearance.train?.wagonAllocated);
const transportIssued = isBookingMilestoneDone(bookingMilestones, "EXPORT_TRANSPORT_ISSUED");
const offloadDone = clearance.offload?.offloaded ?? Boolean(clearance.offloaded);
return (
<Stack gap="md">
@@ -213,6 +223,58 @@ export function ExportClearanceStepper({
/>
</Stepper.Step>
<Stepper.Step
label="Request transit assignee"
description="Ask GL Djibouti to name the officer handling this shipment"
icon={
clearance.transitAssignee?.name ? (
<CheckCircle2 size={14} />
) : undefined
}
>
{showEt && canEt ? (
<TransitAssigneePanel
entityId={entityId}
isBooking={isBooking}
transitAssignee={clearance.transitAssignee}
side="ET"
onChanged={onChanged}
/>
) : (
<StepStatus
done={Boolean(clearance.transitAssignee?.name)}
pendingLabel="Waiting for GL Ethiopia to request a transit assignee from GL Djibouti."
doneLabel={`Transit assignee: ${clearance.transitAssignee?.name ?? ""}`}
/>
)}
</Stepper.Step>
<Stepper.Step
label="Customs declaration"
description="GL Ethiopia uploads — releases the export"
icon={declared ? <CheckCircle2 size={14} /> : <FileText size={14} />}
>
{showEt && canEt && !effectiveBookingCreated && (activeStep >= 2 || declared) ? (
<Stack gap="sm">
<DeclarationStep
entityId={entityId}
isBooking={isBooking}
replaceMode={declared}
workflowFiles={workflowFiles}
onChanged={onChanged}
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
/>
</Stack>
) : (
<StepStatus
done={declared}
pendingLabel="Waiting for GL Ethiopia to upload the customs declaration."
doneLabel="Declaration uploaded."
/>
)}
</Stepper.Step>
<Stepper.Step
label="Release Order"
description="GL Djibouti uploads RO + vessel date"
@@ -255,31 +317,13 @@ export function ExportClearanceStepper({
</Text>
) : null}
<StepStatus
done={isMilestoneDone(clearance.milestones, "RELEASE_ORDER_SECURED")}
done={isMilestoneDone(clearance.milestones, "RELEASE_ORDER_SECURED") && released}
pendingLabel="Waiting for GL Djibouti to upload the Release Order."
doneLabel="Release Order secured."
doneLabel="Release Order secured — export released."
/>
</Stack>
)}
</Stepper.Step>
<Stepper.Step
label="Customs declaration"
description="GL Ethiopia uploads — releases the export"
icon={declared ? <CheckCircle2 size={14} /> : <FileText size={14} />}
>
{showEt && canEt && !effectiveBookingCreated && (activeStep >= 2 || declared) ? (
<Stack gap="sm">
<DeclarationStep
entityId={entityId}
isBooking={isBooking}
replaceMode={declared}
workflowFiles={workflowFiles}
onChanged={onChanged}
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
/>
{declared && !released ? (
{/* RO is secured but the auto-release never fired (legacy in-flight
contracts from before the RO step auto-released). */}
{isMilestoneDone(clearance.milestones, "RELEASE_ORDER_SECURED") && !released ? (
<ConfirmExportReleaseFallback
entityId={entityId}
isBooking={isBooking}
@@ -287,12 +331,6 @@ export function ExportClearanceStepper({
/>
) : null}
</Stack>
) : (
<StepStatus
done={declared && released}
pendingLabel="Waiting for GL Ethiopia to upload the customs declaration."
doneLabel="Declaration uploaded — export released."
/>
)}
</Stepper.Step>
@@ -432,6 +470,21 @@ export function ExportClearanceStepper({
<GatepassStep clearance={clearance} />
</Stepper.Step>
{/* Read-only: operations record the offload when the train is unloaded
at the Djibouti port. Stats ride in the description so they stay
visible after the flow moves on to the final invoice. */}
<Stepper.Step
label="Offload"
description={offloadSummary(clearance.offload, Boolean(clearance.offloaded))}
color={offloadDone ? undefined : "gray"}
icon={<PackageOpen size={14} />}
completedIcon={
offloadDone ? <CheckCircle2 size={14} /> : <PackageOpen size={14} />
}
>
<OffloadStep clearance={clearance} />
</Stepper.Step>
<Stepper.Step
label="Final invoice & payment"
description="GL Djibouti invoices after offload; customer pays"
@@ -656,6 +709,8 @@ function FinalInvoiceStep({
const invoice = clearance.finalInvoice ?? null;
const paid = invoice?.status === "PAID";
// Raised as a draft — the customer approves it before paying.
const approved = Boolean(invoice?.approvedAt);
// Export: OFFLOADED is a DJ doc milestone that may never be recorded, so the
// secured gate pass is enough to open invoicing. Sending an invoice is optional.
@@ -684,7 +739,7 @@ function FinalInvoiceStep({
</Text>
</div>
<Badge color={paid ? "edr-green" : "yellow"} variant="light">
{invoice.status}
{approved ? invoice.status : "AWAITING CUSTOMER APPROVAL"}
</Badge>
</Group>
</Paper>
@@ -722,9 +777,11 @@ function FinalInvoiceStep({
<StepStatus
done={false}
pendingLabel={
invoice.slipFile
? "Payment slip attached — confirm to settle the invoice."
: "Waiting for the customer to pay and attach the payment slip."
!approved
? "Waiting for the customer to review and approve the invoice."
: invoice.slipFile
? "Payment slip attached — confirm to settle the invoice."
: "Waiting for the customer to pay and attach the payment slip."
}
doneLabel=""
/>
@@ -754,6 +811,7 @@ function FinalInvoiceStep({
<>
<Text size="sm" c="dimmed">
Send the final invoice to the customer if post-arrival charges apply (optional).
The customer approves it before paying.
</Text>
<Button
color="edr-green"

View File

@@ -0,0 +1,479 @@
import { useMemo, useState } from "react";
import {
ActionIcon,
Badge,
Box,
Button,
Group,
Loader,
Menu,
Modal,
Paper,
Stack,
Switch,
Text,
TextInput,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Download,
Eye,
EyeOff,
FileText,
MoreVertical,
Pencil,
Share2,
Trash2,
Upload,
UserCheck,
} from "lucide-react";
import dayjs from "dayjs";
import toast from "react-hot-toast";
import type { Freight } from "@edr/types";
import { isViewable } from "@edr/ui-common";
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
import { useFileViewer } from "@/hooks/useFileViewer";
import { downloadBookingFile, fetchViewableFile } from "@/services/files.service";
import { glExchangeService } from "@/services/glExchange.service";
const SIDES: Record<Freight.GlExchangeDocument["side"], { label: string; color: string }> =
{
ET: { label: "GL Ethiopia", color: "edr-green" },
DJ: { label: "GL Djibouti", color: "blue" },
};
function formatBytes(bytes: number): string {
if (!bytes) return "0 B";
const units = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${parseFloat((bytes / 1024 ** i).toFixed(1))} ${units[i]}`;
}
export interface GlExchangePanelProps {
/** Booking or contract id both desks are working on — the thread key. */
entityId: string;
}
/**
* GL Ethiopia ↔ GL Djibouti document exchange. Either desk attaches any file
* under a title of its own choosing; both desks see the whole thread, only the
* uploader can change or remove what they posted, and each document is shared
* with the customer's portal or kept between the desks.
*/
export function GlExchangePanel({ entityId }: GlExchangePanelProps) {
const queryClient = useQueryClient();
const { view, viewer } = useFileViewer();
const [formDoc, setFormDoc] = useState<
Freight.GlExchangeDocument | "new" | null
>(null);
const [pendingDelete, setPendingDelete] =
useState<Freight.GlExchangeDocument | null>(null);
const {
data: documents = [],
isLoading,
isError,
} = useQuery({
queryKey: ["gl-exchange", entityId],
queryFn: () => glExchangeService.list(entityId),
enabled: Boolean(entityId),
});
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: ["gl-exchange", entityId] });
const removeMutation = useMutation({
mutationFn: (id: string) => glExchangeService.remove(id),
onSuccess: async () => {
setPendingDelete(null);
await invalidate();
toast.success("Document removed");
},
onError: (e: unknown) =>
toast.error(e instanceof Error ? e.message : "Could not remove document"),
});
const stats = useMemo(
() => ({
et: documents.filter((d) => d.side === "ET").length,
dj: documents.filter((d) => d.side === "DJ").length,
shared: documents.filter((d) => d.visibleToCustomer).length,
}),
[documents],
);
return (
<Stack gap="md">
<Paper withBorder radius="md" p="md">
<Group justify="space-between" align="flex-start" wrap="wrap" gap="sm">
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon variant="light" color="edr-green" radius="md" size={44}>
<Share2 size={20} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Text fw={700} fz={16}>
Document exchange
</Text>
<Text size="xs" c="dimmed">
Share any document with the other Global Logistics desk. Both
desks see everything here; only the uploader can edit or remove
a document, and only documents marked visible reach the customer.
</Text>
</Box>
</Group>
<Button
color="edr-green"
radius="md"
leftSection={<Upload size={16} />}
onClick={() => setFormDoc("new")}
>
Share document
</Button>
</Group>
{documents.length > 0 ? (
<Group gap={8} mt="md">
<Badge variant="light" color="edr-green" radius="sm" tt="none">
{stats.et} from GL Ethiopia
</Badge>
<Badge variant="light" color="blue" radius="sm" tt="none">
{stats.dj} from GL Djibouti
</Badge>
<Badge variant="light" color="gray" radius="sm" tt="none">
{stats.shared} visible to customer
</Badge>
</Group>
) : null}
</Paper>
{isLoading ? (
<Group justify="center" py={40} gap={10}>
<Loader size="sm" color="edr-green" />
<Text size="sm" c="dimmed">
Loading shared documents
</Text>
</Group>
) : isError ? (
<Text size="sm" c="red">
Could not load the shared documents.
</Text>
) : documents.length === 0 ? (
<EmptyState onShare={() => setFormDoc("new")} />
) : (
<Stack gap={8}>
{documents.map((doc) => (
<DocumentRow
key={doc.id}
doc={doc}
onView={view}
onEdit={() => setFormDoc(doc)}
onDelete={() => setPendingDelete(doc)}
/>
))}
</Stack>
)}
<DocumentFormModal
entityId={entityId}
doc={formDoc === "new" ? null : formDoc}
opened={formDoc != null}
onClose={() => setFormDoc(null)}
onSaved={() => {
setFormDoc(null);
void invalidate();
}}
/>
<Modal
opened={pendingDelete != null}
onClose={() => setPendingDelete(null)}
title={<Text fw={700}>Remove shared document</Text>}
radius="md"
size="sm"
>
<Stack gap="md">
<Text size="sm">
Remove <b>{pendingDelete?.title}</b> from the exchange? The other
desk and the customer, if it was shared will no longer see it.
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setPendingDelete(null)}>
Cancel
</Button>
<Button
color="red"
loading={removeMutation.isPending}
leftSection={<Trash2 size={15} />}
onClick={() => removeMutation.mutate(pendingDelete!.id)}
>
Remove
</Button>
</Group>
</Stack>
</Modal>
{viewer}
</Stack>
);
}
function EmptyState({ onShare }: { onShare: () => void }) {
return (
<Box
py={44}
style={{
borderRadius: 12,
border: "1px dashed var(--mantine-color-gray-4)",
background: "var(--mantine-color-gray-0)",
textAlign: "center",
}}
>
<Stack gap={10} align="center">
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
<FileText size={22} />
</ThemeIcon>
<Text size="sm" c="dimmed" maw={380}>
Nothing shared yet. Anything either desk uploads here scans,
correspondence, corrected forms is visible to the other side
immediately.
</Text>
<Button
variant="light"
color="edr-green"
radius="md"
leftSection={<Upload size={15} />}
onClick={onShare}
>
Share the first document
</Button>
</Stack>
</Box>
);
}
function DocumentRow({
doc,
onView,
onEdit,
onDelete,
}: {
doc: Freight.GlExchangeDocument;
onView: (file: { name: string; url: string }) => void;
onEdit: () => void;
onDelete: () => void;
}) {
const side = SIDES[doc.side];
const canPreview = isViewable({ name: doc.file.name, url: "" });
return (
<Paper withBorder radius="md" p="sm">
<Group justify="space-between" wrap="nowrap" align="flex-start" gap="sm">
<Group gap={12} wrap="nowrap" align="flex-start" style={{ minWidth: 0 }}>
<ThemeIcon variant="light" color={side.color} radius="md" size={40}>
<FileText size={18} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Group gap={8} wrap="nowrap">
<Text size="sm" fw={700} truncate>
{doc.title}
</Text>
<Badge size="xs" variant="light" color={side.color} radius="sm" tt="none">
{side.label}
</Badge>
<Badge
size="xs"
variant="light"
color={doc.visibleToCustomer ? "teal" : "gray"}
radius="sm"
tt="none"
leftSection={
doc.visibleToCustomer ? <Eye size={11} /> : <EyeOff size={11} />
}
>
{doc.visibleToCustomer ? "Visible to customer" : "GL only"}
</Badge>
</Group>
<Text size="xs" c="dimmed" mt={4} truncate>
{doc.file.name} · {formatBytes(doc.file.size)} ·{" "}
{doc.uploadedByName ?? "Global Logistics"} ·{" "}
{dayjs(doc.uploadedAt).format("D MMM YYYY, HH:mm")}
</Text>
</Box>
</Group>
<Group gap={6} wrap="nowrap">
{canPreview ? (
<Tooltip label="Preview">
<Button
size="compact-xs"
variant="default"
radius="md"
leftSection={<Eye size={13} />}
onClick={() =>
void fetchViewableFile(doc.file.id, doc.file.name).then(onView)
}
>
View
</Button>
</Tooltip>
) : null}
<Tooltip label="Download">
<Button
size="compact-xs"
variant="light"
radius="md"
leftSection={<Download size={13} />}
onClick={() =>
void downloadBookingFile(doc.file.id, doc.file.name)
}
>
Download
</Button>
</Tooltip>
{doc.canEdit ? (
<Menu position="bottom-end" radius="md" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray" aria-label="Document actions">
<MoreVertical size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item leftSection={<Pencil size={14} />} onClick={onEdit}>
Edit title, visibility or file
</Menu.Item>
<Menu.Item
color="red"
leftSection={<Trash2 size={14} />}
onClick={onDelete}
>
Remove
</Menu.Item>
</Menu.Dropdown>
</Menu>
) : (
<Tooltip label={`Only ${doc.uploadedByName ?? "the uploader"} can edit this`}>
<ThemeIcon variant="subtle" color="gray" size={28}>
<UserCheck size={15} />
</ThemeIcon>
</Tooltip>
)}
</Group>
</Group>
</Paper>
);
}
function DocumentFormModal({
entityId,
doc,
opened,
onClose,
onSaved,
}: {
entityId: string;
doc: Freight.GlExchangeDocument | null;
opened: boolean;
onClose: () => void;
onSaved: () => void;
}) {
const editing = doc != null;
const [title, setTitle] = useState("");
const [visible, setVisible] = useState(false);
const [file, setFile] = useState<File | null>(null);
// Re-seed the form whenever a different document (or "new") opens it.
const [seededFor, setSeededFor] = useState<string | null>(null);
const seedKey = opened ? (doc?.id ?? "new") : null;
if (seedKey !== seededFor) {
setSeededFor(seedKey);
setTitle(doc?.title ?? "");
setVisible(doc?.visibleToCustomer ?? false);
setFile(null);
}
const save = useMutation({
mutationFn: () =>
editing
? glExchangeService.update(doc.id, {
title: title.trim(),
visibleToCustomer: visible,
file,
})
: glExchangeService.upload(entityId, {
title: title.trim(),
visibleToCustomer: visible,
file: file!,
}),
onSuccess: () => {
toast.success(editing ? "Document updated" : "Document shared");
onSaved();
},
onError: (e: unknown) =>
toast.error(e instanceof Error ? e.message : "Could not save document"),
});
return (
<Modal
opened={opened}
onClose={onClose}
title={
<Group gap={8}>
<Share2 size={18} />
<Text fw={700}>{editing ? "Edit shared document" : "Share a document"}</Text>
</Group>
}
radius="md"
size="md"
>
<Stack gap="md">
<TextInput
label="Document title"
placeholder="e.g. Corrected packing list for container TCLU1234567"
description="What the other desk (and the customer, if shared) will see."
value={title}
onChange={(e) => setTitle(e.currentTarget.value)}
maxLength={300}
required
/>
<PhasedFileDropzone
label={editing ? "Replacement file (optional)" : "File"}
description={
editing
? "Leave empty to keep the current file."
: "Any document type — PDF, image, spreadsheet."
}
accept="*/*"
value={file}
onChange={setFile}
replaceMode={editing}
/>
<Switch
checked={visible}
onChange={(e) => setVisible(e.currentTarget.checked)}
color="edr-green"
label="Visible to the customer"
description="Shows in the customer's booking documents. Off keeps it between the two GL desks."
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose} disabled={save.isPending}>
Cancel
</Button>
<Button
color="edr-green"
loading={save.isPending}
disabled={!title.trim() || (!editing && !file)}
leftSection={<Upload size={16} />}
onClick={() => save.mutate()}
>
{editing ? "Save changes" : "Share document"}
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -27,6 +27,7 @@ import {
FileText,
MessageSquareWarning,
PackageCheck,
PackageOpen,
Receipt,
ShieldAlert,
Ship,
@@ -61,7 +62,8 @@ export type ClearanceViewLike = Pick<
| "nextAction"
| "dutyRequired"
| "dutyAdvice"
| "dutyDispute"
| "draftDeclaration"
| "draftDeclarationChangeRequest"
| "transitAssignee"
| "roHold"
| "roHoldReason"
@@ -77,6 +79,7 @@ export type ClearanceViewLike = Pick<
| "t1Closed"
| "t1ClosedAt"
| "offloaded"
| "offload"
| "finalInvoice"
| "vesselDepartureDate"
| "vesselArrivalDate"
@@ -113,32 +116,49 @@ function computeImportActiveStep(
bookingMilestones: MilestoneRow[],
t1Uploaded: boolean,
freightPaid: boolean,
isBooking: boolean,
): number {
if (!isMilestoneDone(clearance.milestones, "DOCUMENTS_APPROVED")) return 0;
if (!isMilestoneDone(clearance.milestones, "DECLARED")) return 1;
if (!clearance.transitAssignee?.name) return 1;
// Draft declaration is a booking-only step — the customer only ever reviews
// it on the booking-scoped portal page, so it never applies (and never
// gates) on the contract-scoped pre-booking page. Also a backward-compat
// guard: a booking that already has a real declaration filed got there
// before this step existed — never send it backward for a draft it was
// never asked to send.
if (
isBooking &&
!isMilestoneDone(clearance.milestones, "DRAFT_DECLARATION_ACCEPTED") &&
!isMilestoneDone(clearance.milestones, "DECLARED")
) {
return 2;
}
if (!isMilestoneDone(clearance.milestones, "DECLARED")) return 3;
if (
clearance.dutyRequired === null ||
clearance.dutyRequired === undefined ||
(clearance.dutyRequired && !isMilestoneDone(clearance.milestones, "DUTY_TAXES_ADVISED"))
) {
return 2;
return 4;
}
if (
clearance.dutyRequired &&
!isMilestoneDone(clearance.milestones, "DUTY_TAX_PAID")
) {
return 3;
return 5;
}
if (!isMilestoneDone(clearance.milestones, "TRANSIT_PERMIT_UPLOADED")) return 4;
if (!clearance.preClearanceFinalized) return 5;
if (!isMilestoneDone(clearance.milestones, "DO_COLLECTED")) return 6;
if (!bookingCreated) return 7;
if (!isMilestoneDone(clearance.milestones, "TRANSIT_PERMIT_UPLOADED")) return 6;
if (!clearance.preClearanceFinalized) return 7;
if (!isMilestoneDone(clearance.milestones, "DO_COLLECTED")) return 8;
if (!bookingCreated) return 9;
// The customer pays the train/freight charges on the booking. Until that
// settles the gate pass is not granted for this booking, so the flow stops here.
if (!freightPaid) return 8;
if (!clearance.gatepassGranted) return 9;
if (!t1Uploaded && !clearance.t1?.closed) return 10;
if (!clearance.t1?.closed) return 11;
if (!freightPaid) return 10;
if (!clearance.gatepassGranted) return 11;
// Step 12 is the read-only Offload step — cargo comes off the train at
// arrival, i.e. AFTER the T1 steps below, so it never gates the flow.
if (!t1Uploaded && !clearance.t1?.closed) return 13;
if (!clearance.t1?.closed) return 14;
// Risk is "assigned" when the booking milestone says so OR the clearance view
// already carries a riskLevel. The ET page derives its bookingMilestones from a
// separately-fetched booking id that can lag or mismatch the booking carrying
@@ -146,15 +166,15 @@ function computeImportActiveStep(
const riskAssigned =
Boolean(clearance.riskLevel) ||
isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED");
if (!riskAssigned) return 12;
if (!riskAssigned) return 15;
// Additional duty round is optional — resolved once skipped or paid.
const secondDutyResolved =
clearance.secondDuty?.skipped ||
clearance.secondDuty?.paid ||
isBookingMilestoneDone(bookingMilestones, "SECOND_DUTY_PAID");
if (!secondDutyResolved) return 13;
if (!clearance.importReleaseGranted) return 14;
return 15;
if (!secondDutyResolved) return 16;
if (!clearance.importReleaseGranted) return 17;
return 18;
}
function t1FilesFromWorkflow(
@@ -267,6 +287,7 @@ export function PhasedClearanceActionPanel({
const freightPaid =
isBookingMilestoneDone(bookingMilestones, "FREIGHT_PAYMENT_SETTLED") ||
Boolean(clearance.gatepassGranted);
const offloadDone = clearance.offload?.offloaded ?? Boolean(clearance.offloaded);
const activeStep = useMemo(
() =>
isImport
@@ -276,6 +297,7 @@ export function PhasedClearanceActionPanel({
bookingMilestones,
t1Uploaded,
freightPaid,
isBooking,
)
: 0,
[
@@ -333,6 +355,76 @@ export function PhasedClearanceActionPanel({
/>
</Stepper.Step>
<Stepper.Step
label="Request transit assignee"
description="Ask GL Djibouti to name the officer handling this shipment"
icon={
clearance.transitAssignee?.name ? (
<CheckCircle2 size={14} />
) : undefined
}
>
{showEt && canEt ? (
<TransitAssigneePanel
entityId={entityId}
isBooking={isBooking}
transitAssignee={clearance.transitAssignee}
side="ET"
onChanged={onChanged}
/>
) : (
<StepStatus
done={Boolean(clearance.transitAssignee?.name)}
pendingLabel="Waiting for GL Ethiopia to request a transit assignee from GL Djibouti."
doneLabel={`Transit assignee: ${clearance.transitAssignee?.name ?? ""}`}
/>
)}
</Stepper.Step>
<Stepper.Step
label="Draft declaration"
description="Send the customer a draft declaration with an estimated price"
icon={
isMilestoneDone(clearance.milestones, "DRAFT_DECLARATION_ACCEPTED") ? (
<CheckCircle2 size={14} />
) : (
<FileText size={14} />
)
}
>
{showEt && canEt && activeStep === 2 ? (
<DraftDeclarationStep
bookingId={entityId}
clearance={clearance}
onChanged={onChanged}
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
/>
) : (
<Stack gap="sm">
{(clearance.draftDeclaration?.files ?? []).map((file, index) => (
<PhasedUploadedFileRow
key={file.id}
label={`Draft declaration document ${index + 1}`}
file={file}
onView={onViewFile}
onDownload={onDownloadFile}
compact
/>
))}
<StepStatus
done={isMilestoneDone(clearance.milestones, "DRAFT_DECLARATION_ACCEPTED")}
pendingLabel={
clearance.draftDeclaration
? "Waiting for the customer to accept the draft declaration."
: "Send the customer a draft declaration to review."
}
doneLabel="Draft declaration accepted by the customer."
/>
</Stack>
)}
</Stepper.Step>
<Stepper.Step
label="Customs declaration"
description="Upload declaration documents"
@@ -344,24 +436,10 @@ export function PhasedClearanceActionPanel({
)
}
>
{/* Djibouti must name the transit officer first — the declaration
is filed against whoever handles the shipment there, and the
API refuses the upload until the name is in. */}
{showEt &&
canEt &&
!clearance.transitAssignee?.name &&
!isMilestoneDone(clearance.milestones, "DECLARED") ? (
<TransitAssigneePanel
entityId={entityId}
isBooking={isBooking}
transitAssignee={clearance.transitAssignee}
side="ET"
onChanged={onChanged}
/>
) : showEt &&
canEt &&
!clearance.bookingReady &&
(activeStep >= 1 ||
(activeStep >= 3 ||
isMilestoneDone(clearance.milestones, "DECLARED")) ? (
<DeclarationStep
entityId={entityId}
@@ -398,7 +476,7 @@ export function PhasedClearanceActionPanel({
description="Advise amount and attach notice"
icon={<Receipt size={14} />}
>
{showEt && canEt && activeStep === 2 ? (
{showEt && canEt && activeStep === 4 ? (
<DutyStep
entityId={entityId}
isBooking={isBooking}
@@ -460,7 +538,7 @@ export function PhasedClearanceActionPanel({
{showEt &&
canEt &&
!bookingCreated &&
(activeStep >= 4 ||
(activeStep >= 6 ||
isMilestoneDone(clearance.milestones, "TRANSIT_PERMIT_UPLOADED")) ? (
<TransitPermitStep
entityId={entityId}
@@ -504,7 +582,7 @@ export function PhasedClearanceActionPanel({
description="Hand off to GL Djibouti"
icon={<PackageCheck size={14} />}
>
{showEt && canEt && activeStep === 5 ? (
{showEt && canEt && activeStep === 7 ? (
<FinalizePreClearanceStep
entityId={entityId}
isBooking={isBooking}
@@ -624,6 +702,22 @@ export function PhasedClearanceActionPanel({
<ImportGatepassStep clearance={clearance} freightPaid={freightPaid} />
</Stepper.Step>
{/* Read-only: offload is recorded by operations when the train
reaches the destination, which happens after the T1 steps — so
it never holds the active pointer, and its icon stays neutral
until it actually happens. */}
<Stepper.Step
label="Offload"
description={offloadSummary(clearance.offload, Boolean(clearance.offloaded))}
color={offloadDone ? undefined : "gray"}
icon={<PackageOpen size={14} />}
completedIcon={
offloadDone ? <CheckCircle2 size={14} /> : <PackageOpen size={14} />
}
>
<OffloadStep clearance={clearance} />
</Stepper.Step>
<Stepper.Step
label="T1 transport documents"
description="GL Djibouti uploads after the gate pass is secured"
@@ -1049,6 +1143,91 @@ function ImportGatepassStep({
);
}
/**
* Compact offload line for the step's description row — the only part of a
* Mantine step that stays visible once the flow has moved past it.
*/
export function offloadSummary(
offload: ClearanceViewLike["offload"],
offloaded: boolean,
): string {
if (!offload?.offloaded && !offloaded) {
return "Cargo comes off the train at its destination";
}
const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
const bits = [
offload?.containers ? plural(offload.containers, "container") : null,
offload?.wagons ? plural(offload.wagons, "wagon") : null,
offload?.weightTons ? `${offload.weightTons.toLocaleString()} t` : null,
offload?.destination ?? null,
].filter(Boolean);
return bits.length ? bits.join(" · ") : "Offloaded";
}
/**
* Offload stats for the booking, read-only. Recorded by the warehouse
* auto-unload that runs when the train reaches the booking's destination —
* nothing here is actioned from clearance.
*/
export function OffloadStep({
clearance,
}: {
clearance: ClearanceViewLike;
}) {
const offload = clearance.offload ?? null;
const done = offload?.offloaded ?? Boolean(clearance.offloaded);
if (!done) {
return (
<StepStatus
done={false}
pendingLabel="Waiting for the cargo to be offloaded at its destination (recorded by operations on arrival)."
doneLabel=""
/>
);
}
const stats: Array<[string, string]> = [
["Containers", offload?.containers ? String(offload.containers) : "—"],
["Wagons", offload?.wagons ? String(offload.wagons) : "—"],
[
"Weight",
offload?.weightTons ? `${offload.weightTons.toLocaleString()} t` : "—",
],
["Destination", offload?.destination ?? "—"],
["GRN", offload?.grnNumber ?? "—"],
["Location", offload?.location ?? "—"],
];
return (
<Paper withBorder radius="md" p="sm" bg="var(--mantine-color-edr-green-0)">
<Group gap="xs" wrap="nowrap" mb="xs">
<Badge color="edr-green" variant="light" leftSection={<CheckCircle2 size={12} />}>
Offloaded
</Badge>
<Text size="sm" c="dimmed">
{offload?.offloadedAt
? new Date(offload.offloadedAt).toLocaleString()
: "Recorded on arrival"}
{offload?.inventoryStatus ? ` · ${offload.inventoryStatus}` : ""}
</Text>
</Group>
<Group gap="lg" wrap="wrap">
{stats.map(([label, value]) => (
<Stack key={label} gap={0}>
<Text size="xs" c="dimmed">
{label}
</Text>
<Text size="sm" fw={600}>
{value}
</Text>
</Stack>
))}
</Group>
</Paper>
);
}
const RISK_LEVEL_COLOR: Record<string, string> = {
GREEN: "green",
YELLOW: "yellow",
@@ -1569,6 +1748,145 @@ export function DeclarationStep({
);
}
/**
* GL Ethiopia sends a draft customs declaration (estimated price + files) for
* the customer to review in the portal before the real declaration is filed.
* Booking-only — the customer only ever sees this on the booking-scoped page.
*/
function DraftDeclarationStep({
bookingId,
clearance,
onChanged,
onViewFile,
onDownloadFile,
}: {
bookingId: string;
clearance: ClearanceViewLike;
onChanged?: () => void;
onViewFile?: (file: { name: string; url: string }) => void;
onDownloadFile?: (file: { id: string; name: string }) => void;
}) {
const [files, setFiles] = useState<File[]>([]);
const [price, setPrice] = useState<number | string>(
clearance.draftDeclaration?.price ?? "",
);
const [currency, setCurrency] = useState(clearance.draftDeclaration?.currency ?? "ETB");
const [loading, setLoading] = useState(false);
const changeRequest = clearance.draftDeclarationChangeRequest;
const existingFiles = clearance.draftDeclaration?.files ?? [];
const replaceMode = existingFiles.length > 0;
return (
<Stack gap="md">
{/* The customer sent this draft back — their words drive the
correction, so they lead the step. */}
{changeRequest ? (
<Alert
color="orange"
radius="md"
icon={<MessageSquareWarning size={16} />}
title={
changeRequest.rounds > 1
? `Customer requested a change (round ${changeRequest.rounds})`
: "Customer requested a change"
}
>
<Stack gap={4}>
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
{changeRequest.note}
</Text>
<Text size="xs" c="dimmed">
Raised {new Date(changeRequest.raisedAt).toLocaleString()} send a
corrected draft below.
</Text>
</Stack>
</Alert>
) : null}
{existingFiles.length > 0 ? (
<Stack gap={8}>
<Text size="xs" fw={700} c="dimmed" tt="uppercase">
Current draft
</Text>
{existingFiles.map((file, index) => (
<PhasedUploadedFileRow
key={file.id}
label={`Draft declaration document ${index + 1}`}
file={file}
onView={onViewFile}
onDownload={onDownloadFile}
/>
))}
</Stack>
) : null}
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-gray-0)">
<Stack gap="md">
<Group grow align="flex-start">
<NumberInput
label="Estimated price"
placeholder="0.00"
value={price}
onChange={setPrice}
min={0}
size="sm"
thousandSeparator=","
/>
<Select
label="Currency"
data={["ETB", "USD"]}
value={currency}
onChange={(v) => setCurrency(v ?? "ETB")}
size="sm"
/>
</Group>
<PhasedMultiFileDropzone
label="Draft declaration documents"
description={
replaceMode
? "Replace the draft — upload one or more corrected documents."
: "Upload one or more draft declaration documents (PDF or image)."
}
value={files}
onChange={setFiles}
replaceMode={replaceMode}
disabled={loading}
/>
</Stack>
</Paper>
<Button
color="edr-green"
loading={loading}
disabled={files.length === 0 || price === ""}
leftSection={<Upload size={16} />}
fullWidth
onClick={async () => {
setLoading(true);
try {
await bookingsService.uploadDraftDeclaration(
bookingId,
files,
Number(price),
currency,
);
setFiles([]);
toast.success(replaceMode ? "Corrected draft sent" : "Draft sent to customer");
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Upload failed");
} finally {
setLoading(false);
}
}}
>
{replaceMode ? "Send corrected draft" : "Send draft to customer"}
</Button>
</Stack>
);
}
function DutyStep({
entityId,
isBooking,
@@ -1597,35 +1915,9 @@ function DutyStep({
const noticeFile = findWorkflowFile(workflowFiles, "duty_tax_notice");
const hasExistingNotice = Boolean(noticeFile);
const dispute = clearance.dutyDispute;
return (
<Stack gap="md">
{/* The customer rejected the last advice — their words drive the
correction, so they lead the step. */}
{dispute ? (
<Alert
color="orange"
radius="md"
icon={<MessageSquareWarning size={16} />}
title={
dispute.rounds > 1
? `Customer asked for a correction (round ${dispute.rounds})`
: "Customer asked for a correction"
}
>
<Stack gap={4}>
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
{dispute.note}
</Text>
<Text size="xs" c="dimmed">
Raised {new Date(dispute.raisedAt).toLocaleString()} re-advise
below to send a corrected notice.
</Text>
</Stack>
</Alert>
) : null}
{noticeFile ? (
<Stack gap={8}>
<Text size="xs" fw={700} c="dimmed" tt="uppercase">

View File

@@ -5,18 +5,19 @@ import {
Button,
Group,
Paper,
Select,
Stack,
Text,
TextInput,
Textarea,
} from "@mantine/core";
import { useMutation } from "@tanstack/react-query";
import { useMutation, useQuery } from "@tanstack/react-query";
import { CheckCircle2, Clock, Send, UserCheck } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import { bookingsService } from "@/services/bookings.service";
import { contractsService } from "@/services/contracts.service";
import { transitAgentsService } from "@/services/transit-agents.service";
export interface TransitAssigneePanelProps {
/** Booking id when `isBooking`, contract id otherwise. */
@@ -62,10 +63,20 @@ export function TransitAssigneePanel({
onChanged,
}: TransitAssigneePanelProps) {
const [note, setNote] = useState("");
const [assignee, setAssignee] = useState(transitAssignee?.name ?? "");
const [transitAgentId, setTransitAgentId] = useState<string | null>(null);
const [changing, setChanging] = useState(false);
const service = isBooking ? bookingsService : contractsService;
const { data: assignableAgents, isLoading: loadingAgents } = useQuery({
queryKey: ["transit-agents", "assignable"],
queryFn: () => transitAgentsService.listAssignable(),
enabled: side === "DJ",
});
const agentOptions = (assignableAgents ?? []).map((a) => ({
value: a.id,
label: a.name,
}));
const request = useMutation({
mutationFn: async () => {
await service.requestTransitAssignee(entityId, note.trim());
@@ -79,7 +90,8 @@ export function TransitAssigneePanel({
const assign = useMutation({
mutationFn: async () => {
await service.assignTransitAssignee(entityId, assignee.trim());
if (!transitAgentId) return;
await service.assignTransitAssignee(entityId, transitAgentId);
},
onSuccess: () => {
toast.success("Transit assignee sent to GL Ethiopia");
@@ -114,7 +126,7 @@ export function TransitAssigneePanel({
variant="light"
radius="md"
onClick={() => {
setAssignee(transitAssignee!.name ?? "");
setTransitAgentId(null);
setChanging(true);
}}
>
@@ -152,13 +164,16 @@ export function TransitAssigneePanel({
GL Ethiopia: {transitAssignee.requestNote}
</Text>
) : null}
<TextInput
<Select
label="Transit officer"
description="Name of the person handling this shipment in Djibouti"
placeholder="e.g. Ahmed Bourhan"
value={assignee}
onChange={(e) => setAssignee(e.currentTarget.value)}
disabled={readOnly}
description="Active, currently-valid transit agents only — configure the roster in Transit Agents settings"
placeholder={loadingAgents ? "Loading…" : "Select transit officer"}
data={agentOptions}
value={transitAgentId}
onChange={setTransitAgentId}
searchable
disabled={readOnly || loadingAgents}
nothingFoundMessage="No active, valid transit agents — add one in Transit Agents settings"
/>
<Group justify="flex-end" gap="sm">
{changing ? (
@@ -171,7 +186,7 @@ export function TransitAssigneePanel({
radius="md"
leftSection={<Send size={15} />}
loading={assign.isPending}
disabled={readOnly || !assignee.trim()}
disabled={readOnly || !transitAgentId}
onClick={() => assign.mutate()}
>
Send assignment

View File

@@ -66,6 +66,19 @@ export const formatCell = (value: unknown, format?: ColumnFormat): ReactNode =>
);
}
if (format === "validityBadge") {
const status = String(value);
const label =
status === "VALID" ? "Valid" : status === "EXPIRED" ? "Expired" : "Not started";
const color =
status === "VALID" ? "edr-green" : status === "EXPIRED" ? "red" : "yellow";
return (
<Badge color={color} variant="filled" size="sm" radius="md">
{label}
</Badge>
);
}
if (format === "code") {
return (
<Badge

View File

@@ -34,12 +34,13 @@ import type {
} from "@/types/trainScheduling";
import { WindowPhasePill } from "./batchVisuals";
import { ForecastPanel } from "./ForecastPanel";
import { forecastIsLive } from "./batchForecast";
import { forecastIsLive, rankBookings } from "./batchForecast";
/**
* Priority Tracking tab — live, glanceable ranking of every booking on this
* schedule in the exact order the batch engine boards them (government first,
* then rule-engine priority score, then oldest). Bookings above the train's
* then window cycle — bookings compete only within their own cycle — then
* rule-engine priority score, then oldest). Bookings above the train's
* wagon-capacity line render as "selected" (green), below it as the waiting
* list; during the PAYMENT phase selected bookings show a live pay-window
* countdown. Purely presentational — data comes from the batch-board detail
@@ -286,19 +287,11 @@ export const PriorityTrackingTab = memo(function PriorityTrackingTab({
);
const showForecast = forecastAvailable && view === "forecast";
// Rank exactly as the batch engine does: government first, then priority score
// desc, then oldest (fullyExecutedAt / selectedForBatchAt as the tiebreak the
// backend uses). The board already returns them in this order, but re-sort
// defensively so the tab is correct even if the source order ever changes.
const ranked = useMemo(() => {
const time = (b: BatchBoardBookingDetail) =>
b.fullyExecutedAt ? new Date(b.fullyExecutedAt).getTime() : Number.MAX_SAFE_INTEGER;
return [...bookings].sort((a, b) => {
if (a.isGovernment !== b.isGovernment) return a.isGovernment ? -1 : 1;
if (b.priorityScore !== a.priorityScore) return b.priorityScore - a.priorityScore;
return time(a) - time(b);
});
}, [bookings]);
// Rank exactly as the batch engine does: government first, then window cycle
// (bookings only compete within the cycle they arrived in — an earlier cycle
// boards before a later one regardless of score), then priority desc, then
// oldest. Shared with the forecast sim so both views agree.
const ranked = useMemo(() => rankBookings(bookings), [bookings]);
const scoreMax = useMemo(() => maxScore(ranked), [ranked]);
// Wagon-slot cap from the board DTO (derived from train length and the
@@ -395,7 +388,8 @@ export const PriorityTrackingTab = memo(function PriorityTrackingTab({
<Stack gap={2}>
<Text fw={700}>Priority ranking</Text>
<Text size="xs" c="dimmed">
Government first, then rule-engine score, then earliest booked.
Government first, then booking window (earlier cycles board
first), then rule-engine score, then earliest booked.
</Text>
</Stack>
</Group>

View File

@@ -61,7 +61,12 @@ export interface ForecastResult {
full: boolean;
}
/** Engine rank order: government first, then priority desc, then oldest booked. */
/**
* Engine rank order: government first, then window cycle asc (bookings compete
* only within the cycle they arrived in — earlier cycles board first no matter
* the score; pending-contract rows sink last), then priority desc, then oldest
* booked.
*/
export function rankBookings(
bookings: BatchBoardBookingDetail[],
): BatchBoardBookingDetail[] {
@@ -69,8 +74,11 @@ export function rankBookings(
b.fullyExecutedAt
? new Date(b.fullyExecutedAt).getTime()
: Number.MAX_SAFE_INTEGER;
const cycle = (b: BatchBoardBookingDetail) =>
b.windowCycleNo ?? Number.MAX_SAFE_INTEGER;
return [...bookings].sort((a, b) => {
if (a.isGovernment !== b.isGovernment) return a.isGovernment ? -1 : 1;
if (cycle(a) !== cycle(b)) return cycle(a) - cycle(b);
if (b.priorityScore !== a.priorityScore)
return b.priorityScore - a.priorityScore;
return time(a) - time(b);

View File

@@ -142,7 +142,7 @@ export const TrainConsistView = ({
weightMax={trainSet?.locomotive?.maxPullWeightTons ?? null}
lengthUsed={lengthUsed}
lengthMax={trainSet?.locomotive?.maxTrainLengthMeters ?? null}
wagonCount={wagons.length}
wagonCount={loadedCount}
wagonMax={maxWagons}
/>

View File

@@ -144,6 +144,8 @@ export const URL_CONSTANTS = {
CLEARANCE_DECLARATION: (id: string) =>
`/bookings/${id}/clearance/declaration`,
CLEARANCE_DUTY: (id: string) => `/bookings/${id}/clearance/duty`,
CLEARANCE_DRAFT_DECLARATION: (id: string) =>
`/bookings/${id}/clearance/draft-declaration`,
CLEARANCE_TRANSIT_ASSIGNEE_REQUEST: (id: string) =>
`/bookings/${id}/clearance/transit-assignee/request`,
CLEARANCE_TRANSIT_ASSIGNEE_ASSIGN: (id: string) =>
@@ -166,6 +168,13 @@ export const URL_CONSTANTS = {
CLEARANCE_DJ_QUEUE: "/bookings/clearance/dj-queue",
},
// GL Ethiopia ↔ GL Djibouti document exchange, keyed by the booking or
// contract both desks are working on.
GL_EXCHANGE: {
FOR_ENTITY: (entityId: string) => `/gl-exchange/${entityId}`,
DOCUMENT: (documentId: string) => `/gl-exchange/documents/${documentId}`,
},
CONTRACTS: {
BASE: "/contracts",
LIST_SUMMARY: "/contracts/list-summary",
@@ -467,6 +476,10 @@ export const URL_CONSTANTS = {
APPROVAL_RULE_BY_ID: (id: string) => `/approval-rules/${id}`,
APPROVAL_RULES_CHAIN: "/approval-rules/chain",
APPROVAL_RULES_POSITION_TYPES: "/approval-rules/position-types",
TRANSIT_AGENTS: "/transit-agents",
TRANSIT_AGENT_BY_ID: (id: string) => `/transit-agents/${id}`,
TRANSIT_AGENTS_ASSIGNABLE: "/transit-agents/assignable",
},
RATE_MATRIX: {
BASE: "/api/rate-matrices",

View File

@@ -270,6 +270,41 @@ export const CONTRACT_STATUS_META: Record<string, StatusMeta> = {
},
};
/** Statuses where the next action sits with the customer (portal side). */
const WITH_CUSTOMER_STATUSES = new Set([
"DRAFT",
"PRICE_CHANGED_PENDING_CONFIRM",
"CHANGES_REQUESTED",
"CONTRACT_READY", // generated contract awaits the customer's signature
"AWAITING_CLEARANCE_DOCUMENTS",
"RENEWAL_DRAFT",
]);
/** Statuses where the next action sits with EDR staff. */
const WITH_EDR_STATUSES = new Set([
"SUBMITTED",
"PENDING_APPROVAL",
"APPROVED",
"APPROVED_PENDING_SIGNATURE",
"SIGNED_CUSTOMER",
"CLEARANCE_UNDER_REVIEW",
"CLEARANCE_READY_FOR_BOOKING",
"RENEWAL_SUBMITTED",
"RENEWAL_PENDING_APPROVAL",
]);
/**
* Whose court the contract is in. Null for states with no pending party
* (active, closed, rejected…).
*/
export function contractCourt(
status: ContractStatus | string,
): "customer" | "edr" | null {
if (WITH_CUSTOMER_STATUSES.has(status)) return "customer";
if (WITH_EDR_STATUSES.has(status)) return "edr";
return null;
}
export const CONTRACT_LIST_TABS = [
{ key: "all", label: "All contracts", statuses: null as string[] | null },
{

View File

@@ -6,6 +6,7 @@ import {
LayoutGrid,
Milestone,
Package,
Truck,
} from "lucide-react";
import {
Container,
@@ -36,6 +37,7 @@ import {
BookingContractSummaryCard,
BookingContainerUnitsCard,
BookingDocumentsPanel,
BookingTrucksPanel,
ContractOrdersPanel,
} from "@/components/bookings/detail";
import { WarehouseInfoCard } from "@/components/warehouses";
@@ -141,7 +143,9 @@ export default function BookingRequestDetailPage() {
? "orders"
: requestedTab === "documents"
? "documents"
: "overview";
: requestedTab === "trucks"
? "trucks"
: "overview";
const setActiveTab = (tab: string | null) => {
const next = new URLSearchParams(searchParams);
if (tab && tab !== "overview") next.set("tab", tab);
@@ -207,6 +211,9 @@ export default function BookingRequestDetailPage() {
>
Documents
</Tabs.Tab>
<Tabs.Tab value="trucks" leftSection={<Truck size={16} />}>
Trucks
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="overview">
@@ -223,6 +230,9 @@ export default function BookingRequestDetailPage() {
<Tabs.Panel value="documents">
<BookingDocumentsPanel bookingId={booking.id} />
</Tabs.Panel>
<Tabs.Panel value="trucks">
<BookingTrucksPanel bookingId={booking.id} />
</Tabs.Panel>
</Tabs>
</Grid.Col>

View File

@@ -254,6 +254,7 @@ export default function DocumentClearanceDetailPage() {
milestones={bookingMilestones}
showOpsTabs={Boolean(id)}
showWorkflowFilesTab={isPhasedGeneral}
exchangeEntityId={id}
tradeDirection={booking?.tradeDirection ?? "IMPORT"}
workflowFiles={workflowFiles}
onViewFile={view}

View File

@@ -9,6 +9,7 @@ import {
TextInput,
ThemeIcon,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { useDebouncedValue } from "@mantine/hooks";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { FileText, Inbox, RefreshCw, Search, User, X } from "lucide-react";
@@ -16,6 +17,7 @@ import { useCallback, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink";
import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { PageContainer, PageHeader } from "@/components/page";
import { bookingsService } from "@/services/bookings.service";
@@ -49,6 +51,34 @@ const BOOKING_STATUS_OPTIONS = [
{ value: "CLEARANCE_READY", label: "Clearance ready" },
];
const TRADE_DIRECTION_OPTIONS = [
{ value: "IMPORT", label: "Import" },
{ value: "EXPORT", label: "Export" },
{ value: "DOMESTIC", label: "Domestic" },
];
const FREIGHT_TYPE_OPTIONS = [
{ value: "CONTAINER", label: "Container" },
{ value: "BULK", label: "Bulk" },
];
const OWNERSHIP_OPTIONS = [
{ value: "true", label: "Government" },
{ value: "false", label: "Private" },
];
function startOfDayIso(d: Date): string {
const x = new Date(d);
x.setHours(0, 0, 0, 0);
return x.toISOString();
}
function endOfDayIso(d: Date): string {
const x = new Date(d);
x.setHours(23, 59, 59, 999);
return x.toISOString();
}
export default function ClearanceDocumentsPage() {
const navigate = useNavigate();
const [query, setQuery] = useState("");
@@ -56,6 +86,11 @@ export default function ClearanceDocumentsPage() {
const [bookingStatuses, setBookingStatuses] = useState(
BOOKING_STATUS_OPTIONS[0].value,
);
const [directionFilter, setDirectionFilter] = useState<string | null>(null);
const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(null);
const [ownershipFilter, setOwnershipFilter] = useState<string | null>(null);
const [createdFrom, setCreatedFrom] = useState<Date | null>(null);
const [createdTo, setCreatedTo] = useState<Date | null>(null);
const { pagination, setPagination } = usePagination({ pageSize: PAGE_SIZE });
const search = debouncedQuery.trim() || undefined;
@@ -67,7 +102,18 @@ export default function ClearanceDocumentsPage() {
const page = pagination.pageIndex + 1;
const bookingsQuery = useQuery({
queryKey: ["clearance-documents", "bookings", bookingStatuses, page, search],
queryKey: [
"clearance-documents",
"bookings",
bookingStatuses,
directionFilter,
freightTypeFilter,
ownershipFilter,
createdFrom,
createdTo,
page,
search,
],
queryFn: () =>
// Self-clearance instances carry bookingType=ONE_TIME whatever their
// contract kind, so customsClearingEnabled=false + the three per-booking
@@ -78,6 +124,13 @@ export default function ClearanceDocumentsPage() {
page,
pageSize: PAGE_SIZE,
search,
...(directionFilter ? { tradeDirection: directionFilter } : {}),
...(freightTypeFilter ? { freightType: freightTypeFilter } : {}),
...(ownershipFilter
? { isGovernment: ownershipFilter as "true" | "false" }
: {}),
...(createdFrom ? { createdFrom: startOfDayIso(createdFrom) } : {}),
...(createdTo ? { createdTo: endOfDayIso(createdTo) } : {}),
}),
placeholderData: keepPreviousData,
});
@@ -115,9 +168,18 @@ export default function ClearanceDocumentsPage() {
{
id: "contractRef",
header: () => <span className={bookingTable.headerCell}>Contract</span>,
cell: ({ row }) => (
<Text size="sm">{row.original.contractReference ?? "—"}</Text>
),
cell: ({ row }) => {
const b = row.original;
return b.contractId && b.contractReference ? (
<ContractReferenceLink
contractId={b.contractId}
contractReference={b.contractReference}
className="block truncate text-sm text-foreground underline underline-offset-2 hover:text-muted-foreground"
/>
) : (
<Text size="sm"></Text>
);
},
},
{
id: "shipment",
@@ -238,6 +300,73 @@ export default function ClearanceDocumentsPage() {
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group>
<Group gap="sm" mt="sm" wrap="wrap">
<Select
placeholder="Direction"
data={TRADE_DIRECTION_OPTIONS}
value={directionFilter}
onChange={(v) => {
setDirectionFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 130 }}
aria-label="Filter by direction"
/>
<Select
placeholder="Freight type"
data={FREIGHT_TYPE_OPTIONS}
value={freightTypeFilter}
onChange={(v) => {
setFreightTypeFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 140 }}
aria-label="Filter by freight type"
/>
<Select
placeholder="Gov / Private"
data={OWNERSHIP_OPTIONS}
value={ownershipFilter}
onChange={(v) => {
setOwnershipFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 140 }}
aria-label="Filter by ownership"
/>
<DateInput
placeholder="Created from"
value={createdFrom}
onChange={(v) => {
setCreatedFrom(v ? new Date(v) : null);
resetPage();
}}
maxDate={createdTo ?? undefined}
clearable
radius="lg"
style={{ minWidth: 140 }}
aria-label="Created from"
/>
<DateInput
placeholder="Created to"
value={createdTo}
onChange={(v) => {
setCreatedTo(v ? new Date(v) : null);
resetPage();
}}
minDate={createdFrom ?? undefined}
clearable
radius="lg"
style={{ minWidth: 140 }}
aria-label="Created to"
/>
</Group>
</Box>
{showEmpty ? (

View File

@@ -352,6 +352,7 @@ export default function ContractClearanceDetailPage() {
milestones={bookingMilestones}
showOpsTabs={Boolean(linkedBookingId)}
showWorkflowFilesTab={phasedCustoms}
exchangeEntityId={id}
tradeDirection={contract?.tradeDirection ?? "IMPORT"}
workflowFiles={workflowFiles}
onViewFile={view}

View File

@@ -15,12 +15,14 @@ import {
Files,
Flame,
History,
Info,
LayoutGrid,
Milestone,
Package,
Receipt,
RefreshCw,
Route as RouteIcon,
ShieldCheck,
Snowflake,
Users,
} from "lucide-react";
@@ -35,6 +37,7 @@ import {
Group,
Loader,
Paper,
SimpleGrid,
Stack,
Tabs,
Text,
@@ -48,7 +51,10 @@ import { PageContainer } from "@/components/page";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { detailStyles } from "@/components/bookings/detail/booking-detail.styles";
import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge";
import {
ContractCourtBadge,
ContractStatusBadge,
} from "@/components/contracts/ContractStatusBadge";
import { ContractWorkflowStepper } from "@/components/contracts/ContractWorkflowStepper";
import { ContractActionsToolbar } from "@/components/contracts/ContractActionsToolbar";
import { ContractApprovalStepsCard } from "@/components/contracts/ContractApprovalStepsCard";
@@ -374,6 +380,7 @@ export default function ContractRequestDetailPage() {
status={contract.status}
isRenewal={Boolean(contract.renewalOfId)}
/>
<ContractCourtBadge status={contract.status} />
<Badge variant="light" color="gray" radius="sm" tt="uppercase">
{contract.contractKind === "GENERAL" ? "General" : "One-time"}
</Badge>
@@ -553,6 +560,100 @@ export default function ContractRequestDetailPage() {
<ContractCustomerCard contract={contract} />
) : (
<Stack gap="lg">
<SectionCard
icon={Info}
title="Contract information"
subtitle="Full commercial and operational detail for this contract."
>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
<InfoRow
label="Service type"
value={contract.serviceType?.serviceName ?? "—"}
/>
<InfoRow
label="Payment currency"
value={contract.paymentCurrency ?? "—"}
/>
<InfoRow
label="Customs clearing"
value={
contract.customsClearingEnabled
? "Included automatically"
: contract.customsClearingAgent
? `Customer's agent — ${contract.customsClearingAgent}`
: "Not included"
}
/>
{contract.equipmentReturn ? (
<InfoRow
label="Equipment return"
value={
contract.equipmentReturn === "WITH_RETURN"
? "With return"
: "Without return"
}
/>
) : null}
<InfoRow
label="Contract type"
value={contract.contractType ?? "Standard"}
/>
{contract.contractValidityDays != null ? (
<InfoRow
label="Validity period"
value={`${contract.contractValidityDays} days`}
/>
) : null}
{contract.estimatedShipmentDate ? (
<InfoRow
label="Estimated shipment date"
value={formatDate(contract.estimatedShipmentDate)}
/>
) : null}
{contract.firstMilePickupAddress ? (
<InfoRow
label="First-mile pickup"
value={contract.firstMilePickupAddress}
/>
) : null}
{contract.lastMileDeliveryAddress ? (
<InfoRow
label="Last-mile delivery"
value={contract.lastMileDeliveryAddress}
/>
) : null}
</SimpleGrid>
{contract.financialTerms ? (
<Box
mt="md"
pt="md"
style={{ borderTop: "1px solid var(--mantine-color-gray-2)" }}
>
<Text
size="xs"
c="dimmed"
fw={600}
tt="uppercase"
mb={4}
style={{ letterSpacing: 0.3 }}
>
Financial terms
</Text>
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
{contract.financialTerms}
</Text>
</Box>
) : null}
</SectionCard>
<SectionCard
icon={ShieldCheck}
title="Approval & signing timeline"
subtitle="Every dated step in this contract's approval chain, plus signatures — the same record kept in the sidebar, always visible here."
>
<ContractMilestonesTimeline contract={contract} />
</SectionCard>
<SectionCard icon={RouteIcon} title="Routes">
{routes.length === 0 ? (
<Text size="sm" c="dimmed">
@@ -761,6 +862,25 @@ export default function ContractRequestDetailPage() {
);
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div>
<Text
size="xs"
c="dimmed"
fw={600}
tt="uppercase"
style={{ letterSpacing: 0.3 }}
>
{label}
</Text>
<Text size="sm" fw={500} mt={2}>
{value}
</Text>
</div>
);
}
function MetaItem({
icon: Icon,
text,

View File

@@ -34,7 +34,10 @@ import { useCallback, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { ContractApprovalProgressCell } from "@/components/contracts/ContractApprovalProgressCell";
import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge";
import {
ContractCourtBadge,
ContractStatusBadge,
} from "@/components/contracts/ContractStatusBadge";
import {
ContractStatusTabs,
type ContractStatusTabKey,
@@ -334,6 +337,19 @@ export default function ContractRequestsPage() {
</div>
),
},
{
id: "court",
size: COLUMN_WIDTH,
meta: COLUMN_META,
header: () => (
<span className={bookingTable.headerCell}>Waiting on</span>
),
cell: ({ row }) => (
<div className="py-1">
<ContractCourtBadge status={row.original.status} />
</div>
),
},
{
id: "approval",
size: COLUMN_WIDTH,
@@ -666,7 +682,7 @@ export default function ContractRequestsPage() {
}}
// table-fixed makes the per-column 120px widths stick; without
// it auto-layout re-widens columns once cells wrap.
containerClassName="border-0 shadow-none bg-transparent [&_table]:table-fixed [&_table]:min-w-[840px]"
containerClassName="border-0 shadow-none bg-transparent [&_table]:table-fixed [&_table]:min-w-[960px]"
footer={DataTableFooter}
/>
</Box>

View File

@@ -20,6 +20,7 @@ import {
AlertTriangle,
ClipboardList,
FileText,
Share2,
Upload,
} from "lucide-react";
import type { Freight } from "@edr/types";
@@ -33,6 +34,7 @@ import { PageHeader } from "@/components/page/PageHeader";
import { ClearanceReviewSection } from "@/components/bookings/detail/ClearanceReviewSection";
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
import { GlExchangePanel } from "@/components/contracts/GlExchangePanel";
import {
GlClearanceUploadModal,
type GlClearanceUploadKind,
@@ -228,6 +230,9 @@ export default function GlClearanceDetailPage() {
>
Customs documents (all steps)
</Tabs.Tab>
<Tabs.Tab value="exchange" leftSection={<Share2 size={14} />}>
Document exchange
</Tabs.Tab>
{incidentBookingId ? (
<Tabs.Tab value="incidents" leftSection={<AlertTriangle size={14} />}>
Incidents
@@ -237,23 +242,20 @@ export default function GlClearanceDetailPage() {
<Tabs.Panel value="workflow">
{/* GL Ethiopia cannot file the import customs declaration until this
desk names the officer handling the shipment in transit, so the
ask sits above everything else on the page. Exports have no such
gate — Djibouti's steps come after the declaration. */}
{isImport ? (
<Box mb="md">
<TransitAssigneePanel
entityId={id!}
isBooking={data.kind === "booking"}
transitAssignee={data.clearance.transitAssignee}
side="DJ"
readOnly={
!hasPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions)
}
onChanged={() => void refetch()}
/>
</Box>
) : null}
desk names the officer handling the shipment in transit. Exports also
need transit assignment at the DJ stage after ET requests it. */}
<Box mb="md">
<TransitAssigneePanel
entityId={id!}
isBooking={data.kind === "booking"}
transitAssignee={data.clearance.transitAssignee}
side="DJ"
readOnly={
!hasPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions)
}
onChanged={() => void refetch()}
/>
</Box>
<Grid>
<Grid.Col span={{ base: 12, lg: 7 }}>
@@ -342,6 +344,10 @@ export default function GlClearanceDetailPage() {
)}
</Tabs.Panel>
<Tabs.Panel value="exchange">
<GlExchangePanel entityId={id!} />
</Tabs.Panel>
{incidentBookingId ? (
<Tabs.Panel value="incidents">
<SectionCard icon={AlertTriangle} title="Incident reports" accent="edr-green">

View File

@@ -68,6 +68,7 @@ const METHOD_OPTIONS: { value: PaymentMethod; label: string }[] = [
{ value: "card", label: "Card" },
{ value: "dmoney", label: "D-Money" },
{ value: "cac-bank", label: "CAC Bank" },
{ value: "cbe-bill", label: "CBE Bill" },
];
const STATUS_COLORS: Record<string, string> = {

View File

@@ -10,6 +10,7 @@ export type ColumnFormat =
| "boolean"
| "activeBadge"
| "rateStatus"
| "validityBadge"
| "date"
| "number"
| "currency"
@@ -483,6 +484,39 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
{ name: "isActive", label: "Active", type: "boolean" },
],
},
{
slug: "transit-agents",
label: "Transit Agents",
category: "configuration",
subtitle:
"Djibouti transit officers GL Djibouti may assign to a shipment — each carries a validity window",
searchPlaceholder: "Search transit agents by name...",
cardTitleKey: "name",
columns: [
{ id: "name", header: "Name", accessorKey: "name" },
{ id: "validFrom", header: "Valid from", accessorKey: "validFrom", format: "date" },
{ id: "validTo", header: "Valid to", accessorKey: "validTo", format: "date" },
{
id: "validityStatus",
header: "Validity",
accessorKey: "validityStatus",
format: "validityBadge",
},
activeColumn,
],
formFields: [
{ name: "name", label: "Name", type: "text", required: true },
{ name: "validFrom", label: "Valid from", type: "date", required: true },
{
name: "validTo",
label: "Valid to",
type: "date",
required: true,
description: "Expired or not-yet-started agents can't be assigned — extend the dates or add a new one",
},
{ name: "isActive", label: "Active", type: "boolean", description: "Off suspends the officer regardless of the validity window" },
],
},
{
slug: "yard-distances",
label: "Yard Distances",

View File

@@ -338,10 +338,10 @@ export const bookingsService = {
note,
}),
/** GL Djibouti names (or changes) that officer — unblocks the declaration. */
assignTransitAssignee: (id: string, assignee: string) =>
/** GL Djibouti picks (or changes) that officer — unblocks the declaration. */
assignTransitAssignee: (id: string, transitAgentId: string) =>
postBooking<BookingDetail>(B.CLEARANCE_TRANSIT_ASSIGNEE_ASSIGN(id), {
assignee,
transitAgentId,
}),
uploadDeclaration: async (
@@ -382,6 +382,22 @@ export const bookingsService = {
return unwrap(response.data) as BookingDetail;
},
uploadDraftDeclaration: async (
id: string,
files: File[],
price: number,
currency: string,
): Promise<BookingDetail> => {
const form = new FormData();
files.forEach((file, index) => form.append(`draft_declaration_${index}`, file));
form.append("price", String(price));
form.append("currency", currency);
const response = await client.post(B.CLEARANCE_DRAFT_DECLARATION(id), form, {
headers: { "Content-Type": "multipart/form-data" },
});
return unwrap(response.data) as BookingDetail;
},
finalizePreClearance: (id: string) =>
postBooking<BookingDetail>(B.CLEARANCE_FINALIZE_PRE(id)),

View File

@@ -331,10 +331,10 @@ export const contractsService = {
{ note },
),
/** GL Djibouti names (or changes) that officer — unblocks the declaration. */
assignTransitAssignee: (id: string, assignee: string) =>
/** GL Djibouti picks (or changes) that officer — unblocks the declaration. */
assignTransitAssignee: (id: string, transitAgentId: string) =>
postContract<Freight.IContract>(C.CLEARANCE_TRANSIT_ASSIGNEE_ASSIGN(id), {
assignee,
transitAgentId,
}),
/**

View File

@@ -0,0 +1,60 @@
import type { Freight } from "@edr/types";
import { api as client } from "../auth/http";
import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from "@/constants/URLS";
const X = URL_CONSTANTS.GL_EXCHANGE;
export interface GlExchangeUpload {
title: string;
visibleToCustomer: boolean;
file: File;
}
export interface GlExchangeEdit {
title?: string;
visibleToCustomer?: boolean;
/** Optional replacement bytes — omit to keep the stored file. */
file?: File | null;
}
const multipart = { headers: { "Content-Type": "multipart/form-data" } };
/** GL Ethiopia ↔ GL Djibouti shared documents for one booking or contract. */
export const glExchangeService = {
list: async (entityId: string): Promise<Freight.GlExchangeDocument[]> => {
const response = await client.get(X.FOR_ENTITY(entityId));
return (unwrap(response.data) ?? []) as Freight.GlExchangeDocument[];
},
upload: async (
entityId: string,
input: GlExchangeUpload,
): Promise<Freight.GlExchangeDocument> => {
const form = new FormData();
form.append("file", input.file);
form.append("title", input.title);
form.append("visibleToCustomer", String(input.visibleToCustomer));
const response = await client.post(X.FOR_ENTITY(entityId), form, multipart);
return unwrap(response.data) as Freight.GlExchangeDocument;
},
update: async (
documentId: string,
input: GlExchangeEdit,
): Promise<Freight.GlExchangeDocument> => {
const form = new FormData();
if (input.file) form.append("file", input.file);
if (input.title != null) form.append("title", input.title);
if (input.visibleToCustomer != null) {
form.append("visibleToCustomer", String(input.visibleToCustomer));
}
const response = await client.patch(X.DOCUMENT(documentId), form, multipart);
return unwrap(response.data) as Freight.GlExchangeDocument;
},
remove: async (documentId: string): Promise<void> => {
await client.delete(X.DOCUMENT(documentId));
},
};

View File

@@ -19,7 +19,8 @@ export type PaymentMethod =
| "waafi"
| "card"
| "dmoney"
| "cac-bank";
| "cac-bank"
| "cbe-bill";
export interface PaymentRow {
id: string;

View File

@@ -94,6 +94,7 @@ const RESOURCE_BASE: Record<RuleEngineResourceSlug, string> = {
"shipping-lines": URL_CONSTANTS.RULE_ENGINE.SHIPPING_LINES,
rates: URL_CONSTANTS.RULE_ENGINE.RATES,
"approval-rules": URL_CONSTANTS.RULE_ENGINE.APPROVAL_RULES,
"transit-agents": URL_CONSTANTS.RULE_ENGINE.TRANSIT_AGENTS,
};
const byIdPath = (resource: RuleEngineResourceSlug, id: string): string => {

View File

@@ -0,0 +1,20 @@
import { api } from "../auth/http";
import { URL_CONSTANTS } from "../constants/URLS";
export interface TransitAgent {
id: string;
name: string;
validFrom: string;
validTo: string;
isActive: boolean;
}
export const transitAgentsService = {
/** Active + currently inside its validity window — the assignment dropdown. */
async listAssignable() {
const response = await api.get<TransitAgent[]>(
URL_CONSTANTS.RULE_ENGINE.TRANSIT_AGENTS_ASSIGNABLE,
);
return response.data;
},
};

View File

@@ -91,6 +91,7 @@ export interface ContainerItem {
contractId: string | null;
hasLastMile: boolean;
handoverSigned: boolean;
inspectionStatus: string | null;
}
/** A pre-dispatch EXPORT train that has inventory waiting to be loaded. */
@@ -136,6 +137,8 @@ const cleanParams = (params: object) =>
/** An assigned EDR last-mile truck, shaped for the arrival/exit weighing prefill. */
export interface LastMileArrivalTruck {
/** The last-mile leg this truck belongs to — feed straight into lastMileService.truckDetentionPreview(lastMileId). */
lastMileId: string;
vehicleId: string;
truckPlateNumber: string | null;
trailerPlateNumber: string | null;

View File

@@ -10,7 +10,8 @@ export type RuleEngineResourceSlug =
| "yard-distances"
| "shipping-lines"
| "rates"
| "approval-rules";
| "approval-rules"
| "transit-agents";
/**
* Mirrors the API's shared `PaginationMeta` (@edr/types). The `has*` flags are

View File

@@ -411,6 +411,12 @@ export type BookingAllocationStatus =
| "FAILED";
export interface BatchBoardBookingDetail extends BatchBoardBooking {
/**
* 0-based booking-window cycle the booking entered the pool in. Ranking is
* per-cycle: an earlier cycle always boards before a later one regardless of
* priority score. Null while the contract is still pending.
*/
windowCycleNo: number | null;
fullyExecutedAt: string | null;
selectedForBatchAt: string | null;
allocationStatus: BookingAllocationStatus;

View File

@@ -168,6 +168,8 @@ export const URL_CONSTANTS = {
`/api/contracts/bookings/${bookingId}/milestones`,
BOOKING_DUTY_SLIP: (bookingId: string) =>
`/api/contracts/bookings/${bookingId}/duty-slip`,
BOOKING_FINAL_INVOICE_APPROVE: (bookingId: string) =>
`/api/contracts/bookings/${bookingId}/final-invoice/approve`,
BOOKING_FINAL_INVOICE_SLIP: (bookingId: string) =>
`/api/contracts/bookings/${bookingId}/final-invoice-slip`,
BOOKING_SECOND_DUTY_SLIP: (bookingId: string) =>

View File

@@ -9,6 +9,7 @@ import {
Divider,
Group,
Loader,
Modal,
Paper,
SimpleGrid,
Stack,
@@ -72,6 +73,12 @@ export default function InvoiceDetailPage() {
} = useQuery(api.invoices.get.queryOptions({ input: { id } }));
const [payModalOpen, setPayModalOpen] = useState(false);
// CBE bill payment: the bill reference to pay at any CBE channel (no redirect).
const [billAction, setBillAction] = useState<{
billReference?: string;
instructions?: string;
expiresAt?: string;
} | null>(null);
// Ownership-checked: POST /billing/my-invoices/:id/pay only ever charges
// one of the signed-in customer's own invoices (unlike the admin-facing
@@ -80,6 +87,11 @@ export default function InvoiceDetailPage() {
mutationFn: (method: PaymentMethod) =>
api.invoices.pay.call({ id, payload: { method, platform: "web" } }),
onSuccess: (data, method) => {
if (data?.clientAction?.type === "SHOW_BILL_REFERENCE") {
setPayModalOpen(false);
setBillAction(data.clientAction);
return;
}
const redirectUrl =
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
? data.clientAction.url
@@ -393,6 +405,62 @@ export default function InvoiceDetailPage() {
}
onConfirm={(method) => payMutation.mutate(method)}
/>
{/* CBE bill payment — show the bill number; settlement arrives via CBE, not the browser */}
<Modal
opened={!!billAction}
onClose={() => setBillAction(null)}
centered
radius={18}
size={440}
title={<Text fw={800}>Pay at CBE</Text>}
>
<Stack gap="sm">
<Text fz="sm" c={MUTED}>
{billAction?.instructions ??
"Pay this bill at any CBE branch, the CBE Birr app, mobile banking or USSD."}
</Text>
<Group
justify="space-between"
px={16}
py={13}
style={{ borderRadius: 12, border: `1px solid ${BORDER}` }}
>
<Text ff="monospace" fz={24} fw={800} c={INK} style={{ letterSpacing: 3 }}>
{billAction?.billReference}
</Text>
<Button
variant="light"
size="xs"
onClick={() => {
if (billAction?.billReference) {
navigator.clipboard?.writeText(billAction.billReference);
toast.success("Bill number copied");
}
}}
>
Copy
</Button>
</Group>
<Text fz="sm" c={MUTED}>
Amount due:{" "}
<Text span fw={700} c={INK}>
{formatCurrency(amountDue, invoice.currency)}
</Text>
</Text>
{billAction?.expiresAt && (
<Text fz="sm" c={MUTED}>
Pay before:{" "}
<Text span fw={700} c={INK}>
{fmtDate(billAction.expiresAt)}
</Text>
</Text>
)}
<Text fz="xs" c={MUTED}>
The invoice updates automatically once CBE confirms your payment.
</Text>
</Stack>
</Modal>
</Stack>
</Box>
);

View File

@@ -14,6 +14,7 @@ import {
} from "@mantine/core";
import {
AlertTriangle,
Check,
Download,
Eye,
FileBadge,
@@ -86,9 +87,11 @@ export function BookingClearanceWorkflowBanner({
clearance.dutyRequired &&
clearance.dutyAdvice &&
!dutyPaid;
// A dispute clears the advice while it's open — show the "waiting on GL"
// state instead of the pay panel until GL re-advises.
const dutyDisputePending = Boolean(clearance.dutyDispute);
// A change request clears the draft while it's open — show the "waiting on
// GL" state instead of the review panel until GL sends a corrected draft.
const draftDeclarationChangeRequestPending = Boolean(
clearance.draftDeclarationChangeRequest,
);
return (
<Paper withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
@@ -114,9 +117,20 @@ export function BookingClearanceWorkflowBanner({
</Alert>
) : null}
{dutyDisputePending && clearance.dutyDispute ? (
<DutyDisputePendingCard dispute={clearance.dutyDispute} />
) : dutyPending && clearance.dutyAdvice ? (
{draftDeclarationChangeRequestPending && clearance.draftDeclarationChangeRequest ? (
<DraftDeclarationChangeRequestedCard
changeRequest={clearance.draftDeclarationChangeRequest}
/>
) : clearance.draftDeclaration && !clearance.draftDeclaration.accepted ? (
<DraftDeclarationPanel
draftDeclaration={clearance.draftDeclaration}
bookingId={booking.id}
onView={(f) => view(f)}
onChanged={() => void refetch()}
/>
) : null}
{dutyPending && clearance.dutyAdvice ? (
<DutyAdvicePanel
dutyAdvice={clearance.dutyAdvice}
bookingId={booking.id}
@@ -193,9 +207,6 @@ function DutyAdvicePanel({
}) {
const [file, setFile] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const [disputing, setDisputing] = useState(false);
const [note, setNote] = useState("");
const [submittingDispute, setSubmittingDispute] = useState(false);
const noticeFile = dutyAdvice.noticeFile;
return (
@@ -250,11 +261,68 @@ function DutyAdvicePanel({
Submit payment slip
</Button>
{disputing ? (
</Stack>
</Paper>
);
}
/**
* GL Ethiopia sent a draft customs declaration — an estimated price + files
* the customer must accept before the real declaration is filed, or send back
* with a note asking for a change.
*/
function DraftDeclarationPanel({
draftDeclaration,
bookingId,
onView,
onChanged,
}: {
draftDeclaration: NonNullable<Freight.ClearanceView["draftDeclaration"]>;
bookingId: string;
onView: (file: { name: string; url: string }) => void;
onChanged: () => void;
}) {
const [accepting, setAccepting] = useState(false);
const [requestingChange, setRequestingChange] = useState(false);
const [note, setNote] = useState("");
const [submitting, setSubmitting] = useState(false);
return (
<Paper withBorder radius="md" p="md" bg="#FFFBF0">
<Stack gap="sm">
<GroupLabel icon={FileBadge} text="Draft customs declaration" />
<Text size="sm">
Estimated price:{" "}
<strong>
{draftDeclaration.price.toLocaleString()} {draftDeclaration.currency}
</strong>
</Text>
<Stack gap={4}>
{draftDeclaration.files.map((file, index) => (
<Anchor
key={file.id}
component="button"
type="button"
onClick={() => onView({ name: file.name, url: file.url })}
size="sm"
>
<Group gap={6} wrap="nowrap">
<Eye size={14} />
Draft declaration document {index + 1} ({file.name})
</Group>
</Anchor>
))}
</Stack>
<Text size="sm" c="dimmed">
Review the draft above. Accept it to let GL Ethiopia proceed with the
real customs declaration, or request a change if something is wrong.
</Text>
{requestingChange ? (
<Stack gap={6}>
<Textarea
label="What's wrong with this amount?"
placeholder="Explain why you're disputing the advised duty/tax…"
label="What needs to change?"
placeholder="Explain what should be corrected on the draft…"
minRows={2}
autosize
value={note}
@@ -265,7 +333,7 @@ function DutyAdvicePanel({
variant="default"
size="xs"
onClick={() => {
setDisputing(false);
setRequestingChange(false);
setNote("");
}}
>
@@ -274,23 +342,23 @@ function DutyAdvicePanel({
<Button
color="red"
size="xs"
loading={submittingDispute}
loading={submitting}
disabled={!note.trim()}
onClick={async () => {
setSubmittingDispute(true);
setSubmitting(true);
try {
await bookingsService.disputeBookingClearanceDuty(
await bookingsService.requestDraftDeclarationChange(
bookingId,
note.trim(),
);
toast.success("Sent to GL Ethiopia for review");
setDisputing(false);
setRequestingChange(false);
setNote("");
onChanged();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to send");
} finally {
setSubmittingDispute(false);
setSubmitting(false);
}
}}
>
@@ -299,26 +367,48 @@ function DutyAdvicePanel({
</Group>
</Stack>
) : (
<Anchor
component="button"
type="button"
size="sm"
c="dimmed"
onClick={() => setDisputing(true)}
>
Not right? Request a change
</Anchor>
<Group gap="xs">
<Button
color="edr-green"
size="sm"
loading={accepting}
leftSection={<Check size={15} />}
onClick={async () => {
setAccepting(true);
try {
await bookingsService.acceptDraftDeclaration(bookingId);
toast.success("Draft accepted");
onChanged();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to accept");
} finally {
setAccepting(false);
}
}}
>
Accept draft
</Button>
<Anchor
component="button"
type="button"
size="sm"
c="dimmed"
onClick={() => setRequestingChange(true)}
>
Not right? Request a change
</Anchor>
</Group>
)}
</Stack>
</Paper>
);
}
/** The customer's dispute is open — GL Ethiopia owes a corrected advice. */
function DutyDisputePendingCard({
dispute,
/** The customer's change request is open — GL Ethiopia owes a corrected draft. */
function DraftDeclarationChangeRequestedCard({
changeRequest,
}: {
dispute: NonNullable<Freight.ClearanceView["dutyDispute"]>;
changeRequest: NonNullable<Freight.ClearanceView["draftDeclarationChangeRequest"]>;
}) {
return (
<Alert
@@ -326,18 +416,18 @@ function DutyDisputePendingCard({
radius="md"
icon={<MessageSquareWarning size={16} />}
title={
dispute.rounds > 1
? `Waiting on GL Ethiopia (round ${dispute.rounds})`
changeRequest.rounds > 1
? `Waiting on GL Ethiopia (round ${changeRequest.rounds})`
: "Waiting on GL Ethiopia"
}
>
<Stack gap={4}>
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
{dispute.note}
{changeRequest.note}
</Text>
<Text size="xs" c="dimmed">
Sent {new Date(dispute.raisedAt).toLocaleString()} you'll see the
corrected amount here once GL Ethiopia re-advises.
Sent {new Date(changeRequest.raisedAt).toLocaleString()} you'll see
the corrected draft here once GL Ethiopia sends it.
</Text>
</Stack>
</Alert>
@@ -373,7 +463,11 @@ function FinalInvoiceDueCard({
}) {
const [slip, setSlip] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [approving, setApproving] = useState(false);
const paid = invoice.status === "PAID";
// GL Djibouti raises it as a draft: nothing is payable until the customer
// reviews the attached invoice and approves it.
const approved = Boolean(invoice.approvedAt);
return (
<Paper
@@ -402,11 +496,17 @@ function FinalInvoiceDueCard({
<Group gap={8} align="center">
<FileBadge size={16} color={paid ? GREEN : "#B07C1F"} />
<Text fw={700} fz={15} c={INK}>
{paid ? "Final invoice paid" : "Final invoice due"} {" "}
{invoice.invoiceNumber}
{paid
? "Final invoice paid"
: approved
? "Final invoice due"
: "Final invoice — your approval needed"}{" "}
{invoice.invoiceNumber}
</Text>
<Badge color={paid ? "edr-green" : "yellow"} variant="light" radius="sm">
{invoiceStatusLabel(invoice.status)}
{approved
? invoiceStatusLabel(invoice.status)
: "Awaiting your approval"}
</Badge>
</Group>
<Text fz={20} fw={800} mt={6} c={INK}>
@@ -419,8 +519,9 @@ function FinalInvoiceDueCard({
) : null}
{!paid ? (
<Text fz={13} c="#9A6B1F" mt={6}>
Pay the amount above and attach your payment slip Global
Logistics will confirm the payment.
{approved
? "Pay the amount above and attach your payment slip — Global Logistics will confirm the payment."
: "Review the invoice document from Global Logistics Djibouti and approve it to proceed with payment."}
</Text>
) : null}
</div>
@@ -458,7 +559,30 @@ function FinalInvoiceDueCard({
View payment slip
</Button>
) : null}
{!paid ? (
{!paid && !approved ? (
<Button
color="edr-green"
radius="md"
size="sm"
loading={approving}
leftSection={<Check size={15} />}
onClick={async () => {
setApproving(true);
try {
await contractsService.approveFinalInvoice(bookingId);
toast.success("Invoice approved — you can now pay");
onChanged();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Approval failed");
} finally {
setApproving(false);
}
}}
>
Approve invoice
</Button>
) : null}
{!paid && approved ? (
<>
<FileInput
placeholder={

View File

@@ -393,6 +393,31 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
</SectionCard>
)}
{/* ── 1b. Documents Global Logistics shared with you ──────────────── */}
{(clearance?.exchangeDocuments?.length ?? 0) > 0 && (
<SectionCard>
<CardTitle>Shared by Global Logistics</CardTitle>
<Text fz="12.5px" c="dimmed" mt={4} mb="sm">
Documents the clearance team shared with you for this shipment.
</Text>
<Stack gap={0}>
{clearance!.exchangeDocuments!.map((doc, i, arr) => (
<FileRow
key={doc.id}
title={doc.title}
meta={`${doc.file.name} · shared ${new Date(
doc.uploadedAt,
).toLocaleDateString()}`}
file={doc.file}
pill={<Pill tone="green" label="Shared with you" />}
last={i === arr.length - 1}
onView={view}
/>
))}
</Stack>
</SectionCard>
)}
{/* ── 2. Contract & company documents ─────────────────────────────── */}
{hasContract && contract && (
<SectionCard>

View File

@@ -14,7 +14,7 @@ interface ProviderOption {
accent: string;
}
// Only Telebirr and Waafi are enabled for now.
// Only Telebirr, Waafi and CBE bill payment are enabled for now.
const PROVIDERS: ProviderOption[] = [
{
method: "TELEBIRR",
@@ -32,6 +32,14 @@ const PROVIDERS: ProviderOption[] = [
currencies: ["USD"],
accent: "#2E5B96",
},
{
method: "CBE_BILL",
label: "CBE bill payment",
description: "Pay at any CBE branch, app or USSD · ETB",
logo: "/assets/edr-logo.png",
currencies: ["ETB"],
accent: "#5B2D8C",
},
];
/**

View File

@@ -422,11 +422,13 @@ export function Step8Review({
label="Customs clearing"
value={customsValue}
/>
<SummaryItem
icon={<Package size={18} />}
label="Hazardous cargo"
value={
values.isHazardous ? (
{/* Step-3 toggles appear only when the customer selected them —
an off toggle is left off the summary entirely. */}
{values.isHazardous && (
<SummaryItem
icon={<Package size={18} />}
label="Hazardous cargo"
value={
<>
Yes
<Group gap={6} mt={6}>
@@ -438,27 +440,24 @@ export function Step8Review({
</Badge>
</Group>
</>
) : (
"No"
)
}
/>
<SummaryItem
icon={<Package size={18} />}
label="Refrigerated"
value={values.isRefrigerated ? "Yes" : "No"}
/>
{values.cargoType === "container" && (
<SummaryItem
icon={<RotateCcw size={18} />}
label="Empty-container return"
value={
values.equipmentReturn === "with_return"
? "With return"
: "Without return"
}
/>
)}
{values.isRefrigerated && (
<SummaryItem
icon={<Package size={18} />}
label="Refrigerated"
value="Yes"
/>
)}
{values.cargoType === "container" &&
values.equipmentReturn === "with_return" && (
<SummaryItem
icon={<RotateCcw size={18} />}
label="Empty-container return"
value="With return"
/>
)}
</Box>
</Paper>

View File

@@ -398,13 +398,21 @@ export const bookingsService = {
return data.data ?? data;
},
disputeBookingClearanceDuty: async (
acceptDraftDeclaration: async (id: string): Promise<Freight.IBooking> => {
const { data } = await client.post(
`/api/bookings/${id}/clearance/draft-declaration/accept`,
);
return data.data ?? data;
},
requestDraftDeclarationChange: async (
id: string,
note: string,
): Promise<Freight.IBooking> => {
const { data } = await client.post(`/api/bookings/${id}/clearance/duty/dispute`, {
note,
});
const { data } = await client.post(
`/api/bookings/${id}/clearance/draft-declaration/change`,
{ note },
);
return data.data ?? data;
},

View File

@@ -439,6 +439,14 @@ export const contractsService = {
return data.data ?? data;
},
/** Customer approves the drafted GL final invoice — unlocks the slip upload. */
approveFinalInvoice: async (bookingId: string): Promise<unknown> => {
const { data } = await client.post(
C.BOOKING_FINAL_INVOICE_APPROVE(bookingId),
);
return data.data ?? data;
},
/** Customer attaches the payment slip for the GL final invoice (export). */
uploadFinalInvoiceSlip: async (
bookingId: string,

View File

@@ -12,7 +12,8 @@ export type PaymentMethod =
| "WAAFI"
| "CARD"
| "DMONEY"
| "CAC_BANK";
| "CAC_BANK"
| "CBE_BILL";
export type PaymentPlatform = "web" | "mobile";
@@ -26,13 +27,17 @@ export interface InitiatePaymentPayload {
}
export interface ClientAction {
type: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP";
type: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP" | "SHOW_BILL_REFERENCE";
url?: string;
appId?: string;
receiveCode?: string;
shortCode?: string;
providerOrderId?: string;
message?: string;
/** SHOW_BILL_REFERENCE (CBE bill payment) */
billReference?: string;
instructions?: string;
expiresAt?: string;
}
export interface InitiateResponse {

View File

@@ -0,0 +1,4 @@
-- CBE Unified Bill Payment (docs/cbe/CBE_IMPLEMENTATION_PLAN.md §4.3): new inbound biller
-- method. Value must stay byte-identical to @edr/types ProviderMethod.CBE_BILL — the
-- passenger payments service casts between the two enums directly.
ALTER TYPE "passenger"."PaymentMethodType" ADD VALUE IF NOT EXISTS 'CBE_BILL';

View File

@@ -147,6 +147,7 @@ enum PaymentMethodType {
WAAFI
DMONEY
CAC_BANK
CBE_BILL
@@schema("passenger")
}

View File

@@ -10,6 +10,8 @@ import { Cron, CronExpression } from '@nestjs/schedule';
import { VerifaydaService } from '../verifayda/verifayda.service';
import { CurrencyService } from '../currency/currency.service';
import { FareEngineService } from '../fare-engine/fare-engine.service';
import { PaymentsService } from '../payments/payments.service';
import { MAX_PAYMENT_HOURS } from '../../common/utils/payment-deadline.utils';
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto';
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
@@ -109,6 +111,7 @@ export class BookingsService {
private readonly currencyService: CurrencyService,
private readonly fareEngine: FareEngineService,
private readonly auditService: AuditService,
private readonly paymentsService: PaymentsService,
) {}
async findByIamUserId(iamUserId: string, filters: BookingFilters = {}) {
@@ -2129,6 +2132,14 @@ export class BookingsService {
const booking = await this.prisma.booking.findUnique({ where: { bookingRef }, include: { seats: true, paymentIntent: true } });
if (!booking) throw new NotFoundException('Booking not found');
if (booking.status === 'CANCELLED') throw new BadRequestException('Booking already cancelled');
// Verify-before-cancel: a still-PENDING_PAYMENT booking may actually be paid (its confirm event
// was lost/late). reconcileAndConfirmIfPaid confirms it synchronously if so — refuse to cancel a
// paid, or currently-unverifiable, booking as "unpaid".
if (booking.status === 'PENDING_PAYMENT') {
const { paid, verified } = await this.paymentsService.reconcileAndConfirmIfPaid(booking.id);
if (paid) throw new BadRequestException('Payment for this booking has completed; it is now confirmed and cannot be cancelled as unpaid.');
if (!verified) throw new BadRequestException('Could not verify payment status right now; please try again shortly.');
}
const refundAmount = booking.status === 'CONFIRMED' ? Math.floor(booking.totalMinor * 0.8) : 0;
await this.prisma.bookingCancellation.create({ data: { bookingId: booking.id, cancelledBy: booking.passengerId, reason, refundAmount, refundMethod: booking.paymentIntent?.method ?? 'ORIGINAL', refundStatus: 'PENDING' } });
await this.seatsService.releaseSeats(booking.id);
@@ -2265,11 +2276,24 @@ export class BookingsService {
@Cron(CronExpression.EVERY_MINUTE)
async expirePendingBookings() {
const cutoff = new Date(Date.now() - 20 * 60 * 1000);
// NEUTRALIZED (was 20 minutes): the payment window is MAX_PAYMENT_HOURS (2h). Bookings must
// NEVER be cancelled at 20 minutes — the payer still has up to 2 hours, and the seat hold is
// held for exactly this window. Aligned to the 2-hour window so this cron can only ever act as
// a safe backup to the primary deadline-aware sweep (TasksService.cancelExpiredPendingBookings);
// it never cancels prematurely, and paid bookings are still protected by the guard below.
const cutoff = new Date(Date.now() - MAX_PAYMENT_HOURS * 60 * 60 * 1000);
const expired = await this.prisma.booking.findMany({ where: { status: 'PENDING_PAYMENT', createdAt: { lt: cutoff } }, include: { seats: true } });
for (const b of expired) {
await this.seatsService.releaseSeats(b.id);
await this.prisma.booking.update({ where: { id: b.id }, data: { status: 'CANCELLED' } });
try {
// Never cancel a paid booking whose confirm event was lost/late — verify first (this
// confirms it synchronously if paid). Skip when paid or currently unverifiable.
const { paid, verified } = await this.paymentsService.reconcileAndConfirmIfPaid(b.id);
if (paid || !verified) continue;
await this.seatsService.releaseSeats(b.id);
await this.prisma.booking.update({ where: { id: b.id }, data: { status: 'CANCELLED' } });
} catch (err) {
this.logger.error(`expirePendingBookings failed for ${b.id}: ${err instanceof Error ? err.message : String(err)}`);
}
}
}

View File

@@ -4,11 +4,17 @@ import {
HttpCode,
HttpStatus,
Post,
SetMetadata,
UseGuards,
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payments.dto";
import {
PaymentEventDto,
MarkPaidResponseDto,
BillQueryRequestDto,
BillQueryResponseDto,
} from "./internal-payments.dto";
import { PaymentsService } from "./payments.service";
/**
@@ -18,6 +24,9 @@ import { PaymentsService } from "./payments.service";
* consumer when RabbitMQ lands — the handler logic is transport-agnostic.
*/
@ApiTags("Internal Payments")
// isPublic only skips the global IAM user-JWT guard — these routes stay protected by
// ServiceAuthGuard's shared service token (the payment service is not an IAM user).
@SetMetadata("isPublic", true)
@UseGuards(ServiceAuthGuard)
@Controller("internal/payments")
export class InternalPaymentsController {
@@ -32,4 +41,16 @@ export class InternalPaymentsController {
async markPaid(@Body() event: PaymentEventDto): Promise<MarkPaidResponseDto> {
return this.paymentsService.handlePaymentEvent(event);
}
@Post("bill-query")
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary:
"Live still-payable check + payer name for a CBE bill (called while CBE is on the line)",
})
async billQuery(
@Body() request: BillQueryRequestDto,
): Promise<BillQueryResponseDto> {
return this.paymentsService.billQuery(request.referenceId);
}
}

View File

@@ -55,3 +55,24 @@ export class MarkPaidResponseDto {
@ApiPropertyOptional() alreadyFinalized?: boolean;
@ApiPropertyOptional() reason?: string;
}
/**
* CBE bill-query hop (docs/cbe/CBE_IMPLEMENTATION_PLAN.md Phase 4): the payment service asks
* "is this order still payable, by whom, for how much" while a CBE teller/app is on the line.
*/
export class BillQueryRequestDto {
@ApiProperty({ enum: PaymentReferenceType })
@IsEnum(PaymentReferenceType)
referenceType!: PaymentReferenceType;
@ApiProperty() @IsString() referenceId!: string;
}
export class BillQueryResponseDto {
@ApiProperty() stillPayable!: boolean;
@ApiPropertyOptional() payerName?: string | null;
@ApiPropertyOptional() currentAmountMinor?: number | null;
@ApiPropertyOptional() currency?: string | null;
/** When stillPayable=false: "CANCELLED" | "ALREADY_PAID" | "EXPIRED". */
@ApiPropertyOptional() reason?: string | null;
}

View File

@@ -22,6 +22,17 @@ export interface PaymentDiagnostic {
provider: ProviderStatus | null;
}
/** Settlement check from POST /payments/reconcile (verify-before-cancel). */
export interface SettlementResult {
/** At least one intent for the order is paid (incl. a late capture just registered). */
paid: boolean;
/** The paying intent when `paid`. */
intent?: PaymentIntentSnapshot;
/** Settlement could not be confirmed — a provider query errored, a payment is in flight, OR the
* payment service was unreachable. The caller MUST NOT cancel the order. */
unverifiable: boolean;
}
/**
* Thin HTTP client for the payment microservice (apps/edr-payment-api) — the passenger app's
* side of the Phase 6 cutover (docs/payment-service §10). Domain validation stays here;
@@ -85,6 +96,31 @@ export class PaymentClientService {
}
}
/**
* POST /payments/reconcile — settlement check before cancelling an order. Live-queries every
* intent at the provider and registers any late capture found. A transport failure (payment
* service unreachable) is caught and returned as `unverifiable: true` — NEVER as "not paid" — so
* the caller does not cancel a booking whose payment simply could not be verified.
*/
async reconcileByReference(
referenceType: PaymentReferenceType,
referenceId: string,
): Promise<SettlementResult> {
try {
return await this.call<SettlementResult>("POST", "/payments/reconcile", {
service: PaymentService.PASSENGER,
referenceType,
referenceId,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.warn(
`reconcile ${referenceType}/${referenceId} failed: ${message}; treating as unverifiable (will not cancel)`,
);
return { paid: false, unverifiable: true };
}
}
/**
* POST /payments/intents/:id/confirm — submit an OTP for a COLLECT_OTP provider (CAC Bank).
* A wrong/expired OTP comes back as 400 from the payment service; surface that as a

View File

@@ -2,9 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { ModuleRef } from '@nestjs/core';
import { PrismaService } from '../../common/prisma.service';
import { PaymentClientService } from './payment-client.service';
import { PaymentsService } from './payments.service';
import { PaymentReferenceType, ProviderPaymentStatus } from '@edr/types';
// PaymentsService is request-scoped (AuditService.@Inject(REQUEST) bubbles up).
// This service keeps only singleton deps so its @Cron method registers correctly,
@@ -16,12 +14,11 @@ export class PaymentSyncService {
constructor(
private readonly prisma: PrismaService,
private readonly paymentClient: PaymentClientService,
private readonly moduleRef: ModuleRef,
) {}
// ─────────────────────────────────────────────────────────────────────────
// Every 1 min: poll the payment service for any PENDING_PAYMENT bookings
// Every 30 min: poll the payment service for any PENDING_PAYMENT bookings
// whose payment intent has moved to SUCCEEDED on the gateway but whose
// confirmation event was never delivered (missed RabbitMQ message, network
// blip, etc.). finalizePaymentSuccess() is fully idempotent so re-running
@@ -30,7 +27,7 @@ export class PaymentSyncService {
// Processes at most 50 bookings per cycle to avoid hammering the payment
// service; the next tick picks up the remainder.
// ─────────────────────────────────────────────────────────────────────────
@Cron('*/1 * * * *')
@Cron('*/30 * * * *')
async syncPaymentStatuses() {
const BATCH_SIZE = 50;
@@ -47,7 +44,6 @@ export class PaymentSyncService {
if (bookings.length === 0) return;
let confirmed = 0;
let failed = 0;
let errored = 0;
// resolve() (not get()) because PaymentsService is scoped — same pattern
@@ -59,37 +55,17 @@ export class PaymentSyncService {
);
for (const booking of bookings) {
if (!booking.paymentIntent) continue;
try {
const snapshot = await this.paymentClient.getIntentByReference(
PaymentReferenceType.BOOKING,
booking.id,
);
if (!snapshot) continue;
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
const result = await paymentsService.finalizePaymentSuccess({
intentId: booking.paymentIntent.id,
providerTxnId: snapshot.providerTxnId,
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
});
if (!result.alreadyFinalized) {
this.logger.log(`Payment sync confirmed: ${booking.bookingRef}`);
confirmed++;
}
} else if (
snapshot.status === ProviderPaymentStatus.FAILED ||
snapshot.status === ProviderPaymentStatus.CANCELLED
) {
this.logger.warn(
`Payment sync: ${booking.bookingRef} intent is ${snapshot.status}` +
`booking will be auto-cancelled at payment deadline`,
);
failed++;
// Reconcile ALL intents at the provider — including terminal (cancelled/expired) ones —
// and confirm synchronously if any is paid. Unlike getIntentByReference this catches BOTH
// a lost confirm event (payment-api already SUCCEEDED) AND a payment recorded only at the
// provider (local intent terminal). Idempotent, so a re-run is safe.
const { paid } = await paymentsService.reconcileAndConfirmIfPaid(booking.id);
if (paid) {
this.logger.log(`Payment sync confirmed: ${booking.bookingRef}`);
confirmed++;
}
// REQUIRES_ACTION / PROCESSING → still pending, retry next cycle
// not paid / unverifiable → still pending; retried next cycle (or cancelled at deadline)
} catch (err) {
this.logger.error(
`Payment sync error for ${booking.bookingRef}: ` +
@@ -99,10 +75,9 @@ export class PaymentSyncService {
}
}
if (confirmed > 0 || failed > 0 || errored > 0) {
if (confirmed > 0 || errored > 0) {
this.logger.log(
`Payment sync run: ${bookings.length} checked, ` +
`${confirmed} confirmed, ${failed} failed/cancelled, ${errored} errors`,
`Payment sync run: ${bookings.length} checked, ${confirmed} confirmed, ${errored} errors`,
);
}
}

View File

@@ -25,6 +25,7 @@ export enum PaymentMethodTypeEnum {
CAC_BANK = "CAC_BANK", // Djibouti (OTP debit)
CARD = "CARD", // International
WALLET = "WALLET", // Internal
CBE_BILL = "CBE_BILL", // Ethiopia (pay at any CBE channel by bill number)
}
export type PaymentPlatformDto = "web" | "mobile";
@@ -115,8 +116,10 @@ export class SupportedPaymentMethodDto {
}
export class ClientActionDto {
@ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP"] })
type: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP";
@ApiProperty({
enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP", "SHOW_BILL_REFERENCE"],
})
type: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP" | "SHOW_BILL_REFERENCE";
@ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" })
url?: string;
@ApiPropertyOptional({
@@ -135,6 +138,14 @@ export class ClientActionDto {
providerOrderId?: string;
@ApiPropertyOptional({ description: "Set when type=COLLECT_OTP" })
message?: string;
@ApiPropertyOptional({
description: "Set when type=SHOW_BILL_REFERENCE (CBE bill payment)",
})
billReference?: string;
@ApiPropertyOptional({ description: "Set when type=SHOW_BILL_REFERENCE" })
instructions?: string;
@ApiPropertyOptional({ description: "Set when type=SHOW_BILL_REFERENCE" })
expiresAt?: string;
}
export class InitiateResponseDto {

View File

@@ -3,6 +3,7 @@ import { PaymentsService } from "./payments.service";
import { PaymentClientService } from "./payment-client.service";
import { CurrencyService } from "../currency/currency.service";
import { PrismaService } from "../../common/prisma.service";
import { AuditService } from "../../common/audit.service";
import { SeatsService } from "../seats/seats.service";
import { TicketsService } from "../tickets/tickets.service";
import { EventEmitter2 } from "@nestjs/event-emitter";
@@ -27,6 +28,7 @@ describe("PaymentsService", () => {
booking: {
findUnique: jest.fn(),
update: jest.fn(),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
paymentIntent: {
findUnique: jest.fn(),
@@ -81,6 +83,8 @@ describe("PaymentsService", () => {
convertEtbMinorToChargeMajor: jest.fn((minor: number) =>
Promise.resolve(minor),
),
displayMinorToChargeMajor: jest.fn((minor: number) => minor / 100),
convertMinorToChargeMajor: jest.fn(async (minor: number) => minor / 100),
getRateOrThrow: jest.fn(),
};
@@ -109,6 +113,7 @@ describe("PaymentsService", () => {
{ provide: EventEmitter2, useValue: mockEventEmitter },
{ provide: PaymentClientService, useValue: mockPaymentClient },
{ provide: CurrencyService, useValue: mockCurrencyService },
{ provide: AuditService, useValue: { log: jest.fn() } },
],
}).compile();

View File

@@ -24,7 +24,12 @@ import {
PaymentRegionEnum,
ForceConfirmDto,
} from "./payments.dto";
import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payments.dto";
import {
PaymentEventDto,
MarkPaidResponseDto,
BillQueryResponseDto,
} from "./internal-payments.dto";
import { computePaymentDeadline } from "../../common/utils/payment-deadline.utils";
import {
PaymentClientService,
PaymentDiagnostic,
@@ -222,6 +227,17 @@ export class PaymentsService {
);
}
// CBE settles ETB only (docs/cbe/CBE_IMPLEMENTATION_PLAN.md D8). payerAccount is NOT
// required — CBE identifies the payer at its own channel.
if (
method === PaymentMethodType.CBE_BILL &&
(booking.currency ?? "ETB").toUpperCase() !== "ETB"
) {
throw new BadRequestException(
"CBE bill payment is only available for bookings charged in ETB",
);
}
const correctTotalMinor = await this.resolveBookingTotal(booking as any);
// Patch the DB if the stored total is wrong (single-leg for a round-trip package booking)
@@ -244,74 +260,9 @@ export class PaymentsService {
return this.initiateWalletPayment(booking);
}
// Double-charge guard for payment-method switches. Before opening a fresh charge over
// this booking, reconcile any still-open intent against the authoritative provider
// status — the booking-status check above only blocks once the booking is CONFIRMED,
// which leaves a window where the first attempt actually paid but the mark-paid
// webhook/poll hasn't landed yet.
const existingIntent = await this.prisma.paymentIntent.findUnique({
where: { bookingId: booking.id },
});
if (existingIntent && NON_TERMINAL_STATUSES.includes(existingIntent.status)) {
let snapshot: PaymentIntentSnapshot | null = null;
try {
snapshot = await this.paymentClient.getIntentByReference(
PaymentReferenceType.BOOKING,
booking.id,
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.warn(
`payment reconcile before initiate failed for booking ${booking.id}: ${message}; treating existing intent as still open`,
);
}
// The previous attempt actually paid (provider SUCCEEDED, event just late):
// converge the booking now and return it — never charge a second time.
if (snapshot?.status === ProviderPaymentStatus.SUCCEEDED) {
let intent = await this.syncIntentProjection(booking.id, snapshot);
await this.finalizePaymentSuccess({
intentId: intent.id,
providerTxnId: snapshot.providerTxnId,
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
});
intent = await this.prisma.paymentIntent.findUniqueOrThrow({
where: { id: intent.id },
});
return this.formatIntentResponse(intent);
}
// A web↔mobile switch needs a different clientAction (Telebirr: REDIRECT for web
// vs LAUNCH_APP for the native app). Detect the open session's platform from its
// clientAction shape so a platform change is NOT blocked below: it must flow
// through to re-initiate, where the payment service retires the stale session and
// opens a fresh one with the correct launch method for the requested platform.
const requestedMobile = (dto.platform ?? "web") === "mobile";
const storedAction = (snapshot?.clientAction ??
existingIntent.clientAction) as unknown as ClientAction | null;
const platformChanged =
(storedAction?.type === "LAUNCH_APP") !== requestedMobile;
// Still pending at the provider (REQUIRES_ACTION/PROCESSING) — or the payment
// service was unreachable and the local status is non-terminal. Block the switch:
// return the existing intent so the payer completes or waits out the open attempt
// rather than opening a second concurrent charge. A platform switch is exempt — it
// falls through so a session with the correct clientAction is opened for it.
if (
!platformChanged &&
(!snapshot ||
snapshot.status === ProviderPaymentStatus.REQUIRES_ACTION ||
snapshot.status === ProviderPaymentStatus.PROCESSING)
) {
const intent = snapshot
? await this.syncIntentProjection(booking.id, snapshot)
: existingIntent;
return this.formatIntentResponse(intent);
}
// Otherwise the provider reports FAILED/CANCELLED, or the payer switched platform —
// fall through and initiate the newly selected method below.
}
// Free method changes: no reuse/blocking. Every initiate opens a fresh provider session; the
// single passenger projection row (upserted by bookingId below) tracks the latest session.
// Confirm-once is enforced when a payment succeeds (finalizePaymentSuccess), not here.
const { returnUrl, failureUrl } = this.resolveReturnUrls(
method,
requestOrigin,
@@ -324,15 +275,22 @@ export class PaymentsService {
const paymentMethod = await this.prisma.paymentMethod.findUnique({
where: { type: method },
});
const chargeCurrency = (
paymentMethod?.currency ?? booking.currency
).toUpperCase();
const chargeCurrency =
method === PaymentMethodType.CBE_BILL
? "ETB"
: (paymentMethod?.currency ?? booking.currency).toUpperCase();
const bookingDisplayCurrency = ((booking as any).displayCurrency ?? 'ETB').toUpperCase();
const bookingDisplayTotalMinor = (booking as any).displayTotalMinor as number | null;
let chargeAmount: number;
if (
if (method === PaymentMethodType.CBE_BILL) {
// Force ETB, no conversion (D8) — eligibility was already checked above.
chargeAmount = this.currencyService.displayMinorToChargeMajor(
booking.totalMinor,
"ETB",
);
} else if (
chargeCurrency === bookingDisplayCurrency &&
chargeCurrency !== 'ETB' &&
bookingDisplayTotalMinor != null
@@ -350,6 +308,20 @@ export class PaymentsService {
);
}
// CBE_BILL: the bill lives in CBE's system for as long as the booking is payable, so the
// intent expiry is the booking's own payment deadline — never a provider-session TTL
// (plan §6.4); payerName feeds the mandatory Full_Name of CBE's query response.
let payerName: string | undefined;
let expiresAt: string | undefined;
if (method === PaymentMethodType.CBE_BILL) {
payerName =
booking.seats.find((s) => s.leg === 1)?.passengerName ??
booking.seats[0]?.passengerName;
expiresAt = (
await this.computeBookingPaymentDeadline(booking.id)
)?.toISOString();
}
const snapshot = await this.paymentClient.initiate({
service: PaymentServiceEnum.PASSENGER,
referenceType: PaymentReferenceType.BOOKING,
@@ -362,6 +334,8 @@ export class PaymentsService {
payerAccount: dto.payerAccount,
returnUrl,
failureUrl,
payerName,
expiresAt,
});
let intent = await this.syncIntentProjection(booking.id, snapshot);
@@ -415,6 +389,97 @@ export class PaymentsService {
return this.formatIntentStatus(intent);
}
/**
* CBE bill-query (docs/cbe/CBE_IMPLEMENTATION_PLAN.md Phase 4): live still-payable check +
* payer identity for a booking. Called by the payment service while a CBE teller/app is
* waiting — read-only and fast. This is the double-payment guard: once the booking is
* confirmed by ANY method, stillPayable=false and CBE refuses the bill (§6.3).
*/
async billQuery(bookingId: string): Promise<BillQueryResponseDto> {
const booking = await this.prisma.booking.findUnique({
where: { id: bookingId },
include: { seats: true, passenger: { include: { user: true } } },
});
if (!booking) return { stillPayable: false, reason: "CANCELLED" };
const base = {
// Full_Name is mandatory in CBE's envelope: lead passenger first, then account holder.
payerName:
booking.seats.find((s) => s.leg === 1)?.passengerName ??
booking.seats[0]?.passengerName ??
booking.passenger?.user?.fullName ??
null,
currentAmountMinor: this.currencyService.displayMinorToChargeMajor(
booking.totalMinor,
"ETB",
),
currency: "ETB",
};
if (booking.status === "CONFIRMED" || booking.paidAt) {
return { ...base, stillPayable: false, reason: "ALREADY_PAID" };
}
if (booking.status !== "PENDING_PAYMENT") {
return { ...base, stillPayable: false, reason: "CANCELLED" };
}
const deadline = await this.computeBookingPaymentDeadline(booking.id);
if (deadline && deadline.getTime() < Date.now()) {
return { ...base, stillPayable: false, reason: "EXPIRED" };
}
return { ...base, stillPayable: true, reason: null };
}
/**
* The booking's payment deadline, resolved exactly like the auto-cancel job: the booking's
* origin-segment time and that stop's own check-in window, falling back to the route default.
*/
private async computeBookingPaymentDeadline(
bookingId: string,
): Promise<Date | null> {
const booking = await this.prisma.booking.findUnique({
where: { id: bookingId },
select: {
createdAt: true,
originStationId: true,
schedule: {
select: {
departureAt: true,
stopTimes: {
select: {
stationId: true,
plannedArrivalAt: true,
plannedDepartureAt: true,
},
},
route: {
select: {
checkinMinutesBefore: true,
stops: {
select: { stationId: true, checkinMinutesBefore: true },
},
},
},
},
},
},
});
if (!booking?.schedule) return null;
const originStop = booking.schedule.stopTimes?.find(
(s) => s.stationId === booking.originStationId,
);
const dep = (originStop?.plannedArrivalAt ??
originStop?.plannedDepartureAt ??
booking.schedule.departureAt) as Date;
const originRouteStop = booking.schedule.route?.stops?.find(
(s) => s.stationId === booking.originStationId,
);
const checkinMinutes =
originRouteStop?.checkinMinutesBefore ??
booking.schedule.route?.checkinMinutesBefore ??
undefined;
return computePaymentDeadline(booking.createdAt, dep, checkinMinutes);
}
private resolveReturnUrls(
method: PaymentMethodType,
requestOrigin?: string | null,
@@ -898,10 +963,76 @@ export class PaymentsService {
return value;
}
/**
* Ask the payment service (over HTTP — bypassing the possibly-down RabbitMQ) whether a booking is
* actually paid, and CONFIRM it synchronously if so. Used by (a) every cancellation site as a
* verify-before-cancel guard, and (b) the PaymentSyncService poller as lost-event recovery. Unlike
* getIntentByReference, POST /payments/reconcile loops ALL intents and live-queries even
* terminal (cancelled/expired) ones — so it catches a payment recorded only at the provider.
*
* - paid → the confirming payment is synced + finalized HERE (synchronously); the booking is
* now CONFIRMED, so a cancellation caller must NOT cancel.
* - not paid → verified unpaid; a cancellation caller may proceed.
* - unverifiable (provider query errored, in-flight, or payment service unreachable) → a
* cancellation caller must NOT cancel this cycle; defer and retry later.
*/
async reconcileAndConfirmIfPaid(
bookingId: string,
): Promise<{ paid: boolean; verified: boolean }> {
const settlement = await this.paymentClient.reconcileByReference(
PaymentReferenceType.BOOKING,
bookingId,
);
if (settlement.unverifiable) {
this.logger.warn(
`reconcile-before-cancel: settlement UNVERIFIABLE for booking ${bookingId} — not cancelling`,
);
return { paid: false, verified: false };
}
if (settlement.paid) {
if (settlement.intent) {
// Paid, but the confirm event may have been lost. Confirm synchronously (idempotent).
const intent = await this.syncIntentProjection(
bookingId,
settlement.intent,
);
await this.finalizePaymentSuccess({
intentId: intent.id,
providerTxnId: settlement.intent.providerTxnId,
paidAt: settlement.intent.paidAt
? new Date(settlement.intent.paidAt)
: undefined,
}).catch((err) => {
this.logger.error(
`reconcile-before-cancel: finalize failed for booking ${bookingId}: ${
err instanceof Error ? err.message : String(err)
}`,
);
return { alreadyFinalized: false };
});
this.logger.log(
`reconcile-before-cancel: booking ${bookingId} is PAID (${settlement.intent.merchantOrderId}) — confirmed, NOT cancelling`,
);
} else {
this.logger.error(
`reconcile-before-cancel: booking ${bookingId} reported PAID but no intent snapshot — NOT cancelling`,
);
}
return { paid: true, verified: true };
}
// Verified not paid — safe to cancel.
return { paid: false, verified: true };
}
async finalizePaymentSuccess(input: {
intentId: string;
providerTxnId?: string;
paidAt?: Date;
/** Staff force-confirm: confirm the booking even if it is not PENDING_PAYMENT. */
force?: boolean;
}): Promise<{ alreadyFinalized: boolean }> {
const intent = await this.prisma.paymentIntent.findUnique({
where: { id: input.intentId },
@@ -911,32 +1042,35 @@ export class PaymentsService {
// Idempotency guard — but still repair missing tickets. They can be absent
// when the first finalization threw from generate() after the transaction
// committed: the caller got a 500, retried, and now hits this early-return.
const ticketCount = await this.prisma.ticket.count({ where: { bookingId: intent.bookingId } });
if (ticketCount === 0) {
try {
await this.ticketsService.generate(intent.bookingId);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
this.logger.warn(
`Ticket generation failed on idempotency retry for booking ${intent.bookingId}: ${msg}. Attempting smart seat reassignment.`,
);
// Only repair for a CONFIRMED booking: a SUCCEEDED intent on a CANCELLED booking is a
// recorded orphan payment (booking cancelled, seats possibly reassigned) and must NEVER
// generate a ticket.
const idempotencyBooking = await this.prisma.booking.findUnique({
where: { id: intent.bookingId },
select: { status: true },
});
if (idempotencyBooking?.status === "CONFIRMED") {
const ticketCount = await this.prisma.ticket.count({ where: { bookingId: intent.bookingId } });
if (ticketCount === 0) {
try {
await this.ticketsService.smartAssignAndGenerate(intent.bookingId);
} catch (retryErr) {
this.logger.error(
`Error generating ticket on idempotency retry for booking ${intent.bookingId}: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`,
await this.ticketsService.generate(intent.bookingId);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
this.logger.warn(
`Ticket generation failed on idempotency retry for booking ${intent.bookingId}: ${msg}. Attempting smart seat reassignment.`,
);
try {
await this.ticketsService.smartAssignAndGenerate(intent.bookingId);
} catch (retryErr) {
this.logger.error(
`Error generating ticket on idempotency retry for booking ${intent.bookingId}: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`,
);
}
}
}
}
return { alreadyFinalized: true };
}
if (intent.status === PaymentIntentStatus.CANCELLED) {
throw new BadRequestException(
"PaymentIntent is cancelled; cannot finalize",
);
}
const booking = await this.prisma.booking.findUnique({
where: { id: intent.bookingId },
include: { seats: true },
@@ -944,7 +1078,24 @@ export class PaymentsService {
if (!booking) throw new NotFoundException("Booking not found");
const paidAt = this.sanitizePaidAt(input.paidAt);
await this.prisma.$transaction(async (tx) => {
// Atomic confirm-once. A booking may have many intents (free method changes); only the FIRST
// success on a still-PENDING_PAYMENT booking confirms it + generates the ticket. The conditional
// update is the race guard: two simultaneous payments both reach here, but exactly one flips
// PENDING_PAYMENT→CONFIRMED (count 1) — the other gets count 0 and is register-only (the payment
// is already stored on the payment-api ledger; we don't confirm, don't ticket, don't mark this
// row SUCCEEDED). `force` (staff) confirms regardless of the current booking status.
const confirmed = await this.prisma.$transaction(async (tx) => {
const res = input.force
? await tx.booking.updateMany({
where: { id: booking.id, status: { not: "CONFIRMED" } },
data: { status: "CONFIRMED" },
})
: await tx.booking.updateMany({
where: { id: booking.id, status: "PENDING_PAYMENT" },
data: { status: "CONFIRMED" },
});
if (res.count === 0) return 0;
await tx.paymentIntent.update({
where: { id: intent.id },
data: {
@@ -952,14 +1103,23 @@ export class PaymentsService {
providerTxnId:
input.providerTxnId ?? intent.providerTxnId ?? undefined,
paidAt,
failureCode: null,
failureMessage: null,
},
});
await tx.booking.update({
where: { id: booking.id },
data: { status: "CONFIRMED" },
});
return res.count;
});
if (confirmed === 0) {
// Booking already confirmed by another payment (or not payable and not forced). This capture
// is registered on the payment-api ledger; do not confirm, ticket, or touch this row.
this.logger.error(
`Capture on non-payable booking ${booking.id} (status=${booking.status}), intent ${intent.id} ` +
`txn=${input.providerTxnId ?? intent.providerTxnId ?? "n/a"} — registered in payment-api; not confirming`,
);
return { alreadyFinalized: true };
}
try {
await this.seatsService.confirmSeats(booking.seats.map((s) => s.seatId));
} catch (err) {
@@ -1087,38 +1247,93 @@ export class PaymentsService {
return { processed: false, reason: "booking-not-found" };
}
// C-4 guard: a settlement must cover what the passenger was quoted. Compare the provider-settled
// amount against the booking's display-currency total (the amount the customer agreed to pay);
// a short payment must NOT confirm the booking. Amount-only — the display↔charge currency
// divergence is tracked separately under the USD/DJF findings. The 1% tolerance absorbs rounding.
const expectedMinor = booking.displayTotalMinor ?? booking.totalMinor;
const shortPayTolerance = Math.max(1, Math.round(expectedMinor * 0.01));
if (event.amountMinor < expectedMinor - shortPayTolerance) {
// C-4 guard: a settlement must cover what the passenger was quoted. `event.amountMinor`
// carries the charge amount in MAJOR units (the intent's "real/major price" — what
// initiate sent, e.g. 1500.00 ETB), while booking totals are stored in minor units, so
// normalize before comparing; a short payment must NOT confirm the booking. Amount-only —
// the display↔charge currency divergence is tracked separately under the USD/DJF
// findings. The 1% tolerance absorbs rounding.
const expectedMajor = (booking.displayTotalMinor ?? booking.totalMinor) / 100;
const shortPayTolerance = Math.max(0.01, expectedMajor * 0.01);
if (event.amountMinor < expectedMajor - shortPayTolerance) {
this.logger.error(
`mark-paid: short payment for booking ${booking.id} — settled ${event.amountMinor} ${event.currency} < expected ${expectedMinor} ${booking.displayCurrency}; not confirming`,
`mark-paid: short payment for booking ${booking.id} — settled ${event.amountMinor} ${event.currency} < expected ${expectedMajor} ${booking.displayCurrency}; not confirming`,
);
return { processed: false, reason: "amount-mismatch" };
}
// Local intent row is a projection during the strangler migration: reuse it when the
// legacy initiate path created one, otherwise materialize it from the event.
let intent = await this.prisma.paymentIntent.findUnique({
where: { bookingId: event.referenceId },
});
if (!intent) {
intent = await this.prisma.paymentIntent.create({
data: {
bookingId: event.referenceId,
// Payment on an already-CANCELLED booking: record the success on the passenger projection too
// (it is already registered on the payment-api ledger), but NEVER confirm the booking and NEVER
// generate a ticket — the seats may already be held by another passenger. Refund is manual.
if (booking.status === "CANCELLED") {
await this.prisma.paymentIntent.upsert({
where: { bookingId: event.referenceId },
update: {
status: PaymentIntentStatus.SUCCEEDED,
method: event.provider as unknown as PaymentMethodType,
amountMinor: event.amountMinor,
currency: event.currency,
method: event.provider as unknown as PaymentMethodType,
status: PaymentIntentStatus.PROCESSING,
merchantOrderId: event.merchantOrderId,
providerTxnId: event.providerTxnId,
paidAt: this.sanitizePaidAt(
event.paidAt ? new Date(event.paidAt) : undefined,
),
},
create: {
bookingId: event.referenceId,
status: PaymentIntentStatus.SUCCEEDED,
method: event.provider as unknown as PaymentMethodType,
amountMinor: event.amountMinor,
currency: event.currency,
merchantOrderId: event.merchantOrderId,
providerTxnId: event.providerTxnId,
paidAt: this.sanitizePaidAt(
event.paidAt ? new Date(event.paidAt) : undefined,
),
},
});
this.logger.error(
`Payment on CANCELLED booking ${booking.id} (merchantOrder=${event.merchantOrderId}, ` +
`txn=${event.providerTxnId ?? "n/a"}) — recorded on passenger + payment-api; NOT confirming ` +
`(seats may be reassigned). Refund required.`,
);
return { processed: true, alreadyFinalized: true };
}
// Sequential duplicate on an already-CONFIRMED booking: this success is a second payment,
// already registered on the payment-api ledger. Do NOT touch the passenger row — it must keep
// the confirming payment. (The concurrent-race case is caught atomically in finalizePaymentSuccess.)
if (booking.status !== "PENDING_PAYMENT") {
this.logger.error(
`Duplicate capture on ${booking.status} booking ${booking.id} (merchantOrder=${event.merchantOrderId}, ` +
`txn=${event.providerTxnId ?? "n/a"}) — registered in payment-api; not confirming`,
);
return { processed: true, alreadyFinalized: true };
}
// Booking is payable — point the single passenger projection row at THIS paying session (so the
// row reflects the payment that confirms the booking, even if the payer switched methods), then
// finalize (which does the atomic confirm-once).
const intent = await this.prisma.paymentIntent.upsert({
where: { bookingId: event.referenceId },
update: {
method: event.provider as unknown as PaymentMethodType,
amountMinor: event.amountMinor,
currency: event.currency,
merchantOrderId: event.merchantOrderId,
providerTxnId: event.providerTxnId,
},
create: {
bookingId: event.referenceId,
amountMinor: event.amountMinor,
currency: event.currency,
method: event.provider as unknown as PaymentMethodType,
status: PaymentIntentStatus.PROCESSING,
merchantOrderId: event.merchantOrderId,
providerTxnId: event.providerTxnId,
},
});
const { alreadyFinalized } = await this.finalizePaymentSuccess({
intentId: intent.id,
providerTxnId: event.providerTxnId,
@@ -1174,6 +1389,7 @@ export class PaymentsService {
return this.finalizePaymentSuccess({
intentId: intent.id,
providerTxnId: dto.paymentReference ?? intent.providerTxnId ?? undefined,
force: true,
}).then(async (result) => {
await this.auditService.log({ action: 'UPDATE', entityType: 'Payment', entityId: intent.id, newData: { status: 'FORCE_CONFIRMED', bookingId, paymentMethod: dto.paymentMethod, paymentReference: dto.paymentReference } });
return result;

View File

@@ -2,10 +2,11 @@ import { Module } from '@nestjs/common';
import { PrismaModule } from '../../common/prisma.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { CurrencyModule } from '../currency/currency.module';
import { PaymentsModule } from '../payments/payments.module';
import { TasksService } from './tasks.service';
@Module({
imports: [PrismaModule, NotificationsModule, CurrencyModule],
imports: [PrismaModule, NotificationsModule, CurrencyModule, PaymentsModule],
providers: [TasksService],
})
export class TasksModule {}

View File

@@ -1,8 +1,10 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { ModuleRef } from '@nestjs/core';
import { PrismaService } from '../../common/prisma.service';
import { SmsClientService } from '../notifications/sms-client.service';
import { CurrencyService } from '../currency/currency.service';
import { PaymentsService } from '../payments/payments.service';
import { MAX_PAYMENT_HOURS, CUTOFF_MINUTES, computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
// Retention windows
@@ -28,6 +30,10 @@ export class TasksService {
private readonly prisma: PrismaService,
private readonly sms: SmsClientService,
private readonly currencyService: CurrencyService,
// ModuleRef (NOT direct injection): PaymentsService is request-scoped (AuditService injects
// REQUEST), and injecting a request-scoped provider here would make TasksService request-scoped
// too — which silently stops all its @Cron methods from firing. Resolve it per-tick instead.
private readonly moduleRef: ModuleRef,
) {}
// ─────────────────────────────────────────────────────────────────────────
@@ -290,6 +296,14 @@ export class TasksService {
let cancelledCount = 0;
// resolve() (not direct injection) because PaymentsService is request-scoped — same pattern
// as PaymentSyncService. strict:false resolves it from the app context.
const paymentsService = await this.moduleRef.resolve(
PaymentsService,
undefined,
{ strict: false },
);
for (const booking of expiredBookings) {
try {
// Re-verify exact deadline to avoid racing with a concurrent payment confirmation.
@@ -308,6 +322,18 @@ export class TasksService {
const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes);
if (now < paymentDeadline) continue;
// Deadline passed — but NEVER cancel a booking that is actually paid. The payment.succeeded
// event may have been lost (RabbitMQ down) or arrived late, leaving a paid booking stuck
// PENDING_PAYMENT. Ask the payment service over HTTP; it confirms the booking synchronously
// if paid. Only proceed to cancel when settlement is VERIFIED unpaid.
const settlement = await paymentsService.reconcileAndConfirmIfPaid(booking.id);
if (settlement.paid || !settlement.verified) {
this.logger.log(
`Skip auto-cancel ${booking.bookingRef}: ${settlement.paid ? 'PAID → confirmed' : 'unverifiable → deferred'}`,
);
continue;
}
// 1a. Release held seats (Journey rows are the occupancy source of truth once paid)
await this.prisma.journey.deleteMany({ where: { bookingId: booking.id } as any });

Some files were not shown because too many files have changed in this diff Show More