diff --git a/apps/edr-freight-api/src/migrations/3590000000000-BookingClearanceCharge.ts b/apps/edr-freight-api/src/migrations/3590000000000-BookingClearanceCharge.ts new file mode 100644 index 000000000..042e47554 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3590000000000-BookingClearanceCharge.ts @@ -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 { + 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 { + await queryRunner.query( + `DROP TABLE IF EXISTS "freight"."booking_clearance_charge"`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/3600000000000-BookingClearanceEvent.ts b/apps/edr-freight-api/src/migrations/3600000000000-BookingClearanceEvent.ts new file mode 100644 index 000000000..ce7ef49ed --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3600000000000-BookingClearanceEvent.ts @@ -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 { + 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 { + await queryRunner.query( + `DROP TABLE IF EXISTS "freight"."booking_clearance_event"`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts b/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts index a0c82fa4d..deda35e1c 100644 --- a/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts +++ b/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts @@ -47,6 +47,10 @@ export const AUDIT_ENDPOINTS: Readonly> = { "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"], diff --git a/apps/edr-freight-api/src/modules/bookings/booking-clearance-charge.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-clearance-charge.service.ts new file mode 100644 index 000000000..f41bff39d --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-clearance-charge.service.ts @@ -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 = { + PORT_CHARGES: 'clearance_charge_port', + MISCELLANEOUS: 'clearance_charge_misc', +}; + +const CHARGE_LABEL: Record = { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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})`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts index 871d03c72..fd8e7192c 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts @@ -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 }; diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts index b67366431..fbfb0daa4 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts @@ -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 }; diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts index 0726445d5..b55c8d4b0 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts @@ -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 }; diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.paired-decision.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.paired-decision.spec.ts index 91b1bfd91..6496208e7 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.paired-decision.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.paired-decision.spec.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 41e8fc78b..805b2216f 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -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 @@ -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 { 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 { 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 { 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 { + async finalizeClearance(bookingId: string, userId?: string): Promise { 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 { 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; diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index a2584559e..b1be94016 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -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); } diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index b3ea4a546..a522a6225 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 7b042e0c7..3ad75242d 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -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 { }); } + /** + * 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> { + 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 { 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> { + return resolveIamUserNames(this.dataSource, staffIds); + } + /** GL marks a document APPROVED or QUERIED (with an optional note). */ async setDocumentReviewStatus( bookingId: string, diff --git a/apps/edr-freight-api/src/modules/bookings/clearance-doc-history.util.ts b/apps/edr-freight-api/src/modules/bookings/clearance-doc-history.util.ts new file mode 100644 index 000000000..e86c8f644 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/clearance-doc-history.util.ts @@ -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 "" queried: ` + * (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; +}): 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)); +} diff --git a/apps/edr-freight-api/src/modules/bookings/clearance-documents-open.spec.ts b/apps/edr-freight-api/src/modules/bookings/clearance-documents-open.spec.ts new file mode 100644 index 000000000..893b7c735 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/clearance-documents-open.spec.ts @@ -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 => + ({ 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); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/clearance-event.service.ts b/apps/edr-freight-api/src/modules/bookings/clearance-event.service.ts new file mode 100644 index 000000000..cc5c1acab --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/clearance-event.service.ts @@ -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 | 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 { + 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 { + 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(), + ) + : new Map(); + + 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(), + })); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/clearance.util.ts b/apps/edr-freight-api/src/modules/bookings/clearance.util.ts index c7fc71449..56a73fa58 100644 --- a/apps/edr-freight-api/src/modules/bookings/clearance.util.ts +++ b/apps/edr-freight-api/src/modules/bookings/clearance.util.ts @@ -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([ + '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; +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/clearance-charge.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/clearance-charge.dto.ts new file mode 100644 index 000000000..f2bf15c0e --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/clearance-charge.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-clearance-charge.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-clearance-charge.entity.ts new file mode 100644 index 000000000..8530ad11e --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-clearance-charge.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-clearance-event.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-clearance-event.entity.ts new file mode 100644 index 000000000..247ea12a6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-clearance-event.entity.ts @@ -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 | null; +} diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts index dd87184fd..5cee12d3b 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts @@ -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()), }; 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 { diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts index 4b53f4dee..d9fe1825f 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts @@ -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 { @@ -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 { 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 { + async assignTransitAssignee( + bookingId: string, + transitAgentId: string, + userId?: string, + ): Promise { 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 { + async acceptDraftDeclaration(bookingId: string, userId?: string): Promise { 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 { + async uploadDutySlip( + bookingId: string, + file: Express.Multer.File, + userId?: string, + ): Promise { 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 { + async finalizePreClearance(bookingId: string, userId?: string): Promise { 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); } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-staff-stamp.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-staff-stamp.spec.ts index 6d370c276..99fff9479 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-staff-stamp.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-staff-stamp.spec.ts @@ -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); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index e6f208ae2..48c90fcbc 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -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, }); diff --git a/apps/edr-freight-api/src/modules/files/files.repository.ts b/apps/edr-freight-api/src/modules/files/files.repository.ts index 9a2552c7a..e4164c3b2 100644 --- a/apps/edr-freight-api/src/modules/files/files.repository.ts +++ b/apps/edr-freight-api/src/modules/files/files.repository.ts @@ -84,6 +84,21 @@ export class FilesRepository extends BaseRepository { }); } + /** + * 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 { + 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 diff --git a/apps/edr-freight-api/src/modules/files/files.service.ts b/apps/edr-freight-api/src/modules/files/files.service.ts index e76240704..e2481848a 100644 --- a/apps/edr-freight-api/src/modules/files/files.service.ts +++ b/apps/edr-freight-api/src/modules/files/files.service.ts @@ -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 { + 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 `[]`). diff --git a/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.service.ts b/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.service.ts index 9bb4f3a37..bfc544f88 100644 --- a/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.service.ts +++ b/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.service.ts @@ -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 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 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 { 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 { 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 { 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(); } diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx index e4e78f462..291bc0d05 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx @@ -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 ( + + {history.map((ev, i) => { + const meta = EVENT_META[ev.type]; + const Icon = meta.icon; + return ( + } + title={ + + {meta.label(ev.byName)} + + } + > + + {formatDateTime(ev.at)} + + {ev.note ? ( + + {ev.note} + + ) : null} + + ); + })} + + ); +} + function StatPill({ color, label, @@ -551,11 +623,6 @@ function DocReviewCard({ {hasFile ? doc.file!.name : "Not uploaded by customer"} - {hasFile && doc.uploadedAt ? ( - - Uploaded {formatDateTime(doc.uploadedAt)} - - ) : null} @@ -607,11 +674,8 @@ function DocReviewCard({ - {doc.reviewedAt && (status === "APPROVED" || status === "QUERIED") && ( - - {status === "APPROVED" ? "Approved" : "Queried"} by{" "} - {doc.reviewedByName ?? "staff"} · {formatDateTime(doc.reviewedAt)} - + {(doc.history?.length ?? 0) > 0 && ( + )} {status === "QUERIED" && doc.note && ( diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceChargesTab.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceChargesTab.tsx new file mode 100644 index 000000000..6f3fb530f --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceChargesTab.tsx @@ -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 ( + + + Loading charges… + + ); + } + + 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(); + for (const c of charges ?? []) { + if (c.amount != null && c.currency) + totals.set(c.currency, (totals.get(c.currency) ?? 0) + c.amount); + } + + return ( + + + port && bill.mutate({ chargeId: port.id, amount, currency }) + } + onSend={() => port && send.mutate(port.id)} + djUpload={ + roleMode === "DJ" && (!port || port.status === "DOC_UPLOADED") ? ( + f && uploadPort.mutate(f)} + accept="application/pdf,image/*" + disabled={busy} + > + {(props) => ( + + )} + + ) : null + } + /> + + + misc && bill.mutate({ chargeId: misc.id, amount, currency }) + } + onSend={() => misc && send.mutate(misc.id)} + etCreate={ + roleMode === "ET" && !misc && port?.status === "PAID" ? ( + + createMisc.mutate({ file, amount, currency }) + } + /> + ) : null + } + /> + + {totals.size > 0 && ( + + + + Total billed + + + {[...totals.entries()].map(([currency, amount]) => ( + + {amount.toLocaleString(undefined, { + minimumFractionDigits: 2, + })}{" "} + {currency} + + ))} + + + + )} + + ); +} + +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(charge?.amount ?? ""); + const [currency, setCurrency] = useState(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 ( + + + + + + + {title} + + {charge?.uploadedAt && ( + + Document uploaded + {charge.uploadedByName ? ` by ${charge.uploadedByName}` : ""} ·{" "} + {formatDateTime(charge.uploadedAt)} + + )} + {charge?.billedAt && ( + + Billed{charge.billedByName ? ` by ${charge.billedByName}` : ""} ·{" "} + {formatDateTime(charge.billedAt)} + + )} + {charge?.paidAt && ( + + Paid · {formatDateTime(charge.paidAt)} + {charge.invoiceNumber ? ` (invoice ${charge.invoiceNumber})` : ""} + + )} + + + + {charge?.amount != null && charge.currency && ( + + {charge.amount.toLocaleString(undefined, { + minimumFractionDigits: 2, + })}{" "} + {charge.currency} + + )} + {meta && ( + + {meta.label} + + )} + + + + {charge?.file && ( + + + + {charge.file.name} + + {isViewable({ name: charge.file.name, url: "" }) && ( + + + void fetchViewableFile( + charge.file!.id, + charge.file!.name, + ).then(onViewFile) + } + c="edr-green" + style={{ + display: "flex", + background: "transparent", + border: "none", + cursor: "pointer", + }} + > + + + + )} + + + void downloadBookingFile(charge.file!.id, charge.file!.name) + } + c="edr-green" + style={{ + display: "flex", + background: "transparent", + border: "none", + cursor: "pointer", + }} + > + + + + + )} + + {!charge && ( + + {emptyHint} + + )} + {djUpload && {djUpload}} + {etCreate && {etCreate}} + + {showBillForm && ( + + + v && setCurrency(v)} + w={100} + /> + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceHistoryTab.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceHistoryTab.tsx new file mode 100644 index 000000000..c63558593 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceHistoryTab.tsx @@ -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 ( + + + Loading history… + + ); + } + + if (!events || events.length === 0) { + return ( + + + No clearance actions recorded yet. Actions from now on — approvals, + queries, workflow steps, charges — appear here automatically. + + + ); + } + + return ( + + + {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 ( + } + title={ + + + {ev.label} + + + {actor.label} + + + } + > + + {ev.actorName ? `${ev.actorName} · ` : ""} + {formatDateTime(ev.at)} + + {note ? ( + + {note} + + ) : null} + + ); + })} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceOpsTabs.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceOpsTabs.tsx index a61646d6f..8672e60d3 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceOpsTabs.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceOpsTabs.tsx @@ -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 ) : null} + {showCharges ? ( + }> + Customer charges + + ) : null} + {bookingId && showExchange ? ( + }> + History + + ) : null} {showOpsTabs && canOps && riskMs ? ( }> Risk assignment @@ -131,6 +149,22 @@ export function ClearanceOpsTabs({ ) : null} + {showCharges ? ( + + + + ) : null} + + {bookingId && showExchange ? ( + + + + ) : null} + {showOpsTabs && canOps && riskMs && bookingId ? ( diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx index 39a0e9d77..d98039947 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx @@ -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 ) : 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 ? ( + } + > + {awaitingReview} needs approval + + ) : clearance.allApproved ? ( void refetch()} /> diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx index 503e6dc60..392c028aa 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx @@ -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: () => Status, cell: ({ row }) => ( - - {prettyStatus(row.original.status)} - + {/* 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 ? ( + + Needs approval + + ) : /* 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 ? ( + + Documents approved + + ) : ( + + {prettyStatus(row.original.status)} + + )} {row.original.bookingCreated ? ( }> Document exchange + {data.kind === "booking" ? ( + }> + Customer charges + + ) : null} + {data.kind === "booking" ? ( + }> + History + + ) : null} {incidentBookingId ? ( }> Incidents @@ -369,6 +383,18 @@ export default function GlClearanceDetailPage() { + {data.kind === "booking" ? ( + + + + ) : null} + + {data.kind === "booking" ? ( + + + + ) : null} + {incidentBookingId ? ( diff --git a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts index f558d8271..f2b6c3de6 100644 --- a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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(B.CLEARANCE_TRANSIT_ASSIGNEE_REQUEST(id), { diff --git a/apps/edr-freight-web/backoffice/src/types/booking.ts b/apps/edr-freight-web/backoffice/src/types/booking.ts index d3d6c0293..258203002 100644 --- a/apps/edr-freight-web/backoffice/src/types/booking.ts +++ b/apps/edr-freight-web/backoffice/src/types/booking.ts @@ -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). */ diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx index 3002356f7..f5fa3f934 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx @@ -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.`} ) : status === "DOCUMENTS_UNDER_REVIEW" ? ( }> diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/ClearanceFlow.tsx b/apps/edr-freight-web/portal/src/pages/bookings/clearance/ClearanceFlow.tsx index ee96799b3..fd8739a4a 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/clearance/ClearanceFlow.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/ClearanceFlow.tsx @@ -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."} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts b/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts index 297b0c152..354220e21 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts @@ -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 diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/useClearanceFlow.ts b/apps/edr-freight-web/portal/src/pages/bookings/clearance/useClearanceFlow.ts index c50a7c046..17a154d37 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/clearance/useClearanceFlow.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/useClearanceFlow.ts @@ -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 diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 3ed0ec903..ae9fb7793 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -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 | 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[];