From c138da61377358a8e413138a10321d87570b6554 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 20 Aug 2026 05:24:57 +0000 Subject: [PATCH] feat(bookings): two-level clearance charges (port + misc) billed to customer with invoices --- .../3590000000000-BookingClearanceCharge.ts | 46 ++ .../booking-clearance-charge.service.ts | 332 +++++++++++ .../modules/bookings/bookings.controller.ts | 94 ++++ .../src/modules/bookings/bookings.module.ts | 4 + .../bookings/dto/clearance-charge.dto.ts | 16 + .../booking-clearance-charge.entity.ts | 68 +++ .../contracts/contract-staff-stamp.spec.ts | 37 +- .../contracts/contract-transition.service.ts | 31 +- .../stamp-settings/stamp-settings.service.ts | 42 +- .../contracts/ClearanceChargesTab.tsx | 525 ++++++++++++++++++ .../components/contracts/ClearanceOpsTabs.tsx | 24 +- .../pages/contracts/GlClearanceDetailPage.tsx | 13 + .../src/services/bookings.service.ts | 63 +++ packages/types/src/freight/index.ts | 39 ++ 14 files changed, 1281 insertions(+), 53 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3590000000000-BookingClearanceCharge.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/booking-clearance-charge.service.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/dto/clearance-charge.dto.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/entities/booking-clearance-charge.entity.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/ClearanceChargesTab.tsx 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/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..88986735f --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-clearance-charge.service.ts @@ -0,0 +1,332 @@ +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'; + +/** 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 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(), + }), + ); + } + 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(), + }); + return this.list(bookingId); + } + + /** GL Ethiopia issues the payable invoice to the customer. */ + async sendCharge( + bookingId: string, + chargeId: 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, + }); + 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(), + }), + ); + 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(), + }); + 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/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index a2584559e..8c9ee3b41 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,8 @@ import { } from "@nestjs/swagger"; import type { Response } from "express"; +import { BookingClearanceChargeService } from './booking-clearance-charge.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 +173,7 @@ export class BookingsController { private readonly userTradeAccessService: UserTradeAccessService, private readonly wagonCancellationService: BookingWagonCancellationService, private readonly consolidationApprovalService: ConsolidationApprovalService, + private readonly clearanceChargeService: BookingClearanceChargeService, ) {} @Post() @@ -1073,6 +1077,96 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } + // ── 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, + ) { + return this.clearanceChargeService.sendCharge(id, chargeId); + } + + @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()) 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..5983f55f2 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,8 @@ 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 { BookingContainer } from './entities/booking-container.entity'; import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity'; import { BookingContractSignature } from './entities/booking-contract-signature.entity'; @@ -76,6 +78,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; CustomerTruckAssignment, CustomerTruckContainer, ConsolidationApproval, + BookingClearanceCharge, ]), BillingModule, DocumentsModule, @@ -111,6 +114,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; BookingTransitionService, BookingContractService, BookingInvoiceService, + BookingClearanceChargeService, ContractTemplateResolver, ContractViewModelBuilder, ContractPricingScheduleBuilder, 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/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/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/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/ClearanceOpsTabs.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceOpsTabs.tsx index a61646d6f..95dc13d37 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, Receipt, Share2, ShieldAlert } from "lucide-react"; import type { Freight } from "@edr/types"; import { useAuth } from "@/auth/useAuth"; @@ -9,6 +9,7 @@ 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 { GlExchangePanel } from "@/components/contracts/GlExchangePanel"; export interface ClearanceOpsTabsProps { @@ -68,6 +69,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 +107,11 @@ export function ClearanceOpsTabs({ Document exchange ) : null} + {showCharges ? ( + }> + Customer charges + + ) : null} {showOpsTabs && canOps && riskMs ? ( }> Risk assignment @@ -131,6 +143,16 @@ export function ClearanceOpsTabs({ ) : null} + {showCharges ? ( + + + + ) : null} + {showOpsTabs && canOps && riskMs && bookingId ? ( diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx index 76e0ff4ad..95e8ff296 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx @@ -20,6 +20,7 @@ import { AlertTriangle, ClipboardList, FileText, + Receipt, Share2, Upload, } from "lucide-react"; @@ -36,6 +37,7 @@ import { BookingCompanyCard } from "@/components/bookings/detail/BookingCompanyC import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection"; import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel"; import { GlExchangePanel } from "@/components/contracts/GlExchangePanel"; +import { ClearanceChargesTab } from "@/components/contracts/ClearanceChargesTab"; import { GlClearanceUploadModal, type GlClearanceUploadKind, @@ -247,6 +249,11 @@ export default function GlClearanceDetailPage() { }> Document exchange + {data.kind === "booking" ? ( + }> + Customer charges + + ) : null} {incidentBookingId ? ( }> Incidents @@ -369,6 +376,12 @@ export default function GlClearanceDetailPage() { + {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..bdfb46cd6 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,69 @@ export const bookingsService = { return unwrap(response.data) as Freight.ClearanceView; }, + // ── 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/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 02f4f3f0e..fb3a51508 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,40 @@ export interface PricingBreakdown { export type DocumentReviewStatus = "PENDING" | "APPROVED" | "QUERIED"; +// ── 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";