Merge branch 'freight_feature/usermanagement' of github.com:Tria-plc/edr-platform into freight_feature/usermanagement

This commit is contained in:
marshalyordanos
2026-08-20 10:59:31 +03:00
40 changed files with 2498 additions and 142 deletions

View File

@@ -0,0 +1,46 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Post-finalization clearance charges billed to the customer: one PORT_CHARGES
* and one MISCELLANEOUS row max per booking, each carrying a document, amount,
* currency and its own payable invoice.
*/
export class BookingClearanceCharge3590000000000 implements MigrationInterface {
name = 'BookingClearanceCharge3590000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "freight"."booking_clearance_charge" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
"deleted_at" timestamptz,
"booking_id" uuid NOT NULL,
"type" character varying(20) NOT NULL,
"status" character varying(20) NOT NULL DEFAULT 'DOC_UPLOADED',
"file_record_id" uuid,
"amount" numeric(14,2),
"currency" character varying(8),
"invoice_id" uuid,
"uploaded_by_staff_id" uuid,
"uploaded_at" timestamptz,
"billed_by_staff_id" uuid,
"billed_at" timestamptz,
"paid_at" timestamptz,
CONSTRAINT "pk_booking_clearance_charge" PRIMARY KEY ("id"),
CONSTRAINT "fk_booking_clearance_charge_booking" FOREIGN KEY ("booking_id")
REFERENCES "freight"."bookings"("id") ON DELETE CASCADE
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_booking_clearance_charge_booking_type"
ON "freight"."booking_clearance_charge" ("booking_id", "type")
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP TABLE IF EXISTS "freight"."booking_clearance_charge"`,
);
}
}

View File

@@ -0,0 +1,37 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/** Per-booking clearance action history — drives the History tab. */
export class BookingClearanceEvent3600000000000 implements MigrationInterface {
name = 'BookingClearanceEvent3600000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "freight"."booking_clearance_event" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
"deleted_at" timestamptz,
"booking_id" uuid NOT NULL,
"action" character varying(64) NOT NULL,
"label" character varying(500) NOT NULL,
"actor_type" character varying(16) NOT NULL DEFAULT 'STAFF',
"actor_id" uuid,
"actor_name" character varying(150),
"metadata" jsonb,
CONSTRAINT "pk_booking_clearance_event" PRIMARY KEY ("id"),
CONSTRAINT "fk_booking_clearance_event_booking" FOREIGN KEY ("booking_id")
REFERENCES "freight"."bookings"("id") ON DELETE CASCADE
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_booking_clearance_event_booking_created"
ON "freight"."booking_clearance_event" ("booking_id", "created_at")
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP TABLE IF EXISTS "freight"."booking_clearance_event"`,
);
}
}

View File

@@ -47,6 +47,10 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"POST /api/bookings/:id/clearance/proceed": ["Customer requests operation with a schedule day", "POST", "Booking"],
"POST /api/bookings/:id/clearance/release-order": ["Upload Booking Release Order", "POST", "Booking"],
"POST /api/bookings/:id/clearance/review": ["GL reviews a clearance document (Approve | Query)", "POST", "Booking"],
"POST /api/bookings/:id/clearance/charges/port-document": ["GL Djibouti uploads the port-charges document", "POST", "Booking"],
"PATCH /api/bookings/:id/clearance/charges/:chargeId/bill": ["GL Ethiopia sets or revises a clearance charge's amount + currency", "PATCH", "Booking"],
"POST /api/bookings/:id/clearance/charges/:chargeId/send": ["GL Ethiopia issues the clearance charge invoice to the customer", "POST", "Booking"],
"POST /api/bookings/:id/clearance/charges/miscellaneous": ["GL Ethiopia creates the miscellaneous clearance charge", "POST", "Booking"],
"POST /api/bookings/:id/clearance/ro-amendment": ["Request Booking RO Amendment", "POST", "Booking"],
"POST /api/bookings/:id/clearance/transit-assignee/assign": ["GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns", "POST", "Booking"],
"POST /api/bookings/:id/clearance/transit-assignee/request": ["GL ET asks GL Djibouti to name the transit officer — required before the import customs declaration", "POST", "Booking"],

View File

@@ -0,0 +1,391 @@
import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { DataSource } from 'typeorm';
import { Freight } from '@edr/types';
import { BillingService, InvoiceEventPayload } from '../billing/billing.service';
import { Invoice } from '../billing/entities/invoice.entity';
import { FilesService } from '../files/files.service';
import { BookingsService } from './bookings.service';
import { BookingsRepository } from './bookings.repository';
import { Booking } from './entities/booking.entity';
import {
BookingClearanceCharge,
ClearanceChargeType,
} from './entities/booking-clearance-charge.entity';
import { ClearanceEventService } from './clearance-event.service';
/** File-record codes the charge documents are stored under on the booking. */
const CHARGE_FILE_CODE: Record<ClearanceChargeType, string> = {
PORT_CHARGES: 'clearance_charge_port',
MISCELLANEOUS: 'clearance_charge_misc',
};
const CHARGE_LABEL: Record<ClearanceChargeType, string> = {
PORT_CHARGES: 'Port charges',
MISCELLANEOUS: 'Miscellaneous charges',
};
/**
* Post-finalization clearance charges billed to the customer. Two levels per
* booking: GL Djibouti uploads the port-charges document; GL Ethiopia bills it
* (amount + currency) and sends the invoice; once that invoice is paid GL
* Ethiopia may create and send the miscellaneous charge. ETB invoices are paid
* through the portal gateway, other currencies through Finance's manual
* settlement worklist — both settle via `clearance_charge.invoice.paid`.
*/
@Injectable()
export class BookingClearanceChargeService {
private readonly logger = new Logger(BookingClearanceChargeService.name);
constructor(
private readonly dataSource: DataSource,
private readonly filesService: FilesService,
private readonly billing: BillingService,
private readonly bookingsService: BookingsService,
private readonly bookingsRepository: BookingsRepository,
private readonly clearanceEvents: ClearanceEventService,
) {}
private repo() {
return this.dataSource.getRepository(BookingClearanceCharge);
}
/**
* Charges are a post-finalization step: block while the customer's clearance
* documents are still being collected/reviewed.
*/
private assertClearanceFinalized(booking: Booking): void {
const inReview =
booking.status === 'AWAITING_DOCUMENTS' ||
booking.status === 'DOCUMENTS_UNDER_REVIEW';
if (inReview && !booking.preClearanceFinalizedAt) {
throw new BadRequestException(
'Clearance charges open after document clearance is finalized.',
);
}
}
async list(bookingId: string): Promise<Freight.ClearanceCharge[]> {
const charges = await this.repo().find({
where: { bookingId },
order: { createdAt: 'ASC' },
});
if (charges.length === 0) return [];
const files = await this.filesService.findByResource(bookingId, 'bookings');
const fileById = new Map(files.map((f) => [f.id, f]));
const names = await this.bookingsRepository.resolveStaffNames(
charges.flatMap((c) => [c.uploadedByStaffId, c.billedByStaffId]),
);
const invoiceIds = charges
.map((c) => c.invoiceId)
.filter((id): id is string => Boolean(id));
const invoices = invoiceIds.length
? await this.dataSource
.getRepository(Invoice)
.find({ where: invoiceIds.map((id) => ({ id })) })
: [];
const invoiceById = new Map(invoices.map((i) => [i.id, i]));
return charges.map((c) => {
const file = c.fileRecordId ? (fileById.get(c.fileRecordId) ?? null) : null;
return {
id: c.id,
bookingId: c.bookingId,
type: c.type,
status: c.status,
file: file ? { id: file.id, name: file.name, url: file.url } : null,
amount: c.amount != null ? Number(c.amount) : null,
currency: c.currency ?? null,
invoiceId: c.invoiceId ?? null,
invoiceNumber: c.invoiceId
? (invoiceById.get(c.invoiceId)?.invoiceNumber ?? null)
: null,
uploadedByName: c.uploadedByStaffId
? (names.get(c.uploadedByStaffId) ?? null)
: null,
uploadedAt: c.uploadedAt ? c.uploadedAt.toISOString() : null,
billedByName: c.billedByStaffId
? (names.get(c.billedByStaffId) ?? null)
: null,
billedAt: c.billedAt ? c.billedAt.toISOString() : null,
paidAt: c.paidAt ? c.paidAt.toISOString() : null,
};
});
}
/** GL Djibouti uploads (or replaces, until billed) the port-charges document. */
async uploadPortDocument(
bookingId: string,
file: Express.Multer.File,
staffId: string,
): Promise<Freight.ClearanceCharge[]> {
const booking = await this.bookingsService.findById(bookingId);
this.assertClearanceFinalized(booking);
const existing = await this.repo().findOne({
where: { bookingId, type: 'PORT_CHARGES' },
});
if (existing && existing.status !== 'DOC_UPLOADED') {
throw new ConflictException(
'The port charge has already been billed — ask GL Ethiopia to revise it instead.',
);
}
const record = await this.filesService.upsertByCode(
{
resourceId: bookingId,
resource: 'bookings',
code: CHARGE_FILE_CODE.PORT_CHARGES,
file,
},
{ userId: staffId },
);
if (existing) {
await this.repo().update(existing.id, {
fileRecordId: record.id,
uploadedByStaffId: staffId,
uploadedAt: new Date(),
});
} else {
await this.repo().save(
this.repo().create({
bookingId,
type: 'PORT_CHARGES',
status: 'DOC_UPLOADED',
fileRecordId: record.id,
uploadedByStaffId: staffId,
uploadedAt: new Date(),
}),
);
}
await this.clearanceEvents.record({
bookingId,
action: 'CHARGE_PORT_DOC_UPLOADED',
label: existing
? 'Replaced the port-charges document'
: 'Uploaded the port-charges document',
actorId: staffId,
metadata: { fileName: file.originalname },
});
return this.list(bookingId);
}
/**
* GL Ethiopia sets (or, on the customer's request, revises) amount +
* currency. Revising a SENT charge cancels its unpaid invoice; a PAID charge
* is immutable.
*/
async billCharge(
bookingId: string,
chargeId: string,
input: { amount: number; currency: string },
staffId: string,
): Promise<Freight.ClearanceCharge[]> {
const charge = await this.repo().findOne({
where: { id: chargeId, bookingId },
});
if (!charge) throw new NotFoundException('Clearance charge not found');
if (charge.status === 'PAID') {
throw new ConflictException('A paid charge can no longer be changed.');
}
if (!(input.amount > 0)) {
throw new BadRequestException('Amount must be greater than zero.');
}
if (!input.currency?.trim()) {
throw new BadRequestException('Currency is required.');
}
if (charge.status === 'SENT' && charge.invoiceId) {
await this.billing.cancelInvoice(charge.invoiceId);
}
await this.repo().update(charge.id, {
amount: input.amount.toFixed(2),
currency: input.currency.trim().toUpperCase(),
status: 'BILLED',
invoiceId: null,
billedByStaffId: staffId,
billedAt: new Date(),
});
await this.clearanceEvents.record({
bookingId,
action: 'CHARGE_BILLED',
label: `${charge.status === 'SENT' ? 'Revised' : 'Billed'} ${CHARGE_LABEL[
charge.type
].toLowerCase()}: ${input.amount} ${input.currency.trim().toUpperCase()}`,
actorId: staffId,
metadata: {
chargeType: charge.type,
amount: input.amount,
currency: input.currency.trim().toUpperCase(),
revised: charge.status === 'SENT',
},
});
return this.list(bookingId);
}
/** GL Ethiopia issues the payable invoice to the customer. */
async sendCharge(
bookingId: string,
chargeId: string,
staffId?: string,
): Promise<Freight.ClearanceCharge[]> {
const charge = await this.repo().findOne({
where: { id: chargeId, bookingId },
});
if (!charge) throw new NotFoundException('Clearance charge not found');
if (charge.status !== 'BILLED') {
throw new ConflictException(
'Set the amount and currency before sending the charge to the customer.',
);
}
const booking = await this.bookingsService.findById(bookingId);
const invoice = await this.billing.generateInvoice({
source: Freight.InvoiceSource.ClearanceCharge,
// The charge's own id, NOT the booking id — booking-scoped invoice
// lookups (findPayable/expirePayable/CBE billQuery) must never match it.
sourceId: charge.id,
type: charge.type,
companyId: booking.companyId,
companyProfileId: booking.companyProfileId,
currency: charge.currency ?? 'ETB',
lines: [
{
chargeType: charge.type,
description: `${CHARGE_LABEL[charge.type]}${booking.reference ?? bookingId}`,
amount: Number(charge.amount),
},
],
});
await this.repo().update(charge.id, {
status: 'SENT',
invoiceId: invoice.id,
});
await this.clearanceEvents.record({
bookingId,
action: 'CHARGE_INVOICE_SENT',
label: `Sent ${CHARGE_LABEL[charge.type].toLowerCase()} invoice ${invoice.invoiceNumber} to the customer`,
actorId: staffId ?? null,
metadata: {
chargeType: charge.type,
invoiceNumber: invoice.invoiceNumber,
amount: Number(charge.amount),
currency: charge.currency,
},
});
this.logger.log(
`Clearance charge ${charge.type} on booking ${bookingId} sent as invoice ${invoice.invoiceNumber}`,
);
return this.list(bookingId);
}
/**
* GL Ethiopia creates the miscellaneous charge whole (document + amount +
* currency). Second payment level: allowed only once the port charge is paid.
*/
async createMiscellaneous(
bookingId: string,
file: Express.Multer.File,
input: { amount: number; currency: string },
staffId: string,
): Promise<Freight.ClearanceCharge[]> {
const booking = await this.bookingsService.findById(bookingId);
this.assertClearanceFinalized(booking);
const port = await this.repo().findOne({
where: { bookingId, type: 'PORT_CHARGES' },
});
if (port?.status !== 'PAID') {
throw new ConflictException(
'Miscellaneous charges open after the port charge is paid.',
);
}
const existing = await this.repo().findOne({
where: { bookingId, type: 'MISCELLANEOUS' },
});
if (existing) {
throw new ConflictException(
'This booking already has a miscellaneous charge — revise it instead.',
);
}
if (!(input.amount > 0)) {
throw new BadRequestException('Amount must be greater than zero.');
}
if (!input.currency?.trim()) {
throw new BadRequestException('Currency is required.');
}
const record = await this.filesService.upsertByCode(
{
resourceId: bookingId,
resource: 'bookings',
code: CHARGE_FILE_CODE.MISCELLANEOUS,
file,
},
{ userId: staffId },
);
await this.repo().save(
this.repo().create({
bookingId,
type: 'MISCELLANEOUS',
status: 'BILLED',
fileRecordId: record.id,
amount: input.amount.toFixed(2),
currency: input.currency.trim().toUpperCase(),
uploadedByStaffId: staffId,
uploadedAt: new Date(),
billedByStaffId: staffId,
billedAt: new Date(),
}),
);
await this.clearanceEvents.record({
bookingId,
action: 'CHARGE_MISC_CREATED',
label: `Created miscellaneous charge: ${input.amount} ${input.currency.trim().toUpperCase()}`,
actorId: staffId,
metadata: {
amount: input.amount,
currency: input.currency.trim().toUpperCase(),
fileName: file.originalname,
},
});
return this.list(bookingId);
}
/** Gateway and manual settlements both land here (`${source}.invoice.paid`). */
@OnEvent('clearance_charge.invoice.paid')
async onChargeInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
const charge = await this.repo().findOne({
where: { id: payload.sourceId },
});
if (!charge || charge.status === 'PAID') return;
await this.repo().update(charge.id, {
status: 'PAID',
paidAt: new Date(),
});
await this.clearanceEvents.record({
bookingId: charge.bookingId,
action: 'CHARGE_PAID',
label: `${CHARGE_LABEL[charge.type]} paid (invoice ${payload.invoiceNumber})`,
actorType: 'SYSTEM',
metadata: {
chargeType: charge.type,
invoiceNumber: payload.invoiceNumber,
},
});
this.logger.log(
`Clearance charge ${charge.type} on booking ${charge.bookingId} paid (invoice ${payload.invoiceNumber})`,
);
}
}

View File

@@ -59,6 +59,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
{ record: jest.fn() } as never, // clearanceEvents
{ emit: jest.fn() } as never, // events
);
return { service, bookingsRepository, ruleEngineService, contractService };

View File

@@ -68,6 +68,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
{ record: jest.fn() } as never, // clearanceEvents
{ emit: jest.fn() } as never, // events
);
return { service, bookingsRepository };
@@ -172,6 +173,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
{ record: jest.fn() } as never, // clearanceEvents
{ emit: jest.fn() } as never, // events
);
return { service, bookingsRepository };
@@ -262,6 +264,7 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
{ record: jest.fn() } as never, // clearanceEvents
{ emit: jest.fn() } as never, // events
);
return { service, bookingsRepository, filesService };

View File

@@ -71,6 +71,7 @@ describe('BookingTransitionService — operation review', () => {
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
{ record: jest.fn() } as never, // clearanceEvents
{ emit: jest.fn() } as never, // events
);
return { service, bookingsRepository, bookingBatchService, invoiceService };
@@ -172,6 +173,7 @@ describe('BookingTransitionService — requestOperation export space gate', () =
{} as never, // invoiceService
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
notifier as never,
{ record: jest.fn() } as never, // clearanceEvents
{ emit: jest.fn() } as never, // events
);
return { service, bookingsRepository, bookingBatchService };

View File

@@ -32,6 +32,7 @@ describe('BookingTransitionService — paired staff decisions', () => {
{} as never, // invoiceService
{} as never, // containerValidationService
{} as never, // notifier
{ record: jest.fn() } as never, // clearanceEvents
{} as never, // events
undefined, // milestoneService
dataSource as never,

View File

@@ -27,7 +27,15 @@ import { BookingPricingService } from './booking-pricing.service';
import { ContainerValidationService } from './container-validation.service';
import { BookingsRepository } from './bookings.repository';
import { assertBookingStatus } from './booking-status.util';
import { clearanceCodesForBooking } from './clearance.util';
import {
clearanceCodesForBooking,
clearanceDocumentsOpen,
} from './clearance.util';
import {
buildClearanceDocHistory,
type ClearanceDocEvent,
} from './clearance-doc-history.util';
import { ClearanceEventService } from './clearance-event.service';
import { computeNextStep, type BookingNextStep } from './booking-next-step.util';
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
import { PriceLineItemDto } from './dto/generate-price-response.dto';
@@ -68,6 +76,7 @@ export class BookingTransitionService {
private readonly invoiceService: BookingInvoiceService,
private readonly containerValidationService: ContainerValidationService,
private readonly notifier: BookingLifecycleNotifierService,
private readonly clearanceEvents: ClearanceEventService,
private readonly events: EventEmitter2,
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
// Optional + last so the hand-constructed service in *.spec.ts files keeps
@@ -627,8 +636,13 @@ export class BookingTransitionService {
file: { id: string; name: string; url: string } | null;
reviewStatus: "PENDING" | "APPROVED" | "QUERIED" | null;
note: string | null;
uploadedAt: string | null;
reviewedAt: string | null;
reviewedByName: string | null;
history: ClearanceDocEvent[];
}>;
allApproved: boolean;
documentsOpen: boolean;
phase?: string | null;
milestones?: unknown[];
nextAction?: unknown;
@@ -652,6 +666,18 @@ export class BookingTransitionService {
const reviewByKey = new Map(
reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r]),
);
const allVersions = await this.filesService.findAllVersionsByResource(
bookingId,
"bookings",
);
const queryNotes = await this.bookingsRepository.findReviewNotes(
bookingId,
"CHANGES_REQUESTED",
);
const reviewerNames = await this.bookingsRepository.resolveStaffNames([
...reviews.map((r) => r.reviewedByStaffId),
...queryNotes.map((n) => n.authorId),
]);
const documents: Awaited<
ReturnType<BookingTransitionService["getClearanceView"]>
@@ -680,6 +706,18 @@ export class BookingTransitionService {
file: file ? { id: file.id, name: file.name, url: file.url } : null,
reviewStatus: review?.status ?? null,
note: review?.note ?? null,
uploadedAt: file?.createdAt ? file.createdAt.toISOString() : null,
reviewedAt: review?.reviewedAt ? review.reviewedAt.toISOString() : null,
reviewedByName: review?.reviewedByStaffId
? (reviewerNames.get(review.reviewedByStaffId) ?? null)
: null,
history: buildClearanceDocHistory({
fileKey: field.fileKey,
allVersions,
queryNotes,
review,
names: reviewerNames,
}),
});
}
};
@@ -700,6 +738,18 @@ export class BookingTransitionService {
file: { id: f.id, name: f.name, url: f.url },
reviewStatus: review?.status ?? null,
note: review?.note ?? null,
uploadedAt: f.createdAt ? f.createdAt.toISOString() : null,
reviewedAt: review?.reviewedAt ? review.reviewedAt.toISOString() : null,
reviewedByName: review?.reviewedByStaffId
? (reviewerNames.get(review.reviewedByStaffId) ?? null)
: null,
history: buildClearanceDocHistory({
fileKey: f.code,
allVersions,
queryNotes,
review,
names: reviewerNames,
}),
});
}
@@ -712,6 +762,7 @@ export class BookingTransitionService {
outputCode,
documents,
allApproved,
documentsOpen: clearanceDocumentsOpen(booking),
};
}
@@ -751,12 +802,17 @@ export class BookingTransitionService {
async submitClearanceDocuments(
bookingId: string,
files: Express.Multer.File[],
userId?: string,
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [
"AWAITING_DOCUMENTS",
"DOCUMENTS_UNDER_REVIEW",
]);
// Documents stay open until the shipment is paid — a customs shipment keeps
// collecting paperwork (amended invoices, port documents) well past
// clearance finalization. See {@link clearanceDocumentsOpen}.
if (!clearanceDocumentsOpen(booking)) {
throw new ConflictException(
`Clearance documents are closed for this booking (status "${booking.status}").`,
);
}
const { inputCode } = clearanceCodesForBooking(booking);
if (!inputCode) {
throw new BadRequestException(
@@ -794,21 +850,41 @@ export class BookingTransitionService {
});
}
await this.bookingsRepository.update(bookingId, {
status: "DOCUMENTS_UNDER_REVIEW",
} as never);
// Only the pre-finalization submission drives the booking into review.
// A later addition (an amended invoice while the shipment is already
// scheduled) must never rewind the status or reopen the phased workflow —
// it lands as a new PENDING document for GL to approve where it stands.
const inDocumentPhase =
booking.status === "AWAITING_DOCUMENTS" ||
booking.status === "DOCUMENTS_UNDER_REVIEW";
if (this.isPhasedCustoms(booking)) {
await this.workflowService.onCustomerDocsUploadedForBooking(
bookingId,
booking.tradeDirection ?? 'IMPORT',
);
await this.workflowService.onDocumentReviewReopenedForBooking(bookingId);
if (inDocumentPhase) {
await this.bookingsRepository.update(bookingId, {
clearanceCurrentPhase: ContractDocPhase.GlEtReview,
status: "DOCUMENTS_UNDER_REVIEW",
} as never);
if (this.isPhasedCustoms(booking)) {
await this.workflowService.onCustomerDocsUploadedForBooking(
bookingId,
booking.tradeDirection ?? 'IMPORT',
);
await this.workflowService.onDocumentReviewReopenedForBooking(bookingId);
await this.bookingsRepository.update(bookingId, {
clearanceCurrentPhase: ContractDocPhase.GlEtReview,
} as never);
}
}
const fileKeys = files.map((f) => f.fieldname);
await this.clearanceEvents.record({
bookingId,
action: 'DOCS_SUBMITTED',
label: `Customer submitted ${files.length} clearance document(s): ${fileKeys.join(', ')}`,
actorType: 'CUSTOMER',
actorId: userId ?? null,
metadata: { fileKeys },
});
const fresh = await this.bookingsService.findById(bookingId);
this.notifier.clearanceDocsUploadedToStaff(fresh);
return fresh;
@@ -861,7 +937,14 @@ export class BookingTransitionService {
note?: string,
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]);
// GL keeps reviewing for as long as the customer can still submit — the
// two sides share one predicate so they can never drift apart. Documents
// added after clearance was finalized still need approving/querying.
if (!clearanceDocumentsOpen(booking)) {
throw new ConflictException(
`Clearance documents are closed for this booking (status "${booking.status}").`,
);
}
const { inputCode, outputCode } = clearanceCodesForBooking(booking);
const existing =
@@ -878,15 +961,6 @@ export class BookingTransitionService {
"A note is required when querying a document",
);
}
if (
status === 'QUERIED' &&
this.isPhasedCustoms(booking) &&
booking.preClearanceFinalizedAt
) {
throw new BadRequestException(
'Customer documents cannot be queried after pre-clearance is finalized.',
);
}
await this.bookingsRepository.setDocumentReviewStatus(
bookingId,
@@ -896,6 +970,16 @@ export class BookingTransitionService {
staffId,
note,
);
await this.clearanceEvents.record({
bookingId,
action: status === 'APPROVED' ? 'DOC_APPROVED' : 'DOC_QUERIED',
label:
status === 'APPROVED'
? `Approved document "${fileKey.replace(/_/g, ' ')}"`
: `Opened query on document "${fileKey.replace(/_/g, ' ')}"`,
actorId: staffId,
metadata: { fileKey, note: note ?? null },
});
if (status === "QUERIED") {
await this.bookingsRepository.createReviewNote(
bookingId,
@@ -903,7 +987,10 @@ export class BookingTransitionService {
"CHANGES_REQUESTED",
staffId,
);
if (this.isPhasedCustoms(booking)) {
// Reopening the review phase only makes sense while clearance is still
// being decided. Querying a document that arrived afterwards must not
// drag a finalized shipment back into the GL review phase.
if (this.isPhasedCustoms(booking) && !booking.preClearanceFinalizedAt) {
await this.workflowService.onDocumentReviewReopenedForBooking(bookingId);
await this.bookingsRepository.update(bookingId, {
clearanceCurrentPhase: ContractDocPhase.GlEtReview,
@@ -915,7 +1002,10 @@ export class BookingTransitionService {
if (status === "QUERIED") {
this.notifier.documentQueried(updated, fileKey, note ?? '');
}
if (this.isPhasedCustoms(updated)) {
// Same reasoning as the query branch: advance the workflow only while
// clearance is still open. Approving a late-added document leaves an
// already-finalized shipment's phase exactly where it is.
if (this.isPhasedCustoms(updated) && !updated.preClearanceFinalizedAt) {
const allApproved = await this.isClearanceFullyApproved(updated);
if (allApproved) {
await this.workflowService.onAllDocsApprovedForBooking(bookingId);
@@ -936,6 +1026,7 @@ export class BookingTransitionService {
async uploadClearanceOutputDocuments(
bookingId: string,
files: Express.Multer.File[],
userId?: string,
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]);
@@ -956,6 +1047,15 @@ export class BookingTransitionService {
file,
});
}
await this.clearanceEvents.record({
bookingId,
action: 'OUTPUT_DOCS_UPLOADED',
label: `Uploaded customs output document(s): ${files
.map((f) => f.fieldname.replace(/_/g, ' '))
.join(', ')}`,
actorId: userId ?? null,
metadata: { fileKeys: files.map((f) => f.fieldname) },
});
return this.bookingsService.findById(bookingId);
}
@@ -963,7 +1063,7 @@ export class BookingTransitionService {
* GL confirms clearance: requires every customer document APPROVED (100% gate)
* and, for customs, the required output documents present → CLEARANCE_READY.
*/
async finalizeClearance(bookingId: string): Promise<Booking> {
async finalizeClearance(bookingId: string, userId?: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
if (this.isPhasedCustoms(booking)) {
throw new BadRequestException(
@@ -1019,6 +1119,12 @@ export class BookingTransitionService {
await this.bookingsRepository.update(bookingId, {
status: "CLEARANCE_READY",
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'CLEARANCE_FINALIZED',
label: 'Finalized document approval — clearance ready',
actorId: userId ?? null,
});
const fresh = await this.bookingsService.findById(bookingId);
this.notifier.clearanceReady(fresh);
return fresh;
@@ -1045,6 +1151,8 @@ export class BookingTransitionService {
* the customer pools, so the gate here would wrongly reject them).
*/
bypassDayPool?: boolean;
/** Acting user, recorded in the clearance history. */
userId?: string;
},
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
@@ -1159,6 +1267,14 @@ export class BookingTransitionService {
scheduledDate: date,
requestedTrainScheduleId: requestedId,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'OPERATION_REQUESTED',
label: `Requested operation for shipment day ${scheduledDate}`,
actorType: 'CUSTOMER',
actorId: opts?.userId ?? null,
metadata: { scheduledDate },
});
const fresh = await this.bookingsService.findById(bookingId);
this.notifier.operationRequestedToStaff(fresh);
return fresh;

View File

@@ -1,4 +1,5 @@
import {
BadRequestException,
Body,
Controller,
Delete,
@@ -38,6 +39,9 @@ import {
} from "@nestjs/swagger";
import type { Response } from "express";
import { BookingClearanceChargeService } from './booking-clearance-charge.service';
import { ClearanceEventService } from './clearance-event.service';
import { BillClearanceChargeDto } from './dto/clearance-charge.dto';
import { BookingContractService } from './booking-contract.service';
import { BookingPricingService } from './booking-pricing.service';
import { BookingTransitionService } from './booking-transition.service';
@@ -170,6 +174,8 @@ export class BookingsController {
private readonly userTradeAccessService: UserTradeAccessService,
private readonly wagonCancellationService: BookingWagonCancellationService,
private readonly consolidationApprovalService: ConsolidationApprovalService,
private readonly clearanceChargeService: BookingClearanceChargeService,
private readonly clearanceEventService: ClearanceEventService,
) {}
@Post()
@@ -971,10 +977,12 @@ export class BookingsController {
async submitClearanceDocuments(
@Param("id", ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.submitClearanceDocuments(
id,
files ?? [],
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@@ -991,11 +999,13 @@ export class BookingsController {
async proceedToOperation(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: RequestOperationDto,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.requestOperation(
id,
dto.scheduledDate,
dto.trainScheduleId ?? null,
{ userId: resolveAuthUserId(user) },
);
return this.transitionService.enrichBookingResponse(booking);
}
@@ -1073,6 +1083,114 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Get(":id/clearance/history")
@BookingStaff([
FREIGHT_PERMS.contracts.clearanceEtActions,
FREIGHT_PERMS.contracts.clearanceDjActions,
])
@ApiOperation({
summary:
"Clearance action history for the booking — reviews, workflow steps, charges (newest first)",
})
getClearanceHistory(@Param("id", ParseUUIDPipe) id: string) {
return this.clearanceEventService.list(id);
}
// ── Clearance charges (post-finalization customer billing) ────────────────
@Get(":id/clearance/charges")
@BookingStaff([
FREIGHT_PERMS.contracts.clearanceEtActions,
FREIGHT_PERMS.contracts.clearanceDjActions,
])
@ApiOperation({
summary: "Clearance charges billed to the customer (port + miscellaneous)",
})
getClearanceCharges(@Param("id", ParseUUIDPipe) id: string) {
return this.clearanceChargeService.list(id);
}
@Post(":id/clearance/charges/port-document")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@UseInterceptors(FileInterceptor("file"))
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary: "GL Djibouti uploads (or replaces, until billed) the port-charges document",
})
uploadPortChargeDocument(
@Param("id", ParseUUIDPipe) id: string,
@UploadedFile() file: Express.Multer.File,
@CurrentUser() user: AuthUserPayload,
) {
if (!file) throw new BadRequestException("A document file is required");
return this.clearanceChargeService.uploadPortDocument(
id,
file,
resolveAuthUserId(user),
);
}
@Patch(":id/clearance/charges/:chargeId/bill")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({
summary:
"GL Ethiopia sets or revises the charge's amount + currency (revising a sent charge cancels its unpaid invoice)",
})
billClearanceCharge(
@Param("id", ParseUUIDPipe) id: string,
@Param("chargeId", ParseUUIDPipe) chargeId: string,
@Body() dto: BillClearanceChargeDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.clearanceChargeService.billCharge(
id,
chargeId,
dto,
resolveAuthUserId(user),
);
}
@Post(":id/clearance/charges/:chargeId/send")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({
summary:
"GL Ethiopia issues the charge's payable invoice to the customer (ETB pays via gateway, other currencies via manual settlement)",
})
sendClearanceCharge(
@Param("id", ParseUUIDPipe) id: string,
@Param("chargeId", ParseUUIDPipe) chargeId: string,
@CurrentUser() user: AuthUserPayload,
) {
return this.clearanceChargeService.sendCharge(
id,
chargeId,
resolveAuthUserId(user),
);
}
@Post(":id/clearance/charges/miscellaneous")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@UseInterceptors(FileInterceptor("file"))
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary:
"GL Ethiopia creates the miscellaneous charge (document + amount + currency); unlocked once the port charge is paid",
})
createMiscellaneousCharge(
@Param("id", ParseUUIDPipe) id: string,
@UploadedFile() file: Express.Multer.File,
@Body() dto: BillClearanceChargeDto,
@CurrentUser() user: AuthUserPayload,
) {
if (!file) throw new BadRequestException("A document file is required");
return this.clearanceChargeService.createMiscellaneous(
id,
file,
dto,
resolveAuthUserId(user),
);
}
@Post(":id/clearance/output-documents")
@BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput)
@UseInterceptors(AnyFilesInterceptor())
@@ -1081,10 +1199,12 @@ export class BookingsController {
async uploadClearanceOutput(
@Param("id", ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.uploadClearanceOutputDocuments(
id,
files ?? [],
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@@ -1095,8 +1215,14 @@ export class BookingsController {
summary:
"GL finalizes clearance (requires 100% approved) → CLEARANCE_READY",
})
async finalizeClearance(@Param("id", ParseUUIDPipe) id: string) {
const booking = await this.transitionService.finalizeClearance(id);
async finalizeClearance(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.finalizeClearance(
id,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@@ -1109,8 +1235,13 @@ export class BookingsController {
async requestBookingTransitAssignee(
@Param('id', ParseUUIDPipe) id: string,
@Body('note') note: string | undefined,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.bookingClearanceService.requestTransitAssignee(id, note);
const booking = await this.bookingClearanceService.requestTransitAssignee(
id,
note,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@@ -1123,8 +1254,13 @@ export class BookingsController {
async assignBookingTransitAssignee(
@Param('id', ParseUUIDPipe) id: string,
@Body('transitAgentId', ParseUUIDPipe) transitAgentId: string,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.bookingClearanceService.assignTransitAssignee(id, transitAgentId);
const booking = await this.bookingClearanceService.assignTransitAssignee(
id,
transitAgentId,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@@ -1208,8 +1344,14 @@ export class BookingsController {
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);
async acceptBookingDraftDeclaration(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.bookingClearanceService.acceptDraftDeclaration(
id,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@@ -1235,8 +1377,14 @@ export class BookingsController {
@Post(':id/clearance/finalize-pre-clearance')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({ summary: 'GL ET finalizes import pre-clearance on booking' })
async finalizeBookingPreClearance(@Param('id', ParseUUIDPipe) id: string) {
const booking = await this.bookingClearanceService.finalizePreClearance(id);
async finalizeBookingPreClearance(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.bookingClearanceService.finalizePreClearance(
id,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@@ -1248,8 +1396,13 @@ export class BookingsController {
async uploadBookingDutySlip(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFile() file: Express.Multer.File,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.bookingClearanceService.uploadDutySlip(id, file);
const booking = await this.bookingClearanceService.uploadDutySlip(
id,
file,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}

View File

@@ -37,6 +37,10 @@ import { ContainerValidationService } from './container-validation.service';
import { BookingsService } from './bookings.service';
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
import { BookingDocumentReview } from './entities/booking-document-review.entity';
import { BookingClearanceCharge } from './entities/booking-clearance-charge.entity';
import { BookingClearanceChargeService } from './booking-clearance-charge.service';
import { BookingClearanceEvent } from './entities/booking-clearance-event.entity';
import { ClearanceEventService } from './clearance-event.service';
import { BookingContainer } from './entities/booking-container.entity';
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
@@ -76,6 +80,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
CustomerTruckAssignment,
CustomerTruckContainer,
ConsolidationApproval,
BookingClearanceCharge,
BookingClearanceEvent,
]),
BillingModule,
DocumentsModule,
@@ -111,6 +117,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
BookingTransitionService,
BookingContractService,
BookingInvoiceService,
BookingClearanceChargeService,
ClearanceEventService,
ContractTemplateResolver,
ContractViewModelBuilder,
ContractPricingScheduleBuilder,
@@ -126,6 +134,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
exports: [
BookingsService,
BookingsRepository,
ClearanceEventService,
BookingPricingService,
ContainerValidationService,
BookingInvoiceService,

View File

@@ -13,6 +13,7 @@ import {
} from 'typeorm';
import { computeFacets, FacetBucket } from '../../common/utils/facets.util';
import { resolveIamUserNames } from '../../common/utils/iam-user-name.util';
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { Contract } from '../contracts/entities/contract.entity';
@@ -619,6 +620,29 @@ export class BookingsRepository extends BaseRepository<Booking> {
});
}
/**
* Bookings (of those given) that have at least one customer document still
* waiting on GL — PENDING or QUERIED. Includes ad-hoc `custom_*` documents,
* which no milestone tracks, so a file added after clearance was finalized
* still surfaces as needing review. One query for a whole queue page.
*/
async findBookingsWithUnreviewedDocuments(
bookingIds: string[],
): Promise<Set<string>> {
if (bookingIds.length === 0) return new Set();
const rows = (await this.dataSource
.getRepository(BookingDocumentReview)
.createQueryBuilder('r')
.select('DISTINCT r.booking_id', 'bookingId')
.where('r.booking_id IN (:...bookingIds)', { bookingIds })
.andWhere('r.status IN (:...statuses)', {
statuses: ['PENDING', 'QUERIED'],
})
.andWhere('r.deleted_at IS NULL')
.getRawMany()) as Array<{ bookingId: string }>;
return new Set(rows.map((r) => r.bookingId));
}
findDocumentReview(
bookingId: string,
settingCode: string,
@@ -660,6 +684,13 @@ export class BookingsRepository extends BaseRepository<Booking> {
await repo.save(repo.create({ ...input, status: 'PENDING' }));
}
/** Display names for reviewer staff ids — one query for the whole set. */
async resolveStaffNames(
staffIds: (string | null | undefined)[],
): Promise<Map<string, string>> {
return resolveIamUserNames(this.dataSource, staffIds);
}
/** GL marks a document APPROVED or QUERIED (with an optional note). */
async setDocumentReviewStatus(
bookingId: string,

View File

@@ -0,0 +1,74 @@
import type { BookingDocumentReview } from './entities/booking-document-review.entity';
import type { BookingReviewNote } from './entities/booking-review-note.entity';
import type { FileRecord } from '../files/entities/file.entity';
/** One entry of a clearance document's per-card audit trail, oldest first. */
export interface ClearanceDocEvent {
type: 'UPLOADED' | 'RESUBMITTED' | 'QUERIED' | 'APPROVED';
at: string;
byName: string | null;
note: string | null;
}
/**
* Query review notes are written as `Document "<fileKey>" queried: <note>`
* (see BookingTransitionService.reviewDocument) — the only place a past query
* decision survives after the customer re-uploads and the review row resets.
*/
const QUERY_NOTE_RE = /^Document "(.+?)" queried: ([\s\S]*)$/;
/**
* Per-document audit trail assembled from data the flow already persists:
* every stored file version (first = customer upload, later ones = the
* customer's amendment responses), every query note (who opened it, when,
* why), and the review row's current approval. Approvals that were later
* reset by a re-upload are the one thing not kept anywhere — the trail shows
* the decision that currently stands.
*/
export function buildClearanceDocHistory(input: {
fileKey: string;
/** All versions of all files on the booking, createdAt ASC, deleted included. */
allVersions: FileRecord[];
/** CHANGES_REQUESTED review notes for the booking. */
queryNotes: BookingReviewNote[];
review: BookingDocumentReview | null;
/** staff id → display name. */
names: Map<string, string>;
}): ClearanceDocEvent[] {
const { fileKey, allVersions, queryNotes, review, names } = input;
const events: ClearanceDocEvent[] = [];
const versions = allVersions.filter((v) => v.code === fileKey);
versions.forEach((v, i) => {
events.push({
type: i === 0 ? 'UPLOADED' : 'RESUBMITTED',
at: v.createdAt.toISOString(),
byName: v.uploadedByName ?? null,
note: null,
});
});
for (const n of queryNotes) {
const m = QUERY_NOTE_RE.exec(n.note);
if (!m || m[1] !== fileKey) continue;
events.push({
type: 'QUERIED',
at: n.createdAt.toISOString(),
byName: n.authorId ? (names.get(n.authorId) ?? null) : null,
note: m[2] || null,
});
}
if (review?.status === 'APPROVED' && review.reviewedAt) {
events.push({
type: 'APPROVED',
at: review.reviewedAt.toISOString(),
byName: review.reviewedByStaffId
? (names.get(review.reviewedByStaffId) ?? null)
: null,
note: null,
});
}
return events.sort((a, b) => a.at.localeCompare(b.at));
}

View File

@@ -0,0 +1,47 @@
import { Booking } from './entities/booking.entity';
import { clearanceDocumentsOpen } from './clearance.util';
/**
* The customer may attach clearance documents — and GL may review them — right
* up to payment, not merely until clearance is finalized. Both the upload and
* the review endpoint gate on this one predicate, so a drift here silently
* desynchronizes the two sides.
*/
const booking = (patch: Partial<Booking>): Booking =>
({ status: 'CLEARANCE_READY', paymentStatus: 'PENDING', ...patch }) as Booking;
describe('clearanceDocumentsOpen', () => {
it('stays open across the whole pre-payment flow', () => {
for (const status of [
'AWAITING_DOCUMENTS',
'DOCUMENTS_UNDER_REVIEW',
'CLEARANCE_READY',
'OPERATION_REQUEST_PENDING',
'SELECTED_FOR_BATCH',
'PNR_GENERATED',
'PAYMENT_VERIFICATION_IN_PROGRESS',
]) {
expect(clearanceDocumentsOpen(booking({ status }))).toBe(true);
}
});
it('closes once the shipment is paid or finished', () => {
for (const status of ['PAID', 'IN_TRANSIT', 'ARRIVED', 'COMPLETED']) {
expect(clearanceDocumentsOpen(booking({ status }))).toBe(false);
}
});
it('closes on a dead booking', () => {
for (const status of ['REJECTED', 'CANCELLED', 'EXPIRED']) {
expect(clearanceDocumentsOpen(booking({ status }))).toBe(false);
}
});
it('closes when payment settled before the status caught up', () => {
expect(
clearanceDocumentsOpen(
booking({ status: 'PNR_GENERATED', paymentStatus: 'PAID' }),
),
).toBe(false);
});
});

View File

@@ -0,0 +1,89 @@
import { Injectable, Logger } from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import { Freight } from '@edr/types';
import { resolveIamUserNames } from '../../common/utils/iam-user-name.util';
import {
BookingClearanceEvent,
ClearanceEventActorType,
} from './entities/booking-clearance-event.entity';
export interface RecordClearanceEventInput {
bookingId: string;
action: string;
/** Human sentence for the History tab, frozen at write time. */
label: string;
actorType?: ClearanceEventActorType;
/** IAM user id (staff or portal customer); name is resolved here. */
actorId?: string | null;
metadata?: Record<string, unknown> | null;
/** Join the caller's transaction so the event commits (or rolls back) with the action. */
manager?: EntityManager;
}
/**
* The clearance History tab's write/read path. Every clearance mutation calls
* {@link record} — document reviews, phased workflow steps, customer charges.
* Recording is deliberately NOT fire-and-forget: the insert shares the caller's
* transaction when a manager is passed, and otherwise a failed insert fails the
* action, because a silent gap in an audit trail is worse than a retry.
*/
@Injectable()
export class ClearanceEventService {
private readonly logger = new Logger(ClearanceEventService.name);
constructor(private readonly dataSource: DataSource) {}
async record(input: RecordClearanceEventInput): Promise<void> {
const mg = input.manager ?? this.dataSource.manager;
const actorName = input.actorId
? ((await resolveIamUserNames(this.dataSource, [input.actorId])).get(
input.actorId,
) ?? null)
: null;
await mg.save(
mg.create(BookingClearanceEvent, {
bookingId: input.bookingId,
action: input.action,
label: input.label,
actorType: input.actorType ?? 'STAFF',
actorId: input.actorId ?? null,
actorName,
metadata: input.metadata ?? null,
}),
);
this.logger.log(
`clearance-history ${input.action} on booking ${input.bookingId}${
actorName ? ` by ${actorName}` : ''
}`,
);
}
/** History for one booking, newest first. */
async list(bookingId: string): Promise<Freight.ClearanceHistoryEvent[]> {
const rows = await this.dataSource
.getRepository(BookingClearanceEvent)
.find({ where: { bookingId }, order: { createdAt: 'DESC' } });
// Rows whose actor name failed to resolve at write time get one more try.
const missing = rows
.filter((r) => !r.actorName && r.actorId)
.map((r) => r.actorId as string);
const names = missing.length
? await resolveIamUserNames(this.dataSource, missing).catch(
() => new Map<string, string>(),
)
: new Map<string, string>();
return rows.map((r) => ({
id: r.id,
action: r.action,
label: r.label,
actorType: r.actorType,
actorName:
r.actorName ?? (r.actorId ? (names.get(r.actorId) ?? null) : null),
metadata: r.metadata ?? null,
at: r.createdAt.toISOString(),
}));
}
}

View File

@@ -113,3 +113,36 @@ export function clearanceCodesForBooking(booking: Booking): {
includesCustoms,
};
}
/**
* Statuses after which clearance documents are closed: the shipment is paid
* and moving. Everything before that — review, clearance ready, operation
* request, batch selection, PNR, payment verification — still accepts new
* customer documents and still lets GL review them.
*/
const CLEARANCE_DOCS_CLOSED_STATUSES = new Set<string>([
'PAID',
'IN_TRANSIT',
'ARRIVED',
'COMPLETED',
'REJECTED',
'CANCELLED',
'EXPIRED',
]);
/**
* True while the customer may still attach clearance documents and GL may
* still approve or query them.
*
* Clearance finalization is NOT the cut-off: a customs shipment keeps
* collecting paperwork (amended invoices, revised packing lists, port
* documents) right up to the final invoice being settled. Both the customer's
* upload endpoint and GL's review endpoint gate on this one predicate, so the
* two sides can never drift apart.
*/
export function clearanceDocumentsOpen(booking: Booking): boolean {
if (CLEARANCE_DOCS_CLOSED_STATUSES.has(booking.status)) return false;
// Payment settled ahead of the status transition (webhook ordering).
if (booking.paymentStatus === 'PAID') return false;
return true;
}

View File

@@ -0,0 +1,16 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsNumber, IsPositive, IsString, Length } from 'class-validator';
export class BillClearanceChargeDto {
@ApiProperty({ example: 12500.5 })
@Type(() => Number)
@IsNumber()
@IsPositive()
amount!: number;
@ApiProperty({ example: 'ETB' })
@IsString()
@Length(3, 8)
currency!: string;
}

View File

@@ -0,0 +1,68 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Booking } from './booking.entity';
export const CLEARANCE_CHARGE_TYPES = ['PORT_CHARGES', 'MISCELLANEOUS'] as const;
export type ClearanceChargeType = (typeof CLEARANCE_CHARGE_TYPES)[number];
export const CLEARANCE_CHARGE_STATUSES = [
'DOC_UPLOADED',
'BILLED',
'SENT',
'PAID',
] as const;
export type ClearanceChargeStatus = (typeof CLEARANCE_CHARGE_STATUSES)[number];
/**
* Post-finalization clearance charge billed to the customer — at most one
* PORT_CHARGES and one MISCELLANEOUS row per booking. GL Djibouti uploads the
* port-charges document (DOC_UPLOADED); GL Ethiopia sets amount + currency
* (BILLED) and issues the invoice (SENT); the billing `clearance_charge.invoice.paid`
* event marks it PAID. MISCELLANEOUS is created whole by GL Ethiopia and only
* after the port charge is paid.
*/
@Entity({ schema: 'freight', name: 'booking_clearance_charge' })
@Index(['bookingId', 'type'], { unique: true })
export class BookingClearanceCharge extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@ManyToOne(() => Booking, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'booking_id' })
booking?: Booking;
@Column({ name: 'type', type: 'varchar', length: 20 })
type!: ClearanceChargeType;
@Column({ name: 'status', type: 'varchar', length: 20, default: 'DOC_UPLOADED' })
status!: ClearanceChargeStatus;
/** The supporting charge document (FileRecord). */
@Column({ name: 'file_record_id', type: 'uuid', nullable: true })
fileRecordId?: string | null;
@Column({ name: 'amount', type: 'numeric', precision: 14, scale: 2, nullable: true })
amount?: string | null;
@Column({ name: 'currency', type: 'varchar', length: 8, nullable: true })
currency?: string | null;
/** The payable invoice issued for this charge (null until SENT). */
@Column({ name: 'invoice_id', type: 'uuid', nullable: true })
invoiceId?: string | null;
@Column({ name: 'uploaded_by_staff_id', type: 'uuid', nullable: true })
uploadedByStaffId?: string | null;
@Column({ name: 'uploaded_at', type: 'timestamptz', nullable: true })
uploadedAt?: Date | null;
@Column({ name: 'billed_by_staff_id', type: 'uuid', nullable: true })
billedByStaffId?: string | null;
@Column({ name: 'billed_at', type: 'timestamptz', nullable: true })
billedAt?: Date | null;
@Column({ name: 'paid_at', type: 'timestamptz', nullable: true })
paidAt?: Date | null;
}

View File

@@ -0,0 +1,48 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Booking } from './booking.entity';
export const CLEARANCE_EVENT_ACTOR_TYPES = ['STAFF', 'CUSTOMER', 'SYSTEM'] as const;
export type ClearanceEventActorType = (typeof CLEARANCE_EVENT_ACTOR_TYPES)[number];
/**
* One row per action in a booking's clearance flow — the History tab's source
* of truth. Written explicitly (and, where the caller runs one, inside the
* caller's transaction) by every clearance mutation: document review, phased
* workflow steps (transit, declaration, duty, DO/RO, permits), and customer
* charges. `action` is a stable machine code; `label` is the human sentence
* rendered as written, so old rows survive later wording changes.
*/
@Entity({ schema: 'freight', name: 'booking_clearance_event' })
@Index(['bookingId', 'createdAt'])
export class BookingClearanceEvent extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@ManyToOne(() => Booking, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'booking_id' })
booking?: Booking;
/** Stable machine code, e.g. DOC_APPROVED, DECLARATION_UPLOADED. */
@Column({ name: 'action', type: 'varchar', length: 64 })
action!: string;
/** Human sentence shown in the History tab, frozen at write time. */
@Column({ name: 'label', type: 'varchar', length: 500 })
label!: string;
@Column({ name: 'actor_type', type: 'varchar', length: 16, default: 'STAFF' })
actorType!: ClearanceEventActorType;
/** IAM user id of the actor (null for SYSTEM events). */
@Column({ name: 'actor_id', type: 'uuid', nullable: true })
actorId?: string | null;
/** Display name resolved at write time (iam.users); null when unresolvable. */
@Column({ name: 'actor_name', type: 'varchar', length: 150, nullable: true })
actorName?: string | null;
/** Action details: fileKey, note, amount, currency, file names, … */
@Column({ name: 'metadata', type: 'jsonb', nullable: true })
metadata?: Record<string, unknown> | null;
}

View File

@@ -40,6 +40,9 @@ function makeService(overrides?: {
findDocumentReviews: jest.fn().mockResolvedValue([]),
update: jest.fn().mockResolvedValue(booking),
findByStatuses: jest.fn().mockResolvedValue([]),
findBookingsWithUnreviewedDocuments: jest
.fn()
.mockResolvedValue(new Set<string>()),
};
const bookingsService = {
findById: jest.fn().mockResolvedValue(booking),
@@ -115,6 +118,7 @@ function makeService(overrides?: {
} as never, // transit agents
{ findAll: jest.fn().mockResolvedValue([]) } as never, // contracts repository
{ getScopedYardIds: jest.fn().mockResolvedValue(overrides?.yardScope ?? null) } as never, // yard scope
{ record: jest.fn() } as never, // clearanceEvents
);
return {

View File

@@ -35,6 +35,13 @@ import { ContractsRepository } from './contracts.repository';
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDeliveryOrderUploads, persistDraftDeclarationUploads, persistReleaseOrderUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util';
import {
buildClearanceDocHistory,
type ClearanceDocEvent,
} from '../bookings/clearance-doc-history.util';
import { ClearanceEventService } from '../bookings/clearance-event.service';
import { clearanceDocumentsOpen } from '../bookings/clearance.util';
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
export interface BookingClearanceView {
@@ -52,8 +59,13 @@ export interface BookingClearanceView {
file: { id: string; name: string; url: string } | null;
reviewStatus: 'PENDING' | 'APPROVED' | 'QUERIED' | null;
note: string | null;
uploadedAt: string | null;
reviewedAt: string | null;
reviewedByName: string | null;
history: ClearanceDocEvent[];
}>;
allApproved: boolean;
documentsOpen: boolean;
phase?: string | null;
milestones?: Array<{
id: string;
@@ -160,6 +172,7 @@ export class BookingClearanceService {
private readonly transitAgentsService: TransitAgentsService,
private readonly contractsRepository: ContractsRepository,
private readonly yardScope: YardScopeService,
private readonly clearanceEvents: ClearanceEventService,
) {}
private async assertPhasedCustoms(booking: Booking): Promise<void> {
@@ -185,6 +198,18 @@ export class BookingClearanceService {
const fileByCode = new Map(files.map((f) => [f.code, f]));
const reviews = await this.bookingsRepository.findDocumentReviews(bookingId);
const reviewByKey = new Map(reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r]));
const allVersions = await this.filesService.findAllVersionsByResource(
bookingId,
'bookings',
);
const queryNotes = await this.bookingsRepository.findReviewNotes(
bookingId,
'CHANGES_REQUESTED',
);
const reviewerNames = await this.bookingsRepository.resolveStaffNames([
...reviews.map((r) => r.reviewedByStaffId),
...queryNotes.map((n) => n.authorId),
]);
const documents: BookingClearanceView['documents'] = [];
@@ -208,6 +233,18 @@ export class BookingClearanceService {
file: file ? { id: file.id, name: file.name, url: file.url } : null,
reviewStatus: review?.status ?? null,
note: review?.note ?? null,
uploadedAt: file?.createdAt ? file.createdAt.toISOString() : null,
reviewedAt: review?.reviewedAt ? review.reviewedAt.toISOString() : null,
reviewedByName: review?.reviewedByStaffId
? (reviewerNames.get(review.reviewedByStaffId) ?? null)
: null,
history: buildClearanceDocHistory({
fileKey: field.fileKey,
allVersions,
queryNotes,
review,
names: reviewerNames,
}),
});
}
};
@@ -227,6 +264,18 @@ export class BookingClearanceService {
file: { id: f.id, name: f.name, url: f.url },
reviewStatus: review?.status ?? null,
note: review?.note ?? null,
uploadedAt: f.createdAt ? f.createdAt.toISOString() : null,
reviewedAt: review?.reviewedAt ? review.reviewedAt.toISOString() : null,
reviewedByName: review?.reviewedByStaffId
? (reviewerNames.get(review.reviewedByStaffId) ?? null)
: null,
history: buildClearanceDocHistory({
fileKey: f.code,
allVersions,
queryNotes,
review,
names: reviewerNames,
}),
});
}
@@ -307,6 +356,7 @@ export class BookingClearanceService {
outputCode,
documents,
allApproved,
documentsOpen: clearanceDocumentsOpen(booking),
phase,
milestones: milestones.map((m) => ({
id: m.id,
@@ -479,6 +529,7 @@ export class BookingClearanceService {
async requestTransitAssignee(
bookingId: string,
note: string | undefined,
userId?: string,
): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
@@ -486,6 +537,13 @@ export class BookingClearanceService {
transitAssigneeRequestedAt: new Date(),
transitAssigneeRequestNote: note?.trim() || null,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'TRANSIT_ASSIGNEE_REQUESTED',
label: 'Requested a transit assignee from GL Djibouti',
actorId: userId ?? null,
metadata: { note: note?.trim() || null },
});
this.notifier.transitAssigneeRequested(booking, note?.trim() ?? null);
return this.bookingsService.findById(bookingId);
@@ -497,7 +555,11 @@ export class BookingClearanceService {
* Answering unblocks the declaration for Ethiopia. A later call overwrites
* the name (reassignment) and re-notifies.
*/
async assignTransitAssignee(bookingId: string, transitAgentId: string): Promise<Booking> {
async assignTransitAssignee(
bookingId: string,
transitAgentId: string,
userId?: string,
): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (!booking.transitAssigneeRequestedAt) {
throw new BadRequestException(
@@ -511,6 +573,13 @@ export class BookingClearanceService {
transitAssigneeName: agent.name,
transitAssigneeAssignedAt: new Date(),
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'TRANSIT_ASSIGNEE_ASSIGNED',
label: `Assigned transit officer "${agent.name}"`,
actorId: userId ?? null,
metadata: { transitAgentId, agentName: agent.name, previous },
});
this.notifier.transitAssigneeAssigned(booking, agent.name, previous);
return this.bookingsService.findById(bookingId);
@@ -563,6 +632,13 @@ export class BookingClearanceService {
? ContractDocPhase.GlEtPostClearance
: ContractDocPhase.CustomerDuty,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'DECLARATION_UPLOADED',
label: `Uploaded customs declaration (${files.length} file(s))`,
actorId: userId ?? null,
metadata: { fileNames: files.map((f) => f.originalname) },
});
return this.bookingsService.findById(bookingId);
}
@@ -616,6 +692,19 @@ export class BookingClearanceService {
);
this.notifier.dutyAdvised(booking, dto.amount, dto.currency ?? 'ETB');
}
await this.clearanceEvents.record({
bookingId,
action: 'DUTY_ADVISED',
label: dto.dutyRequired
? `Advised duty/tax of ${dto.amount} ${dto.currency ?? 'ETB'}`
: 'Advised that no duty/tax applies',
actorId: userId ?? null,
metadata: {
dutyRequired: dto.dutyRequired,
amount: dto.amount ?? null,
currency: dto.currency ?? null,
},
});
return this.bookingsService.findById(bookingId);
}
@@ -663,6 +752,14 @@ export class BookingClearanceService {
clearanceCurrentPhase: ContractDocPhase.GlEtOutput,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'DRAFT_DECLARATION_SENT',
label: `Sent draft customs declaration (estimated ${price} ${currency})`,
actorId: userId ?? null,
metadata: { price, currency, fileNames: files.map((f) => f.originalname) },
});
const updated = await this.bookingsService.findById(bookingId);
this.notifier.draftDeclarationReady(updated, price, currency);
return updated;
@@ -672,7 +769,7 @@ export class BookingClearanceService {
* The customer accepts the draft declaration — GL Ethiopia may now file the
* real customs declaration.
*/
async acceptDraftDeclaration(bookingId: string): Promise<Booking> {
async acceptDraftDeclaration(bookingId: 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.');
@@ -684,6 +781,13 @@ export class BookingClearanceService {
}
await this.workflowService.completeMilestoneForBooking(bookingId, 'DRAFT_DECLARATION_ACCEPTED');
await this.clearanceEvents.record({
bookingId,
action: 'DRAFT_DECLARATION_ACCEPTED',
label: 'Customer accepted the draft customs declaration',
actorType: 'CUSTOMER',
actorId: userId ?? null,
});
return this.bookingsService.findById(bookingId);
}
@@ -733,12 +837,25 @@ export class BookingClearanceService {
clearanceCurrentPhase: ContractDocPhase.GlEtOutput,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'DRAFT_DECLARATION_CHANGE_REQUESTED',
label: 'Customer requested a change to the draft declaration',
actorType: 'CUSTOMER',
actorId: userId ?? null,
metadata: { note: note.trim() },
});
const updated = await this.bookingsService.findById(bookingId);
this.notifier.draftDeclarationChangeRequested(updated, note.trim());
return updated;
}
async uploadDutySlip(bookingId: string, file: Express.Multer.File): Promise<Booking> {
async uploadDutySlip(
bookingId: string,
file: Express.Multer.File,
userId?: string,
): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (booking.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Duty slip upload applies only to import bookings.');
@@ -760,6 +877,15 @@ export class BookingClearanceService {
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'DUTY_SLIP_UPLOADED',
label: 'Customer uploaded the duty/tax payment slip',
actorType: 'CUSTOMER',
actorId: userId ?? null,
metadata: { fileName: file.originalname },
});
this.notifier.dutySlipUploadedToStaff(booking, 'first');
return this.bookingsService.findById(bookingId);
}
@@ -792,11 +918,18 @@ export class BookingClearanceService {
await this.bookingsRepository.update(bookingId, {
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'TRANSIT_PERMIT_UPLOADED',
label: `Uploaded transit permit (${files.length} file(s))`,
actorId: userId ?? null,
metadata: { fileNames: files.map((f) => f.originalname) },
});
return this.bookingsService.findById(bookingId);
}
async finalizePreClearance(bookingId: string): Promise<Booking> {
async finalizePreClearance(bookingId: string, userId?: string): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (booking.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Pre-clearance finalize applies only to import bookings.');
@@ -816,6 +949,12 @@ export class BookingClearanceService {
preClearanceFinalizedAt: new Date(),
clearanceCurrentPhase: ContractDocPhase.GlDjCollection,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'PRE_CLEARANCE_FINALIZED',
label: 'Finalized pre-clearance — handed over to GL Djibouti collection',
actorId: userId ?? null,
});
// GL Djibouti may have uploaded the DO early (un-gated) — count it now.
const files = await this.filesService.findByResource(bookingId, 'bookings');
@@ -849,6 +988,17 @@ export class BookingClearanceService {
vesselArrivalDate,
doCollectedDate,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'DELIVERY_ORDER_UPLOADED',
label: 'Uploaded Delivery Order',
actorId: userId ?? null,
metadata: {
vesselArrivalDate: vesselArrivalDate ?? null,
doCollectedDate: doCollectedDate ?? null,
fileNames: (files ?? []).map((f) => f.originalname),
},
});
if (booking.preClearanceFinalizedAt) {
await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId);
@@ -906,6 +1056,16 @@ export class BookingClearanceService {
vesselDepartureDate,
roAmendmentRequestedAt: null,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'RELEASE_ORDER_UPLOADED',
label: `Uploaded Release Order (vessel departs ${vesselDepartureDate})`,
actorId: userId ?? null,
metadata: {
vesselDepartureDate,
fileNames: (files ?? []).map((f) => f.originalname),
},
});
if (leadDays < minDays) {
const reason = `Vessel departs in ${leadDays} day(s) — minimum lead time is ${minDays} day(s). Request a port amendment or upload a new RO with a later date.`;
@@ -965,6 +1125,13 @@ export class BookingClearanceService {
userId,
);
}
await this.clearanceEvents.record({
bookingId,
action: 'RO_AMENDMENT_REQUESTED',
label: 'Requested a port amendment on the Release Order',
actorId: userId ?? null,
metadata: { note: reason },
});
return this.bookingsService.findById(bookingId);
}
@@ -980,6 +1147,12 @@ export class BookingClearanceService {
'EXPORT_RELEASED',
);
await this.workflowService.onExportReleasedForBooking(bookingId, userId);
await this.clearanceEvents.record({
bookingId,
action: 'EXPORT_RELEASE_CONFIRMED',
label: 'Confirmed export release',
actorId: userId ?? null,
});
return this.bookingsService.findById(bookingId);
}
@@ -991,8 +1164,30 @@ export class BookingClearanceService {
for (const b of candidates) {
if (!this.isPhasedCustomsBooking(b)) continue;
const milestones = await this.workflowService.listMilestonesForBooking(b.id);
if (belongsOnEtClearanceQueue(milestones)) filtered.push(b);
if (!belongsOnEtClearanceQueue(milestones)) continue;
// Surfaced on the queue row: every required document is approved even
// though the booking status stays DOCUMENTS_UNDER_REVIEW until finalize.
(b as Booking & { allDocsApproved?: boolean }).allDocsApproved =
milestones.some(
(m) =>
m.milestoneCode === 'DOCUMENTS_APPROVED' &&
(m.status === 'COMPLETED' || m.status === 'SKIPPED'),
);
filtered.push(b);
}
// A document added after clearance was finalized lands as PENDING without
// moving the booking's status — the row would otherwise still read
// "Clearance ready" while GL has something waiting. Ad-hoc documents are
// tracked by no milestone, so this reads the review rows directly.
const pending = await this.bookingsRepository.findBookingsWithUnreviewedDocuments(
filtered.map((b) => b.id),
);
for (const b of filtered) {
(b as Booking & { hasDocumentsAwaitingReview?: boolean })
.hasDocumentsAwaitingReview = pending.has(b.id);
}
const rows = await this.attachContractSummary(filtered);
return this.narrowToYardScope(rows, user);
}

View File

@@ -3,17 +3,20 @@ import { BadRequestException } from '@nestjs/common';
import { ContractTransitionService } from './contract-transition.service';
/**
* Where the booking-contract view reads the global stamp live, the contracts
* path SNAPSHOTS it onto the signature row at signing time, so replacing the
* company stamp can never restamp an already-executed contract. These specs
* pin the sourcing split: EDR always seals with the global stamp and staff
* never supply one, while the customer must upload their own.
* The staff signature seals with the ONE global stamp by REFERENCE: the
* signature row stores the current global stampFileId instead of re-uploading
* a copy per contract. That id stays valid after the stamp is replaced
* (StampSettingsService never deletes retired stamp files), so each contract
* keeps the exact seal it was signed with. These specs pin the sourcing
* split: EDR always seals with the global stamp and staff never supply one,
* while the customer must upload their own.
*/
describe('applySignature stamp sourcing', () => {
const contract = { id: 'c-1', reference: 'CTR-1', status: 'SIGNED_CUSTOMER' };
const GLOBAL_STAMP = 'data:image/png;base64,RURS';
const GLOBAL_STAMP_FILE_ID = 'file-global-stamp';
const build = (globalStamp: string | null = GLOBAL_STAMP) => {
const build = (globalStampFileId: string | null = GLOBAL_STAMP_FILE_ID) => {
const uploads: Array<{ code: string; image: string }> = [];
const saved: unknown[] = [];
const service = Object.create(
@@ -22,7 +25,10 @@ describe('applySignature stamp sourcing', () => {
Object.assign(service, {
logger: { warn: jest.fn(), log: jest.fn() },
stampSettings: {
getStampImageUrl: jest.fn().mockResolvedValue(globalStamp),
get: jest.fn().mockResolvedValue({
id: 's-1',
stampFileId: globalStampFileId,
}),
},
contractsRepository: {
saveSignature: jest.fn((row: unknown) => {
@@ -62,35 +68,36 @@ describe('applySignature stamp sourcing', () => {
signatureImageBase64: 'data:image/png;base64,U0lH',
};
it('seals the EDR side with the global stamp', async () => {
it('seals the EDR side by referencing the global stamp file, without re-uploading it', async () => {
const { service, uploads, saved } = build();
await apply(service, staffDto);
expect(uploads).toContainEqual({ code: 'stamp_staff', image: GLOBAL_STAMP });
expect(uploads.map((u) => u.code)).toEqual(['signature_staff']);
expect(saved[0]).toEqual(
expect.objectContaining({ stampFileId: 'file-stamp_staff' }),
expect.objectContaining({ stampFileId: GLOBAL_STAMP_FILE_ID }),
);
});
it('ignores a stamp a staff client tries to supply', async () => {
const { service, uploads } = build();
const { service, uploads, saved } = build();
await apply(service, {
...staffDto,
stampImageBase64: 'data:image/png;base64,SEFDSw==',
});
expect(uploads).toContainEqual({ code: 'stamp_staff', image: GLOBAL_STAMP });
expect(uploads.map((u) => u.image)).not.toContain(
'data:image/png;base64,SEFDSw==',
);
expect(saved[0]).toEqual(
expect.objectContaining({ stampFileId: GLOBAL_STAMP_FILE_ID }),
);
});
/**
* Failing loudly matters here: getStampImageUrl degrades to null when the
* stamp cannot be inlined, and silently executing an unsealed contract would
* be worse than refusing to counter-sign.
* Failing loudly matters here: silently executing an unsealed contract
* would be worse than refusing to counter-sign.
*/
it('refuses to counter-sign when no global stamp is configured', async () => {
const { service, saved } = build(null);

View File

@@ -1128,17 +1128,27 @@ export class ContractTransitionService {
);
}
// Snapshot whichever stamp applies onto the signature row rather than
// referencing the global one, so replacing the company stamp later can
// never restamp an already-executed contract.
let stampImageBase64 = dto.stampImageBase64 ?? null;
// STAFF seals by REFERENCE to the one global stamp file — no per-contract
// copy of the image. Safe because StampSettingsService.setStamp/clearStamp
// never delete a replaced stamp file: the referenced id keeps rendering
// the exact seal that was current at signing, even after the global stamp
// is later replaced. The customer's stamp is their own upload and is still
// stored per contract.
let stampFileId: string | null = null;
if (role === 'STAFF') {
stampImageBase64 = await this.stampSettings.getStampImageUrl();
if (!stampImageBase64) {
stampFileId = (await this.stampSettings.get()).stampFileId ?? null;
if (!stampFileId) {
throw new BadRequestException(
'No company stamp is configured. Upload the company stamp under Settings before counter-signing contracts.',
);
}
} else if (dto.stampImageBase64) {
const stampRecord = await this.uploadSignatureAsset(
contract,
`stamp_${role.toLowerCase()}`,
dto.stampImageBase64,
);
stampFileId = stampRecord.id;
}
const fileRecord = await this.uploadSignatureAsset(
@@ -1146,13 +1156,6 @@ export class ContractTransitionService {
`signature_${role.toLowerCase()}`,
imageBase64,
);
const stampRecord = stampImageBase64
? await this.uploadSignatureAsset(
contract,
`stamp_${role.toLowerCase()}`,
stampImageBase64,
)
: null;
await this.contractsRepository.saveSignature({
contractId: contract.id,
@@ -1160,7 +1163,7 @@ export class ContractTransitionService {
signerDisplayName,
signedAt: new Date(),
signatureFileId: fileRecord.id,
stampFileId: stampRecord?.id ?? null,
stampFileId,
consentText: dto.consentText ?? null,
});

View File

@@ -84,6 +84,21 @@ export class FilesRepository extends BaseRepository<FileRecord> {
});
}
/**
* Every version of every document on a resource, oldest first — superseded
* versions included. One query for a whole document grid's upload history.
*/
findAllVersionsByResource(
resourceId: string,
resource: string,
): Promise<FileRecord[]> {
return this.repository.find({
where: { resourceId, resource },
withDeleted: true,
order: { createdAt: "ASC" },
});
}
/**
* Documents belonging to any of the given resources that a reviewer has asked
* the customer to correct. Used by the approval gate, so it takes a list of

View File

@@ -327,6 +327,14 @@ export class FilesService {
return this.filesRepository.findByResource(resourceId, resource);
}
/** All versions of every document on a resource (superseded included), oldest first. */
findAllVersionsByResource(
resourceId: string,
resource: string,
): Promise<FileRecord[]> {
return this.filesRepository.findAllVersionsByResource(resourceId, resource);
}
/**
* Files for many resources of one kind, grouped by resource id. Resources with
* no files are absent from the map (callers should default to `[]`).

View File

@@ -1,9 +1,7 @@
import { Injectable, Logger } from "@nestjs/common";
import { Readable } from "stream";
import { DataSource } from "typeorm";
import { FilesService } from "../files/files.service";
import { FileRecord } from "../files/entities/file.entity";
import { MinioService } from "../minio/minio.service";
import { StampSettingsRepository } from "./stamp-settings.repository";
import { StampSetting } from "./entities/stamp-setting.entity";
@@ -28,7 +26,6 @@ export class StampSettingsService {
private readonly repository: StampSettingsRepository,
private readonly filesService: FilesService,
private readonly minioService: MinioService,
private readonly dataSource: DataSource,
) {}
/** The settings row, created empty on first access. */
@@ -53,14 +50,12 @@ export class StampSettingsService {
* `data:` URL or null. Never throws — document generation must succeed even
* if the stamp lookup fails; callers fall back to their own seal on null.
*
* The data-URL-or-null guarantee is load-bearing, not cosmetic. Callers do
* two things with this value that a bare MinIO URL silently corrupts:
* ContractTransitionService base64-decodes it to snapshot the seal onto a
* signature row (a URL decodes to garbage bytes, not an error, permanently
* sealing an executed contract with a broken image), and the HTML render
* path inlines it into an <img> that headless Chromium cannot fetch. So
* where getView() may hand a raw URL to a browser that can load it, this
* degrades to null and lets the caller draw its text/vector seal instead.
* The data-URL-or-null guarantee is load-bearing, not cosmetic: the HTML
* render paths inline this value into an <img> that headless Chromium
* cannot fetch over the network. So where getView() may hand a raw URL to
* a browser that can load it, this degrades to null and lets the caller
* draw its text/vector seal instead. (Contract signing no longer consumes
* this — staff signatures reference the stampFileId directly.)
*/
async getStampImageUrl(): Promise<string | null> {
try {
@@ -81,13 +76,20 @@ export class StampSettingsService {
}
}
/** Replace the stamp image, storing it in MinIO via FilesService. */
/**
* Replace the stamp image, storing it in MinIO via FilesService.
*
* The replaced file is NEVER deleted: contract signatures reference stamp
* files by id (ContractTransitionService points staff signatures at the
* current stampFileId instead of copying the image), so each retired file
* is the immutable record of which seal executed the contracts signed while
* it was current. Deleting it would strip the seal off those contracts.
*/
async setStamp(
stampImageBase64: string,
updatedById?: string | null,
): Promise<StampSettingView> {
const current = await this.get();
const previousFileId = current.stampFileId ?? null;
const fileRecord = await this.filesService.upload({
resourceId: current.id,
@@ -102,28 +104,22 @@ export class StampSettingsService {
updatedById: updatedById ?? null,
});
if (previousFileId && previousFileId !== fileRecord.id) {
await this.dataSource.getRepository(FileRecord).delete(previousFileId);
}
this.logger.log(`Company stamp updated by ${updatedById ?? "unknown user"}`);
return this.getView();
}
/** Clear the stamp (invoices fall back to the programmatic seal). */
/**
* Clear the stamp (invoices fall back to the programmatic seal). The file
* is kept for the same reason as in {@link setStamp}.
*/
async clearStamp(updatedById?: string | null): Promise<StampSettingView> {
const current = await this.get();
const previousFileId = current.stampFileId ?? null;
await this.repository.update(current.id, {
stampFileId: null,
updatedById: updatedById ?? null,
});
if (previousFileId) {
await this.dataSource.getRepository(FileRecord).delete(previousFileId);
}
return this.getView();
}

View File

@@ -14,6 +14,7 @@ import {
Text,
Textarea,
ThemeIcon,
Timeline,
Tooltip,
} from "@mantine/core";
import {
@@ -24,6 +25,7 @@ import {
FileCheck2,
FileText,
MessageSquareWarning,
RefreshCw,
Upload,
} from "lucide-react";
import toast from "react-hot-toast";
@@ -458,6 +460,76 @@ export function ClearanceReviewSection({
);
}
const EVENT_META: Record<
Freight.ClearanceDocumentEvent["type"],
{ color: string; icon: typeof Upload; label: (byName: string | null) => string }
> = {
UPLOADED: {
color: "blue",
icon: Upload,
label: (n) => `Uploaded by ${n ?? "customer"}`,
},
RESUBMITTED: {
color: "blue",
icon: RefreshCw,
label: (n) => `Re-submitted by ${n ?? "customer"}`,
},
QUERIED: {
color: "red",
icon: MessageSquareWarning,
label: (n) => `Query opened by ${n ?? "staff"}`,
},
APPROVED: {
color: "edr-green",
icon: CheckCircle2,
label: (n) => `Approved by ${n ?? "staff"}`,
},
};
/** Per-document audit trail: uploads, amendment responses, queries, approval. */
function DocHistoryTimeline({
history,
}: {
history: Freight.ClearanceDocumentEvent[];
}) {
return (
<Timeline
mt="sm"
ml={4}
bulletSize={20}
lineWidth={2}
active={history.length - 1}
color="gray"
>
{history.map((ev, i) => {
const meta = EVENT_META[ev.type];
const Icon = meta.icon;
return (
<Timeline.Item
key={`${ev.type}:${ev.at}:${i}`}
color={meta.color}
bullet={<Icon size={11} />}
title={
<Text fz="12.5px" fw={600} c="edr-text" lh={1.3}>
{meta.label(ev.byName)}
</Text>
}
>
<Text fz="11px" c="dimmed">
{formatDateTime(ev.at)}
</Text>
{ev.note ? (
<Text fz="11.5px" c="red.8" mt={2}>
{ev.note}
</Text>
) : null}
</Timeline.Item>
);
})}
</Timeline>
);
}
function StatPill({
color,
label,
@@ -551,11 +623,6 @@ function DocReviewCard({
<Text fz="12px" c="edr-muted" truncate>
{hasFile ? doc.file!.name : "Not uploaded by customer"}
</Text>
{hasFile && doc.uploadedAt ? (
<Text fz="11px" c="dimmed">
Uploaded {formatDateTime(doc.uploadedAt)}
</Text>
) : null}
</Box>
</Group>
@@ -607,11 +674,8 @@ function DocReviewCard({
</Group>
</Group>
{doc.reviewedAt && (status === "APPROVED" || status === "QUERIED") && (
<Text fz="11.5px" c="dimmed" mt={6}>
{status === "APPROVED" ? "Approved" : "Queried"} by{" "}
{doc.reviewedByName ?? "staff"} · {formatDateTime(doc.reviewedAt)}
</Text>
{(doc.history?.length ?? 0) > 0 && (
<DocHistoryTimeline history={doc.history!} />
)}
{status === "QUERIED" && doc.note && (

View File

@@ -0,0 +1,525 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Badge,
Box,
Button,
FileButton,
Group,
Loader,
NumberInput,
Paper,
Select,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import {
CheckCircle2,
Download,
Eye,
FileText,
Receipt,
Send,
Upload,
} from "lucide-react";
import toast from "react-hot-toast";
import type { Freight } from "@edr/types";
import { isViewable } from "@edr/ui-common";
import { bookingsService } from "@/services/bookings.service";
import {
downloadBookingFile,
fetchViewableFile,
} from "@/services/files.service";
import { formatDateTime } from "@/lib/format";
import { extractErrorMessage } from "@/utils/errorExtractor";
const CURRENCIES = ["ETB", "USD"];
const STATUS_META: Record<
Freight.ClearanceChargeStatus,
{ label: string; color: string }
> = {
DOC_UPLOADED: { label: "Awaiting billing", color: "yellow" },
BILLED: { label: "Ready to send", color: "blue" },
SENT: { label: "Sent — unpaid", color: "orange" },
PAID: { label: "Paid", color: "edr-green" },
};
export interface ClearanceChargesTabProps {
bookingId: string;
/** DJ uploads the port document; ET bills, sends and creates miscellaneous. */
roleMode: "ET" | "DJ";
onViewFile: (file: { name: string; url: string }) => void;
}
/**
* Post-finalization charges billed to the customer, two levels: port charges
* (document from GL Djibouti, billed by GL Ethiopia) then miscellaneous
* (created whole by GL Ethiopia once the port charge is paid). Each level
* issues its own payable invoice — ETB settles through the portal gateway
* (CBE), other currencies through Finance's manual settlement.
*/
export function ClearanceChargesTab({
bookingId,
roleMode,
onViewFile,
}: ClearanceChargesTabProps) {
const qc = useQueryClient();
const { data: charges, isLoading } = useQuery({
queryKey: ["clearance-charges", bookingId],
queryFn: () => bookingsService.getClearanceCharges(bookingId),
});
const refresh = (next: Freight.ClearanceCharge[]) =>
qc.setQueryData(["clearance-charges", bookingId], next);
const onError = (e: unknown) =>
toast.error(extractErrorMessage(e, "Could not update the charge"));
const uploadPort = useMutation({
mutationFn: (file: File) =>
bookingsService.uploadPortChargeDocument(bookingId, file),
onSuccess: (next) => {
toast.success("Port-charges document uploaded");
refresh(next);
},
onError,
});
const bill = useMutation({
mutationFn: (p: { chargeId: string; amount: number; currency: string }) =>
bookingsService.billClearanceCharge(bookingId, p.chargeId, p),
onSuccess: (next) => {
toast.success("Charge amount saved");
refresh(next);
},
onError,
});
const send = useMutation({
mutationFn: (chargeId: string) =>
bookingsService.sendClearanceCharge(bookingId, chargeId),
onSuccess: (next) => {
toast.success("Invoice sent to the customer");
refresh(next);
},
onError,
});
const createMisc = useMutation({
mutationFn: (p: { file: File; amount: number; currency: string }) =>
bookingsService.createMiscellaneousCharge(bookingId, p.file, p),
onSuccess: (next) => {
toast.success("Miscellaneous charge created");
refresh(next);
},
onError,
});
if (isLoading) {
return (
<Group justify="center" py="xl" gap={10}>
<Loader size="sm" color="edr-green" />
<Text c="dimmed">Loading charges</Text>
</Group>
);
}
const port = (charges ?? []).find((c) => c.type === "PORT_CHARGES") ?? null;
const misc = (charges ?? []).find((c) => c.type === "MISCELLANEOUS") ?? null;
const busy =
uploadPort.isPending || bill.isPending || send.isPending || createMisc.isPending;
const totals = new Map<string, number>();
for (const c of charges ?? []) {
if (c.amount != null && c.currency)
totals.set(c.currency, (totals.get(c.currency) ?? 0) + c.amount);
}
return (
<Stack gap="md" maw={860}>
<ChargeCard
title="1 · Port charges"
charge={port}
roleMode={roleMode}
busy={busy}
emptyHint={
roleMode === "DJ"
? "Upload the port-charges document to start this charge."
: "Waiting for GL Djibouti to upload the port-charges document."
}
onViewFile={onViewFile}
onBill={(amount, currency) =>
port && bill.mutate({ chargeId: port.id, amount, currency })
}
onSend={() => port && send.mutate(port.id)}
djUpload={
roleMode === "DJ" && (!port || port.status === "DOC_UPLOADED") ? (
<FileButton
onChange={(f) => f && uploadPort.mutate(f)}
accept="application/pdf,image/*"
disabled={busy}
>
{(props) => (
<Button
{...props}
size="compact-sm"
color="edr-green"
radius="md"
leftSection={<Upload size={14} />}
loading={uploadPort.isPending}
>
{port ? "Replace document" : "Upload document"}
</Button>
)}
</FileButton>
) : null
}
/>
<ChargeCard
title="2 · Miscellaneous charges"
charge={misc}
roleMode={roleMode}
busy={busy}
emptyHint={
port?.status !== "PAID"
? "Unlocks once the port charge is paid."
: roleMode === "ET"
? "Create the miscellaneous charge with its document, amount and currency."
: "GL Ethiopia creates this charge once the port charge is paid."
}
onViewFile={onViewFile}
onBill={(amount, currency) =>
misc && bill.mutate({ chargeId: misc.id, amount, currency })
}
onSend={() => misc && send.mutate(misc.id)}
etCreate={
roleMode === "ET" && !misc && port?.status === "PAID" ? (
<MiscCreateForm
busy={createMisc.isPending}
onCreate={(file, amount, currency) =>
createMisc.mutate({ file, amount, currency })
}
/>
) : null
}
/>
{totals.size > 0 && (
<Paper withBorder radius="md" p="md">
<Group justify="space-between">
<Text fz="13px" fw={700} c="edr-text">
Total billed
</Text>
<Group gap="md">
{[...totals.entries()].map(([currency, amount]) => (
<Text key={currency} fz="14px" fw={800} c="edr-text">
{amount.toLocaleString(undefined, {
minimumFractionDigits: 2,
})}{" "}
{currency}
</Text>
))}
</Group>
</Group>
</Paper>
)}
</Stack>
);
}
function ChargeCard({
title,
charge,
roleMode,
busy,
emptyHint,
onViewFile,
onBill,
onSend,
djUpload,
etCreate,
}: {
title: string;
charge: Freight.ClearanceCharge | null;
roleMode: "ET" | "DJ";
busy: boolean;
emptyHint: string;
onViewFile: (file: { name: string; url: string }) => void;
onBill: (amount: number, currency: string) => void;
onSend: () => void;
djUpload?: React.ReactNode;
etCreate?: React.ReactNode;
}) {
const [editing, setEditing] = useState(false);
const [amount, setAmount] = useState<number | string>(charge?.amount ?? "");
const [currency, setCurrency] = useState<string>(charge?.currency ?? "ETB");
const status = charge?.status ?? null;
const meta = status ? STATUS_META[status] : null;
// ET enters/revises the amount while the charge is unpaid.
const showBillForm =
roleMode === "ET" &&
charge != null &&
(charge.status === "DOC_UPLOADED" || editing);
return (
<Paper withBorder radius="md" p="md">
<Group justify="space-between" wrap="nowrap" align="flex-start">
<Group gap={10} wrap="nowrap">
<Receipt size={18} color="var(--mantine-color-edr-green-6)" />
<Box>
<Text fz="14px" fw={700} c="edr-text">
{title}
</Text>
{charge?.uploadedAt && (
<Text fz="11.5px" c="dimmed">
Document uploaded
{charge.uploadedByName ? ` by ${charge.uploadedByName}` : ""} ·{" "}
{formatDateTime(charge.uploadedAt)}
</Text>
)}
{charge?.billedAt && (
<Text fz="11.5px" c="dimmed">
Billed{charge.billedByName ? ` by ${charge.billedByName}` : ""} ·{" "}
{formatDateTime(charge.billedAt)}
</Text>
)}
{charge?.paidAt && (
<Text fz="11.5px" c="edr-green.8" fw={600}>
Paid · {formatDateTime(charge.paidAt)}
{charge.invoiceNumber ? ` (invoice ${charge.invoiceNumber})` : ""}
</Text>
)}
</Box>
</Group>
<Group gap={8} wrap="nowrap">
{charge?.amount != null && charge.currency && (
<Text fz="14px" fw={800} c="edr-text">
{charge.amount.toLocaleString(undefined, {
minimumFractionDigits: 2,
})}{" "}
{charge.currency}
</Text>
)}
{meta && (
<Badge variant="light" color={meta.color} radius="sm">
{meta.label}
</Badge>
)}
</Group>
</Group>
{charge?.file && (
<Group gap={8} mt="sm" wrap="nowrap">
<FileText size={15} color="var(--mantine-color-edr-green-6)" />
<Text fz="12.5px" c="edr-text" truncate style={{ minWidth: 0 }}>
{charge.file.name}
</Text>
{isViewable({ name: charge.file.name, url: "" }) && (
<Tooltip label="View">
<Box
component="button"
type="button"
onClick={() =>
void fetchViewableFile(
charge.file!.id,
charge.file!.name,
).then(onViewFile)
}
c="edr-green"
style={{
display: "flex",
background: "transparent",
border: "none",
cursor: "pointer",
}}
>
<Eye size={15} />
</Box>
</Tooltip>
)}
<Tooltip label="Download">
<Box
component="button"
type="button"
onClick={() =>
void downloadBookingFile(charge.file!.id, charge.file!.name)
}
c="edr-green"
style={{
display: "flex",
background: "transparent",
border: "none",
cursor: "pointer",
}}
>
<Download size={15} />
</Box>
</Tooltip>
</Group>
)}
{!charge && (
<Text fz="12.5px" c="dimmed" mt="xs">
{emptyHint}
</Text>
)}
{djUpload && <Box mt="sm">{djUpload}</Box>}
{etCreate && <Box mt="sm">{etCreate}</Box>}
{showBillForm && (
<Group mt="sm" gap={8} align="flex-end" wrap="wrap">
<NumberInput
label="Amount"
size="xs"
radius="md"
min={0.01}
decimalScale={2}
value={amount}
onChange={setAmount}
w={160}
/>
<Select
label="Currency"
size="xs"
radius="md"
data={CURRENCIES}
value={currency}
onChange={(v) => v && setCurrency(v)}
w={100}
/>
<Button
size="compact-sm"
color="edr-green"
radius="md"
disabled={busy || !(Number(amount) > 0)}
onClick={() => {
onBill(Number(amount), currency);
setEditing(false);
}}
>
Save amount
</Button>
{editing && (
<Button
size="compact-sm"
variant="subtle"
color="gray"
radius="md"
disabled={busy}
onClick={() => setEditing(false)}
>
Cancel
</Button>
)}
</Group>
)}
{roleMode === "ET" && charge && !showBillForm && charge.status !== "PAID" && (
<Group mt="sm" gap={8} justify="flex-end">
<Button
size="compact-sm"
variant="light"
color="gray"
radius="md"
disabled={busy}
onClick={() => {
setAmount(charge.amount ?? "");
setCurrency(charge.currency ?? "ETB");
setEditing(true);
}}
>
{charge.status === "SENT" ? "Revise (cancels invoice)" : "Edit amount"}
</Button>
{charge.status === "BILLED" && (
<Tooltip label="ETB is payable online via CBE; other currencies go to Finance's manual settlement.">
<Button
size="compact-sm"
color="edr-green"
radius="md"
leftSection={<Send size={14} />}
disabled={busy}
onClick={onSend}
>
Send invoice to customer
</Button>
</Tooltip>
)}
{charge.status === "SENT" && charge.invoiceNumber && (
<Badge variant="light" color="orange" radius="sm">
Invoice {charge.invoiceNumber}
</Badge>
)}
</Group>
)}
{charge?.status === "PAID" && (
<Group mt="sm" gap={6} justify="flex-end">
<CheckCircle2 size={14} color="var(--mantine-color-edr-green-6)" />
<Text fz="12px" c="edr-green.8" fw={600}>
Settled
</Text>
</Group>
)}
</Paper>
);
}
function MiscCreateForm({
busy,
onCreate,
}: {
busy: boolean;
onCreate: (file: File, amount: number, currency: string) => void;
}) {
const [file, setFile] = useState<File | null>(null);
const [amount, setAmount] = useState<number | string>("");
const [currency, setCurrency] = useState("ETB");
return (
<Group gap={8} align="flex-end" wrap="wrap">
<FileButton onChange={setFile} accept="application/pdf,image/*" disabled={busy}>
{(props) => (
<Button
{...props}
size="compact-sm"
variant="light"
color="edr-green"
radius="md"
leftSection={<Upload size={14} />}
>
{file ? file.name : "Choose document"}
</Button>
)}
</FileButton>
<NumberInput
label="Amount"
size="xs"
radius="md"
min={0.01}
decimalScale={2}
value={amount}
onChange={setAmount}
w={160}
/>
<Select
label="Currency"
size="xs"
radius="md"
data={CURRENCIES}
value={currency}
onChange={(v) => v && setCurrency(v)}
w={100}
/>
<Button
size="compact-sm"
color="edr-green"
radius="md"
disabled={busy || !file || !(Number(amount) > 0)}
loading={busy}
onClick={() => file && onCreate(file, Number(amount), currency)}
>
Create charge
</Button>
</Group>
);
}

View File

@@ -0,0 +1,116 @@
import { useQuery } from "@tanstack/react-query";
import { Badge, Group, Loader, Paper, Text, Timeline } from "@mantine/core";
import {
CheckCircle2,
CircleDot,
FileText,
MessageSquareWarning,
Receipt,
Send,
Ship,
Upload,
UserCheck,
} from "lucide-react";
import type { Freight } from "@edr/types";
import { bookingsService } from "@/services/bookings.service";
import { formatDateTime } from "@/lib/format";
/** Icon + color per action family; unknown actions fall back to a neutral dot. */
function eventMeta(action: string): { icon: typeof Upload; color: string } {
if (action === "DOC_APPROVED" || action.endsWith("_ACCEPTED") || action.endsWith("_FINALIZED") || action.endsWith("_CONFIRMED"))
return { icon: CheckCircle2, color: "edr-green" };
if (action === "DOC_QUERIED" || action.includes("CHANGE_REQUESTED") || action.includes("AMENDMENT"))
return { icon: MessageSquareWarning, color: "red" };
if (action.startsWith("CHARGE_"))
return { icon: Receipt, color: action === "CHARGE_PAID" ? "edr-green" : "orange" };
if (action.includes("TRANSIT_ASSIGNEE")) return { icon: UserCheck, color: "blue" };
if (action.includes("ORDER")) return { icon: Ship, color: "blue" };
if (action.includes("SENT")) return { icon: Send, color: "blue" };
if (action.includes("UPLOAD") || action.includes("SUBMITTED"))
return { icon: Upload, color: "blue" };
if (action.includes("DOC")) return { icon: FileText, color: "gray" };
return { icon: CircleDot, color: "gray" };
}
const ACTOR_BADGE: Record<
Freight.ClearanceHistoryEvent["actorType"],
{ label: string; color: string }
> = {
STAFF: { label: "Staff", color: "blue" },
CUSTOMER: { label: "Customer", color: "grape" },
SYSTEM: { label: "System", color: "gray" },
};
/**
* Full per-booking clearance action trail: document reviews, phased workflow
* steps (transit, declaration, duty, DO/RO, permits) and customer charges —
* every event with who did it and when, newest first.
*/
export function ClearanceHistoryTab({ bookingId }: { bookingId: string }) {
const { data: events, isLoading } = useQuery({
queryKey: ["clearance-history", bookingId],
queryFn: () => bookingsService.getClearanceHistory(bookingId),
});
if (isLoading) {
return (
<Group justify="center" py="xl" gap={10}>
<Loader size="sm" color="edr-green" />
<Text c="dimmed">Loading history</Text>
</Group>
);
}
if (!events || events.length === 0) {
return (
<Paper withBorder radius="md" p="lg">
<Text size="sm" c="dimmed">
No clearance actions recorded yet. Actions from now on approvals,
queries, workflow steps, charges appear here automatically.
</Text>
</Paper>
);
}
return (
<Paper withBorder radius="md" p="lg" maw={760}>
<Timeline bulletSize={22} lineWidth={2} active={events.length - 1} color="gray">
{events.map((ev) => {
const meta = eventMeta(ev.action);
const Icon = meta.icon;
const actor = ACTOR_BADGE[ev.actorType];
const note =
typeof ev.metadata?.note === "string" ? ev.metadata.note : null;
return (
<Timeline.Item
key={ev.id}
color={meta.color}
bullet={<Icon size={12} />}
title={
<Group gap={8} wrap="wrap">
<Text fz="13px" fw={600} c="edr-text" lh={1.35}>
{ev.label}
</Text>
<Badge size="xs" variant="light" color={actor.color} radius="sm">
{actor.label}
</Badge>
</Group>
}
>
<Text fz="11.5px" c="dimmed">
{ev.actorName ? `${ev.actorName} · ` : ""}
{formatDateTime(ev.at)}
</Text>
{note ? (
<Text fz="12px" c="red.8" mt={2}>
{note}
</Text>
) : null}
</Timeline.Item>
);
})}
</Timeline>
</Paper>
);
}

View File

@@ -1,6 +1,6 @@
import type { ReactNode } from "react";
import { Badge, Stack, Tabs, Text } from "@mantine/core";
import { AlertTriangle, FileText, Share2, ShieldAlert } from "lucide-react";
import { AlertTriangle, FileText, History, Receipt, Share2, ShieldAlert } from "lucide-react";
import type { Freight } from "@edr/types";
import { useAuth } from "@/auth/useAuth";
@@ -9,6 +9,8 @@ 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 { ClearanceChargesTab } from "@/components/contracts/ClearanceChargesTab";
import { ClearanceHistoryTab } from "@/components/contracts/ClearanceHistoryTab";
import { GlExchangePanel } from "@/components/contracts/GlExchangePanel";
export interface ClearanceOpsTabsProps {
@@ -68,6 +70,12 @@ export function ClearanceOpsTabs({
Boolean(exchangeEntityId) &&
(hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) ||
hasPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions));
// Post-finalization customer billing. This layout is only rendered on the ET
// clearance pages — the DJ page (GlClearanceDetailPage) mounts its own tab.
const showCharges =
Boolean(bookingId) &&
Boolean(onViewFile) &&
hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions);
// Risk assignment + incident reporting hit bookings:operations endpoints.
const canOps = hasPermission(user, FREIGHT_PERMS.bookings.operations);
const hasTabs = (showOpsTabs && hasOps) || showDocuments || showExchange;
@@ -100,6 +108,16 @@ export function ClearanceOpsTabs({
Document exchange
</Tabs.Tab>
) : null}
{showCharges ? (
<Tabs.Tab value="charges" leftSection={<Receipt size={14} />}>
Customer charges
</Tabs.Tab>
) : null}
{bookingId && showExchange ? (
<Tabs.Tab value="history" leftSection={<History size={14} />}>
History
</Tabs.Tab>
) : null}
{showOpsTabs && canOps && riskMs ? (
<Tabs.Tab value="risk" leftSection={<ShieldAlert size={14} />}>
Risk assignment
@@ -131,6 +149,22 @@ export function ClearanceOpsTabs({
</Tabs.Panel>
) : null}
{showCharges ? (
<Tabs.Panel value="charges">
<ClearanceChargesTab
bookingId={bookingId!}
roleMode="ET"
onViewFile={onViewFile!}
/>
</Tabs.Panel>
) : null}
{bookingId && showExchange ? (
<Tabs.Panel value="history">
<ClearanceHistoryTab bookingId={bookingId} />
</Tabs.Panel>
) : null}
{showOpsTabs && canOps && riskMs && bookingId ? (
<Tabs.Panel value="risk">
<SectionCard icon={ShieldAlert} title="Customs risk" accent="edr-green">

View File

@@ -105,8 +105,15 @@ export default function DocumentClearanceDetailPage() {
const queried = docs.filter((d) => d.reviewStatus === "QUERIED").length;
const pending = total - approved - queried;
const pct = total === 0 ? 0 : Math.round((approved / total) * 100);
return { total, approved, queried, pending, pct };
// Documents actually sitting with GL: a file is present but not approved.
// Excludes required slots the customer never filled — those are on the
// customer, not on GL.
const awaitingReview = docs.filter(
(d) => d.file && d.reviewStatus !== "APPROVED",
).length;
return { total, approved, queried, pending, pct, awaitingReview };
}, [clearance]);
const awaitingReview = stats.awaitingReview;
const reference = booking?.reference ?? "Clearance";
// Phased customs clearance runs on every contract booking now — ONE_TIME and
@@ -149,24 +156,12 @@ export default function DocumentClearanceDetailPage() {
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
)[0]?.note ?? null;
const docsPhaseComplete =
clearance?.milestones?.some(
(m) => m.milestoneCode === "DOCUMENTS_APPROVED" && m.status === "COMPLETED",
) ?? false;
// Querying a document is only possible while the booking is actually in
// review — the server enforces exactly that (reviewDocument asserts
// DOCUMENTS_UNDER_REVIEW), so once clearance is finalized the button could
// only ever produce a 400.
//
// `preClearanceFinalized` alone was not enough: it is a phased-customs field,
// so a non-customs booking (self-clearance, and every shipping-line booking)
// never sets it and kept offering Query after Operations had finalized.
const queriesLocked =
Boolean(
(clearance as Freight.ContractClearanceView | undefined)
?.preClearanceFinalized,
) ||
(booking?.status != null && booking.status !== "DOCUMENTS_UNDER_REVIEW");
// Documents stay reviewable for as long as the customer can still submit
// them — until the shipment is paid, not merely until clearance is
// finalized. `documentsOpen` is the server's own predicate (the same one
// both the upload and review endpoints gate on), so the buttons are shown
// exactly when the API would accept them.
const documentsClosed = clearance?.documentsOpen === false;
const workflowFiles =
(clearance as Freight.ContractClearanceView | undefined)?.workflowFiles ?? [];
@@ -231,7 +226,19 @@ export default function DocumentClearanceDetailPage() {
Customs
</Badge>
) : null}
{clearance.allApproved ? (
{/* Waiting on GL: an uploaded document with no decision yet, or
one under query. `allApproved` only covers the REQUIRED set,
so an ad-hoc file added after clearance never moves it. */}
{awaitingReview > 0 ? (
<Badge
variant="filled"
color="orange"
radius="sm"
leftSection={<Clock size={13} />}
>
{awaitingReview} needs approval
</Badge>
) : clearance.allApproved ? (
<Badge
variant="light"
color="edr-green"
@@ -346,8 +353,8 @@ export default function DocumentClearanceDetailPage() {
<ClearanceReviewSection
bookingId={id!}
hideSummary
approvalsLocked={isPhasedGeneral && docsPhaseComplete}
queriesLocked={queriesLocked}
approvalsLocked={documentsClosed}
queriesLocked={documentsClosed}
phasedCustoms={isPhasedGeneral}
onChanged={() => void refetch()}
/>

View File

@@ -164,6 +164,8 @@ export default function ContractClearanceListPage() {
tradeDirection: b.tradeDirection ?? "—",
freightType: b.freightType ?? "—",
status: b.status,
allDocsApproved: Boolean(b.allDocsApproved),
hasDocumentsAwaitingReview: Boolean(b.hasDocumentsAwaitingReview),
requested: requestedByBooking.get(b.id) ?? null,
contractId: b.contractId ?? null,
contractReference: b.contractReference ?? null,
@@ -193,8 +195,13 @@ export default function ContractClearanceListPage() {
const counts = useMemo(
() => ({
all: allRows.length,
// Counts anything actually waiting on GL, including a document added
// after clearance was finalized (the status stays CLEARANCE_READY).
review: allRows.filter(
(r) => r.status === "AWAITING_DOCUMENTS" || r.status === "DOCUMENTS_UNDER_REVIEW",
(r) =>
r.status === "AWAITING_DOCUMENTS" ||
r.status === "DOCUMENTS_UNDER_REVIEW" ||
r.hasDocumentsAwaitingReview,
).length,
ready: allRows.filter((r) => r.status === "CLEARANCE_READY" || r.bookingCreated)
.length,
@@ -344,6 +351,10 @@ interface ShipmentBookingRow {
tradeDirection: string;
freightType: string;
status: string;
/** Every required document approved, even before clearance is finalized. */
allDocsApproved: boolean;
/** A customer document is waiting on GL — including one added post-clearance. */
hasDocumentsAwaitingReview: boolean;
/** Requested quantities from the originating shipment request. */
requested: Freight.RequestedShipmentLines | null;
/** Contract this shipment booking was created under. */
@@ -518,13 +529,29 @@ function ShipmentBookingsTable({
header: () => <span className={bookingTable.headerCell}>Status</span>,
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
<Badge
variant="light"
color={shipmentStatusColor(row.original.status)}
radius="sm"
>
{prettyStatus(row.original.status)}
</Badge>
{/* A document is waiting on GL. This outranks the booking status:
a file added after clearance was finalized leaves the status at
CLEARANCE_READY, and the row must still call for the review. */}
{row.original.hasDocumentsAwaitingReview ? (
<Badge variant="filled" color="orange" radius="sm">
Needs approval
</Badge>
) : /* All docs approved but not yet finalized: the booking status is
still DOCUMENTS_UNDER_REVIEW — show the real review state. */
row.original.status === "DOCUMENTS_UNDER_REVIEW" &&
row.original.allDocsApproved ? (
<Badge variant="light" color="edr-green" radius="sm">
Documents approved
</Badge>
) : (
<Badge
variant="light"
color={shipmentStatusColor(row.original.status)}
radius="sm"
>
{prettyStatus(row.original.status)}
</Badge>
)}
{row.original.bookingCreated ? (
<Tooltip label="Booking created by GL Ethiopia" withArrow>
<Badge

View File

@@ -20,6 +20,8 @@ import {
AlertTriangle,
ClipboardList,
FileText,
History,
Receipt,
Share2,
Upload,
} from "lucide-react";
@@ -36,6 +38,8 @@ import { BookingCompanyCard } from "@/components/bookings/detail/BookingCompanyC
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
import { GlExchangePanel } from "@/components/contracts/GlExchangePanel";
import { ClearanceChargesTab } from "@/components/contracts/ClearanceChargesTab";
import { ClearanceHistoryTab } from "@/components/contracts/ClearanceHistoryTab";
import {
GlClearanceUploadModal,
type GlClearanceUploadKind,
@@ -247,6 +251,16 @@ export default function GlClearanceDetailPage() {
<Tabs.Tab value="exchange" leftSection={<Share2 size={14} />}>
Document exchange
</Tabs.Tab>
{data.kind === "booking" ? (
<Tabs.Tab value="charges" leftSection={<Receipt size={14} />}>
Customer charges
</Tabs.Tab>
) : null}
{data.kind === "booking" ? (
<Tabs.Tab value="history" leftSection={<History size={14} />}>
History
</Tabs.Tab>
) : null}
{incidentBookingId ? (
<Tabs.Tab value="incidents" leftSection={<AlertTriangle size={14} />}>
Incidents
@@ -369,6 +383,18 @@ export default function GlClearanceDetailPage() {
<GlExchangePanel entityId={id!} />
</Tabs.Panel>
{data.kind === "booking" ? (
<Tabs.Panel value="charges">
<ClearanceChargesTab bookingId={id!} roleMode="DJ" onViewFile={view} />
</Tabs.Panel>
) : null}
{data.kind === "booking" ? (
<Tabs.Panel value="history">
<ClearanceHistoryTab bookingId={id!} />
</Tabs.Panel>
) : null}
{incidentBookingId ? (
<Tabs.Panel value="incidents">
<SectionCard icon={AlertTriangle} title="Incident reports" accent="edr-green">

View File

@@ -432,6 +432,77 @@ export const bookingsService = {
return unwrap(response.data) as Freight.ClearanceView;
},
/** Clearance action history — reviews, workflow steps, charges (newest first). */
getClearanceHistory: async (
id: string,
): Promise<Freight.ClearanceHistoryEvent[]> => {
const response = await client.get(`/bookings/${id}/clearance/history`);
return unwrap(response.data) as Freight.ClearanceHistoryEvent[];
},
// ── Clearance charges (post-finalization customer billing) ──
getClearanceCharges: async (id: string): Promise<Freight.ClearanceCharge[]> => {
const response = await client.get(`/bookings/${id}/clearance/charges`);
return unwrap(response.data) as Freight.ClearanceCharge[];
},
/** GL Djibouti uploads (or replaces, until billed) the port-charges document. */
uploadPortChargeDocument: async (
id: string,
file: File,
): Promise<Freight.ClearanceCharge[]> => {
const form = new FormData();
form.append("file", file);
const response = await client.post(
`/bookings/${id}/clearance/charges/port-document`,
form,
{ headers: { "Content-Type": "multipart/form-data" } },
);
return unwrap(response.data) as Freight.ClearanceCharge[];
},
/** GL Ethiopia sets or revises a charge's amount + currency. */
billClearanceCharge: async (
id: string,
chargeId: string,
payload: { amount: number; currency: string },
): Promise<Freight.ClearanceCharge[]> => {
const response = await client.patch(
`/bookings/${id}/clearance/charges/${chargeId}/bill`,
payload,
);
return unwrap(response.data) as Freight.ClearanceCharge[];
},
/** GL Ethiopia issues the charge's payable invoice to the customer. */
sendClearanceCharge: async (
id: string,
chargeId: string,
): Promise<Freight.ClearanceCharge[]> => {
const response = await client.post(
`/bookings/${id}/clearance/charges/${chargeId}/send`,
);
return unwrap(response.data) as Freight.ClearanceCharge[];
},
/** GL Ethiopia creates the miscellaneous charge (document + amount + currency). */
createMiscellaneousCharge: async (
id: string,
file: File,
payload: { amount: number; currency: string },
): Promise<Freight.ClearanceCharge[]> => {
const form = new FormData();
form.append("file", file);
form.append("amount", String(payload.amount));
form.append("currency", payload.currency);
const response = await client.post(
`/bookings/${id}/clearance/charges/miscellaneous`,
form,
{ headers: { "Content-Type": "multipart/form-data" } },
);
return unwrap(response.data) as Freight.ClearanceCharge[];
},
/** GL ET asks Djibouti to name the officer handling the shipment in transit. */
requestTransitAssignee: (id: string, note?: string) =>
postBooking<BookingDetail>(B.CLEARANCE_TRANSIT_ASSIGNEE_REQUEST(id), {

View File

@@ -234,6 +234,10 @@ export interface BookingDetail {
equipmentReturn?: string;
customsClearingEnabled?: boolean;
customsClearingAgent?: string | null;
/** ET clearance queue: every required document approved (pre-finalize). */
allDocsApproved?: boolean;
/** ET clearance queue: a customer document is PENDING or QUERIED. */
hasDocumentsAwaitingReview?: boolean;
contractKind?: "ONE_TIME" | "GENERAL" | null;
contractId?: string | null;
/** Reference of the contract this booking was created under (list column + search). */

View File

@@ -66,7 +66,7 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
isBookAction
? "Book your shipment — enter the cargo details and pick a shipment day inside an open booking window."
: "Pick a shipment day and proceed to operation."
}`}
} You can still add documents to this shipment until it is paid.`}
</Alert>
) : status === "DOCUMENTS_UNDER_REVIEW" ? (
<Alert color="blue" radius="md" icon={<Clock size={18} />}>

View File

@@ -95,7 +95,7 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
{needsCompletion
? "Clearance is finalized. Complete your booking now — enter the cargo details and pick a shipment day inside an open booking window."
: awaitingGlCompletion
? "Customs clearance is complete. Global Logistics is completing your booking (cargo details and shipment day) — you will be notified when payment is due."
? "Customs clearance is complete. Global Logistics is completing your booking (cargo details and shipment day) — you will be notified when payment is due. You can still add documents below until the shipment is paid."
: clearance.includesCustoms
? "Customs clearance is complete and your cleared documents are available below. You can now proceed to operation."
: "Your documents are approved. You can now proceed to operation."}

View File

@@ -130,13 +130,14 @@ export function getBookingNextAction(
booking: ActionBooking,
): BookingNextAction | null {
if (booking.status === "CLEARANCE_READY" && isBareInstance(booking)) {
// Customs (Path B): GL completes the booking — the customer can only view
// the finished clearance in the modal.
// Customs (Path B): GL completes the booking, so there is no schedule step
// for the customer here — but documents stay open until the shipment is
// paid, so the modal is still an action surface, not a read-only view.
if (booking.customsClearingEnabled) {
return {
kind: "SCHEDULE_OPERATION",
label: "View clearance",
title: "Clearance complete",
label: "Documents",
title: "Clearance documents",
};
}
// Non-customs (Path A): straight to the booking form — cargo + shipment

View File

@@ -116,8 +116,14 @@ export function useClearanceFlow(booking: Freight.IBooking) {
// just sees that clearance is done and GL is preparing the booking.
const awaitingGlCompletion =
isReady && isBareInstance && Boolean(clearance?.includesCustoms);
// Documents stay open until the shipment is paid: a customs shipment keeps
// collecting paperwork (amended invoices, revised packing lists) well past
// clearance finalization. `documentsOpen` is the server's own predicate, so
// the upload control appears exactly when the API would accept a file.
// Older API builds omit the field — fall back to the previous rule there.
const canUpload =
status === "AWAITING_DOCUMENTS" || status === "DOCUMENTS_UNDER_REVIEW";
clearance?.documentsOpen ??
(status === "AWAITING_DOCUMENTS" || status === "DOCUMENTS_UNDER_REVIEW");
// The very first upload (nothing in review yet). Here every required document
// must be provided. Once GL has started reviewing (DOCUMENTS_UNDER_REVIEW) the
// customer is only re-uploading queried/pending docs, so we don't re-gate on

View File

@@ -188,6 +188,11 @@ export enum InvoiceSource {
LastMile = "lastmile",
/** Customs clearance service fee — billed on the booking invoice with the freight. */
Clearance = "clearance",
/**
* Post-finalization clearance charge (port charges / miscellaneous) billed
* to the customer as its own payable invoice. `sourceId` is the charge id.
*/
ClearanceCharge = "clearance_charge",
/**
* A batch of shipping-line credits billed together. Unlike every other
* source, `sourceId` is the shipping line's id rather than a single record's:
@@ -802,6 +807,65 @@ export interface PricingBreakdown {
export type DocumentReviewStatus = "PENDING" | "APPROVED" | "QUERIED";
// ── Clearance history (per-booking action trail, History tab) ───────────────
/** One recorded clearance action — review, workflow step, or charge. */
export interface ClearanceHistoryEvent {
id: string;
/** Stable machine code, e.g. DOC_APPROVED, DECLARATION_UPLOADED. */
action: string;
/** Human sentence, frozen at write time. */
label: string;
actorType: "STAFF" | "CUSTOMER" | "SYSTEM";
actorName: string | null;
metadata: Record<string, unknown> | null;
at: string;
}
// ── Clearance charges (post-finalization customer billing) ──────────────────
export type ClearanceChargeType = "PORT_CHARGES" | "MISCELLANEOUS";
/**
* DOC_UPLOADED: GL Djibouti uploaded the supporting document (port charges).
* BILLED: GL Ethiopia set amount + currency. SENT: invoice issued to the
* customer (ETB pays via gateway, other currencies via Finance's manual
* settlement). PAID: the invoice settled.
*/
export type ClearanceChargeStatus =
| "DOC_UPLOADED"
| "BILLED"
| "SENT"
| "PAID";
/** One clearance charge level on a booking — at most one per type. */
export interface ClearanceCharge {
id: string;
bookingId: string;
type: ClearanceChargeType;
status: ClearanceChargeStatus;
file: { id: string; name: string; url: string } | null;
amount: number | null;
currency: string | null;
invoiceId: string | null;
invoiceNumber: string | null;
uploadedByName: string | null;
uploadedAt: string | null;
billedByName: string | null;
billedAt: string | null;
paidAt: string | null;
}
/** One entry of a clearance document's audit trail, oldest first. */
export interface ClearanceDocumentEvent {
type: "UPLOADED" | "RESUBMITTED" | "QUERIED" | "APPROVED";
at: string;
/** Actor display name (staff for reviews; null = the customer/unknown). */
byName: string | null;
/** Query reason, for QUERIED events. */
note: string | null;
}
/** One row of the clearance document grid (a required doc + its file + review). */
export interface ClearanceDocument {
fileKey: string;
@@ -813,6 +877,14 @@ export interface ClearanceDocument {
file: { id: string; name: string; url: string } | null;
reviewStatus: DocumentReviewStatus | null;
note: string | null;
/** When the current file version was uploaded — a re-upload (query response) refreshes it. */
uploadedAt?: string | null;
/** When the latest review decision (approve/query) was recorded. */
reviewedAt?: string | null;
/** Display name of the staff member who recorded the decision. */
reviewedByName?: string | null;
/** Full audit trail: uploads, re-submissions, queries, approval. */
history?: ClearanceDocumentEvent[];
}
/**
@@ -852,6 +924,12 @@ export interface ClearanceView {
documents: ClearanceDocument[];
/** True once every required customer document is APPROVED (the 100% gate). */
allApproved: boolean;
/**
* True while the customer may still attach documents and GL may still
* approve or query them — open until the shipment is paid, not merely until
* clearance is finalized.
*/
documentsOpen?: boolean;
/** Phased clearance (GENERAL + customs per-booking). */
phase?: ContractDocPhase | null;
milestones?: IClearanceMilestone[];