diff --git a/apps/edr-freight-api/src/migrations/2920000000000-AddRevisionActorName.ts b/apps/edr-freight-api/src/migrations/2920000000000-AddRevisionActorName.ts new file mode 100644 index 000000000..2263d78fc --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2920000000000-AddRevisionActorName.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Store WHO made a contract edit as a name, not just an id. Denormalised on + * purpose: an audit trail must still read correctly after the user is renamed, + * deactivated or deleted, and `iam.users` lives outside this module's schema. + */ +export class AddRevisionActorName2920000000000 implements MigrationInterface { + name = 'AddRevisionActorName2920000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.contract_document_revisions ADD COLUMN IF NOT EXISTS actor_name varchar(200);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.contract_document_revisions DROP COLUMN IF EXISTS actor_name;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/2930000000000-AddDoCollectionDates.ts b/apps/edr-freight-api/src/migrations/2930000000000-AddDoCollectionDates.ts new file mode 100644 index 000000000..2a7797d82 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2930000000000-AddDoCollectionDates.ts @@ -0,0 +1,42 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Djibouti GL must record WHEN the vessel arrived and WHEN the Delivery Order + * was collected, not just attach the DO file. Both are mandatory on DO upload + * (enforced in the clearance services), so the columns are new and nullable — + * DOs uploaded before this change have no dates to backfill. + * + * `vessel_departure_date` is the EXPORT Release-Order date and stays as-is; the + * import arrival date gets its own column rather than overloading it. + */ +export class AddDoCollectionDates2930000000000 implements MigrationInterface { + name = 'AddDoCollectionDates2930000000000'; + + public async up(queryRunner: QueryRunner): Promise { + for (const table of [ + 'freight.contract_clearance_cycles', + 'freight.bookings', + ]) { + await queryRunner.query( + `ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS vessel_arrival_date date;`, + ); + await queryRunner.query( + `ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS do_collected_date date;`, + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + for (const table of [ + 'freight.contract_clearance_cycles', + 'freight.bookings', + ]) { + await queryRunner.query( + `ALTER TABLE ${table} DROP COLUMN IF EXISTS do_collected_date;`, + ); + await queryRunner.query( + `ALTER TABLE ${table} DROP COLUMN IF EXISTS vessel_arrival_date;`, + ); + } + } +} diff --git a/apps/edr-freight-api/src/migrations/2940000000000-AddBookingRequestCurrency.ts b/apps/edr-freight-api/src/migrations/2940000000000-AddBookingRequestCurrency.ts new file mode 100644 index 000000000..8d3fde437 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2940000000000-AddBookingRequestCurrency.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Currency moved from the contract to the shipment: a contract now quotes in + * USD and the customer picks the billing currency per booking. On a customs + * contract GL books on the customer's behalf, so the shipment request is where + * the customer states the currency — GL reads it when creating the booking. + * + * Nullable: requests submitted before this change fall back to the contract's + * own currency, which is exactly what their bookings already used. + */ +export class AddBookingRequestCurrency2940000000000 implements MigrationInterface { + name = 'AddBookingRequestCurrency2940000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.booking_requests ADD COLUMN IF NOT EXISTS payment_currency varchar(5);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.booking_requests DROP COLUMN IF EXISTS payment_currency;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/2940000000000-AddFileVersionHistory.ts b/apps/edr-freight-api/src/migrations/2940000000000-AddFileVersionHistory.ts new file mode 100644 index 000000000..ad9bae99c --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2940000000000-AddFileVersionHistory.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Keep every version of a stored document. + * + * Replacing a file used to DELETE the previous row outright, so a staff + * correction erased the customer's original upload with no trail. Superseded + * versions are now soft-deleted (already excluded from every read by TypeORM's + * soft-delete filter) and stamped with who replaced them and why, which is what + * the document's version history reads back. + */ +export class AddFileVersionHistory2940000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.files + ADD COLUMN IF NOT EXISTS replaced_by_user_id uuid NULL, + ADD COLUMN IF NOT EXISTS replace_reason text NULL + `); + // History reads walk one document's versions, deleted rows included. + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_files_version_history" + ON freight.files (resource, resource_id, code, created_at DESC) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_files_version_history"`); + await queryRunner.query(` + ALTER TABLE freight.files + DROP COLUMN IF EXISTS replaced_by_user_id, + DROP COLUMN IF EXISTS replace_reason + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2950000000000-AddTransitAssigneeHandshake.ts b/apps/edr-freight-api/src/migrations/2950000000000-AddTransitAssigneeHandshake.ts new file mode 100644 index 000000000..bd9647d44 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2950000000000-AddTransitAssigneeHandshake.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Transit-assignee handshake before the customs declaration. + * + * GL Ethiopia must ask GL Djibouti who will handle the shipment in transit, and + * Djibouti answers with a name, before the declaration can be filed. The whole + * exchange lives on the clearance cycle so it repeats naturally with each cycle + * of a GENERAL contract. + */ +export class AddTransitAssigneeHandshake2950000000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.contract_clearance_cycles + ADD COLUMN IF NOT EXISTS transit_assignee_requested_at timestamptz NULL, + ADD COLUMN IF NOT EXISTS transit_assignee_requested_by_user_id uuid NULL, + ADD COLUMN IF NOT EXISTS transit_assignee_request_note text NULL, + ADD COLUMN IF NOT EXISTS transit_assignee_name text NULL, + ADD COLUMN IF NOT EXISTS transit_assignee_assigned_at timestamptz NULL, + ADD COLUMN IF NOT EXISTS transit_assignee_assigned_by_user_id uuid NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.contract_clearance_cycles + DROP COLUMN IF EXISTS transit_assignee_requested_at, + DROP COLUMN IF EXISTS transit_assignee_requested_by_user_id, + DROP COLUMN IF EXISTS transit_assignee_request_note, + DROP COLUMN IF EXISTS transit_assignee_name, + DROP COLUMN IF EXISTS transit_assignee_assigned_at, + DROP COLUMN IF EXISTS transit_assignee_assigned_by_user_id + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index 290b46433..3908dbbac 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -200,7 +200,7 @@ export class BookingPricingService { // route's container freight, never a frozen OVERWEIGHT_PER_TON value. const frozen = isDerived ? null - : this.frozenRateByCode(frozenRates, mod.surchargeCode, paymentCurrency); + : this.frozenRateByCode(frozenRates, mod.surchargeCode, paymentCurrency, usdToEtb); const unitAmount = frozen ? Number(frozen.unitPrice) : isEtbBooking @@ -547,14 +547,15 @@ export class BookingPricingService { booking.originYardId, booking.destinationYardId, ); - // H15: frozen contract rate for this container size, when present — its - // unitPrice is already in the booking currency (no USD→currency convert). - // It also stands on its own: a contract line prices off the agreed rate - // even when nobody configured a live rate for this leg + type yet. + // H15: frozen contract rate for this container size, when present — + // converted into the booking currency by frozenRateForContainer. It also + // stands on its own: a contract line prices off the agreed rate even when + // nobody configured a live rate for this leg + type yet. const frozen = await this.frozenRateForContainer( frozenRates, container.containerTypeId, paymentCurrency, + usdToEtb, ); const label = await this.containerTypeLabel(container.containerTypeId); if (!rate && !frozen) { @@ -632,7 +633,7 @@ export class BookingPricingService { const unitUsd = Number(fallback.rateValue); // H15: bulk freight uses the frozen BULK_FREIGHT snapshot when present. const frozen = isBulk - ? this.frozenRateByCode(frozenRates, 'BULK_FREIGHT', paymentCurrency) + ? this.frozenRateByCode(frozenRates, 'BULK_FREIGHT', paymentCurrency, usdToEtb) : null; let amount: number; let unitAmount: number; @@ -744,12 +745,13 @@ export class BookingPricingService { break; } - // H15: frozen mile rate (already in booking currency) when the contract - // has one; else the live USD rate converted as before. + // H15: frozen mile rate (converted into the booking currency) when the + // contract has one; else the live USD rate converted as before. const frozen = this.frozenRateByCode( frozenRates, leg.rateType, paymentCurrency, + usdToEtb, ); let amount: number; let unitAmount: number; @@ -908,20 +910,45 @@ export class BookingPricingService { } /** - * The frozen snapshot for a rate code, or null when there is none, its price - * is negative, or it is in a different currency than the booking (in which - * case the live-rate path is safer than a mis-converted frozen price). + * The frozen snapshot for a rate code, expressed in the BOOKING's currency. + * + * A contract quotes in USD and freezes USD unit prices; the customer chooses + * the billing currency per booking. So a currency mismatch is the normal case + * now, not an error — the snapshot is converted rather than discarded. (It + * previously returned null on mismatch, which silently dropped the agreed + * contract price and re-priced the booking at whatever the live rate had + * drifted to.) Grandfathered ETB contracts convert the other way for the same + * reason. + * + * Returns null only when there is no snapshot or its price is unusable. */ private frozenRateByCode( frozenRates: Map | null, code: string, bookingCurrency: string, + usdToEtb: number, ): ContractRateSnapshot | null { const snap = frozenRates?.get(code); if (!snap) return null; - if (snap.currency !== bookingCurrency) return null; - if (!(Number(snap.unitPrice) >= 0)) return null; - return snap; + const unitPrice = Number(snap.unitPrice); + if (!(unitPrice >= 0)) return null; + if (snap.currency === bookingCurrency) return snap; + + // Only USD <-> ETB exist; a rate of 0/NaN would silently zero the price. + if (!(usdToEtb > 0)) return null; + const converted = + snap.currency === 'USD' && bookingCurrency === 'ETB' + ? Math.round(unitPrice * usdToEtb) + : snap.currency === 'ETB' && bookingCurrency === 'USD' + ? unitPrice / usdToEtb + : null; + if (converted == null) return null; + + // A copy — the snapshot rows are shared across the pricing pass. + return Object.assign(Object.create(Object.getPrototypeOf(snap)), snap, { + unitPrice: converted, + currency: bookingCurrency, + }) as ContractRateSnapshot; } /** @@ -933,6 +960,7 @@ export class BookingPricingService { frozenRates: Map | null, containerTypeId: string, bookingCurrency: string, + usdToEtb: number, ): Promise { if (!frozenRates) return null; let sizeFt: number | null = null; @@ -942,7 +970,7 @@ export class BookingPricingService { return null; } if (!sizeFt) return null; - return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency); + return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency, usdToEtb); } /** @@ -985,7 +1013,7 @@ export class BookingPricingService { const hasPerSizeSnapshot = frozenRates?.has('CUSTOMS_CLEARANCE_20FT') || frozenRates?.has('CUSTOMS_CLEARANCE_40FT'); - const legacyFlat = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency); + const legacyFlat = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency, usdToEtb); if (legacyFlat && !hasPerSizeSnapshot) { const amount = Number(legacyFlat.unitPrice); if (amount > 0) { @@ -1014,7 +1042,7 @@ export class BookingPricingService { // unknown type — falls through to the live per-type lookup below } const frozen = sizeFt - ? this.frozenRateByCode(frozenRates, `CUSTOMS_CLEARANCE_${sizeFt}FT`, currency) + ? this.frozenRateByCode(frozenRates, `CUSTOMS_CLEARANCE_${sizeFt}FT`, currency, usdToEtb) : null; const live = onLeg.find((r) => r.containerTypeId === bc.containerTypeId); if (!frozen && !live) { @@ -1049,7 +1077,7 @@ export class BookingPricingService { // flat snapshot share the CUSTOMS_CLEARANCE code; both are the agreed fee. // Live lookup: the rate scoped to the booking's commodity wins; a // commodity-less rate (legacy) is the catch-all fallback. - const frozen = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency); + const frozen = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency, usdToEtb); const live = (booking.cargoTypeId ? onLeg.find( 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 c292a0395..4ebfc9fab 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -916,14 +916,15 @@ export class BookingsController { async uploadBookingDeliveryOrder( @Param('id', ParseUUIDPipe) id: string, @UploadedFile() file: Express.Multer.File, - @Body('vesselDepartureDate') vesselDepartureDate: string | undefined, + @Body('vesselArrivalDate') vesselArrivalDate: string | undefined, + @Body('doCollectedDate') doCollectedDate: string | undefined, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingClearanceService.uploadDeliveryOrder( id, file, resolveAuthUserId(user), - vesselDepartureDate, + { vesselArrivalDate, doCollectedDate }, ); return this.transitionService.enrichBookingResponse(booking); } 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 590bd7262..a0b3b4e0e 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -123,7 +123,9 @@ export class BookingsRepository extends BaseRepository { 'booking.files', FileRecord, 'file', - "file.resource_id = booking.id AND file.resource = 'bookings'", + // Superseded versions are soft-deleted, not dropped — keep them out of + // the live file list (a manual join condition is not filtered for us). + "file.resource_id = booking.id AND file.resource = 'bookings' AND file.deleted_at IS NULL", ) .getOne(); diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 821b9c9f6..404cf0970 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -526,6 +526,14 @@ export class Booking extends BaseEntity { @Column({ name: 'vessel_departure_date', type: 'date', nullable: true }) vesselDepartureDate?: string | null; + /** Import DO: when the vessel arrived in Djibouti. Required on DO upload. */ + @Column({ name: 'vessel_arrival_date', type: 'date', nullable: true }) + vesselArrivalDate?: string | null; + + /** Import DO: when GL Djibouti collected the DO. Required on DO upload. */ + @Column({ name: 'do_collected_date', type: 'date', nullable: true }) + doCollectedDate?: string | null; + @Column({ name: 'ro_amendment_requested_at', type: 'timestamptz', nullable: true }) roAmendmentRequestedAt?: Date | null; 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 810232993..69773010c 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 @@ -19,6 +19,7 @@ import { } from './entities/clearance-milestone.entity'; import { Booking } from '../bookings/entities/booking.entity'; import { clearanceCodesForBooking } from '../bookings/clearance.util'; +import { assertDoCollectionDates } from './contract-clearance.util'; import { ClearanceWorkflowService } from './clearance-workflow.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { GlOperationsService } from './gl-operations.service'; @@ -64,6 +65,9 @@ export interface BookingClearanceView { roHold?: boolean; roHoldReason?: string | null; vesselDepartureDate?: string | null; + /** Import DO dates recorded by GL Djibouti on upload. */ + vesselArrivalDate?: string | null; + doCollectedDate?: string | null; roAmendmentRequestedAt?: string | null; operationReady?: boolean; preClearanceFinalized?: boolean; @@ -261,6 +265,8 @@ export class BookingClearanceService { roHold: Boolean(booking.roHoldReason), roHoldReason: booking.roHoldReason ?? null, vesselDepartureDate: booking.vesselDepartureDate ?? null, + vesselArrivalDate: booking.vesselArrivalDate ?? null, + doCollectedDate: booking.doCollectedDate ?? null, roAmendmentRequestedAt: booking.roAmendmentRequestedAt ? booking.roAmendmentRequestedAt.toISOString() : null, @@ -547,7 +553,7 @@ export class BookingClearanceService { bookingId: string, file: Express.Multer.File, userId?: string, - vesselDepartureDate?: string, + dates?: { vesselArrivalDate?: string; doCollectedDate?: string }, ): Promise { const booking = await this.loadBooking(bookingId); if (booking.tradeDirection !== 'IMPORT') { @@ -556,6 +562,8 @@ export class BookingClearanceService { if (!file) throw new BadRequestException('No Delivery Order uploaded'); + const { vesselArrivalDate, doCollectedDate } = assertDoCollectionDates(dates); + // DO upload is deliberately un-gated: GL Djibouti may attach it at any point, // any file type. The DO_COLLECTED milestone (and operation readiness) still // waits for GL Ethiopia to finalize pre-clearance so the workflow order holds. @@ -566,11 +574,10 @@ export class BookingClearanceService { file, }); - if (vesselDepartureDate?.trim()) { - await this.bookingsRepository.update(bookingId, { - vesselDepartureDate: vesselDepartureDate.trim(), - } as never); - } + await this.bookingsRepository.update(bookingId, { + vesselArrivalDate, + doCollectedDate, + } as never); if (booking.preClearanceFinalizedAt) { await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId); diff --git a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts index a17406d64..8d26799c5 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts @@ -30,18 +30,35 @@ export class BookingRequestService { private readonly notifier: ContractNotifierService, ) {} - /** Only GENERAL contracts that bundle customs use the request → GL → clearance flow. */ - private assertGeneralCustoms(contract: Contract): void { - if ( - contract.contractKind !== 'GENERAL' || - !contract.customsClearingEnabled - ) { + /** + * Shipment requests exist because on a CUSTOMS contract the customer never + * books directly — GL Ethiopia does it for them. The request is how the + * customer states what to ship and, now, which currency to be invoiced in. + * + * GENERAL: each request opens its own per-booking clearance instance. + * ONE_TIME: clearance already ran at the contract level, so the request only + * records the customer's intent; GL creates the single booking from it. + */ + private assertCustomsContract(contract: Contract): void { + if (!contract.customsClearingEnabled) { throw new BadRequestException( - 'Shipment requests apply only to general customs-clearance contracts.', + 'Shipment requests apply only to customs-clearance contracts.', ); } } + /** + * Statuses in which a ONE_TIME customs contract may take a shipment request: + * both signatures are in and the contract is at (or past) its clearance + * phase, but GL has not booked yet. + */ + private static readonly ONE_TIME_REQUESTABLE_STATUSES = [ + 'FULLY_EXECUTED', + 'AWAITING_CLEARANCE_DOCUMENTS', + 'CLEARANCE_UNDER_REVIEW', + 'CLEARANCE_READY_FOR_BOOKING', + ]; + /** Customer submits a shipment request. */ async submit( contractId: string, @@ -50,13 +67,35 @@ export class BookingRequestService { ): Promise { const contract = await this.contractsService.findById(contractId); await this.contractsService.assertCustomerCanAccessContract(userId, contract); - this.assertGeneralCustoms(contract); + this.assertCustomsContract(contract); + const isOneTime = contract.contractKind === 'ONE_TIME'; + if (contract.status === 'CONTRACT_CLOSED') { throw new ConflictException( 'This contract is completed — the full contracted quantity has been booked.', ); } - if (contract.status !== 'CONTRACT_ACTIVE') { + if (isOneTime) { + if ( + !BookingRequestService.ONE_TIME_REQUESTABLE_STATUSES.includes( + contract.status, + ) + ) { + throw new ConflictException( + 'The contract must be fully executed before requesting its shipment.', + ); + } + // A one-time contract carries exactly one shipment, so it carries at most + // one open request — otherwise GL sees two conflicting currencies. + const open = (await this.repo.findForContract(contractId)).find( + (r) => r.status === 'PENDING', + ); + if (open) { + throw new ConflictException( + `Shipment request ${open.reference} is already open on this contract.`, + ); + } + } else if (contract.status !== 'CONTRACT_ACTIVE') { throw new ConflictException( 'The contract must be active before requesting a shipment.', ); @@ -90,10 +129,14 @@ export class BookingRequestService { } } } - await this.contractBookingService.assertRequestWithinCapacity(contract, { - containers: dto.containers, - bulk: dto.bulk, - }); + // Draw-down capacity is a GENERAL concept — a ONE_TIME contract's single + // shipment is bounded by the contract scope itself, checked when GL books. + if (!isOneTime) { + await this.contractBookingService.assertRequestWithinCapacity(contract, { + containers: dto.containers, + bulk: dto.bulk, + }); + } const requestedLines: Freight.RequestedShipmentLines = isContainer ? { @@ -119,10 +162,17 @@ export class BookingRequestService { // reviews the documents in the clearance queue and completes the booking // (container numbers, VGM, shipment day) once clearance is ready. The // instance is created first so a failure leaves no half-linked request. - const booking = await this.contractBookingService.initiateForShipmentRequest( - contract, - { contractRouteId: dto.contractRouteId, userId }, - ); + // GENERAL: the request immediately opens a BARE booking instance that runs + // per-booking phased customs clearance. ONE_TIME: clearance already ran on + // the contract, so there is nothing to open — the request stays PENDING + // until GL creates the contract's single booking from it. + const booking = isOneTime + ? null + : await this.contractBookingService.initiateForShipmentRequest(contract, { + contractRouteId: dto.contractRouteId, + userId, + paymentCurrency: dto.paymentCurrency, + }); const reference = await this.generateReference(); const request = await this.repo.create({ @@ -131,9 +181,14 @@ export class BookingRequestService { requestedByUserId: userId ?? null, contractRouteId: dto.contractRouteId ?? null, scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null, - status: 'ACCEPTED', - createdBookingId: booking.id, + status: booking ? 'ACCEPTED' : 'PENDING', + createdBookingId: booking?.id ?? null, requestedLines, + // Intercity is invoiced in birr whatever the customer picked. + paymentCurrency: + contract.tradeDirection === 'DOMESTIC' + ? 'ETB' + : (dto.paymentCurrency ?? contract.paymentCurrency ?? 'USD'), notes: dto.notes ?? null, } as never); this.notifier.shipmentRequestedToStaff(contract, request.id, request.reference); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 8e102e5a1..859c4e391 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -277,7 +277,7 @@ export class ContractBookingService { createdByUserId: user?.id ?? null, scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null, serviceTypeId: contract.serviceTypeId, - paymentCurrency: contract.paymentCurrency, + paymentCurrency: this.resolveShipmentCurrency(contract, dto.paymentCurrency), contractType: 'NEW', customsClearingEnabled: contract.customsClearingEnabled, customsClearingAgent: contract.customsClearingAgent ?? null, @@ -481,7 +481,7 @@ export class ContractBookingService { createdByUserId: user?.id ?? null, scheduledDate: null, serviceTypeId: contract.serviceTypeId, - paymentCurrency: contract.paymentCurrency, + paymentCurrency: this.resolveShipmentCurrency(contract, null), contractType: 'NEW', customsClearingEnabled: contract.customsClearingEnabled, customsClearingAgent: contract.customsClearingAgent ?? null, @@ -520,7 +520,12 @@ export class ContractBookingService { */ async initiateForShipmentRequest( contract: Contract, - opts: { contractRouteId?: string; userId?: string | null }, + opts: { + contractRouteId?: string; + userId?: string | null; + /** Billing currency the customer chose on the shipment request. */ + paymentCurrency?: string | null; + }, ): Promise { const generalCustoms = contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled); @@ -555,7 +560,7 @@ export class ContractBookingService { createdByUserId: opts.userId ?? null, scheduledDate: null, serviceTypeId: contract.serviceTypeId, - paymentCurrency: contract.paymentCurrency, + paymentCurrency: this.resolveShipmentCurrency(contract, opts?.paymentCurrency), contractType: 'NEW', customsClearingEnabled: contract.customsClearingEnabled, customsClearingAgent: contract.customsClearingAgent ?? null, @@ -733,6 +738,13 @@ export class ContractBookingService { cargoFreeText: dto.cargoFreeText?.trim() || null, cargoTotalWeightVgm: this.resolveBulkTons(dto), equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto), + // Completion is where the cargo — and therefore the price — is fixed, so + // it is also where the billing currency is chosen. A bare instance was + // created before the customer had any figure to look at. + paymentCurrency: this.resolveShipmentCurrency( + contract, + dto.paymentCurrency ?? booking.paymentCurrency, + ), } as never); const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id); @@ -1565,6 +1577,23 @@ export class ContractBookingService { * booking-level override (dto.equipmentReturn ?? contract default) applies. * Bulk freight keeps the legacy behaviour untouched. */ + /** + * The billing currency for a shipment under this contract. + * + * A contract quotes in USD only — the currency is a per-shipment choice now. + * Precedence: intercity is always ETB (domestic transport is invoiced in + * birr), then the customer's explicit choice, then the contract's own + * currency, which is USD for contracts created under the current rule and the + * grandfathered value for older ones. + */ + private resolveShipmentCurrency( + contract: Contract, + requested?: string | null, + ): string { + if (contract.tradeDirection === 'DOMESTIC') return 'ETB'; + return requested?.trim() || contract.paymentCurrency || 'USD'; + } + private resolveShipmentEquipmentReturn( contract: Contract, dto: CreateBookingUnderContractDto, @@ -1795,7 +1824,7 @@ export class ContractBookingService { contractId: contract.id, freightType: contract.freightType, tradeDirection: contract.tradeDirection, - paymentCurrency: contract.paymentCurrency, + paymentCurrency: this.resolveShipmentCurrency(contract, dto.paymentCurrency), serviceTypeId: contract.serviceTypeId, cargoTypeId: this.resolveCargoTypeId(contract, dto), isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'), diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index 88cb2149b..0ceea38ed 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -1,4 +1,9 @@ -import { BadRequestException, ConflictException, Injectable } from '@nestjs/common'; +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { ContractDocPhase, type ClearanceFinalInvoiceSummary, @@ -13,7 +18,10 @@ import { FilesService } from '../files/files.service'; import { ContractsRepository } from './contracts.repository'; import { ContractsService, PaginatedContracts } from './contracts.service'; import { BookingsService } from '../bookings/bookings.service'; -import { contractClearanceCodes } from './contract-clearance.util'; +import { + assertDoCollectionDates, + contractClearanceCodes, +} from './contract-clearance.util'; import { ClearanceWorkflowService } from './clearance-workflow.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ContractNotifierService } from './contract-notifier.service'; @@ -72,9 +80,23 @@ export interface ContractClearanceView { blockedReason?: string | null; } | null; dutyRequired?: boolean | null; + /** + * Pre-declaration handshake with GL Djibouti: who will handle the shipment in + * transit. `name` is null until Djibouti answers, and the declaration step is + * shut until it is set. + */ + transitAssignee?: { + requestedAt: string | null; + requestNote: string | null; + name: string | null; + assignedAt: string | null; + } | null; roHold?: boolean; roHoldReason?: string | null; vesselDepartureDate?: string | null; + /** Import DO dates recorded by GL Djibouti on upload. */ + vesselArrivalDate?: string | null; + doCollectedDate?: string | null; roAmendmentRequestedAt?: string | null; bookingReady?: boolean; preClearanceFinalized?: boolean; @@ -98,6 +120,16 @@ export interface ContractClearanceView { declarationSerial?: string | null; noticeFile?: { id: string; name: string; url: string } | null; } | null; + /** + * The customer's open objection to the advised duty — present only while GL + * has not re-advised (the advice milestone is back to PENDING). `rounds` is + * how many times it has been sent back, so both sides can see the loop. + */ + dutyDispute?: { + note: string; + raisedAt: string; + rounds: number; + } | null; workflowFiles?: ReturnType; /** Import post-allocation T1 transit document state (null until a booking is linked). */ t1?: ClearanceT1State | null; @@ -247,6 +279,19 @@ export class ContractClearanceService { contract = await this.reconcilePrematureBookingReady(contractId, contract, boundary); const phase = this.workflowService.resolvePhase(contract, cycle, milestones); const dutyAdvice = this.buildDutyAdvice(files, milestones); + const dutyDispute = await this.buildDutyDispute(contractId, milestones); + const transitAssignee = cycle + ? { + requestedAt: cycle.transitAssigneeRequestedAt + ? cycle.transitAssigneeRequestedAt.toISOString() + : null, + requestNote: cycle.transitAssigneeRequestNote ?? null, + name: cycle.transitAssigneeName ?? null, + assignedAt: cycle.transitAssigneeAssignedAt + ? cycle.transitAssigneeAssignedAt.toISOString() + : null, + } + : null; let workflowFiles = buildWorkflowFiles( files, contract.tradeDirection ?? 'IMPORT', @@ -361,6 +406,8 @@ export class ContractClearanceService { roHold: Boolean(cycle?.roHoldReason), roHoldReason: cycle?.roHoldReason ?? null, vesselDepartureDate: cycle?.vesselDepartureDate ?? null, + vesselArrivalDate: cycle?.vesselArrivalDate ?? null, + doCollectedDate: cycle?.doCollectedDate ?? null, roAmendmentRequestedAt: cycle?.roAmendmentRequestedAt ? cycle.roAmendmentRequestedAt.toISOString() : null, @@ -373,6 +420,8 @@ export class ContractClearanceService { linkedBookingReviewNote, linkedBookingScheduledDate, dutyAdvice, + dutyDispute, + transitAssignee, workflowFiles, t1, train, @@ -429,6 +478,32 @@ export class ContractClearanceService { }; } + /** + * The customer's duty objection, but only while it is still OPEN — i.e. the + * advice milestone sits back at PENDING because nobody has re-advised yet. + * Re-advising completes that milestone again, which closes the dispute here + * without any extra state to keep in sync; the notes stay as the audit trail + * and their count is the round number. + */ + private async buildDutyDispute( + contractId: string, + milestones: ClearanceMilestone[], + ): Promise { + const advised = milestones.find((m) => m.milestoneCode === 'DUTY_TAXES_ADVISED'); + if (!advised || advised.status === 'COMPLETED') return null; + const notes = await this.contractsRepository.findReviewNotes( + contractId, + 'DUTY_DISPUTE', + ); + const latest = notes[0]; + if (!latest) return null; + return { + note: latest.body, + raisedAt: latest.createdAt.toISOString(), + rounds: notes.length, + }; + } + /** * True when every REQUIRED customer-input field has an APPROVED review row in * the current cycle. The 100% gate before clearance can be finalized. @@ -654,6 +729,92 @@ export class ContractClearanceService { return this.applyReview(contractId, fileKey, status, staffId, 'OPERATIONS', note); } + /** + * GL corrects a clearance document in place instead of bouncing it back to + * the customer. The customer's upload is NOT lost — it is retired into the + * document's version history, stamped with who replaced it and why — and the + * new version starts unreviewed, so GL still has to approve it (or query it) + * before clearance can be finalized. + * + * Use this for the small fixes staff can make faster than the customer can + * (a wrong page order, a missing stamp scan); a query is still the right tool + * when only the customer can produce the correct document. + */ + async replaceDocument( + contractId: string, + fileKey: string, + file: Express.Multer.File, + staffId: string, + reason?: string, + ): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertClearanceReviewableStatus(contract); + if (!file) throw new BadRequestException('No replacement file uploaded'); + if (!reason?.trim()) { + throw new BadRequestException( + 'Say why the document is being replaced — it is kept on the file history.', + ); + } + + const cycle = await this.contractsRepository.currentCycle(contractId); + if (this.isPhasedCustoms(contract) && cycle?.preClearanceFinalizedAt) { + throw new BadRequestException( + 'Documents cannot be changed after pre-clearance is finalized.', + ); + } + + const existing = await this.filesService.findByCode( + contractId, + 'contracts', + fileKey, + ); + if (!existing) { + throw new NotFoundException( + `No document is stored under "${fileKey}" on this contract.`, + ); + } + + await this.filesService.upsertByCode( + { resourceId: contractId, resource: 'contracts', code: fileKey, file }, + { userId: staffId, reason: reason.trim() }, + ); + + // A fresh version is unreviewed by definition: clear any earlier verdict so + // the corrected file is signed off explicitly rather than inheriting a tick. + const { inputCode, outputCode } = contractClearanceCodes(contract); + const reviews = await this.contractsRepository.findDocumentReviews( + contractId, + cycle?.id ?? null, + ); + const settingCode = + reviews.find((r) => r.fileKey === fileKey)?.settingCode ?? + (fileKey.startsWith('custom_') ? 'custom' : (inputCode ?? outputCode ?? 'custom')); + await this.contractsRepository.setDocumentReviewStatus({ + contractId, + clearanceCycleId: cycle?.id ?? null, + settingCode, + fileKey, + status: 'PENDING', + staffId, + note: `Replaced by staff: ${reason.trim()}`, + }); + + await this.contractsRepository.createReviewNote( + contractId, + `Document "${fileKey}" replaced by staff: ${reason.trim()}`, + 'STAFF_NOTE', + staffId, + 'GL_ET', + ); + + return this.contractsService.findById(contractId); + } + + /** Every stored version of one clearance document, newest first. */ + async documentVersions(contractId: string, fileKey: string) { + return this.filesService.versionHistory(contractId, 'contracts', fileKey); + } + private async applyReview( contractId: string, fileKey: string, @@ -968,6 +1129,68 @@ export class ContractClearanceService { // ── Phased clearance actions (ONE_TIME customs, Phase 1) ─────────────────── /** Sync DOCUMENTS_APPROVED when reviews are done but the milestone row lags. */ + /** + * GL Ethiopia asks Djibouti to name the officer who will handle the shipment + * in transit. Nothing else moves until Djibouti answers — the declaration is + * gated on it — so this is the first thing ET does once the documents are + * approved. Re-requesting is allowed (a nudge) and simply restamps the ask. + */ + async requestTransitAssignee( + contractId: string, + note: string | undefined, + userId?: string, + ): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + const cycle = await this.contractsRepository.currentCycle(contractId); + if (!cycle) throw new BadRequestException('No clearance cycle found'); + + await this.contractsRepository.updateCycle(cycle.id, { + transitAssigneeRequestedAt: new Date(), + transitAssigneeRequestedByUserId: userId ?? null, + transitAssigneeRequestNote: note?.trim() || null, + }); + + const updated = await this.contractsService.findById(contractId); + this.notifier.transitAssigneeRequested(updated, note?.trim() ?? null); + return updated; + } + + /** + * GL Djibouti names the transit officer — free text, because the person is + * not a platform user. Answering unblocks the declaration for Ethiopia. A + * later call overwrites the name (reassignment) and re-notifies. + */ + async assignTransitAssignee( + contractId: string, + assignee: string, + userId?: string, + ): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + if (!assignee?.trim()) { + throw new BadRequestException('Name the officer who will handle the transit.'); + } + const cycle = await this.contractsRepository.currentCycle(contractId); + if (!cycle) throw new BadRequestException('No clearance cycle found'); + if (!cycle.transitAssigneeRequestedAt) { + throw new BadRequestException( + 'GL Ethiopia has not requested a transit assignee for this clearance yet.', + ); + } + + const previous = cycle.transitAssigneeName ?? null; + await this.contractsRepository.updateCycle(cycle.id, { + transitAssigneeName: assignee.trim(), + transitAssigneeAssignedAt: new Date(), + transitAssigneeAssignedByUserId: userId ?? null, + }); + + const updated = await this.contractsService.findById(contractId); + this.notifier.transitAssigneeAssigned(updated, assignee.trim(), previous); + return updated; + } + private async ensureDeclarationPrerequisites( contractId: string, contract: Contract, @@ -978,6 +1201,16 @@ export class ContractClearanceService { 'All required customer documents must be approved before uploading a declaration.', ); } + // The transit officer must be named by Djibouti first — the declaration is + // filed against whoever will physically handle the shipment there. + const cycle = await this.contractsRepository.currentCycle(contractId); + if (!cycle?.transitAssigneeName) { + throw new BadRequestException( + cycle?.transitAssigneeRequestedAt + ? 'GL Djibouti has not assigned the transit officer yet — the declaration cannot be filed until they do.' + : 'Request a transit assignee from GL Djibouti before filing the customs declaration.', + ); + } const milestones = await this.workflowService.listMilestones(contractId); const docsApproved = milestones.find((m) => m.milestoneCode === 'DOCUMENTS_APPROVED'); if (docsApproved?.status !== 'COMPLETED' && docsApproved?.status !== 'SKIPPED') { @@ -1084,6 +1317,67 @@ export class ContractClearanceService { return this.contractsService.findById(contractId); } + /** + * The customer disagrees with the advised duty & tax and asks GL Ethiopia to + * correct it. Nothing is paid; the advice milestone reopens so the Duty & tax + * step becomes actionable again on the GL clearance page, with the customer's + * message shown beside it. GL re-advises (same endpoint as the first time), + * which closes the dispute — the loop may run as many rounds as it takes. + */ + async disputeDuty( + contractId: string, + note: string, + userId?: string, + ): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + if (contract.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Duty applies only to import contracts.'); + } + if (!note?.trim()) { + throw new BadRequestException( + 'Say what is wrong with the advised amount so GL can correct it.', + ); + } + + const cycle = await this.contractsRepository.currentCycle(contractId); + if (!cycle?.dutyRequired) { + throw new BadRequestException('Duty/tax is not required for this clearance.'); + } + const milestones = await this.workflowService.listMilestones(contractId); + const byCode = new Map(milestones.map((m) => [m.milestoneCode, m])); + if (byCode.get('DUTY_TAXES_ADVISED')?.status !== 'COMPLETED') { + throw new BadRequestException( + 'There is no advised duty amount to dispute yet.', + ); + } + // Once the slip is in, the money is paid — a dispute then is a refund + // conversation, not a re-advice. + if (byCode.get('DUTY_TAX_PAID')?.status === 'COMPLETED') { + throw new BadRequestException( + 'The duty payment slip has already been submitted — contact GL Ethiopia directly.', + ); + } + + await this.contractsRepository.createReviewNote( + contractId, + note.trim(), + 'DUTY_DISPUTE', + userId, + 'CUSTOMER', + ); + // Back to GL: reopening the milestone is what re-arms the Duty & tax step + // (the stepper picks its active step from milestone completion). + await this.milestoneService.reopenForContract(contractId, 'DUTY_TAXES_ADVISED'); + await this.contractsRepository.updateCycle(cycle.id, { + currentPhase: ContractDocPhase.GlEtOutput, + }); + + const updated = await this.contractsService.findById(contractId); + this.notifier.dutyDisputed(updated, note.trim()); + return updated; + } + async uploadDutySlip( contractId: string, file: Express.Multer.File, @@ -1198,7 +1492,7 @@ export class ContractClearanceService { contractId: string, file: Express.Multer.File, userId?: string, - vesselDepartureDate?: string, + dates?: { vesselArrivalDate?: string; doCollectedDate?: string }, ): Promise { const contract = await this.contractsService.findById(contractId); this.assertPhasedCustoms(contract); @@ -1208,6 +1502,8 @@ export class ContractClearanceService { if (!file) throw new BadRequestException('No Delivery Order uploaded'); + const { vesselArrivalDate, doCollectedDate } = assertDoCollectionDates(dates); + // DO upload is deliberately un-gated: GL Djibouti may attach it at any point, // any file type. The DO_COLLECTED milestone (and booking readiness) still waits // for GL Ethiopia to finalize pre-clearance so the workflow order holds. @@ -1219,9 +1515,10 @@ export class ContractClearanceService { }); const cycle = await this.contractsRepository.currentCycle(contractId); - if (cycle && vesselDepartureDate?.trim()) { + if (cycle) { await this.contractsRepository.updateCycle(cycle.id, { - vesselDepartureDate: vesselDepartureDate.trim(), + vesselArrivalDate, + doCollectedDate, }); } if (cycle?.preClearanceFinalizedAt) { diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts index 2d26f51dc..512ce5288 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts @@ -1,3 +1,5 @@ +import { BadRequestException } from '@nestjs/common'; + import { Contract } from './entities/contract.entity'; import { INTERCITY_DOCUMENTS_SETTING_CODE } from '../bookings/clearance.util'; @@ -84,3 +86,47 @@ export function contractClearanceCodes(contract: Contract): { includesCustoms, }; } + +/** + * Djibouti GL cannot record a Delivery Order without saying WHEN the vessel + * arrived and WHEN the DO was collected — the file alone leaves the import + * timeline unauditable. Shared by the contract and per-booking DO uploads so + * one endpoint can never be laxer than the other. + * + * Returns the normalized `YYYY-MM-DD` pair; throws if either is missing, + * unparseable, or the DO predates the vessel's arrival. + */ +export function assertDoCollectionDates(dates?: { + vesselArrivalDate?: string; + doCollectedDate?: string; +}): { vesselArrivalDate: string; doCollectedDate: string } { + const vesselArrivalDate = normalizeDoDate( + dates?.vesselArrivalDate, + 'Vessel arrival date', + ); + const doCollectedDate = normalizeDoDate( + dates?.doCollectedDate, + 'DO collected date', + ); + + if (doCollectedDate < vesselArrivalDate) { + throw new BadRequestException( + 'DO collected date cannot be earlier than the vessel arrival date.', + ); + } + + return { vesselArrivalDate, doCollectedDate }; +} + +/** `YYYY-MM-DD` or throw — the column is a DATE, so time zones never enter. */ +function normalizeDoDate(value: string | undefined, label: string): string { + const trimmed = value?.trim(); + if (!trimmed) { + throw new BadRequestException(`${label} is required to upload a Delivery Order.`); + } + const date = trimmed.slice(0, 10); + if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || Number.isNaN(Date.parse(date))) { + throw new BadRequestException(`${label} is not a valid date.`); + } + return date; +} diff --git a/apps/edr-freight-api/src/modules/contracts/contract-document-diff.util.ts b/apps/edr-freight-api/src/modules/contracts/contract-document-diff.util.ts index 154777aea..ac2c12075 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-document-diff.util.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-document-diff.util.ts @@ -25,7 +25,18 @@ export type ContractDocumentChange = toOrder: number; } | { kind: 'DOCUMENT_TITLE_CHANGED'; title: string; fromTitle: string | null } - | { kind: 'WHEREAS_CHANGED'; added: number; removed: number }; + | { kind: 'WHEREAS_CHANGED'; added: number; removed: number } + /** + * A contract field (not a document article) changed — the customer editing a + * DRAFT/CHANGES_REQUESTED contract, e.g. its route, cargo or service type. + */ + | { + kind: 'FIELD_CHANGED'; + field: string; + label: string; + from: string | null; + to: string | null; + }; type SnapshotLike = Pick< ContractDocumentSnapshot, @@ -134,6 +145,60 @@ export function diffSnapshots( return changes; } +/** Human label per audited contract field, in the order they read on the form. */ +export const CONTRACT_FIELD_LABELS: Record = { + contractKind: 'Contract kind', + tradeDirection: 'Trade direction', + freightType: 'Freight type', + serviceType: 'Service type', + paymentCurrency: 'Payment currency', + contractType: 'Contract type', + isHazardous: 'Hazardous', + hazardClass: 'Hazard class', + unNumber: 'UN number', + isReefer: 'Reefer', + equipmentReturn: 'Equipment return', + customsClearingAgent: 'Customs clearing agent', + firstMilePickupAddress: 'First-mile pickup address', + lastMileDeliveryAddress: 'Last-mile delivery address', + routes: 'Routes', + cargoScope: 'Cargo scope', +}; + +/** Render a field value for the audit trail — never "[object Object]". */ +function displayValue(value: unknown): string | null { + if (value === null || value === undefined || value === '') return null; + if (typeof value === 'boolean') return value ? 'Yes' : 'No'; + return String(value); +} + +/** + * Compare two flat maps of contract fields and report what changed. Only keys + * present in `after` are considered, so a partial update never reports the + * fields it did not touch. + */ +export function diffContractFields( + before: Record, + after: Record, +): ContractDocumentChange[] { + const changes: ContractDocumentChange[] = []; + + for (const [field, nextRaw] of Object.entries(after)) { + const next = displayValue(nextRaw); + const previous = displayValue(before[field]); + if (next === previous) continue; + changes.push({ + kind: 'FIELD_CHANGED', + field, + label: CONTRACT_FIELD_LABELS[field] ?? field, + from: previous, + to: next, + }); + } + + return changes; +} + /** Short human summary of a change set, e.g. "2 articles edited, 1 article added". */ export function summarizeChanges(changes: ContractDocumentChange[]): string { if (changes.length === 0) return 'No changes'; @@ -148,6 +213,7 @@ export function summarizeChanges(changes: ContractDocumentChange[]): string { const counts = new Map(); const parts: string[] = []; + const fields: string[] = []; for (const change of changes) { const verb = articleVerbs[change.kind]; @@ -157,9 +223,19 @@ export function summarizeChanges(changes: ContractDocumentChange[]): string { parts.push('document title changed'); } else if (change.kind === 'WHEREAS_CHANGED') { parts.push('recitals changed'); + } else if (change.kind === 'FIELD_CHANGED') { + fields.push(change.label.toLowerCase()); } } + if (fields.length > 0) { + parts.push( + fields.length <= 3 + ? `${fields.join(', ')} changed` + : `${fields.length} contract fields changed`, + ); + } + const articleParts = [...counts.entries()].map( ([verb, count]) => `${count} article${count === 1 ? '' : 's'} ${verb}`, ); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts index 2808ea6cf..e38f737c6 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts @@ -1,8 +1,12 @@ import { Injectable, Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; +import { DataSource, Repository } from 'typeorm'; -import { diffSnapshots, summarizeChanges } from './contract-document-diff.util'; +import { + ContractDocumentChange, + diffSnapshots, + summarizeChanges, +} from './contract-document-diff.util'; import { ContractDocumentRevision } from './entities/contract-document-revision.entity'; import type { ContractDocumentSnapshot } from './entities/contract.entity'; @@ -12,6 +16,39 @@ export interface RecordRevisionInput { after: ContractDocumentSnapshot | null; actorId?: string | null; actorRole?: string | null; + actorName?: string | null; + stepId?: string | null; +} + +/** + * `iam.users.name` is a localized object ({ en, am, … }), not a string — a + * plain `String(name)` there yields "[object Object]" in the audit trail. + */ +interface IamUserRow { + name?: Record | string | null; + username?: string | null; + email?: string | null; +} + +/** Best display name for a user row: English label → any locale → login → email. */ +function pickUserName(user: IamUserRow): string | null { + const { name } = user; + if (typeof name === 'string' && name.trim()) return name.trim(); + if (name && typeof name === 'object') { + const localized = + name.en ?? Object.values(name).find((v) => typeof v === 'string' && v.trim()); + if (localized?.trim()) return localized.trim(); + } + return user.username?.trim() || user.email?.trim() || null; +} + +/** Pre-computed changes (contract fields), rather than a document diff. */ +export interface RecordChangesInput { + contractId: string; + changes: ContractDocumentChange[]; + actorId?: string | null; + actorRole?: string | null; + actorName?: string | null; stepId?: string | null; } @@ -22,6 +59,7 @@ export class ContractDocumentHistoryService { constructor( @InjectRepository(ContractDocumentRevision) private readonly revisionRepo: Repository, + @InjectDataSource() private readonly dataSource: DataSource, ) {} /** @@ -30,18 +68,32 @@ export class ContractDocumentHistoryService { * and swallowed. A no-op edit records nothing. */ async record(input: RecordRevisionInput): Promise { + return this.recordChanges({ + ...input, + changes: diffSnapshots(input.before, input.after), + }); + } + + /** + * Append a revision from an already-computed change set — the contract-field + * path, where there is no document snapshot to diff. Same best-effort + * contract as {@link record}: a no-op change set records nothing, and a + * failure here never breaks the edit that triggered it. + */ + async recordChanges(input: RecordChangesInput): Promise { try { - const changes = diffSnapshots(input.before, input.after); - if (changes.length === 0) return; + if (input.changes.length === 0) return; await this.revisionRepo.save( this.revisionRepo.create({ contractId: input.contractId, actorId: input.actorId ?? null, actorRole: input.actorRole ?? null, + actorName: + input.actorName ?? (await this.resolveActorName(input.actorId)), stepId: input.stepId ?? null, - summary: summarizeChanges(changes), - changes, + summary: summarizeChanges(input.changes), + changes: input.changes, }), ); } catch (err) { @@ -51,11 +103,62 @@ export class ContractDocumentHistoryService { } } + /** + * Name for the acting user. `iam.users` is owned by the auth system and has + * no entity here, so it is read directly; a miss is not an error — the trail + * still carries the id, role and timestamp. + */ + private async resolveActorName( + actorId?: string | null, + ): Promise { + if (!actorId) return null; + const names = await this.resolveActorNames([actorId]); + return names.get(actorId) ?? null; + } + + /** Batched {@link resolveActorName} — one query for a whole revision list. */ + private async resolveActorNames( + actorIds: string[], + ): Promise> { + const resolved = new Map(); + const ids = [...new Set(actorIds.filter(Boolean))]; + if (ids.length === 0) return resolved; + + try { + const rows = (await this.dataSource.query( + `SELECT id, name, username, email FROM iam.users WHERE id = ANY($1::uuid[])`, + [ids], + )) as Array; + for (const row of rows) { + const name = pickUserName(row); + if (name) resolved.set(row.id, name); + } + } catch (err) { + this.logger.warn(`Could not resolve actor names: ${String(err)}`); + } + return resolved; + } + /** Revision history for a contract, newest first. */ - list(contractId: string): Promise { - return this.revisionRepo.find({ + async list(contractId: string): Promise { + const revisions = await this.revisionRepo.find({ where: { contractId }, order: { createdAt: 'DESC' }, }); + + // Rows written before actor_name existed still carry an actor_id — resolve + // those for display (one query for the whole list) rather than backfilling. + const missing = revisions + .filter((r) => !r.actorName && r.actorId) + .map((r) => r.actorId as string); + if (missing.length === 0) return revisions; + + const names = await this.resolveActorNames(missing); + for (const revision of revisions) { + if (!revision.actorName && revision.actorId) { + revision.actorName = names.get(revision.actorId) ?? null; + } + } + return revisions; } } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-duty-dispute.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-duty-dispute.spec.ts new file mode 100644 index 000000000..0ba682294 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-duty-dispute.spec.ts @@ -0,0 +1,164 @@ +import { BadRequestException } from '@nestjs/common'; + +import { ContractClearanceService } from './contract-clearance.service'; +import type { Contract } from './entities/contract.entity'; + +/** + * The duty advice → dispute → re-advice loop. GL Ethiopia advises an amount; + * the customer either pays it or sends it back with a reason. Sending it back + * reopens the advice milestone — that is what puts the Duty & tax step back in + * GL's hands — and the round can repeat until the amount is agreed. + */ +describe('ContractClearanceService — duty dispute', () => { + const contract = (over: Partial = {}): Contract => + ({ + id: 'ctr-1', + reference: 'CTR-2026-00042', + tradeDirection: 'IMPORT', + customsClearingEnabled: true, + contractKind: 'ONE_TIME', + ...over, + }) as Contract; + + const milestone = (code: string, status: string) => + ({ milestoneCode: code, status }) as never; + + let repo: { + currentCycle: jest.Mock; + createReviewNote: jest.Mock; + updateCycle: jest.Mock; + findReviewNotes: jest.Mock; + }; + let contractsService: { findById: jest.Mock }; + let workflowService: { listMilestones: jest.Mock }; + let milestoneService: { reopenForContract: jest.Mock }; + let notifier: { dutyDisputed: jest.Mock }; + let service: ContractClearanceService; + + const build = (milestones: unknown[]) => { + workflowService.listMilestones.mockResolvedValue(milestones); + }; + + beforeEach(() => { + repo = { + currentCycle: jest.fn().mockResolvedValue({ id: 'cyc-1', dutyRequired: true }), + createReviewNote: jest.fn().mockResolvedValue(undefined), + updateCycle: jest.fn().mockResolvedValue(undefined), + findReviewNotes: jest.fn().mockResolvedValue([]), + }; + contractsService = { findById: jest.fn().mockResolvedValue(contract()) }; + workflowService = { listMilestones: jest.fn().mockResolvedValue([]) }; + milestoneService = { reopenForContract: jest.fn().mockResolvedValue(undefined) }; + notifier = { dutyDisputed: jest.fn() }; + + service = new ContractClearanceService( + repo as never, + contractsService as never, + {} as never, // bookingsService + {} as never, // filesService + {} as never, // fileUploadSettingsService + workflowService as never, + milestoneService as never, + {} as never, // dropdownSettingsService + {} as never, // glOperationsService + notifier as never, + ); + build([ + milestone('DUTY_TAXES_ADVISED', 'COMPLETED'), + milestone('DUTY_TAX_PAID', 'PENDING'), + ]); + }); + + it('records the objection and hands the step back to GL', async () => { + await service.disputeDuty('ctr-1', ' Declared value is wrong ', 'user-1'); + + expect(repo.createReviewNote).toHaveBeenCalledWith( + 'ctr-1', + 'Declared value is wrong', + 'DUTY_DISPUTE', + 'user-1', + 'CUSTOMER', + ); + // Reopening the advice milestone is what re-arms the Duty & tax step. + expect(milestoneService.reopenForContract).toHaveBeenCalledWith( + 'ctr-1', + 'DUTY_TAXES_ADVISED', + ); + expect(repo.updateCycle).toHaveBeenCalledWith('cyc-1', { + currentPhase: 'GL_ET_OUTPUT', + }); + }); + + it('tells GL Ethiopia, not the customer', async () => { + await service.disputeDuty('ctr-1', 'Too high', 'user-1'); + expect(notifier.dutyDisputed).toHaveBeenCalledWith( + expect.objectContaining({ id: 'ctr-1' }), + 'Too high', + ); + }); + + it('requires a reason — GL cannot correct an unexplained objection', async () => { + await expect(service.disputeDuty('ctr-1', ' ')).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(milestoneService.reopenForContract).not.toHaveBeenCalled(); + }); + + it('refuses when nothing has been advised yet', async () => { + build([milestone('DUTY_TAXES_ADVISED', 'PENDING')]); + await expect(service.disputeDuty('ctr-1', 'Too high')).rejects.toThrow( + /no advised duty amount/i, + ); + }); + + it('refuses once the payment slip is in — that is a refund, not a re-advice', async () => { + build([ + milestone('DUTY_TAXES_ADVISED', 'COMPLETED'), + milestone('DUTY_TAX_PAID', 'COMPLETED'), + ]); + await expect(service.disputeDuty('ctr-1', 'Too high')).rejects.toThrow( + /already been submitted/i, + ); + }); + + it('refuses when duty was never required for this clearance', async () => { + repo.currentCycle.mockResolvedValue({ id: 'cyc-1', dutyRequired: false }); + await expect(service.disputeDuty('ctr-1', 'Too high')).rejects.toThrow( + /not required/i, + ); + }); + + describe('the view', () => { + const buildDispute = (milestones: unknown[]) => + ( + service as unknown as { + buildDutyDispute: (id: string, m: unknown[]) => Promise; + } + ).buildDutyDispute('ctr-1', milestones); + + it('shows the objection while GL still owes a corrected advice', async () => { + repo.findReviewNotes.mockResolvedValue([ + { body: 'Second look please', createdAt: new Date('2026-07-20T09:00:00Z') }, + { body: 'First objection', createdAt: new Date('2026-07-18T09:00:00Z') }, + ]); + + const dispute = await buildDispute([ + milestone('DUTY_TAXES_ADVISED', 'PENDING'), + ]); + + expect(dispute).toMatchObject({ note: 'Second look please', rounds: 2 }); + }); + + it('clears itself once GL re-advises', async () => { + repo.findReviewNotes.mockResolvedValue([ + { body: 'First objection', createdAt: new Date('2026-07-18T09:00:00Z') }, + ]); + + const dispute = await buildDispute([ + milestone('DUTY_TAXES_ADVISED', 'COMPLETED'), + ]); + + expect(dispute).toBeNull(); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-field-diff.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-field-diff.spec.ts new file mode 100644 index 000000000..8726f6c64 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-field-diff.spec.ts @@ -0,0 +1,89 @@ +import { + diffContractFields, + summarizeChanges, +} from './contract-document-diff.util'; + +/** + * The contract-field audit runs on the customer's own edits, so it has to be + * exact: never report a field the edit did not touch, and never render a value + * as "[object Object]" or "true" in the trail a reviewer reads. + */ +describe('diffContractFields', () => { + it('reports only the fields that actually changed', () => { + const changes = diffContractFields( + { freightType: 'BULK', paymentCurrency: 'USD', isReefer: false }, + { freightType: 'CONTAINER', paymentCurrency: 'USD', isReefer: false }, + ); + + expect(changes).toEqual([ + { + kind: 'FIELD_CHANGED', + field: 'freightType', + label: 'Freight type', + from: 'BULK', + to: 'CONTAINER', + }, + ]); + }); + + it('renders booleans as Yes/No, not true/false', () => { + const [change] = diffContractFields({ isHazardous: false }, { isHazardous: true }); + + expect(change).toMatchObject({ label: 'Hazardous', from: 'No', to: 'Yes' }); + }); + + it('treats null, undefined and empty string as "not set"', () => { + expect(diffContractFields({ unNumber: null }, { unNumber: '' })).toEqual([]); + expect(diffContractFields({ unNumber: undefined }, { unNumber: null })).toEqual([]); + + const [set] = diffContractFields({ unNumber: null }, { unNumber: 'UN1234' }); + expect(set).toMatchObject({ from: null, to: 'UN1234' }); + }); + + it('ignores fields absent from the update', () => { + // A partial edit must not report the fields it never sent. + expect(diffContractFields({ freightType: 'BULK', isReefer: true }, {})).toEqual([]); + }); + + it('records a route swap that keeps the same lane count', () => { + const [change] = diffContractFields( + { routes: 'Nagad → Mojo' }, + { routes: 'Nagad → Adama' }, + ); + + expect(change).toMatchObject({ + label: 'Routes', + from: 'Nagad → Mojo', + to: 'Nagad → Adama', + }); + }); + + it('summarises field changes by name, and by count once there are many', () => { + const few = diffContractFields( + { freightType: 'BULK', paymentCurrency: 'USD' }, + { freightType: 'CONTAINER', paymentCurrency: 'ETB' }, + ); + expect(summarizeChanges(few)).toBe('freight type, payment currency changed'); + + const many = diffContractFields( + { a: '1', b: '1', c: '1', d: '1' }, + { a: '2', b: '2', c: '2', d: '2' }, + ); + expect(summarizeChanges(many)).toBe('4 contract fields changed'); + }); + + it('summarises document and field changes together', () => { + const summary = summarizeChanges([ + { kind: 'ARTICLE_BODY_CHANGED', articleId: 'a-1', title: 'Article 1' }, + { + kind: 'FIELD_CHANGED', + field: 'routes', + label: 'Routes', + from: 'A → B', + to: 'A → C', + }, + ]); + + expect(summary).toBe('1 article edited, routes changed'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts index fd81083fc..11681a28f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts @@ -192,6 +192,56 @@ export class ContractNotifierService { }); } + /** + * GL Ethiopia asked Djibouti to name the transit officer. Staff-only, and + * deep-linked to the Djibouti clearance page where the name is entered — the + * customs declaration is blocked until they answer. + */ + transitAssigneeRequested(c: Contract, note: string | null): void { + const msg = + `GL Ethiopia needs a transit assignee for contract ${c.reference} before ` + + `the customs declaration can be filed.${note ? ` Note: "${note}"` : ''}`; + this.logger.log(`TRANSIT ASSIGNEE REQUESTED — ${c.reference}`); + this.inAppStaff(c, `Transit assignee needed — ${c.reference}`, msg, { + type: NotificationType.CLEARANCE_REVIEW, + link: `/dashboard/gl-djibouti/clearance/${c.id}`, + }); + } + + /** Djibouti named (or changed) the transit officer — Ethiopia can proceed. */ + transitAssigneeAssigned( + c: Contract, + assignee: string, + previous: string | null, + ): void { + const msg = previous + ? `GL Djibouti changed the transit assignee for contract ${c.reference} from ` + + `"${previous}" to "${assignee}".` + : `GL Djibouti assigned ${assignee} to handle contract ${c.reference} in transit. ` + + `The customs declaration can now be filed.`; + this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${c.reference}`); + this.inAppStaff(c, `Transit assignee set — ${c.reference}`, msg, { + type: NotificationType.CLEARANCE_REVIEW, + link: `/dashboard/contracts/clearance/${c.id}`, + }); + } + + /** + * The customer disputed the advised duty & tax. This goes to STAFF, not the + * customer: GL Ethiopia is the one who has to re-advise, and the clearance + * page is where they do it. + */ + dutyDisputed(c: Contract, note: string): void { + const msg = + `The customer disputed the duty & tax advised on contract ${c.reference}: ` + + `"${note}". Review and re-advise the amount on the clearance page.`; + this.logger.log(`DUTY DISPUTED — ${c.reference}`); + this.inAppStaff(c, `Duty disputed on ${c.reference}`, msg, { + type: NotificationType.CLEARANCE_REVIEW, + link: `/dashboard/contracts/clearance/${c.id}`, + }); + } + /** A clearance document was queried — customer must re-upload it. */ clearanceDocumentQueried(c: Contract, fileKey: string, note: string): void { const msg = diff --git a/apps/edr-freight-api/src/modules/contracts/contract-revision-actor.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-revision-actor.spec.ts new file mode 100644 index 000000000..3f579d32b --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-revision-actor.spec.ts @@ -0,0 +1,115 @@ +import { ContractDocumentHistoryService } from './contract-document-history.service'; + +/** + * `iam.users.name` is a localized jsonb object, not a string. Reading it + * naively puts "[object Object]" in the audit trail — or, worse, throws and + * leaves every revision anonymous. These specs pin the resolution rules. + */ +describe('ContractDocumentHistoryService actor names', () => { + const build = (rows: unknown[]) => { + const saved: Array> = []; + const service = Object.create( + ContractDocumentHistoryService.prototype, + ) as ContractDocumentHistoryService; + Object.assign(service, { + logger: { warn: jest.fn(), error: jest.fn() }, + dataSource: { query: jest.fn().mockResolvedValue(rows) }, + revisionRepo: { + create: (row: Record) => row, + save: jest.fn((row: Record) => { + saved.push(row); + return Promise.resolve(row); + }), + find: jest.fn().mockResolvedValue([]), + }, + }); + return { service, saved }; + }; + + const change = { + kind: 'FIELD_CHANGED' as const, + field: 'routes', + label: 'Routes', + from: 'A → B', + to: 'A → C', + }; + + it('prefers the English label from the localized name object', async () => { + const { service, saved } = build([ + { id: 'u-1', name: { am: 'ሱፐር አድሚን', en: 'Super Admin' }, username: 'superadmin' }, + ]); + + await service.recordChanges({ contractId: 'c-1', changes: [change], actorId: 'u-1' }); + + expect(saved[0].actorName).toBe('Super Admin'); + }); + + it('falls back to another locale, then username, then email', async () => { + const onlyAmharic = build([{ id: 'u-1', name: { am: 'ሱፐር' }, username: 'x' }]); + await onlyAmharic.service.recordChanges({ + contractId: 'c-1', + changes: [change], + actorId: 'u-1', + }); + expect(onlyAmharic.saved[0].actorName).toBe('ሱፐር'); + + const noName = build([{ id: 'u-1', name: null, username: 'operator', email: 'o@edr' }]); + await noName.service.recordChanges({ + contractId: 'c-1', + changes: [change], + actorId: 'u-1', + }); + expect(noName.saved[0].actorName).toBe('operator'); + + const emailOnly = build([{ id: 'u-1', name: {}, username: null, email: 'o@edr.local' }]); + await emailOnly.service.recordChanges({ + contractId: 'c-1', + changes: [change], + actorId: 'u-1', + }); + expect(emailOnly.saved[0].actorName).toBe('o@edr.local'); + }); + + it('never writes "[object Object]" as the actor name', async () => { + const { service, saved } = build([{ id: 'u-1', name: { en: 'Real Name' } }]); + + await service.recordChanges({ contractId: 'c-1', changes: [change], actorId: 'u-1' }); + + expect(String(saved[0].actorName)).not.toContain('object Object'); + }); + + it('records nothing when the change set is empty', async () => { + const { service, saved } = build([]); + + await service.recordChanges({ contractId: 'c-1', changes: [], actorId: 'u-1' }); + + expect(saved).toHaveLength(0); + }); + + it('still records the revision when the user lookup fails', async () => { + const { service, saved } = build([]); + Object.assign(service, { + dataSource: { query: jest.fn().mockRejectedValue(new Error('iam down')) }, + }); + + await service.recordChanges({ contractId: 'c-1', changes: [change], actorId: 'u-1' }); + + expect(saved).toHaveLength(1); + expect(saved[0].actorName).toBeNull(); + }); + + it('resolves names for legacy rows that predate the actor_name column', async () => { + const { service } = build([{ id: 'u-1', name: { en: 'Abenezer Haile' } }]); + Object.assign(service, { + revisionRepo: { + find: jest + .fn() + .mockResolvedValue([{ id: 'r-1', actorId: 'u-1', actorName: null }]), + }, + }); + + const [revision] = await service.list('c-1'); + + expect(revision.actorName).toBe('Abenezer Haile'); + }); +}); 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 5a22d2fb9..dfd4ea840 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 @@ -274,6 +274,20 @@ export class ContractTransitionService { // shared six templates are never written here. const snapshot = await this.resolveDocumentSnapshot(contract, documentSnapshot); + // Audit whatever staff changed in the accept dialog. The baseline is the + // template this contract would otherwise have frozen as-is, so an untouched + // accept diffs to nothing and records no revision. + if (documentSnapshot) { + const baseline = await this.resolveDocumentSnapshot(contract); + await this.documentHistory.record({ + contractId, + before: baseline, + after: snapshot, + actorId, + actorRole: 'Reviewing staff', + }); + } + await this.contractsRepository.update(contractId, { status: 'PENDING_APPROVAL', approvedByStaffId: actorId, diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index a015cd469..f6b9ec1c4 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -303,8 +303,10 @@ export class ContractsController { @Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContractDto, @UploadedFiles() files: Express.Multer.File[], + // Recorded on the edit's audit revision — who changed the contract. + @CurrentUser() user?: TCurrentUser, ) { - return this.contractsService.update(id, dto, files ?? []); + return this.contractsService.update(id, dto, files ?? [], user?.id); } @Delete(':id') @@ -738,6 +740,92 @@ export class ContractsController { return this.clearanceService.finalizePreClearance(id); } + @Post(':id/clearance/transit-assignee/request') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @ApiOperation({ + summary: + 'GL ET asks GL Djibouti to name the transit officer — required before the customs declaration', + }) + requestTransitAssignee( + @Param('id', ParseUUIDPipe) id: string, + @Body('note') note: string | undefined, + @CurrentUser() user: AuthUserPayload, + ) { + return this.clearanceService.requestTransitAssignee( + id, + note, + resolveAuthUserId(user), + ); + } + + @Post(':id/clearance/transit-assignee/assign') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @ApiOperation({ + summary: + 'GL Djibouti names the transit officer (free text) — unblocks the customs declaration; calling again reassigns', + }) + assignTransitAssignee( + @Param('id', ParseUUIDPipe) id: string, + @Body('assignee') assignee: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.clearanceService.assignTransitAssignee( + id, + assignee, + resolveAuthUserId(user), + ); + } + + @Get(':id/clearance/documents/:fileKey/versions') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceReview) + @ApiOperation({ + summary: + 'Version history of one clearance document — the customer original plus every staff replacement', + }) + documentVersions( + @Param('id', ParseUUIDPipe) id: string, + @Param('fileKey') fileKey: string, + ) { + return this.clearanceService.documentVersions(id, fileKey); + } + + @Post(':id/clearance/documents/:fileKey/replace') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceReview) + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ + summary: + 'GL replaces a clearance document in place (reason required) — the previous version is kept in the file history and the new one needs approving', + }) + replaceClearanceDocument( + @Param('id', ParseUUIDPipe) id: string, + @Param('fileKey') fileKey: string, + @UploadedFile() file: Express.Multer.File, + @Body('reason') reason: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.clearanceService.replaceDocument( + id, + fileKey, + file, + resolveAuthUserId(user), + reason, + ); + } + + @Post(':id/clearance/duty/dispute') + @ApiOperation({ + summary: + 'Customer disputes the advised duty/tax with a reason — reopens the step so GL Ethiopia can re-advise (repeatable)', + }) + disputeContractDuty( + @Param('id', ParseUUIDPipe) id: string, + @Body('note') note: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.clearanceService.disputeDuty(id, note, resolveAuthUserId(user)); + } + @Post(':id/clearance/duty-slip') @UseInterceptors(FileInterceptor('file')) @ApiConsumes('multipart/form-data') @@ -766,19 +854,21 @@ export class ContractsController { @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) @UseInterceptors(FileInterceptor('file')) @ApiConsumes('multipart/form-data') - @ApiOperation({ summary: 'GL DJ uploads Delivery Order (import)' }) + @ApiOperation({ + summary: + 'GL DJ uploads Delivery Order (import) with vessel arrival + DO collected dates', + }) uploadDeliveryOrder( @Param('id', ParseUUIDPipe) id: string, @UploadedFile() file: Express.Multer.File, - @Body('vesselDepartureDate') vesselDepartureDate: string | undefined, + @Body('vesselArrivalDate') vesselArrivalDate: string | undefined, + @Body('doCollectedDate') doCollectedDate: string | undefined, @CurrentUser() user: AuthUserPayload, ) { - return this.clearanceService.uploadDeliveryOrder( - id, - file, - resolveAuthUserId(user), - vesselDepartureDate, - ); + return this.clearanceService.uploadDeliveryOrder(id, file, resolveAuthUserId(user), { + vesselArrivalDate, + doCollectedDate, + }); } @Post(':id/clearance/release-order') diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts index abce1884d..b9c22d1af 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -164,7 +164,9 @@ export class ContractsRepository extends BaseRepository { 'contract.files', FileRecord, 'file', - "file.resource_id = contract.id AND file.resource = 'contracts'", + // Superseded versions are soft-deleted, not dropped — keep them out of + // the live file list (a manual join condition is not filtered for us). + "file.resource_id = contract.id AND file.resource = 'contracts' AND file.deleted_at IS NULL", ) .getOne(); @@ -558,6 +560,17 @@ export class ContractsRepository extends BaseRepository { ); } + /** Review notes of one type, newest first — the duty advice/dispute rounds. */ + async findReviewNotes( + contractId: string, + noteType: ContractReviewNoteType, + ): Promise { + return this.dataSource.getRepository(ContractReviewNote).find({ + where: { contractId, noteType }, + order: { createdAt: 'DESC' }, + }); + } + async findLatestReviewNote( contractId: string, noteType?: ContractReviewNoteType, @@ -725,12 +738,20 @@ export class ContractsRepository extends BaseRepository { ContractClearanceCycle, | 'dutyRequired' | 'vesselDepartureDate' + | 'vesselArrivalDate' + | 'doCollectedDate' | 'roAmendmentRequestedAt' | 'roHoldReason' | 'currentPhase' | 'status' | 'preClearanceFinalizedAt' | 'completedAt' + | 'transitAssigneeRequestedAt' + | 'transitAssigneeRequestedByUserId' + | 'transitAssigneeRequestNote' + | 'transitAssigneeName' + | 'transitAssigneeAssignedAt' + | 'transitAssigneeAssignedByUserId' > >, ): Promise { diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts index 02459f2a5..43aab750f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -26,6 +26,8 @@ import { Contract, CONTRACT_STATUSES, CONTRACT_CUSTOMER_EDITABLE_STATUSES } from import { ContractRoute } from './entities/contract-route.entity'; import { ContractCargoScope } from './entities/contract-cargo-scope.entity'; import { isEffectivelyExpired } from './utils/contract-expiry.util'; +import { diffContractFields } from './contract-document-diff.util'; +import { ContractDocumentHistoryService } from './contract-document-history.service'; import { FileRecord } from '../files/entities/file.entity'; /** Paginated contract list: flat `total` (backoffice) + `meta` block (portal). */ @@ -42,6 +44,35 @@ export interface PaginatedContracts { }; } +/** Route list as a readable lane string, e.g. "Nagad → Mojo, Mojo → Adama". */ +function describeRoutes(routes?: ContractRoute[]): string | null { + if (!routes?.length) return null; + return [...routes] + .sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)) + .map( + (r) => + `${r.originYard?.label ?? r.originYardId} → ${r.destinationYard?.label ?? r.destinationYardId}`, + ) + .join(', '); +} + +/** Cargo scope as a readable string, e.g. "20ft ×2, 40ft ×1" or "Wheat ×500". */ +function describeCargoScope(scope?: ContractCargoScope[]): string | null { + if (!scope?.length) return null; + return scope + .map((row) => { + const label = + row.containerSize ?? + row.cargoType?.cargoTypeName ?? + row.cargoFreeText ?? + row.cargoTypeId ?? + 'cargo'; + return row.quantityCap != null ? `${label} ×${row.quantityCap}` : String(label); + }) + .sort() + .join(', '); +} + const NEEDS_ACTION_STATUSES = [ 'SUBMITTED', 'PENDING_APPROVAL', @@ -57,6 +88,7 @@ export class ContractsService { private readonly companiesService: CompaniesService, private readonly filesService: FilesService, private readonly minioService: MinioService, + private readonly documentHistory: ContractDocumentHistoryService, ) {} /** Generate a unique contract reference number (CTR-YYYY-NNNNN). */ @@ -332,7 +364,11 @@ export class ContractsService { tradeDirection: dto.tradeDirection, freightType: dto.freightType, serviceTypeId: dto.serviceTypeId, - paymentCurrency: dto.paymentCurrency, + // A contract is always QUOTED in USD — the billing currency is chosen per + // booking (or on the shipment request when GL books for the customer), so + // any client-supplied currency here is ignored. Contracts created before + // this rule keep whatever they stored; update() never rewrites it. + paymentCurrency: 'USD', customsClearingEnabled: includesCustoms, customsClearingAgent: includesCustoms ? null : (dto.customsClearingAgent ?? null), equipmentReturn: dto.equipmentReturn ?? null, @@ -490,6 +526,7 @@ export class ContractsService { id: string, dto: UpdateContractDto, files: Express.Multer.File[], + actorId?: string, ): Promise<{ contract: Contract; warnings: string[] }> { const existing = await this.findById(id); if (!CONTRACT_CUSTOMER_EDITABLE_STATUSES.includes(existing.status as never)) { @@ -516,7 +553,9 @@ export class ContractsService { tradeDirection: dto.tradeDirection ?? existing.tradeDirection, freightType, serviceTypeId: dto.serviceTypeId ?? existing.serviceTypeId, - paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency, + // Never rewritten: grandfathered contracts keep the currency (and frozen + // snapshots) they were signed with. + paymentCurrency: existing.paymentCurrency, isHazardous: dto.isHazardous ?? existing.isHazardous, isReefer: dto.isReefer ?? existing.isReefer, // Same rule as create: clearing the flag clears the declaration with it. @@ -576,7 +615,52 @@ export class ContractsService { existing.companyProfileId ?? null, ); - return { contract: await this.findById(id), warnings }; + const updated = await this.findById(id); + // Audit what this edit actually changed. Runs after the writes so the + // "after" side is read back from the contract rather than from the DTO. + await this.recordFieldRevision(existing, updated, actorId); + + return { contract: updated, warnings }; + } + + /** Fields worth auditing on a customer edit, read off a loaded contract. */ + private auditableFields(contract: Contract): Record { + return { + contractKind: contract.contractKind, + tradeDirection: contract.tradeDirection, + freightType: contract.freightType, + serviceType: contract.serviceType?.serviceName ?? contract.serviceTypeId, + paymentCurrency: contract.paymentCurrency, + contractType: contract.contractType, + isHazardous: contract.isHazardous, + hazardClass: contract.hazardClass, + unNumber: contract.unNumber, + isReefer: contract.isReefer, + equipmentReturn: contract.equipmentReturn, + customsClearingAgent: contract.customsClearingAgent, + firstMilePickupAddress: contract.firstMilePickupAddress, + lastMileDeliveryAddress: contract.lastMileDeliveryAddress, + routes: describeRoutes(contract.routes), + cargoScope: describeCargoScope(contract.cargoScope), + }; + } + + /** Append a revision describing a customer's edit to the contract itself. */ + private async recordFieldRevision( + before: Contract, + after: Contract, + actorId?: string, + ): Promise { + const changes = diffContractFields( + this.auditableFields(before), + this.auditableFields(after), + ); + await this.documentHistory.recordChanges({ + contractId: after.id, + changes, + actorId: actorId ?? null, + actorRole: 'Customer', + }); } /** Parse comma-separated or repeated status query values. */ diff --git a/apps/edr-freight-api/src/modules/contracts/do-collection-dates.spec.ts b/apps/edr-freight-api/src/modules/contracts/do-collection-dates.spec.ts new file mode 100644 index 000000000..37ed6c267 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/do-collection-dates.spec.ts @@ -0,0 +1,46 @@ +import { BadRequestException } from '@nestjs/common'; + +import { assertDoCollectionDates } from './contract-clearance.util'; + +describe('assertDoCollectionDates', () => { + it('requires both dates', () => { + expect(() => assertDoCollectionDates(undefined)).toThrow(BadRequestException); + expect(() => + assertDoCollectionDates({ vesselArrivalDate: '2026-07-01' }), + ).toThrow(/DO collected date is required/); + expect(() => + assertDoCollectionDates({ doCollectedDate: '2026-07-01' }), + ).toThrow(/Vessel arrival date is required/); + // Whitespace is not a date. + expect(() => + assertDoCollectionDates({ vesselArrivalDate: ' ', doCollectedDate: ' ' }), + ).toThrow(BadRequestException); + }); + + it('rejects a DO collected before the vessel arrived', () => { + expect(() => + assertDoCollectionDates({ + vesselArrivalDate: '2026-07-10', + doCollectedDate: '2026-07-09', + }), + ).toThrow(/cannot be earlier than the vessel arrival date/); + }); + + it('normalizes an ISO datetime down to its date part', () => { + expect( + assertDoCollectionDates({ + vesselArrivalDate: '2026-07-10T21:00:00.000Z', + doCollectedDate: '2026-07-10T05:00:00.000Z', + }), + ).toEqual({ vesselArrivalDate: '2026-07-10', doCollectedDate: '2026-07-10' }); + }); + + it('rejects a malformed date', () => { + expect(() => + assertDoCollectionDates({ + vesselArrivalDate: '10/07/2026', + doCollectedDate: '2026-07-10', + }), + ).toThrow(/not a valid date/); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-request.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-request.dto.ts index 11d50494a..9f596bbef 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-request.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-request.dto.ts @@ -2,6 +2,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform, Type } from 'class-transformer'; import { IsArray, + IsIn, IsInt, IsNumber, IsOptional, @@ -91,6 +92,15 @@ export class CreateBookingRequestDto { @Type(() => RequestBulkLineDto) bulk?: RequestBulkLineDto; + @ApiPropertyOptional({ + enum: ['ETB', 'USD'], + description: + 'Billing currency for the shipment GL will book. Intercity is always ETB.', + }) + @IsOptional() + @IsIn(['ETB', 'USD']) + paymentCurrency?: string; + @ApiPropertyOptional() @IsOptional() @IsString() diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts index 93a0e9e76..2ab616c00 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts @@ -15,6 +15,8 @@ import { ValidateNested, } from 'class-validator'; +import { PAYMENT_CURRENCIES } from './create-contract.dto'; + /** Per-shipment equipment return — "NA" stays contract-level only. */ const SHIPMENT_EQUIPMENT_RETURNS = ['WITH_RETURN', 'WITHOUT_RETURN'] as const; @@ -150,6 +152,20 @@ export class CreateBookingUnderContractDto { @IsUUID() contractRouteId?: string; + /** + * The contract quotes in USD; the customer picks the billing currency here. + * Omitted → the contract's own currency (USD for contracts created under the + * current rule, the grandfathered currency for older ones). Intercity is + * forced to ETB by the service regardless of what is sent. + */ + @ApiPropertyOptional({ + enum: PAYMENT_CURRENCIES, + description: 'Billing currency for this shipment. Intercity is always ETB.', + }) + @IsOptional() + @IsIn([...PAYMENT_CURRENCIES]) + paymentCurrency?: string; + @ApiPropertyOptional({ description: 'Binding shipment day. Omitted for intercity (DOMESTIC) bookings — staff assign a passing train later.', diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts index 6e68765f1..88ab8beb7 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts @@ -158,9 +158,20 @@ export class CreateContractDto { @IsUUID() serviceTypeId!: string; - @ApiProperty({ enum: PAYMENT_CURRENCIES }) + /** + * Deprecated at the contract level. A contract now always quotes in USD; the + * customer picks the billing currency per booking (or on the shipment request + * when GL books on their behalf). Accepted but ignored on create so older + * clients don't break — the service forces USD. + */ + @ApiPropertyOptional({ + enum: PAYMENT_CURRENCIES, + deprecated: true, + description: 'Ignored — contracts always quote in USD. Choose currency at booking.', + }) + @IsOptional() @IsIn([...PAYMENT_CURRENCIES]) - paymentCurrency!: string; + paymentCurrency?: string; @ApiPropertyOptional({ description: 'Whether EDR/GL handles customs clearance' }) @IsOptional() diff --git a/apps/edr-freight-api/src/modules/contracts/entities/booking-request.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/booking-request.entity.ts index 6582376d3..b530bb5e9 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/booking-request.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/booking-request.entity.ts @@ -43,6 +43,14 @@ export class BookingRequest extends BaseEntity { @Column({ name: 'requested_lines', type: 'jsonb', default: () => "'{}'::jsonb" }) requestedLines!: Freight.RequestedShipmentLines; + /** + * Billing currency the customer chose for this shipment. The contract quotes + * in USD; on a customs contract GL creates the booking, so this is where the + * customer states which currency to be invoiced in. + */ + @Column({ name: 'payment_currency', type: 'varchar', length: 5, nullable: true }) + paymentCurrency?: string | null; + @Column({ name: 'notes', type: 'text', nullable: true }) notes?: string | null; diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract-clearance-cycle.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract-clearance-cycle.entity.ts index 3c101f58e..8f5aa7e81 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract-clearance-cycle.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract-clearance-cycle.entity.ts @@ -43,6 +43,41 @@ export class ContractClearanceCycle extends BaseEntity { @Column({ name: 'vessel_departure_date', type: 'date', nullable: true }) vesselDepartureDate?: string | null; + /** Import DO: when the vessel arrived in Djibouti. Required on DO upload. */ + @Column({ name: 'vessel_arrival_date', type: 'date', nullable: true }) + vesselArrivalDate?: string | null; + + /** Import DO: when GL Djibouti collected the DO. Required on DO upload. */ + @Column({ name: 'do_collected_date', type: 'date', nullable: true }) + doCollectedDate?: string | null; + + /** + * Transit-assignee handshake that runs BEFORE the customs declaration: GL + * Ethiopia asks Djibouti for the officer who will handle the shipment in + * transit, and Djibouti answers with a name. The declaration step stays shut + * until `transitAssigneeName` is set; Djibouti may overwrite it later + * (reassignment) and the newer name simply wins. + */ + @Column({ name: 'transit_assignee_requested_at', type: 'timestamptz', nullable: true }) + transitAssigneeRequestedAt?: Date | null; + + @Column({ name: 'transit_assignee_requested_by_user_id', type: 'uuid', nullable: true }) + transitAssigneeRequestedByUserId?: string | null; + + /** What GL Ethiopia asked for — shown on the Djibouti queue. */ + @Column({ name: 'transit_assignee_request_note', type: 'text', nullable: true }) + transitAssigneeRequestNote?: string | null; + + /** The officer Djibouti named — free text, no user directory to bind to. */ + @Column({ name: 'transit_assignee_name', type: 'text', nullable: true }) + transitAssigneeName?: string | null; + + @Column({ name: 'transit_assignee_assigned_at', type: 'timestamptz', nullable: true }) + transitAssigneeAssignedAt?: Date | null; + + @Column({ name: 'transit_assignee_assigned_by_user_id', type: 'uuid', nullable: true }) + transitAssigneeAssignedByUserId?: string | null; + @Column({ name: 'ro_amendment_requested_at', type: 'timestamptz', nullable: true }) roAmendmentRequestedAt?: Date | null; diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract-document-revision.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract-document-revision.entity.ts index bc7e12e3e..a832cb50c 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract-document-revision.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract-document-revision.entity.ts @@ -21,6 +21,13 @@ export class ContractDocumentRevision extends BaseEntity { @Column({ name: 'actor_id', type: 'uuid', nullable: true }) actorId?: string | null; + /** + * Who made the edit, captured at the time. Denormalised so the trail still + * names them after a rename or a deactivated account. + */ + @Column({ name: 'actor_name', type: 'varchar', length: 200, nullable: true }) + actorName?: string | null; + /** The approval step's required role at the time of the edit. */ @Column({ name: 'actor_role', type: 'varchar', length: 64, nullable: true }) actorRole?: string | null; diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract-review-note.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract-review-note.entity.ts index 5b744338e..4d2a46365 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract-review-note.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract-review-note.entity.ts @@ -8,6 +8,11 @@ export const CONTRACT_REVIEW_NOTE_TYPES = [ 'STAFF_NOTE', 'CUSTOMER_NOTE', 'AMENDMENT', + /** + * The customer disputed the advised duty & tax and asked GL Ethiopia to + * correct it. One row per round — the advice/dispute loop can repeat. + */ + 'DUTY_DISPUTE', ] as const; export type ContractReviewNoteType = (typeof CONTRACT_REVIEW_NOTE_TYPES)[number]; diff --git a/apps/edr-freight-api/src/modules/contracts/shipment-currency.spec.ts b/apps/edr-freight-api/src/modules/contracts/shipment-currency.spec.ts new file mode 100644 index 000000000..7bd109430 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/shipment-currency.spec.ts @@ -0,0 +1,85 @@ +import { ContractBookingService } from './contract-booking.service'; +import { BookingPricingService } from '../bookings/booking-pricing.service'; +import type { Contract } from './entities/contract.entity'; +import type { ContractRateSnapshot } from './entities/contract-rate-snapshot.entity'; + +const contract = (over: Partial): Contract => + ({ tradeDirection: 'IMPORT', paymentCurrency: 'USD', ...over }) as Contract; + +/** The private resolver, reached without standing up the whole Nest graph. */ +const resolveCurrency = (c: Contract, requested?: string | null): string => + ( + ContractBookingService.prototype as unknown as { + resolveShipmentCurrency: (c: Contract, r?: string | null) => string; + } + ).resolveShipmentCurrency(c, requested); + +const snapshot = (currency: string, unitPrice: number): ContractRateSnapshot => + ({ rateCode: 'CONTAINER_20FT', currency, unitPrice }) as ContractRateSnapshot; + +const frozenByCode = ( + snap: ContractRateSnapshot | null, + bookingCurrency: string, + usdToEtb: number, +): ContractRateSnapshot | null => + ( + BookingPricingService.prototype as unknown as { + frozenRateByCode: ( + m: Map | null, + code: string, + bookingCurrency: string, + usdToEtb: number, + ) => ContractRateSnapshot | null; + } + ).frozenRateByCode( + snap ? new Map([['CONTAINER_20FT', snap]]) : null, + 'CONTAINER_20FT', + bookingCurrency, + usdToEtb, + ); + +describe('per-shipment billing currency', () => { + it('takes the customer choice over the contract', () => { + expect(resolveCurrency(contract({}), 'ETB')).toBe('ETB'); + expect(resolveCurrency(contract({}), 'USD')).toBe('USD'); + }); + + it('falls back to the contract currency when none is chosen', () => { + // Grandfathered ETB contract with no explicit choice. + expect(resolveCurrency(contract({ paymentCurrency: 'ETB' }))).toBe('ETB'); + expect(resolveCurrency(contract({}), ' ')).toBe('USD'); + }); + + it('forces ETB on intercity whatever was requested', () => { + const domestic = contract({ tradeDirection: 'DOMESTIC' }); + expect(resolveCurrency(domestic, 'USD')).toBe('ETB'); + expect(resolveCurrency(domestic)).toBe('ETB'); + }); +}); + +describe('frozen contract rate in the booking currency', () => { + it('converts a USD snapshot for an ETB booking instead of dropping it', () => { + // The old behaviour returned null here, which silently re-priced the + // booking at live rates and lost the agreed contract price. + expect(frozenByCode(snapshot('USD', 400), 'ETB', 150)?.unitPrice).toBe(60_000); + }); + + it('converts a grandfathered ETB snapshot back for a USD booking', () => { + expect(frozenByCode(snapshot('ETB', 60_000), 'USD', 150)?.unitPrice).toBe(400); + }); + + it('passes a matching-currency snapshot through untouched', () => { + const snap = snapshot('USD', 400); + expect(frozenByCode(snap, 'USD', 1)).toBe(snap); + }); + + it('refuses to price off an unusable exchange rate', () => { + // Converting with 0 would zero the whole line. + expect(frozenByCode(snapshot('USD', 400), 'ETB', 0)).toBeNull(); + expect(frozenByCode(snapshot('USD', 400), 'ETB', Number.NaN)).toBeNull(); + }); + + it('returns null when there is no snapshot', () => { + expect(frozenByCode(null, 'ETB', 150)).toBeNull(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/transit-assignee.spec.ts b/apps/edr-freight-api/src/modules/contracts/transit-assignee.spec.ts new file mode 100644 index 000000000..da4b2f34b --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/transit-assignee.spec.ts @@ -0,0 +1,175 @@ +import { BadRequestException } from '@nestjs/common'; + +import { ContractClearanceService } from './contract-clearance.service'; +import type { Contract } from './entities/contract.entity'; + +/** + * Pre-declaration transit-assignee handshake. GL Ethiopia asks Djibouti who will + * handle the shipment in transit; Djibouti answers with a name. The customs + * declaration stays shut until that name exists, and Djibouti may send a + * different one later. + */ +describe('ContractClearanceService — transit assignee', () => { + const contract = (over: Partial = {}): Contract => + ({ + id: 'ctr-1', + reference: 'CTR-2026-00042', + tradeDirection: 'IMPORT', + customsClearingEnabled: true, + contractKind: 'ONE_TIME', + ...over, + }) as Contract; + + let repo: { currentCycle: jest.Mock; updateCycle: jest.Mock }; + let contractsService: { findById: jest.Mock }; + let notifier: { + transitAssigneeRequested: jest.Mock; + transitAssigneeAssigned: jest.Mock; + }; + let service: ContractClearanceService; + + const cycle = (over: Record = {}) => ({ + id: 'cyc-1', + transitAssigneeRequestedAt: null, + transitAssigneeName: null, + ...over, + }); + + beforeEach(() => { + repo = { + currentCycle: jest.fn().mockResolvedValue(cycle()), + updateCycle: jest.fn().mockResolvedValue(undefined), + }; + contractsService = { findById: jest.fn().mockResolvedValue(contract()) }; + notifier = { + transitAssigneeRequested: jest.fn(), + transitAssigneeAssigned: jest.fn(), + }; + service = new ContractClearanceService( + repo as never, + contractsService as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + notifier as never, + ); + }); + + describe('request (GL Ethiopia)', () => { + it('stamps the ask and pings Djibouti', async () => { + await service.requestTransitAssignee('ctr-1', ' Reefer, needs a cold-chain officer ', 'et-1'); + + const patch = repo.updateCycle.mock.calls[0][1]; + expect(patch.transitAssigneeRequestedAt).toBeInstanceOf(Date); + expect(patch.transitAssigneeRequestedByUserId).toBe('et-1'); + expect(patch.transitAssigneeRequestNote).toBe( + 'Reefer, needs a cold-chain officer', + ); + expect(notifier.transitAssigneeRequested).toHaveBeenCalled(); + }); + }); + + describe('assign (GL Djibouti)', () => { + it('records the officer and tells Ethiopia they can proceed', async () => { + repo.currentCycle.mockResolvedValue( + cycle({ transitAssigneeRequestedAt: new Date() }), + ); + + await service.assignTransitAssignee('ctr-1', ' Ahmed Bourhan ', 'dj-1'); + + const patch = repo.updateCycle.mock.calls[0][1]; + expect(patch.transitAssigneeName).toBe('Ahmed Bourhan'); + expect(patch.transitAssigneeAssignedByUserId).toBe('dj-1'); + expect(notifier.transitAssigneeAssigned).toHaveBeenCalledWith( + expect.objectContaining({ id: 'ctr-1' }), + 'Ahmed Bourhan', + null, + ); + }); + + it('reassigns, carrying the previous name into the notice', async () => { + repo.currentCycle.mockResolvedValue( + cycle({ + transitAssigneeRequestedAt: new Date(), + transitAssigneeName: 'Ahmed Bourhan', + }), + ); + + await service.assignTransitAssignee('ctr-1', 'Fatouma Ali', 'dj-1'); + + expect(notifier.transitAssigneeAssigned).toHaveBeenCalledWith( + expect.anything(), + 'Fatouma Ali', + 'Ahmed Bourhan', + ); + }); + + it('refuses an empty name', async () => { + repo.currentCycle.mockResolvedValue( + cycle({ transitAssigneeRequestedAt: new Date() }), + ); + await expect( + service.assignTransitAssignee('ctr-1', ' ', 'dj-1'), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('refuses before Ethiopia has asked', async () => { + await expect( + service.assignTransitAssignee('ctr-1', 'Ahmed Bourhan', 'dj-1'), + ).rejects.toThrow(/not requested/i); + }); + }); + + describe('declaration gate', () => { + const ensure = (c: Contract) => + ( + service as unknown as { + ensureDeclarationPrerequisites: (id: string, c: Contract) => Promise; + } + ).ensureDeclarationPrerequisites('ctr-1', c); + + beforeEach(() => { + // Documents are approved; only the assignee decides the outcome here. + ( + service as unknown as { isClearanceFullyApproved: unknown } + ).isClearanceFullyApproved = jest.fn().mockResolvedValue(true); + }); + + it('tells GL to raise the request when none exists', async () => { + await expect(ensure(contract())).rejects.toThrow( + /Request a transit assignee/i, + ); + }); + + it('tells GL to wait when Djibouti has not answered', async () => { + repo.currentCycle.mockResolvedValue( + cycle({ transitAssigneeRequestedAt: new Date() }), + ); + await expect(ensure(contract())).rejects.toThrow(/has not assigned/i); + }); + + it('lets the declaration through once the officer is named', async () => { + repo.currentCycle.mockResolvedValue( + cycle({ + transitAssigneeRequestedAt: new Date(), + transitAssigneeName: 'Ahmed Bourhan', + }), + ); + ( + service as unknown as { workflowService: unknown } + ).workflowService = { + listMilestones: jest + .fn() + .mockResolvedValue([ + { milestoneCode: 'DOCUMENTS_APPROVED', status: 'COMPLETED' }, + ]), + }; + + await expect(ensure(contract())).resolves.toBeUndefined(); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/files/entities/file.entity.ts b/apps/edr-freight-api/src/modules/files/entities/file.entity.ts index 1fbd1459f..7624800d8 100644 --- a/apps/edr-freight-api/src/modules/files/entities/file.entity.ts +++ b/apps/edr-freight-api/src/modules/files/entities/file.entity.ts @@ -53,4 +53,16 @@ export class FileRecord extends BaseEntity { @Column({ name: "reviewed_at", type: "timestamptz", nullable: true }) reviewedAt!: Date | null; + + /** + * Who replaced this version, when a newer file took its place. Superseded + * versions are soft-deleted rather than dropped, so the original a customer + * uploaded survives a staff correction and the two can be compared. + */ + @Column({ name: "replaced_by_user_id", type: "uuid", nullable: true }) + replacedByUserId!: string | null; + + /** Why the file was replaced — shown on the document's version history. */ + @Column({ name: "replace_reason", type: "text", nullable: true }) + replaceReason!: string | 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 583e221ed..9a2552c7a 100644 --- a/apps/edr-freight-api/src/modules/files/files.repository.ts +++ b/apps/edr-freight-api/src/modules/files/files.repository.ts @@ -41,12 +41,47 @@ export class FilesRepository extends BaseRepository { return this.repository.findOne({ where: { resourceId, resource, code } }); } + /** + * Retire the live version(s) of a document code. SOFT delete on purpose: the + * bytes and the row stay so the original upload can still be read back from + * the version history after staff replace it. Every normal read already + * filters soft-deleted rows, so callers see only the current version. + * + * `replacedBy` / `reason` are stamped on the retired row when a newer file is + * taking its place (as opposed to a plain removal). + */ async deleteByCode( resourceId: string, resource: string, code: string, + replacedBy?: { userId?: string | null; reason?: string | null }, ): Promise { - await this.repository.delete({ resourceId, resource, code }); + if (replacedBy) { + await this.repository.update( + { resourceId, resource, code }, + { + replacedByUserId: replacedBy.userId ?? null, + replaceReason: replacedBy.reason ?? null, + }, + ); + } + await this.repository.softDelete({ resourceId, resource, code }); + } + + /** + * Every version of one document code, newest first — superseded versions + * included. The only read that deliberately looks past the soft-delete filter. + */ + findVersionHistory( + resourceId: string, + resource: string, + code: string, + ): Promise { + return this.repository.find({ + where: { resourceId, resource, code }, + withDeleted: true, + order: { createdAt: "DESC" }, + }); } /** diff --git a/apps/edr-freight-api/src/modules/files/files.service.spec.ts b/apps/edr-freight-api/src/modules/files/files.service.spec.ts new file mode 100644 index 000000000..fc6fb82d3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/files/files.service.spec.ts @@ -0,0 +1,112 @@ +import { FilesService } from './files.service'; + +/** + * Replacing a stored document must never destroy the previous one: the customer + * uploaded it, and a staff correction has to stay auditable against it. The old + * row is soft-deleted (so every normal read still returns exactly the current + * version) and stamped with who replaced it and why. + */ +describe('FilesService — document versions', () => { + const file = { + originalname: 'bill-of-lading.pdf', + size: 1234, + mimetype: 'application/pdf', + buffer: Buffer.from('x'), + } as Express.Multer.File; + + let filesRepository: { + deleteByCode: jest.Mock; + create: jest.Mock; + findVersionHistory: jest.Mock; + }; + let service: FilesService; + + beforeEach(() => { + filesRepository = { + deleteByCode: jest.fn().mockResolvedValue(undefined), + create: jest.fn(async (row) => ({ id: 'file-new', ...row })), + findVersionHistory: jest.fn().mockResolvedValue([]), + }; + service = new FilesService( + filesRepository as never, + { + uploadFile: jest.fn().mockResolvedValue('https://minio/bucket/new.pdf'), + getObjectNameFromUrl: (u: string) => u, + getSignedUrl: jest.fn(), + } as never, + ); + }); + + it('stamps the retired version with who replaced it and why', async () => { + await service.upsertByCode( + { resourceId: 'ctr-1', resource: 'contracts', code: 'bill_of_lading', file }, + { userId: 'gl-user-1', reason: 'Customer sent page 2 only' }, + ); + + expect(filesRepository.deleteByCode).toHaveBeenCalledWith( + 'ctr-1', + 'contracts', + 'bill_of_lading', + { userId: 'gl-user-1', reason: 'Customer sent page 2 only' }, + ); + }); + + it('still replaces silently when no replacer is given (system overwrites)', async () => { + await service.upsertByCode({ + resourceId: 'ctr-1', + resource: 'contracts', + code: 'contract_pdf', + file, + }); + + expect(filesRepository.deleteByCode).toHaveBeenCalledWith( + 'ctr-1', + 'contracts', + 'contract_pdf', + undefined, + ); + }); + + it('marks the live row current and the soft-deleted ones superseded', async () => { + filesRepository.findVersionHistory.mockResolvedValue([ + { + id: 'v2', + name: 'corrected.pdf', + url: 'u2', + size: 2, + mimeType: 'application/pdf', + createdAt: new Date('2026-07-20T10:00:00Z'), + deletedAt: null, + replacedByUserId: null, + replaceReason: null, + }, + { + id: 'v1', + name: 'original.pdf', + url: 'u1', + size: 1, + mimeType: 'application/pdf', + createdAt: new Date('2026-07-18T10:00:00Z'), + deletedAt: new Date('2026-07-20T10:00:00Z'), + replacedByUserId: 'gl-user-1', + replaceReason: 'Wrong page order', + }, + ]); + + const versions = await service.versionHistory( + 'ctr-1', + 'contracts', + 'bill_of_lading', + ); + + expect(versions[0]).toMatchObject({ id: 'v2', isCurrent: true, replacedAt: null }); + expect(versions[1]).toMatchObject({ + id: 'v1', + isCurrent: false, + replacedByUserId: 'gl-user-1', + replaceReason: 'Wrong page order', + }); + // The customer's original is still readable — that is the whole point. + expect(versions[1].url).toBe('u1'); + }); +}); 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 e959fa556..ea11e5fe8 100644 --- a/apps/edr-freight-api/src/modules/files/files.service.ts +++ b/apps/edr-freight-api/src/modules/files/files.service.ts @@ -104,13 +104,66 @@ export class FilesService { }); } - /** Replace existing file row for the same resource + code (e.g. contract PDF). */ - async upsertByCode(input: CreateFileInput): Promise { + /** + * Replace the file stored under a resource + code (e.g. contract PDF). The + * previous version is retired, not destroyed — pass `replacedBy` to record who + * swapped it and why, which is what the version history shows. + */ + async upsertByCode( + input: CreateFileInput, + replacedBy?: { userId?: string | null; reason?: string | null }, + ): Promise { const { resourceId, resource, code } = input; - await this.filesRepository.deleteByCode(resourceId, resource, code); + await this.filesRepository.deleteByCode( + resourceId, + resource, + code, + replacedBy, + ); return this.upload(input); } + /** + * Every stored version of one document, newest first. `isCurrent` marks the + * live row; the rest are superseded uploads kept for audit. + */ + async versionHistory( + resourceId: string, + resource: string, + code: string, + ): Promise< + Array<{ + id: string; + name: string; + url: string; + size: number; + mimeType: string; + uploadedAt: string; + isCurrent: boolean; + replacedAt: string | null; + replacedByUserId: string | null; + replaceReason: string | null; + }> + > { + const rows = await this.filesRepository.findVersionHistory( + resourceId, + resource, + code, + ); + return rows.map((row) => ({ + id: row.id, + name: row.name, + url: row.url, + size: row.size, + mimeType: row.mimeType, + uploadedAt: row.createdAt.toISOString(), + isCurrent: row.deletedAt == null, + replacedAt: row.deletedAt ? row.deletedAt.toISOString() : null, + replacedByUserId: row.replacedByUserId, + replaceReason: row.replaceReason, + })); + } + async deleteByCode( resourceId: string, resource: string, diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceDocumentVersionsModal.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceDocumentVersionsModal.tsx new file mode 100644 index 000000000..0015ea47b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceDocumentVersionsModal.tsx @@ -0,0 +1,237 @@ +import { + Badge, + Button, + Group, + Loader, + Modal, + Paper, + Stack, + Text, + Textarea, +} from "@mantine/core"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Download, Eye, History, Upload } from "lucide-react"; +import { useState } from "react"; +import toast from "react-hot-toast"; + +import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone"; +import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; +import { contractsService } from "@/services/contracts.service"; +import { isViewable } from "@edr/ui-common"; + +import { downloadBookingFile, fetchViewableFile } from "@/services/files.service"; + +export interface ClearanceDocumentVersionsModalProps { + contractId: string; + /** The document being inspected; null closes the modal. */ + doc: { fileKey: string; label: string } | null; + onClose: () => void; + /** Hide the replace form (finalized clearance, read-only viewers). */ + canReplace?: boolean; + onReplaced?: () => void; + onView?: (file: { name: string; url: string }) => void; +} + +const fmt = (iso: string) => + new Date(iso).toLocaleString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + +/** + * Version history of one clearance document, and the way to add a version. + * + * Staff can correct a document without bouncing it back to the customer, but + * the customer's original is never overwritten — it drops down this list as a + * superseded version, stamped with who replaced it and why. The corrected file + * comes back unreviewed, so it still has to be approved before finalizing. + */ +export function ClearanceDocumentVersionsModal({ + contractId, + doc, + onClose, + canReplace = false, + onReplaced, + onView, +}: ClearanceDocumentVersionsModalProps) { + const queryClient = useQueryClient(); + const [file, setFile] = useState(null); + const [reason, setReason] = useState(""); + + const { data: versions = [], isLoading } = useQuery({ + queryKey: ["contracts", "clearance-doc-versions", contractId, doc?.fileKey], + queryFn: () => + contractsService.getClearanceDocumentVersions(contractId, doc!.fileKey), + enabled: Boolean(doc), + }); + + const replace = useMutation({ + mutationFn: () => + contractsService.replaceClearanceDocument( + contractId, + doc!.fileKey, + file!, + reason.trim(), + ), + onSuccess: async () => { + toast.success("Document replaced — the previous version is kept on file"); + setFile(null); + setReason(""); + await queryClient.invalidateQueries({ + queryKey: ["contracts", "clearance-doc-versions", contractId, doc?.fileKey], + }); + await queryClient.invalidateQueries({ + queryKey: QUERY_KEYS.CONTRACTS.clearance(contractId), + }); + onReplaced?.(); + }, + }); + + const close = () => { + setFile(null); + setReason(""); + onClose(); + }; + + return ( + + + {doc?.label ?? "Document"} — version history + + } + > + + {isLoading ? ( + + + + ) : versions.length === 0 ? ( + + Nothing uploaded under this document yet. + + ) : ( + + {versions.map((v, index) => ( + + + + + + {v.name} + + {v.isCurrent ? ( + + Current + + ) : index === versions.length - 1 ? ( + + Original + + ) : ( + + Superseded + + )} + + + Uploaded {fmt(v.uploadedAt)} + {v.replacedAt ? ` · replaced ${fmt(v.replacedAt)}` : ""} + + {v.replaceReason ? ( + + Reason: {v.replaceReason} + + ) : null} + + + {isViewable({ name: v.name, url: "" }) && onView ? ( + + ) : null} + + + + + ))} + + )} + + {canReplace ? ( + + + + Replace this document + + + Use this for a correction you can make yourself. The customer's + copy stays in the history above, and the new file has to be + approved before clearance is finalized. + + +