diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 4ec11e082..397845cb3 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -51,6 +51,7 @@ import { SupportChatModule } from "./modules/support-chat/support-chat.module"; import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module"; import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module"; import { ExchangeSettingsModule } from "./modules/exchange-settings/exchange-settings.module"; +import { OperationsReportingModule } from "./modules/operations-reporting/operations-reporting.module"; import { PaymentSettingsModule } from "./modules/payment-settings/payment-settings.module"; import { StampSettingsModule } from "./modules/stamp-settings/stamp-settings.module"; import { LogoSettingsModule } from "./modules/logo-settings/logo-settings.module"; @@ -101,6 +102,7 @@ import { RoutesModule } from "./modules/routes/routes.module"; import { WarehousesModule } from "./modules/warehouses/warehouses.module"; import { OverviewModule } from "./modules/overview/overview.module"; import { ReportsModule } from "./modules/reports/reports.module"; +import { ExportsModule } from "./modules/exports/exports.module"; import { UserTradeAccessModule } from "./modules/user-trade-access/user-trade-access.module"; import { VehiclesModule } from "./modules/vehicles/vehicles.module"; import { DriversModule } from "./modules/drivers/drivers.module"; @@ -219,6 +221,7 @@ if (!process.env.APPLICATION_NAME) { FileUploadSettingsModule, DropdownSettingsModule, ExchangeSettingsModule, + OperationsReportingModule, PaymentSettingsModule, StampSettingsModule, LogoSettingsModule, @@ -239,6 +242,7 @@ if (!process.env.APPLICATION_NAME) { WarehousesModule, OverviewModule, ReportsModule, + ExportsModule, UserTradeAccessModule, VehiclesModule, DriversModule, diff --git a/apps/edr-freight-api/src/common/dto/id-list.transform.spec.ts b/apps/edr-freight-api/src/common/dto/id-list.transform.spec.ts new file mode 100644 index 000000000..a98fce97c --- /dev/null +++ b/apps/edr-freight-api/src/common/dto/id-list.transform.spec.ts @@ -0,0 +1,57 @@ +import { plainToInstance } from 'class-transformer'; +import { validateSync } from 'class-validator'; + +import { FilterBookingDto } from '../../modules/bookings/dto/filter-booking.dto'; +import { ListTrainSchedulesQueryDto } from '../../modules/train-scheduling/dto/list-train-schedules-query.dto'; + +/** + * The route filters carry one id, `a,b`, or a repeated param, and the + * repositories then branch on `?.length` before emitting `IN (:...ids)`. + * Two things have to hold or that breaks at runtime, not compile time: + * the value must always arrive as an array (a bare string would make + * `.length` count characters), and an absent/blank param must arrive as + * `undefined`, never `[]` — TypeORM turns `[]` into the syntax error `IN ()`. + */ +// Real-shaped v4s: the variant nibble must be 8/9/a/b, so `1111…` is NOT a +// valid UUID and would fail `@IsUUID` for reasons that have nothing to do +// with the list transform under test. +const A = '0a5d4b1e-1b2c-4d3e-8f90-1234567890ab'; +const B = '7c9e6679-7425-40de-944b-e07fc1f90ae7'; + +const parse = (cls: new () => T, query: Record): T => + plainToInstance(cls, query); + +describe('route id-list query params', () => { + it('accepts a single id, still as an array', () => { + const dto = parse(FilterBookingDto, { originYardId: A }); + expect(dto.originYardId).toEqual([A]); + expect(validateSync(dto)).toHaveLength(0); + }); + + it('splits a comma-separated list', () => { + const dto = parse(FilterBookingDto, { originYardId: `${A}, ${B}` }); + expect(dto.originYardId).toEqual([A, B]); + expect(validateSync(dto)).toHaveLength(0); + }); + + it('accepts the repeated-param form', () => { + const dto = parse(ListTrainSchedulesQueryDto, { destinationStationId: [A, B] }); + expect(dto.destinationStationId).toEqual([A, B]); + expect(validateSync(dto)).toHaveLength(0); + }); + + it.each([undefined, '', ','])('yields undefined, never [], for %p', (raw) => { + expect(parse(FilterBookingDto, { originYardId: raw }).originYardId).toBeUndefined(); + }); + + it('leaves the two ends independent — one side set, the other absent', () => { + const dto = parse(FilterBookingDto, { originYardId: A }); + expect(dto.originYardId).toEqual([A]); + expect(dto.destinationYardId).toBeUndefined(); + }); + + it('still rejects a non-uuid inside the list', () => { + const dto = parse(FilterBookingDto, { originYardId: `${A},not-a-uuid` }); + expect(validateSync(dto)).not.toHaveLength(0); + }); +}); diff --git a/apps/edr-freight-api/src/common/dto/id-list.transform.ts b/apps/edr-freight-api/src/common/dto/id-list.transform.ts new file mode 100644 index 000000000..3bfe94a77 --- /dev/null +++ b/apps/edr-freight-api/src/common/dto/id-list.transform.ts @@ -0,0 +1,29 @@ +import { Transform } from 'class-transformer'; + +/** + * A query param that carries one id, a comma-separated list (`a,b,c`), or the + * same key repeated — and always lands on the DTO as a `string[]`. + * + * Two details matter: + * + * - It yields `undefined`, never `[]`, when nothing usable is left. `@IsOptional` + * then short-circuits, and — more importantly — a repository that does + * `if (ids?.length)` can never be handed an empty array, which TypeORM turns + * into the syntax error `IN ()`. + * - It is backwards compatible with the single-value form these params used to + * take, so existing deep links and saved views keep working unchanged. + * + * Pair it with `@IsUUID(undefined, { each: true })` (or the relevant `each` + * validator) — this only reshapes the value, it does not validate it. + */ +export const IdListParam = () => + Transform(({ value }: { value: unknown }) => { + const raw = Array.isArray(value) ? value : [value]; + const ids = raw + .flatMap((entry) => + entry === undefined || entry === null ? [] : String(entry).split(','), + ) + .map((s) => s.trim()) + .filter(Boolean); + return ids.length ? ids : undefined; + }); diff --git a/apps/edr-freight-api/src/common/dto/page-size-cap.spec.ts b/apps/edr-freight-api/src/common/dto/page-size-cap.spec.ts new file mode 100644 index 000000000..574fa0ae6 --- /dev/null +++ b/apps/edr-freight-api/src/common/dto/page-size-cap.spec.ts @@ -0,0 +1,32 @@ +import { plainToInstance } from 'class-transformer'; +import { validateSync } from 'class-validator'; + +import { PaginationQueryDto } from './pagination-query.dto'; +import { ListWagonsQueryDto } from '../../modules/wagons/dto/list-wagons-query.dto'; +import { normalizePagination } from '../utils/pagination.util'; + +/** + * The page-size ceiling is stated in three places that must agree: `@Max` on + * PaginationQueryDto, the same `@Max` repeated on ListWagonsQueryDto (which + * doesn't extend it), and `MAX_PAGE_SIZE` in pagination.util. A fourth copy + * lives outside this package — `MAX_PAGE_SIZE` in @edr/ui-common's data-table + * footer, which is what actually asks for the number. Drift between any of + * them shows up as a 400 on the largest rows-per-page option, so pin them. + */ +const errorsFor = (cls: any, pageSize: unknown) => + validateSync(plainToInstance(cls, { pageSize }), { whitelist: false }); + +describe('page size ceiling', () => { + it.each([PaginationQueryDto, ListWagonsQueryDto])('accepts 500 on %p', (cls) => { + expect(errorsFor(cls, 500)).toHaveLength(0); + }); + + it.each([PaginationQueryDto, ListWagonsQueryDto])('rejects 501 on %p', (cls) => { + expect(errorsFor(cls, 501)).not.toHaveLength(0); + }); + + it('does not truncate 500 in the service-side clamp', () => { + expect(normalizePagination({ page: 1, pageSize: 500 }).take).toBe(500); + expect(normalizePagination({ page: 1, pageSize: 501 }).take).toBe(500); + }); +}); diff --git a/apps/edr-freight-api/src/common/dto/pagination-query.dto.ts b/apps/edr-freight-api/src/common/dto/pagination-query.dto.ts index e705b019d..be04dadba 100644 --- a/apps/edr-freight-api/src/common/dto/pagination-query.dto.ts +++ b/apps/edr-freight-api/src/common/dto/pagination-query.dto.ts @@ -19,12 +19,18 @@ export class PaginationQueryDto { @Min(1) page?: number; - @ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 }) + /** + * Ceiling is 500, matching `MAX_PAGE_SIZE` in `common/utils/pagination.util.ts` + * and the backoffice table footer's largest option. The three have to agree: + * a lower value here turns the footer's top preset into a 400, a higher one + * lets a request through that the util then silently truncates. + */ + @ApiPropertyOptional({ default: 20, minimum: 1, maximum: 500 }) @IsOptional() @Transform(({ value }) => parseInt(String(value), 10) || 20) @IsInt() @Min(1) - @Max(100) + @Max(500) pageSize?: number; @ApiPropertyOptional({ diff --git a/apps/edr-freight-api/src/common/utils/pagination.util.ts b/apps/edr-freight-api/src/common/utils/pagination.util.ts index ca4ed35a1..c7097dde7 100644 --- a/apps/edr-freight-api/src/common/utils/pagination.util.ts +++ b/apps/edr-freight-api/src/common/utils/pagination.util.ts @@ -20,7 +20,12 @@ export interface NormalizedPage { } const DEFAULT_PAGE_SIZE = 20; -const MAX_PAGE_SIZE = 100; +/** + * Must stay in step with `@Max` on `PaginationQueryDto.pageSize` and with + * `MAX_PAGE_SIZE` in the backoffice's data-table footer — the DTO rejects, + * this clamps, and the footer is what actually asks for the number. + */ +const MAX_PAGE_SIZE = 500; /** Clamp raw query values into a safe page window (page ≥ 1, pageSize capped). */ export function normalizePagination( diff --git a/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts index 2f1991df2..8990e1b43 100644 --- a/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts @@ -67,6 +67,7 @@ const TRIGGER_ROUTE_LABELS: Partial> = { DEMURRAGE: 'Demurrage / wagon detention', PIL_EXTRA_FEE: 'PIL shipping line extra fee', CUSTOMS_CLEARANCE: 'Customs clearance service', + ETHIOPIAN_CUSTOMS_CLEARANCE: 'Ethiopian customs clearance service', FUEL: 'Fuel surcharge', }; diff --git a/apps/edr-freight-api/src/migrations/3580000000000-EimsBulkRegistration.ts b/apps/edr-freight-api/src/migrations/3580000000000-EimsBulkRegistration.ts new file mode 100644 index 000000000..140a7ad6a --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3580000000000-EimsBulkRegistration.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Columns for `POST /v1/bulkRegister` — see `EimsBulkRegistrationService`. + * + * `eims_system_state.in_flight_conversation_id` is the bulk equivalent of `in_flight_invoice_id`: + * a whole batch, not one invoice, is what's outstanding while MoR processes it asynchronously. + * `invoices.eims_bulk_conversation_id` tags which batch an invoice was submitted in, so a stuck + * batch (webhook never arrived) can be found and reconciled by conversation id. + */ +export class EimsBulkRegistration3580000000000 implements MigrationInterface { + name = "EimsBulkRegistration3580000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.eims_system_state + ADD COLUMN IF NOT EXISTS in_flight_conversation_id text + `); + await queryRunner.query(` + ALTER TABLE freight.invoices + ADD COLUMN IF NOT EXISTS eims_bulk_conversation_id text + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.eims_system_state + DROP COLUMN IF EXISTS in_flight_conversation_id + `); + await queryRunner.query(` + ALTER TABLE freight.invoices + DROP COLUMN IF EXISTS eims_bulk_conversation_id + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3580000000000-OperationsReporting.ts b/apps/edr-freight-api/src/migrations/3580000000000-OperationsReporting.ts new file mode 100644 index 000000000..1813a7b3a --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3580000000000-OperationsReporting.ts @@ -0,0 +1,114 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Reference data for the operations reporting suite (turnaround, delay, + * trainset, TEU, cargo volume). + * + * Two new tables and two new columns: + * + * - `operations_standards` — single-row settings table, same shape as + * `logo_settings` / `exchange_settings`. Holds the railway's standard times + * and charged-tonnage factors. Editable in the backoffice because the + * business calls the corridor standard "flexible". + * - `operations_targets` — the planned side of every "Plan / Operated / + * Implement Rate" table in the spec. One row per period × metric × + * dimension value. + * - `yard_distances.standard_hours` — the per-corridor standard transit time + * (Negad→GMP 21h, →Adama 20h, →Modjo 20.5h, →Sebeta 22h). Null falls back to + * `operations_standards.default_leg_standard_hours`. + * - `cargo_types.full_trainset_wagons` — wagons in a full trainset of this + * cargo (37 for vehicles, 22 for sand). Null falls back to + * `operations_standards.default_full_trainset_wagons`. + * + * The seed row is inserted only when the table is empty, so re-running this + * never overwrites values an operator has since edited. + */ +export class OperationsReporting3580000000000 implements MigrationInterface { + name = "OperationsReporting3580000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.operations_standards ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + station_standard_hours_ethiopia numeric(6,2) NOT NULL DEFAULT 10, + station_standard_hours_djibouti numeric(6,2) NOT NULL DEFAULT 13, + cycle_standard_hours_container numeric(6,2) NOT NULL DEFAULT 65, + cycle_standard_hours_bulk_dmp numeric(6,2) NOT NULL DEFAULT 88, + cycle_standard_hours_bulk_nagad numeric(6,2) NOT NULL DEFAULT 96, + cycle_standard_hours_bulk_bcc numeric(6,2) NOT NULL DEFAULT 96, + default_leg_standard_hours numeric(6,2) NOT NULL DEFAULT 21, + delay_tolerance_minutes integer NOT NULL DEFAULT 30, + charged_tons_full_20ft numeric(8,2) NOT NULL DEFAULT 20, + charged_tons_full_40ft numeric(8,2) NOT NULL DEFAULT 40, + charged_tons_empty_20ft numeric(8,2) NOT NULL DEFAULT 2.24, + charged_tons_empty_40ft numeric(8,2) NOT NULL DEFAULT 3.88, + charged_tons_per_wagon_general numeric(8,2) NOT NULL DEFAULT 70, + charged_tons_per_wagon_perishable numeric(8,2) NOT NULL DEFAULT 38, + default_full_trainset_wagons integer NOT NULL DEFAULT 50, + updated_by_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ); + `); + + // Column defaults carry every value — the seed only needs the row to exist. + await queryRunner.query(` + INSERT INTO freight.operations_standards (id) + SELECT gen_random_uuid() + WHERE NOT EXISTS (SELECT 1 FROM freight.operations_standards WHERE deleted_at IS NULL); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.operations_targets ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + period_type varchar(10) NOT NULL, + period_start date NOT NULL, + metric varchar(20) NOT NULL, + dimension varchar(20) NOT NULL, + dimension_key varchar(60) NOT NULL, + planned_value numeric(14,3) NOT NULL, + note text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ); + `); + + // Partial unique index rather than a table constraint, so a soft-deleted + // target can be re-created — same choice as yard_distances. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS ux_operations_targets_slot + ON freight.operations_targets (period_type, period_start, metric, dimension, dimension_key) + WHERE deleted_at IS NULL; + `); + + // The reports look targets up by period and metric, never by id. + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_operations_targets_lookup + ON freight.operations_targets (metric, period_type, period_start) + WHERE deleted_at IS NULL; + `); + + await queryRunner.query(` + ALTER TABLE freight.yard_distances + ADD COLUMN IF NOT EXISTS standard_hours numeric(6,2); + `); + + await queryRunner.query(` + ALTER TABLE freight.cargo_types + ADD COLUMN IF NOT EXISTS full_trainset_wagons integer; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.cargo_types DROP COLUMN IF EXISTS full_trainset_wagons;`, + ); + await queryRunner.query( + `ALTER TABLE freight.yard_distances DROP COLUMN IF EXISTS standard_hours;`, + ); + await queryRunner.query(`DROP TABLE IF EXISTS freight.operations_targets;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.operations_standards;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/3590000000000-OperationsTargetCargoCategory.ts b/apps/edr-freight-api/src/migrations/3590000000000-OperationsTargetCargoCategory.ts new file mode 100644 index 000000000..c77087044 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3590000000000-OperationsTargetCargoCategory.ts @@ -0,0 +1,51 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * A station's plan is per station AND per cargo type, not per station. + * + * The OCC monthly report plans "Nagad–Mojo multimodal container 122,010 t" and + * "Nagad–Mojo fertilizer 18,000 t" as separate lines against the same station, + * which the single `dimension_key` column cannot express: a station-keyed target + * would apply the whole station's plan to each of its cargo types. + * + * `cargo_category` is nullable, so `cargo_category` and `container_class` + * targets are unaffected — they leave it null and stay keyed on + * `dimension_key` alone. The uniqueness index moves to include it, since + * (station, category) is now the slot. + */ +export class OperationsTargetCargoCategory3590000000000 implements MigrationInterface { + name = "OperationsTargetCargoCategory3590000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.operations_targets + ADD COLUMN IF NOT EXISTS cargo_category varchar(60); + `); + + await queryRunner.query(`DROP INDEX IF EXISTS freight.ux_operations_targets_slot;`); + + // COALESCE rather than a plain column list: a partial unique index treats + // NULLs as distinct, which would let the same category target be entered + // twice over. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS ux_operations_targets_slot + ON freight.operations_targets ( + period_type, period_start, metric, dimension, dimension_key, + COALESCE(cargo_category, '') + ) + WHERE deleted_at IS NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight.ux_operations_targets_slot;`); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS ux_operations_targets_slot + ON freight.operations_targets (period_type, period_start, metric, dimension, dimension_key) + WHERE deleted_at IS NULL; + `); + await queryRunner.query(` + ALTER TABLE freight.operations_targets DROP COLUMN IF EXISTS cargo_category; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3610000000000-MultipleMiscClearanceCharges.ts b/apps/edr-freight-api/src/migrations/3610000000000-MultipleMiscClearanceCharges.ts new file mode 100644 index 000000000..5cdf6f489 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3610000000000-MultipleMiscClearanceCharges.ts @@ -0,0 +1,38 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Clearance charges are no longer one-of-each in a fixed order: GL Ethiopia + * may raise several MISCELLANEOUS charges, and either level may be created + * first. Port charges stay unique per booking (one port bill per shipment), + * enforced by a partial index instead of the old blanket (booking_id, type) + * uniqueness that also capped miscellaneous at one. + */ +export class MultipleMiscClearanceCharges3610000000000 + implements MigrationInterface +{ + name = 'MultipleMiscClearanceCharges3610000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP INDEX IF EXISTS "freight"."uq_booking_clearance_charge_booking_type" + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "uq_booking_clearance_charge_port" + ON "freight"."booking_clearance_charge" ("booking_id") + WHERE "type" = 'PORT_CHARGES' AND "deleted_at" IS NULL + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "idx_booking_clearance_charge_booking" + ON "freight"."booking_clearance_charge" ("booking_id") + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // No-op on the uniqueness: restoring the blanket (booking_id, type) index + // would fail on any booking that has since raised a second miscellaneous + // charge, which is exactly what this migration set out to allow. + await queryRunner.query(` + DROP INDEX IF EXISTS "freight"."uq_booking_clearance_charge_port" + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3620000000000-AdditionalCharge.ts b/apps/edr-freight-api/src/migrations/3620000000000-AdditionalCharge.ts new file mode 100644 index 000000000..2525c1b3d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3620000000000-AdditionalCharge.ts @@ -0,0 +1,43 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** Ad-hoc customer charges finance raises against a booking — Additional Payments tab. */ +export class AdditionalCharge3620000000000 implements MigrationInterface { + name = 'AdditionalCharge3620000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "freight"."additional_charge" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now(), + "deleted_at" timestamptz, + "booking_id" uuid NOT NULL, + "reason" text NOT NULL, + "status" character varying(20) NOT NULL DEFAULT 'DRAFT', + "amount" numeric(14,2) NOT NULL, + "currency" character varying(8) NOT NULL, + "file_record_id" uuid, + "invoice_id" uuid, + "payment_reference" character varying(64), + "created_by_staff_id" uuid, + "sent_by_staff_id" uuid, + "sent_at" timestamptz, + "paid_at" timestamptz, + "cancelled_by_staff_id" uuid, + "cancelled_at" timestamptz, + "cancel_reason" text, + CONSTRAINT "pk_additional_charge" PRIMARY KEY ("id"), + CONSTRAINT "fk_additional_charge_booking" FOREIGN KEY ("booking_id") + REFERENCES "freight"."bookings"("id") ON DELETE CASCADE + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "idx_additional_charge_booking" + ON "freight"."additional_charge" ("booking_id") + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS "freight"."additional_charge"`); + } +} diff --git a/apps/edr-freight-api/src/migrations/3620000000000-SchedulePlannedWagonYards.ts b/apps/edr-freight-api/src/migrations/3620000000000-SchedulePlannedWagonYards.ts new file mode 100644 index 000000000..9313c22a5 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3620000000000-SchedulePlannedWagonYards.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Per-schedule wagon yard plan — where THIS departure expects each consist + * wagon to board, independent of where the wagon physically stands today. + * + * `wagons.current_yard_id` is one physical fact shared by every schedule of a + * built train, so a train standing in Mojo could not be sold from Dire for a + * departure next week. The plan is a sparse jsonb map `{ wagonId: yardId }` + * on the schedule: a wagon missing from the map boards from its physical yard. + * Booking capacity, fleet availability and wagon pinning all read the plan; + * dispatch refuses to leave until the plan and the physical yards agree. + */ +export class SchedulePlannedWagonYards3620000000000 implements MigrationInterface { + name = 'SchedulePlannedWagonYards3620000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN IF NOT EXISTS planned_wagon_yards jsonb + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS planned_wagon_yards + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3630000000000-ClearanceChargeCustomerDecision.ts b/apps/edr-freight-api/src/migrations/3630000000000-ClearanceChargeCustomerDecision.ts new file mode 100644 index 000000000..a9cb259cd --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3630000000000-ClearanceChargeCustomerDecision.ts @@ -0,0 +1,49 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * The customer now approves a clearance charge before it becomes an invoice: + * GL describes the price, SENDs it, the customer ACCEPTs (invoice issued, charge + * locked) or REJECTs with a note (GL revises and re-sends). Charges that were + * already sent as invoices under the old flow are carried over as ACCEPTED so + * their invoices stay payable. + */ +export class ClearanceChargeCustomerDecision3630000000000 + implements MigrationInterface +{ + name = 'ClearanceChargeCustomerDecision3630000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "freight"."booking_clearance_charge" + ADD COLUMN IF NOT EXISTS "description" text, + ADD COLUMN IF NOT EXISTS "customer_note" text, + ADD COLUMN IF NOT EXISTS "customer_decided_at" timestamptz, + ADD COLUMN IF NOT EXISTS "customer_decided_by" uuid + `); + await queryRunner.query(` + UPDATE "freight"."booking_clearance_charge" + SET "status" = 'ACCEPTED' + WHERE "status" = 'SENT' AND "invoice_id" IS NOT NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE "freight"."booking_clearance_charge" + SET "status" = 'SENT' + WHERE "status" = 'ACCEPTED' + `); + await queryRunner.query(` + UPDATE "freight"."booking_clearance_charge" + SET "status" = 'BILLED' + WHERE "status" = 'REJECTED' + `); + await queryRunner.query(` + ALTER TABLE "freight"."booking_clearance_charge" + DROP COLUMN IF EXISTS "description", + DROP COLUMN IF EXISTS "customer_note", + DROP COLUMN IF EXISTS "customer_decided_at", + DROP COLUMN IF EXISTS "customer_decided_by" + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3640000000000-EthiopianCustomsClearance.ts b/apps/edr-freight-api/src/migrations/3640000000000-EthiopianCustomsClearance.ts new file mode 100644 index 000000000..50ceda3ca --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3640000000000-EthiopianCustomsClearance.ts @@ -0,0 +1,69 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Ethiopian-side-only customs clearance: + * + * - service_types.includes_ethiopian_customs_only marks a customs service that + * EDR clears on the Ethiopian side only. Same clearance flow; only the fee + * differs — pricing looks up the ETHIOPIAN_CUSTOMS_CLEARANCE rate instead of + * CUSTOMS_CLEARANCE. + * - rates.trigger widens to 30 chars to fit the new trigger value. + * - CK_rates_yard_scope gains ETHIOPIAN_CUSTOMS_CLEARANCE in its yard-carrying + * branch: it is priced per origin → destination leg like customs clearance. + */ +export class EthiopianCustomsClearance3640000000000 implements MigrationInterface { + name = 'EthiopianCustomsClearance3640000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.service_types + ADD COLUMN IF NOT EXISTS includes_ethiopian_customs_only boolean NOT NULL DEFAULT false + `); + + await queryRunner.query( + `ALTER TABLE freight.rates ALTER COLUMN trigger TYPE varchar(30)`, + ); + + await queryRunner.query( + `ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`, + ); + await queryRunner.query(` + ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_yard_scope" CHECK ( + deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR + CASE + WHEN (trigger = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY')) + OR trigger IN ('CUSTOMS_CLEARANCE', 'ETHIOPIAN_CUSTOMS_CLEARANCE', 'WITH_RETURN', 'FUEL') + THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL + ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL + END + ) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`, + ); + await queryRunner.query(` + ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_yard_scope" CHECK ( + deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR + CASE + WHEN (trigger = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY')) + OR trigger IN ('CUSTOMS_CLEARANCE', 'WITH_RETURN', 'FUEL') + THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL + ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL + END + ) + `); + // Rows on the new trigger would not fit varchar(20) — drop them first. + await queryRunner.query( + `DELETE FROM freight.rates WHERE trigger = 'ETHIOPIAN_CUSTOMS_CLEARANCE'`, + ); + await queryRunner.query( + `ALTER TABLE freight.rates ALTER COLUMN trigger TYPE varchar(20)`, + ); + await queryRunner.query( + `ALTER TABLE freight.service_types DROP COLUMN IF EXISTS includes_ethiopian_customs_only`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts b/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts index deda35e1c..019d99920 100644 --- a/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts +++ b/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts @@ -47,6 +47,7 @@ export const AUDIT_ENDPOINTS: Readonly> = { "POST /api/bookings/:id/clearance/proceed": ["Customer requests operation with a schedule day", "POST", "Booking"], "POST /api/bookings/:id/clearance/release-order": ["Upload Booking Release Order", "POST", "Booking"], "POST /api/bookings/:id/clearance/review": ["GL reviews a clearance document (Approve | Query)", "POST", "Booking"], + "POST /api/bookings/:id/clearance/doc-requests": ["GL asks the customer for additional clearance documents", "POST", "Booking"], "POST /api/bookings/:id/clearance/charges/port-document": ["GL Djibouti uploads the port-charges document", "POST", "Booking"], "PATCH /api/bookings/:id/clearance/charges/:chargeId/bill": ["GL Ethiopia sets or revises a clearance charge's amount + currency", "PATCH", "Booking"], "POST /api/bookings/:id/clearance/charges/:chargeId/send": ["GL Ethiopia issues the clearance charge invoice to the customer", "POST", "Booking"], diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index d64361bf8..247a1dad5 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -13,6 +13,7 @@ import { logCtx } from "@edr/api-common"; import { DataSource, EntityManager, In, SelectQueryBuilder } from "typeorm"; import { Booking } from "../bookings/entities/booking.entity"; +import { AdditionalCharge } from "../bookings/entities/additional-charge.entity"; // Entity-only import (no module edge): portal reads resolve shipping-line // payers straight off the table. import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity"; @@ -46,6 +47,29 @@ export interface PayInvoiceOptions { failureUrl?: string; } +/** + * What an invoice's `sourceId` actually points at, resolved for display. + * + * `source` alone ("warehouse", "booking", …) says which subsystem raised the + * invoice but nothing about *which* record, and `sourceId` is a raw UUID. Every + * source except a shipping-line credit hangs off a booking — directly + * (booking/clearance) or through the warehouse/first-mile/last-mile record — + * so the booking reference is the one label that identifies almost any row. + */ +export interface InvoiceSourceRef { + /** Booking behind the invoice, when there is one. Null for shipping-line credits. */ + bookingId: string | null; + bookingReference: string | null; + tradeDirection: string | null; + /** Warehouse-sourced rows: the goods-received note the fees were raised against. */ + grnNumber: string | null; + /** Shipping-line credit rows: `sourceId` is the line's own id, not a record's. */ + shippingLineName: string | null; +} + +/** Row shape of the backoffice invoice list: the entity plus its resolved source. */ +export type InvoiceListRow = Invoice & { sourceRef: InvoiceSourceRef | null }; + /** Booking context attached to a finance offline-USD invoice row. */ export interface OfflineUsdBookingInfo { id: string; @@ -236,8 +260,32 @@ export class BillingService { qb.andWhere("invoice.status = :status", { status: filter.status }); } if (filter.search) { + // Searches what the row actually shows: its number, who it bills, and + // the source record behind it (booking reference, GRN, shipping line). + // The raw `sourceId` stays matchable so a pasted UUID still resolves. + // Requires the `company` alias — every caller of this joins it. qb.andWhere( - "(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)", + `(invoice.invoiceNumber ILIKE :search + OR invoice.sourceId ILIKE :search + OR company.name ILIKE :search + OR EXISTS ( + SELECT 1 FROM freight.bookings b + LEFT JOIN freight.warehouse_inventory wi ON wi.booking_id = b.id + LEFT JOIN freight.first_mile fm ON fm.booking_id = b.id + LEFT JOIN freight.last_mile lm ON lm.booking_id = b.id + WHERE b.reference ILIKE :search + AND (b.id::text = invoice.source_id + OR wi.id::text = invoice.source_id + OR fm.id::text = invoice.source_id + OR lm.id::text = invoice.source_id)) + OR EXISTS ( + SELECT 1 FROM freight.warehouse_inventory wi2 + WHERE wi2.id::text = invoice.source_id + AND wi2.grn_number ILIKE :search) + OR EXISTS ( + SELECT 1 FROM freight.shipping_line_companies slc + WHERE slc.id::text = invoice.source_id + AND slc.name ILIKE :search))`, { search: `%${filter.search}%` }, ); } @@ -261,7 +309,7 @@ export class BillingService { /** Per-user trade-direction scope, applied via the source booking. */ tradeDirections?: string[]; } = {}, - ): Promise<{ items: Invoice[]; total: number }> { + ): Promise<{ items: InvoiceListRow[]; total: number }> { const page = filter.page && filter.page > 0 ? filter.page : 1; const pageSize = filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20; @@ -277,7 +325,92 @@ export class BillingService { this.applyInvoiceFilters(qb, filter); const [items, total] = await qb.getManyAndCount(); - return { items: await this.attachShippingLineCompanies(items), total }; + const withLines = await this.attachShippingLineCompanies(items); + return { items: await this.attachSourceRefs(withLines), total }; + } + + /** + * Resolve each row's `sourceId` to the record it points at, in one query for + * the whole page. `sourceId` is a bare varchar pointer with no FK and no + * relation to eager-load, and which table it addresses depends on `source` — + * so this walks every candidate table at once and lands on the booking + * through whichever one matched. + * + * `sourceId` is not always a UUID (EIMS self-test rows carry a slug), hence + * the shape guard before every cast — an unguarded `::uuid` throws on those. + */ + private async attachSourceRefs( + invoices: T[], + ): Promise<(T & { sourceRef: InvoiceSourceRef | null })[]> { + const sourceIds = [ + ...new Set(invoices.map((i) => i.sourceId).filter(Boolean)), + ]; + if (!sourceIds.length) { + return invoices.map((invoice) => ({ ...invoice, sourceRef: null })); + } + + const rows: { + sourceId: string; + bookingId: string | null; + bookingReference: string | null; + tradeDirection: string | null; + grnNumber: string | null; + shippingLineName: string | null; + }[] = await this.dataSource.query( + `SELECT s.source_id AS "sourceId", + b.id::text AS "bookingId", + b.reference AS "bookingReference", + b.trade_direction AS "tradeDirection", + wi.grn_number AS "grnNumber", + slc.name AS "shippingLineName" + FROM unnest($1::text[]) AS s(source_id) + LEFT JOIN freight.warehouse_inventory wi + ON wi.id = (CASE WHEN s.source_id ~ '^[0-9a-fA-F-]{36}$' + THEN s.source_id::uuid END) + AND wi.deleted_at IS NULL + LEFT JOIN freight.first_mile fm + ON fm.id = (CASE WHEN s.source_id ~ '^[0-9a-fA-F-]{36}$' + THEN s.source_id::uuid END) + AND fm.deleted_at IS NULL + LEFT JOIN freight.last_mile lm + ON lm.id = (CASE WHEN s.source_id ~ '^[0-9a-fA-F-]{36}$' + THEN s.source_id::uuid END) + AND lm.deleted_at IS NULL + LEFT JOIN freight.bookings b + ON b.id = COALESCE(wi.booking_id, fm.booking_id, lm.booking_id, + CASE WHEN s.source_id ~ '^[0-9a-fA-F-]{36}$' + THEN s.source_id::uuid END) + AND b.deleted_at IS NULL + LEFT JOIN freight.shipping_line_companies slc + ON slc.id = (CASE WHEN s.source_id ~ '^[0-9a-fA-F-]{36}$' + THEN s.source_id::uuid END) + AND slc.deleted_at IS NULL`, + [sourceIds], + ); + + const bySourceId = new Map(rows.map((r) => [r.sourceId, r])); + return invoices.map((invoice) => { + const row = bySourceId.get(invoice.sourceId); + const sourceRef: InvoiceSourceRef | null = row + ? { + bookingId: row.bookingId, + bookingReference: row.bookingReference, + tradeDirection: row.tradeDirection, + grnNumber: row.grnNumber, + shippingLineName: row.shippingLineName, + } + : null; + // Nothing resolved (an EIMS self-test row, a deleted record) → null, + // and the UI falls back to the plain source label. + const resolved = + sourceRef && + (sourceRef.bookingId || + sourceRef.grnNumber || + sourceRef.shippingLineName) + ? sourceRef + : null; + return { ...invoice, sourceRef: resolved }; + }); } /** @@ -336,6 +469,9 @@ export class BillingService { const qb = this.dataSource .getRepository(Invoice) .createQueryBuilder("invoice") + // Joined, not selected: `applyInvoiceFilters` searches the customer name, + // so the alias has to exist even though the summary only sums money. + .leftJoin("invoice.company", "company") .select("invoice.currency", "currency") .addSelect("SUM(invoice.paidAmount)", "collected") .groupBy("invoice.currency"); @@ -1823,6 +1959,14 @@ export class BillingService { .getRepository(Booking) .update({ id: invoice.sourceId }, { pnrCode: billReference }); } + // Same reference, for an ad-hoc additional charge — its own column, since + // an AdditionalCharge doesn't own a Booking-scoped `pnrCode` and a booking + // can carry many of these at once. + if (billReference && invoice.source === Freight.InvoiceSource.AdditionalCharge) { + await this.dataSource + .getRepository(AdditionalCharge) + .update({ id: invoice.sourceId }, { paymentReference: billReference }); + } // Settlement is driven by the payment API (webhook/outbox → payment.succeeded); // billing must not simulate it. Kept for local demos only. diff --git a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts index 43b6e3271..66bf0f452 100644 --- a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts +++ b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts @@ -203,4 +203,12 @@ export class Invoice extends BaseEntity { @ManyToOne(() => Invoice) @JoinColumn({ name: "related_invoice_id" }) relatedInvoice?: Invoice | null; + + /** + * Which `POST /v1/bulkRegister` batch this invoice was submitted in, if any — MoR's own + * conversation id, not one we generate. Lets a stuck batch (webhook never arrived) be found and + * reconciled. Null for every invoice filed through single `/v1/register`. + */ + @Column({ name: "eims_bulk_conversation_id", type: "text", nullable: true }) + eimsBulkConversationId?: string | null; } diff --git a/apps/edr-freight-api/src/modules/bookings/ad-hoc-label.spec.ts b/apps/edr-freight-api/src/modules/bookings/ad-hoc-label.spec.ts new file mode 100644 index 000000000..dd67b7acc --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/ad-hoc-label.spec.ts @@ -0,0 +1,33 @@ +import { adHocLabel } from './clearance.util'; + +/** + * The customer's typed document name travels to the API inside the multipart + * field code (`custom__`) — the only channel a part has — and comes + * back out here for GL's review grid. Mirror of `adHocSlug` in the portal's + * useClearanceFlow. + */ +const adHocSlug = (name: string) => + name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 60); + +const roundTrip = (typed: string) => adHocLabel(`custom_${adHocSlug(typed)}_17877000000000`); + +describe('adHocLabel', () => { + it('recovers the name the customer typed', () => { + expect(roundTrip('Special permit')).toBe('Special permit'); + expect(roundTrip('Fumigation Certificate')).toBe('Fumigation certificate'); + expect(roundTrip('bank slip #2')).toBe('Bank slip 2'); + }); + + it('returns null when there is no name to show, so callers use the filename', () => { + expect(roundTrip('')).toBeNull(); + // Legacy uploads keyed `custom__` carry no name — without the + // digits guard this would surface "1755780000000" as the document label. + expect(adHocLabel('custom_1755780000000_0')).toBeNull(); + expect(adHocLabel('commercial_invoice')).toBeNull(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/additional-charge.repository.ts b/apps/edr-freight-api/src/modules/bookings/additional-charge.repository.ts new file mode 100644 index 000000000..9a7e652b0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/additional-charge.repository.ts @@ -0,0 +1,13 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { AdditionalCharge } from './entities/additional-charge.entity'; + +@Injectable() +export class AdditionalChargeRepository extends BaseRepository { + constructor(@InjectRepository(AdditionalCharge) repository: Repository) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/additional-charge.service.ts b/apps/edr-freight-api/src/modules/bookings/additional-charge.service.ts new file mode 100644 index 000000000..bb5836d1a --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/additional-charge.service.ts @@ -0,0 +1,281 @@ +import { ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { DataSource, EntityManager } from 'typeorm'; +import { Freight, NotificationAudience, NotificationType } from '@edr/types'; + +import { BillingService, InvoiceEventPayload } from '../billing/billing.service'; +import { Invoice } from '../billing/entities/invoice.entity'; +import { FilesService } from '../files/files.service'; +import { NotificationsService } from '../notifications/notifications.service'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { sendCompanyChannels } from '../notifications/notify-company.util'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { BookingsService } from './bookings.service'; +import { BookingsRepository } from './bookings.repository'; +import { AdditionalChargeRepository } from './additional-charge.repository'; +import { AdditionalCharge } from './entities/additional-charge.entity'; +import { CreateAdditionalChargeDto } from './dto/additional-charge.dto'; + +const FILE_RESOURCE = 'additional_charges'; + +/** + * Ad-hoc extra charges finance raises against a booking, independent of + * `BookingClearanceCharge` (which is capped at one PORT_CHARGES/MISCELLANEOUS + * row per booking). Any number per booking, free-text reason. DRAFT until + * sent; sending issues the payable invoice and notifies the customer + * (in-app + SMS + email). Settles via `additional_charge.invoice.paid`, + * same event-driven pattern as every other invoice source. + */ +@Injectable() +export class AdditionalChargeService { + private readonly logger = new Logger(AdditionalChargeService.name); + + constructor( + private readonly dataSource: DataSource, + private readonly repository: AdditionalChargeRepository, + private readonly bookingsRepository: BookingsRepository, + private readonly filesService: FilesService, + private readonly billing: BillingService, + private readonly bookingsService: BookingsService, + private readonly notifications: NotificationsService, + private readonly inbox: NotificationInboxService, + ) {} + + private async findOwned(bookingId: string, chargeId: string): Promise { + const charge = await this.repository.findById(chargeId); + if (!charge || charge.bookingId !== bookingId) { + throw new NotFoundException('Additional charge not found'); + } + return charge; + } + + async list(bookingId: string): Promise { + const rows = await this.repository.findAll({ + where: { bookingId }, + order: { createdAt: 'DESC' }, + }); + return this.toDtoList(rows); + } + + async create( + bookingId: string, + dto: CreateAdditionalChargeDto, + staffId: string, + file?: Express.Multer.File, + ): Promise { + const booking = await this.bookingsService.findById(bookingId); + const shouldSend = dto.action === 'send'; + + const chargeId = await this.dataSource.transaction(async (manager) => { + const repo = manager.getRepository(AdditionalCharge); + let saved = await repo.save( + repo.create({ + bookingId, + reason: dto.reason.trim(), + amount: dto.amount.toFixed(2), + currency: dto.currency.trim().toUpperCase(), + status: 'DRAFT', + createdByStaffId: staffId, + }), + ); + + if (file) { + const record = await this.filesService.upload({ + resourceId: saved.id, + resource: FILE_RESOURCE, + code: FILE_RESOURCE, + file, + uploadedByUserId: staffId, + }); + await repo.update(saved.id, { fileRecordId: record.id }); + } + + if (shouldSend) { + saved = await this.issueInvoice(manager, saved.id, booking, staffId); + } + return saved.id; + }); + + if (shouldSend) await this.notifyCustomerSent(chargeId); + return this.list(bookingId); + } + + async send(bookingId: string, chargeId: string, staffId: string): Promise { + const charge = await this.findOwned(bookingId, chargeId); + if (charge.status !== 'DRAFT') { + throw new ConflictException('Only a draft charge can be sent.'); + } + const booking = await this.bookingsService.findById(bookingId); + + await this.dataSource.transaction((manager) => + this.issueInvoice(manager, charge.id, booking, staffId), + ); + await this.notifyCustomerSent(charge.id); + return this.list(bookingId); + } + + /** Issues the invoice and flips DRAFT → SENT. Notification happens after commit — never inside the transaction. */ + private async issueInvoice( + manager: EntityManager, + chargeId: string, + booking: { id: string; companyId?: string | null; companyProfileId?: string | null; reference?: string | null }, + staffId: string, + ): Promise { + const repo = manager.getRepository(AdditionalCharge); + const charge = await repo.findOneByOrFail({ id: chargeId }); + + const invoice = await this.billing.generateInvoice( + { + source: Freight.InvoiceSource.AdditionalCharge, + sourceId: charge.id, + type: 'ADDITIONAL_CHARGE', + companyId: booking.companyId, + companyProfileId: booking.companyProfileId, + currency: charge.currency, + lines: [ + { + chargeType: 'ADDITIONAL_CHARGE', + description: `${charge.reason} — ${booking.reference ?? booking.id}`, + amount: Number(charge.amount), + }, + ], + }, + manager, + ); + + await repo.update(charge.id, { + status: 'SENT', + invoiceId: invoice.id, + sentByStaffId: staffId, + sentAt: new Date(), + }); + this.logger.log( + `Additional charge ${charge.id} on booking ${booking.id} sent as invoice ${invoice.invoiceNumber}`, + ); + return repo.findOneByOrFail({ id: charge.id }); + } + + private async notifyCustomerSent(chargeId: string): Promise { + try { + const charge = await this.repository.findById(chargeId); + if (!charge) return; + const booking = await this.bookingsService.findById(charge.bookingId); + if (!booking.companyId) return; + const body = `A new charge of ${charge.amount} ${charge.currency} has been added to booking ${booking.reference ?? charge.bookingId}: ${charge.reason}. Pay via the portal.`; + await this.inbox.notify({ + recipients: { companyId: booking.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.INVOICE_ISSUED, + title: 'New charge on your booking', + body, + link: `/bookings/${charge.bookingId}`, + data: { + bookingId: charge.bookingId, + chargeId: charge.id, + amount: Number(charge.amount), + currency: charge.currency, + }, + }); + await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body); + } catch (err) { + this.logger.warn(`Additional charge sent-notify failed for ${chargeId}: ${(err as Error).message}`); + } + } + + async cancel( + bookingId: string, + chargeId: string, + staffId: string, + reason?: string, + ): Promise { + const charge = await this.findOwned(bookingId, chargeId); + if (charge.status !== 'DRAFT' && charge.status !== 'SENT') { + throw new ConflictException('Only a draft or unpaid charge can be cancelled.'); + } + if (charge.status === 'SENT' && charge.invoiceId) { + await this.billing.cancelInvoice(charge.invoiceId); + } + await this.repository.update(charge.id, { + status: 'CANCELLED', + cancelledByStaffId: staffId, + cancelledAt: new Date(), + cancelReason: reason ?? null, + }); + return this.list(bookingId); + } + + /** Gateway and manual settlements both land here (`${source}.invoice.paid`). */ + @OnEvent('additional_charge.invoice.paid') + async onChargeInvoicePaid(payload: InvoiceEventPayload): Promise { + const charge = await this.repository.findById(payload.sourceId); + if (!charge || charge.status === 'PAID') return; + await this.repository.update(charge.id, { status: 'PAID', paidAt: new Date() }); + + try { + const booking = await this.bookingsService.findById(charge.bookingId); + if (!booking.companyId) return; + const body = `Payment received for ${charge.amount} ${charge.currency} on booking ${booking.reference ?? charge.bookingId}: ${charge.reason}.`; + await this.inbox.notify({ + recipients: { companyId: booking.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.PAYMENT_RECEIVED, + title: 'Charge payment received', + body, + link: `/bookings/${charge.bookingId}`, + data: { bookingId: charge.bookingId, chargeId: charge.id }, + }); + await this.inbox.notify({ + recipients: { permissionKeys: [FREIGHT_PERMS.additionalCharges.getNotification] }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.PAYMENT_RECEIVED, + title: 'Additional charge paid', + body, + link: `/bookings/${charge.bookingId}`, + data: { bookingId: charge.bookingId, chargeId: charge.id }, + }); + } catch (err) { + this.logger.warn(`Additional charge paid-notify failed for ${charge.id}: ${(err as Error).message}`); + } + } + + private async toDtoList(rows: AdditionalCharge[]): Promise { + if (!rows.length) return []; + + const filesByCharge = await this.filesService.findByResourceIdsGrouped( + rows.map((r) => r.id), + FILE_RESOURCE, + ); + const names = await this.bookingsRepository.resolveStaffNames( + rows.flatMap((r) => [r.createdByStaffId, r.sentByStaffId]), + ); + + const invoiceIds = rows.map((r) => r.invoiceId).filter((id): id is string => Boolean(id)); + const invoices = invoiceIds.length + ? await this.dataSource.getRepository(Invoice).find({ where: invoiceIds.map((id) => ({ id })) }) + : []; + const invoiceById = new Map(invoices.map((i) => [i.id, i])); + + return rows.map((r) => { + const file = filesByCharge.get(r.id)?.[0]; + return { + id: r.id, + bookingId: r.bookingId, + reason: r.reason, + status: r.status, + amount: Number(r.amount), + currency: r.currency, + file: file ? { id: file.id, name: file.name, url: file.url } : null, + invoiceId: r.invoiceId ?? null, + invoiceNumber: r.invoiceId ? (invoiceById.get(r.invoiceId)?.invoiceNumber ?? null) : null, + paymentReference: r.paymentReference ?? null, + createdByName: r.createdByStaffId ? (names.get(r.createdByStaffId) ?? null) : null, + createdAt: r.createdAt.toISOString(), + sentByName: r.sentByStaffId ? (names.get(r.sentByStaffId) ?? null) : null, + sentAt: r.sentAt?.toISOString() ?? null, + paidAt: r.paidAt?.toISOString() ?? null, + cancelledAt: r.cancelledAt?.toISOString() ?? null, + cancelReason: r.cancelReason ?? null, + }; + }); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-clearance-charge.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-clearance-charge.service.ts index f41bff39d..a1bceb844 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-clearance-charge.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-clearance-charge.service.ts @@ -14,9 +14,11 @@ import { Invoice } from '../billing/entities/invoice.entity'; import { FilesService } from '../files/files.service'; import { BookingsService } from './bookings.service'; import { BookingsRepository } from './bookings.repository'; +import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service'; import { Booking } from './entities/booking.entity'; import { BookingClearanceCharge, + ClearanceChargeStatus, ClearanceChargeType, } from './entities/booking-clearance-charge.entity'; import { ClearanceEventService } from './clearance-event.service'; @@ -32,13 +34,22 @@ const CHARGE_LABEL: Record = { MISCELLANEOUS: 'Miscellaneous charges', }; +/** Statuses the customer sees — drafts (DOC_UPLOADED / BILLED) stay GL-internal. */ +export const CUSTOMER_VISIBLE_CHARGE_STATUSES: ReadonlySet = + new Set(['SENT', 'REJECTED', 'ACCEPTED', 'PAID']); + +/** Once the customer has accepted (invoice issued) or paid, GL cannot touch the charge. */ +export const canStaffEditCharge = (status: ClearanceChargeStatus): boolean => + status !== 'ACCEPTED' && status !== 'PAID'; + /** - * Post-finalization clearance charges billed to the customer. Two levels per - * booking: GL Djibouti uploads the port-charges document; GL Ethiopia bills it - * (amount + currency) and sends the invoice; once that invoice is paid GL - * Ethiopia may create and send the miscellaneous charge. ETB invoices are paid - * through the portal gateway, other currencies through Finance's manual - * settlement worklist — both settle via `clearance_charge.invoice.paid`. + * Post-finalization clearance charges billed to the customer: one port charge + * (document from GL Djibouti, priced by GL Ethiopia) and any number of + * miscellaneous charges. GL prices + describes a charge and SENDs it; the + * customer REJECTs with a note (GL revises, re-sends) or ACCEPTs, which issues + * the payable invoice and locks the charge. ETB invoices are paid through the + * portal gateway, other currencies through Finance's manual settlement + * worklist — both settle via `clearance_charge.invoice.paid`. */ @Injectable() export class BookingClearanceChargeService { @@ -51,6 +62,7 @@ export class BookingClearanceChargeService { private readonly bookingsService: BookingsService, private readonly bookingsRepository: BookingsRepository, private readonly clearanceEvents: ClearanceEventService, + private readonly notifier: BookingLifecycleNotifierService, ) {} private repo() { @@ -104,6 +116,11 @@ export class BookingClearanceChargeService { file: file ? { id: file.id, name: file.name, url: file.url } : null, amount: c.amount != null ? Number(c.amount) : null, currency: c.currency ?? null, + description: c.description ?? null, + customerNote: c.customerNote ?? null, + customerDecidedAt: c.customerDecidedAt + ? c.customerDecidedAt.toISOString() + : null, invoiceId: c.invoiceId ?? null, invoiceNumber: c.invoiceId ? (invoiceById.get(c.invoiceId)?.invoiceNumber ?? null) @@ -121,6 +138,24 @@ export class BookingClearanceChargeService { }); } + /** The customer's view: only charges GL has sent them. */ + async listForCustomer(bookingId: string): Promise { + return (await this.list(bookingId)).filter((c) => + CUSTOMER_VISIBLE_CHARGE_STATUSES.has(c.status), + ); + } + + private async findCharge( + bookingId: string, + chargeId: string, + ): Promise { + const charge = await this.repo().findOne({ + where: { id: chargeId, bookingId }, + }); + if (!charge) throw new NotFoundException('Clearance charge not found'); + return charge; + } + /** GL Djibouti uploads (or replaces, until billed) the port-charges document. */ async uploadPortDocument( bookingId: string, @@ -180,22 +215,21 @@ export class BookingClearanceChargeService { } /** - * GL Ethiopia sets (or, on the customer's request, revises) amount + - * currency. Revising a SENT charge cancels its unpaid invoice; a PAID charge - * is immutable. + * GL Ethiopia sets (or, after a customer rejection, revises) amount + + * currency + description. Allowed until the customer accepts: an ACCEPTED + * charge already carries an invoice and a PAID one is settled. */ async billCharge( bookingId: string, chargeId: string, - input: { amount: number; currency: string }, + input: { amount: number; currency: string; description?: string }, staffId: string, ): Promise { - const charge = await this.repo().findOne({ - where: { id: chargeId, bookingId }, - }); - if (!charge) throw new NotFoundException('Clearance charge not found'); - if (charge.status === 'PAID') { - throw new ConflictException('A paid charge can no longer be changed.'); + const charge = await this.findCharge(bookingId, chargeId); + if (!canStaffEditCharge(charge.status)) { + throw new ConflictException( + 'The customer has accepted this charge — it can no longer be changed.', + ); } if (!(input.amount > 0)) { throw new BadRequestException('Amount must be greater than zero.'); @@ -203,53 +237,117 @@ export class BookingClearanceChargeService { if (!input.currency?.trim()) { throw new BadRequestException('Currency is required.'); } - - if (charge.status === 'SENT' && charge.invoiceId) { - await this.billing.cancelInvoice(charge.invoiceId); + const description = (input.description ?? charge.description ?? '').trim(); + if (charge.type === 'MISCELLANEOUS' && !description) { + throw new BadRequestException('Describe what this charge is for.'); } + const currency = input.currency.trim().toUpperCase(); + const revised = charge.status === 'SENT' || charge.status === 'REJECTED'; + // Back to draft: the customer's previous decision no longer applies. await this.repo().update(charge.id, { amount: input.amount.toFixed(2), - currency: input.currency.trim().toUpperCase(), + currency, + description: description || null, status: 'BILLED', - invoiceId: null, + customerNote: null, + customerDecidedAt: null, + customerDecidedBy: null, billedByStaffId: staffId, billedAt: new Date(), }); await this.clearanceEvents.record({ bookingId, action: 'CHARGE_BILLED', - label: `${charge.status === 'SENT' ? 'Revised' : 'Billed'} ${CHARGE_LABEL[ + label: `${revised ? 'Revised' : 'Billed'} ${CHARGE_LABEL[ charge.type - ].toLowerCase()}: ${input.amount} ${input.currency.trim().toUpperCase()}`, + ].toLowerCase()}: ${input.amount} ${currency}${ + description ? ` — ${description}` : '' + }`, actorId: staffId, metadata: { chargeType: charge.type, amount: input.amount, - currency: input.currency.trim().toUpperCase(), - revised: charge.status === 'SENT', + currency, + description: description || null, + revised, }, }); return this.list(bookingId); } - /** GL Ethiopia issues the payable invoice to the customer. */ + /** + * GL Ethiopia proposes the priced charge to the customer. No invoice yet — + * that is issued when the customer accepts. Re-sending after a rejection + * goes through here too. + */ async sendCharge( bookingId: string, chargeId: string, - staffId?: string, + staffId: string, ): Promise { - const charge = await this.repo().findOne({ - where: { id: chargeId, bookingId }, - }); - if (!charge) throw new NotFoundException('Clearance charge not found'); - if (charge.status !== 'BILLED') { + const charge = await this.findCharge(bookingId, chargeId); + if (charge.status !== 'BILLED' && charge.status !== 'REJECTED') { throw new ConflictException( - 'Set the amount and currency before sending the charge to the customer.', + charge.status === 'DOC_UPLOADED' + ? 'Set the amount and currency before sending the charge to the customer.' + : 'This charge has already been sent to the customer.', ); } + const revised = charge.status === 'REJECTED'; + const amount = Number(charge.amount); + const currency = charge.currency ?? 'ETB'; + await this.repo().update(charge.id, { + status: 'SENT', + customerNote: null, + customerDecidedAt: null, + customerDecidedBy: null, + }); + await this.clearanceEvents.record({ + bookingId, + action: 'CHARGE_SENT', + label: `${revised ? 'Re-sent' : 'Sent'} ${CHARGE_LABEL[ + charge.type + ].toLowerCase()} to the customer for approval: ${amount} ${currency}`, + actorId: staffId ?? null, + metadata: { + chargeType: charge.type, + amount, + currency, + description: charge.description ?? null, + revised, + }, + }); const booking = await this.bookingsService.findById(bookingId); + this.notifier.clearanceChargeProposed(booking, { + label: CHARGE_LABEL[charge.type], + amount, + currency, + description: charge.description ?? null, + revised, + }); + return this.list(bookingId); + } + + /** Customer agrees to the price: the payable invoice is issued and the charge locks. */ + async customerAccept( + bookingId: string, + chargeId: string, + userId: string, + ): Promise { + const booking = await this.bookingsService.findById(bookingId); + await this.bookingsService.assertCustomerCanAccessBooking(userId, booking); + const charge = await this.findCharge(bookingId, chargeId); + if (charge.status !== 'SENT' && charge.status !== 'REJECTED') { + throw new ConflictException( + charge.status === 'ACCEPTED' || charge.status === 'PAID' + ? 'This charge has already been accepted.' + : 'This charge is not awaiting your decision.', + ); + } + const amount = Number(charge.amount); + const currency = charge.currency ?? 'ETB'; const invoice = await this.billing.generateInvoice({ source: Freight.InvoiceSource.ClearanceCharge, // The charge's own id, NOT the booking id — booking-scoped invoice @@ -258,105 +356,156 @@ export class BookingClearanceChargeService { type: charge.type, companyId: booking.companyId, companyProfileId: booking.companyProfileId, - currency: charge.currency ?? 'ETB', + currency, lines: [ { chargeType: charge.type, - description: `${CHARGE_LABEL[charge.type]} — ${booking.reference ?? bookingId}`, - amount: Number(charge.amount), + description: `${CHARGE_LABEL[charge.type]} — ${ + booking.reference ?? bookingId + }${charge.description ? `: ${charge.description}` : ''}`, + amount, }, ], }); await this.repo().update(charge.id, { - status: 'SENT', + status: 'ACCEPTED', invoiceId: invoice.id, + customerNote: null, + customerDecidedAt: new Date(), + customerDecidedBy: userId, }); await this.clearanceEvents.record({ bookingId, - action: 'CHARGE_INVOICE_SENT', - label: `Sent ${CHARGE_LABEL[charge.type].toLowerCase()} invoice ${invoice.invoiceNumber} to the customer`, - actorId: staffId ?? null, + action: 'CHARGE_ACCEPTED', + label: `Customer accepted ${CHARGE_LABEL[ + charge.type + ].toLowerCase()} (${amount} ${currency}) — invoice ${invoice.invoiceNumber} issued`, + actorType: 'CUSTOMER', + actorId: userId, metadata: { chargeType: charge.type, invoiceNumber: invoice.invoiceNumber, - amount: Number(charge.amount), - currency: charge.currency, + amount, + currency, }, }); + this.notifier.clearanceChargeInvoiceIssued(booking, { + label: CHARGE_LABEL[charge.type], + amount, + currency, + invoiceNumber: invoice.invoiceNumber, + }); this.logger.log( - `Clearance charge ${charge.type} on booking ${bookingId} sent as invoice ${invoice.invoiceNumber}`, + `Clearance charge ${charge.type} on booking ${bookingId} accepted; invoice ${invoice.invoiceNumber}`, ); - return this.list(bookingId); + return this.listForCustomer(bookingId); + } + + /** Customer declines the price with a reason; GL revises and re-sends. */ + async customerReject( + bookingId: string, + chargeId: string, + note: string, + userId: string, + ): Promise { + const booking = await this.bookingsService.findById(bookingId); + await this.bookingsService.assertCustomerCanAccessBooking(userId, booking); + const charge = await this.findCharge(bookingId, chargeId); + if (charge.status !== 'SENT') { + throw new ConflictException( + charge.status === 'ACCEPTED' || charge.status === 'PAID' + ? 'This charge has already been accepted.' + : 'This charge is not awaiting your decision.', + ); + } + if (!note?.trim()) { + throw new BadRequestException('Say why you are rejecting this charge.'); + } + await this.repo().update(charge.id, { + status: 'REJECTED', + customerNote: note.trim(), + customerDecidedAt: new Date(), + customerDecidedBy: userId, + }); + await this.clearanceEvents.record({ + bookingId, + action: 'CHARGE_REJECTED', + label: `Customer rejected ${CHARGE_LABEL[charge.type].toLowerCase()}: ${note.trim()}`, + actorType: 'CUSTOMER', + actorId: userId, + metadata: { chargeType: charge.type, note: note.trim() }, + }); + this.notifier.clearanceChargeRejectedToStaff(booking, { + label: CHARGE_LABEL[charge.type], + note: note.trim(), + }); + return this.listForCustomer(bookingId); } /** - * GL Ethiopia creates the miscellaneous charge whole (document + amount + - * currency). Second payment level: allowed only once the port charge is paid. + * GL Ethiopia creates a miscellaneous charge whole (document + amount + + * currency + what it is for). Lands as a BILLED draft; GL sends it next. */ async createMiscellaneous( bookingId: string, file: Express.Multer.File, - input: { amount: number; currency: string }, + input: { amount: number; currency: string; description?: string }, staffId: string, ): Promise { const booking = await this.bookingsService.findById(bookingId); this.assertClearanceFinalized(booking); - const port = await this.repo().findOne({ - where: { bookingId, type: 'PORT_CHARGES' }, - }); - if (port?.status !== 'PAID') { - throw new ConflictException( - 'Miscellaneous charges open after the port charge is paid.', - ); - } - const existing = await this.repo().findOne({ - where: { bookingId, type: 'MISCELLANEOUS' }, - }); - if (existing) { - throw new ConflictException( - 'This booking already has a miscellaneous charge — revise it instead.', - ); - } + // No ordering and no cap: a miscellaneous charge may be raised before, + // after or alongside the port charge, and a booking may carry several. if (!(input.amount > 0)) { throw new BadRequestException('Amount must be greater than zero.'); } if (!input.currency?.trim()) { throw new BadRequestException('Currency is required.'); } + const description = input.description?.trim() ?? ''; + if (!description) { + throw new BadRequestException('Describe what this charge is for.'); + } - const record = await this.filesService.upsertByCode( - { - resourceId: bookingId, - resource: 'bookings', - code: CHARGE_FILE_CODE.MISCELLANEOUS, - file, - }, - { userId: staffId }, - ); - await this.repo().save( + // Save the row first so its id can key the document. A booking may carry + // several miscellaneous charges, and `upsertByCode` retires whatever sits + // under the same code — a shared code would silently delete the previous + // charge's document. + const charge = await this.repo().save( this.repo().create({ bookingId, type: 'MISCELLANEOUS', status: 'BILLED', - fileRecordId: record.id, amount: input.amount.toFixed(2), currency: input.currency.trim().toUpperCase(), + description, uploadedByStaffId: staffId, uploadedAt: new Date(), billedByStaffId: staffId, billedAt: new Date(), }), ); + const record = await this.filesService.upsertByCode( + { + resourceId: bookingId, + resource: 'bookings', + code: `${CHARGE_FILE_CODE.MISCELLANEOUS}_${charge.id}`, + file, + }, + { userId: staffId }, + ); + await this.repo().update(charge.id, { fileRecordId: record.id }); await this.clearanceEvents.record({ bookingId, action: 'CHARGE_MISC_CREATED', - label: `Created miscellaneous charge: ${input.amount} ${input.currency.trim().toUpperCase()}`, + label: `Created miscellaneous charge: ${input.amount} ${input.currency.trim().toUpperCase()} — ${description}`, actorId: staffId, metadata: { amount: input.amount, currency: input.currency.trim().toUpperCase(), + description, fileName: file.originalname, }, }); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-clearance-charge.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-clearance-charge.spec.ts new file mode 100644 index 000000000..ac1a5a225 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-clearance-charge.spec.ts @@ -0,0 +1,22 @@ +import { + CUSTOMER_VISIBLE_CHARGE_STATUSES, + canStaffEditCharge, +} from './booking-clearance-charge.service'; +import { CLEARANCE_CHARGE_STATUSES } from './entities/booking-clearance-charge.entity'; + +describe('clearance charge status guards', () => { + it('locks the charge once the customer has accepted or paid', () => { + expect(canStaffEditCharge('ACCEPTED')).toBe(false); + expect(canStaffEditCharge('PAID')).toBe(false); + for (const s of ['DOC_UPLOADED', 'BILLED', 'SENT', 'REJECTED'] as const) { + expect(canStaffEditCharge(s)).toBe(true); + } + }); + + it('hides GL drafts from the customer and shows everything sent', () => { + const visible = CLEARANCE_CHARGE_STATUSES.filter((s) => + CUSTOMER_VISIBLE_CHARGE_STATUSES.has(s), + ); + expect(visible).toEqual(['SENT', 'REJECTED', 'ACCEPTED', 'PAID']); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts index 78c07bd22..ad0e62ef3 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts @@ -202,6 +202,17 @@ export class BookingLifecycleNotifierService { } /** A clearance document was queried and needs the customer to re-upload. */ + /** GL asked the customer for additional clearance document(s). */ + additionalDocsRequested(b: Booking, note: string): void { + const msg = + `Additional document(s) requested on booking ${b.reference}: ` + + `${note} Please upload them from the portal.`; + void this.notifyContact(b, msg, 'ADDITIONAL DOCUMENTS REQUESTED'); + this.inApp(b, 'Additional documents requested', msg, { + type: NotificationType.DOCUMENT_ACTION, + }); + } + documentQueried(b: Booking, fileKey: string, note: string): void { const msg = `A clearance document on booking ${b.reference} needs attention: "${fileKey}". ` + @@ -410,6 +421,55 @@ export class BookingLifecycleNotifierService { }); } + // ── Clearance charges (port + miscellaneous) ─────────────────────────────── + + /** GL proposed (or re-proposed) a clearance charge — the customer accepts or rejects it in the portal. */ + clearanceChargeProposed( + b: Booking, + c: { + label: string; + amount: number; + currency: string; + description: string | null; + revised: boolean; + }, + ): void { + const msg = + `${c.revised ? 'Revised ' + c.label.toLowerCase() : c.label} of ${c.amount} ${c.currency}` + + `${c.description ? ` (${c.description})` : ''} on booking ${b.reference} ` + + `await your approval. Please accept or reject them in the portal.`; + void this.notifyContact(b, msg, c.revised ? 'CLEARANCE CHARGE REVISED' : 'CLEARANCE CHARGE SENT'); + this.inApp(b, c.revised ? `${c.label} revised` : `${c.label} need your approval`, msg, { + type: NotificationType.INVOICE_ISSUED, + }); + } + + /** The customer accepted a clearance charge — its invoice is now payable. */ + clearanceChargeInvoiceIssued( + b: Booking, + c: { label: string; amount: number; currency: string; invoiceNumber: string }, + ): void { + const msg = + `Invoice ${c.invoiceNumber} for ${c.label.toLowerCase()} (${c.amount} ${c.currency}) ` + + `on booking ${b.reference} is ready. Please pay it from the portal.`; + void this.notifyContact(b, msg, 'CLEARANCE CHARGE INVOICE'); + this.inApp(b, `${c.label} invoice issued`, msg, { + type: NotificationType.INVOICE_ISSUED, + }); + } + + /** The customer rejected a clearance charge — GL Ethiopia revises and re-sends. */ + clearanceChargeRejectedToStaff(b: Booking, c: { label: string; note: string }): void { + const msg = + `The customer rejected the ${c.label.toLowerCase()} on booking ${this.ref(b)}: ` + + `"${c.note}". Revise and re-send from the clearance page.`; + this.inAppStaff(b, `${c.label} rejected — ${this.ref(b)}`, msg, { + recipients: CLEARANCE_DESK, + type: NotificationType.CLEARANCE_REVIEW, + link: `/dashboard/clearance/${b.id}`, + }); + } + /** GL confirmed the final-invoice payment slip. */ finalInvoicePaid(b: Booking): void { const msg = `Your final invoice payment for booking ${b.reference} has been confirmed. Thank you.`; diff --git a/apps/edr-freight-api/src/modules/bookings/booking-payables.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-payables.service.ts new file mode 100644 index 000000000..e815fe929 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-payables.service.ts @@ -0,0 +1,107 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { Freight } from '@edr/types'; + +/** Invoice statuses a customer can still settle (mirrors the portal's PAYABLE_STATUSES). */ +const PAYABLE_INVOICE_STATUSES = ['ISSUED', 'PENDING', 'PARTIALLY_PAID', 'OVERDUE']; +/** Booking statuses at which the freight invoice is actually due (mirrors BookingsService). */ +const FREIGHT_PAYABLE_BOOKING_STATUSES = [ + 'FULLY_EXECUTED', + 'SELECTED_FOR_BATCH', + 'AWAITING_PAYMENT', +]; + +/** + * One row per outstanding item. `invoices.status` / `bookings.status` are + * Postgres enums, hence the ::text casts. `amount` is NULL for items that only need the + * customer's review (a proposed clearance charge, a draft final invoice) so + * they count but do not inflate "amount due". + */ +const SQL = ` + -- Central invoices on the booking: freight (only while the booking is in a + -- payable status), wagon-cancellation fee, GL final invoice (+ its DRAFT, + -- which waits for the customer's approval). + SELECT i.source_id AS "bookingId", i.currency, + CASE WHEN i.status::text = 'DRAFT' THEN NULL ELSE i.balance_amount END AS amount + FROM freight.invoices i + JOIN freight.bookings b ON b.id::text = i.source_id AND b.deleted_at IS NULL + WHERE i.company_id = $1 AND i.deleted_at IS NULL AND i.source = 'booking' + AND ( + (i.status::text = ANY($2::text[]) AND i.balance_amount > 0 + AND (i.type IN ('WAGON_CANCEL_FEE', 'GL_FINAL') OR b.status::text = ANY($3::text[]))) + OR (i.type = 'GL_FINAL' AND i.status::text = 'DRAFT') + ) + UNION ALL + -- Accepted clearance charges whose invoice is still unpaid. + SELECT c.booking_id::text, i.currency, i.balance_amount + FROM freight.invoices i + JOIN freight.booking_clearance_charge c ON c.id::text = i.source_id AND c.deleted_at IS NULL + WHERE i.company_id = $1 AND i.deleted_at IS NULL AND i.source = 'clearance_charge' + AND i.status::text = ANY($2::text[]) AND i.balance_amount > 0 + UNION ALL + -- Clearance charges waiting for the customer to accept or reject the price. + SELECT c.booking_id::text, c.currency, NULL::numeric + FROM freight.booking_clearance_charge c + JOIN freight.bookings b ON b.id = c.booking_id AND b.deleted_at IS NULL + WHERE b.company_id = $1 AND c.deleted_at IS NULL AND c.status = 'SENT' + UNION ALL + -- Duty / tax advised by customs, payment slip not uploaded yet. + SELECT m.booking_id::text, m.metadata->>'dutyCurrency', + NULLIF(m.metadata->>'dutyAmount', '')::numeric + FROM freight.clearance_milestones m + JOIN freight.bookings b ON b.id = m.booking_id AND b.deleted_at IS NULL + WHERE b.company_id = $1 AND m.deleted_at IS NULL AND m.status = 'COMPLETED' + AND ( + (m.milestone_code = 'DUTY_TAXES_ADVISED' AND NOT EXISTS ( + SELECT 1 FROM freight.clearance_milestones p + WHERE p.booking_id = m.booking_id AND p.milestone_code = 'DUTY_TAX_PAID' + AND p.status = 'COMPLETED' AND p.deleted_at IS NULL)) + OR + (m.milestone_code = 'SECOND_DUTY_ADVISED' AND NOT EXISTS ( + SELECT 1 FROM freight.clearance_milestones p + WHERE p.booking_id = m.booking_id AND p.milestone_code = 'SECOND_DUTY_PAID' + AND p.status = 'COMPLETED' AND p.deleted_at IS NULL)) + ) +`; + +/** + * Everything a customer still has to act on, per booking, in one query. Drives + * the "Pay" badge on the home and booking-list rows; the booking's Payments tab + * composes the same items client-side from the per-booking endpoints. + */ +@Injectable() +export class BookingPayablesService { + constructor(private readonly dataSource: DataSource) {} + + async summarizeForCompany( + companyId: string, + ): Promise { + const rows: Array<{ + bookingId: string; + currency: string | null; + amount: string | null; + }> = await this.dataSource.query(SQL, [ + companyId, + PAYABLE_INVOICE_STATUSES, + FREIGHT_PAYABLE_BOOKING_STATUSES, + ]); + + const byBooking = new Map(); + for (const r of rows) { + const s = byBooking.get(r.bookingId) ?? { + bookingId: r.bookingId, + count: 0, + totals: [], + }; + s.count += 1; + const amount = Number(r.amount ?? 0); + if (r.currency && amount > 0) { + const t = s.totals.find((x) => x.currency === r.currency); + if (t) t.amount += amount; + else s.totals.push({ currency: r.currency, amount }); + } + byBooking.set(r.bookingId, s); + } + return [...byBooking.values()]; + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts index 75a179b5c..ba1aaa875 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts @@ -57,6 +57,7 @@ describe('BookingPricingService — domestic corridor', () => { exchangeService as never, { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, {} as never, + { findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never, ); }); @@ -333,6 +334,7 @@ describe('BookingPricingService — customs clearance fee billed on the booking : [], }), } as never, + { findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never, ); const containerBooking = (overrides: Record = {}) => @@ -388,6 +390,30 @@ describe('BookingPricingService — customs clearance fee billed on the booking expect(line!.amount).toBe(200); }); + it('prices an Ethiopian-customs-only service off ETHIOPIAN_CUSTOMS_CLEARANCE, not the full fee', async () => { + const ethiopianFee = { + ...containerFee20, + id: 'rate-et-20', + rateType: 'ETHIOPIAN_CUSTOMS_CLEARANCE', + trigger: 'ETHIOPIAN_CUSTOMS_CLEARANCE', + rateValue: 40, + } as Rate; + // No serviceType relation on the booking (like the GL/portal shipment + // preview) — the flag must be resolved from serviceTypeId. + const service = makeService({ liveRates: [containerFee20, ethiopianFee] }); + (service as unknown as { serviceTypesService: { findById: jest.Mock } }).serviceTypesService = { + findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: true }), + }; + const result = await service.computePriceForBooking( + containerBooking({ serviceTypeId: 'st-et', serviceType: undefined }), + ); + + const line = result.lineItems.find((l) => l.code === 'ETHIOPIAN_CUSTOMS_CLEARANCE_20FT'); + expect(line).toBeDefined(); + expect(line!.amount).toBe(160); + expect(result.lineItems.some((l) => l.code === 'CUSTOMS_CLEARANCE_20FT')).toBe(false); + }); + it('hard-blocks a container type with no fee configured (never free clearance)', async () => { const service = makeService({ liveRates: [bulkFeePerTon] }); const result = await service.computePriceForBooking(containerBooking()); @@ -553,6 +579,7 @@ describe('BookingPricingService — bulk base freight units', () => { wagonTypes: wagonCapacity !== undefined ? [{ capacityTons: wagonCapacity }] : [], }), } as never, + { findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never, ); // 12 machines, not 12 tonnes — a PER_ITEM commodity records its count here. @@ -683,6 +710,7 @@ describe('BookingPricingService — PER_WAGON container freight', () => { { getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never, { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, { findById: jest.fn() } as never, + { findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never, ); const booking = ( 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 f7dc40a82..49732d2df 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 @@ -3,6 +3,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { CargoTypesService } from '../rule-engine/services/cargo-types.service'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { RatesService } from '../rule-engine/services/rates.service'; +import { ServiceTypesService } from '../rule-engine/services/service-types.service'; import { Rate } from '../rule-engine/entities/rate.entity'; import { isBulkQuantityUnit } from '../rule-engine/entities/rate-unit.util'; import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity'; @@ -84,6 +85,7 @@ export class BookingPricingService { private readonly exchangeService: ExchangeService, private readonly containerValidationService: ContainerValidationService, private readonly cargoTypesService: CargoTypesService, + private readonly serviceTypesService: ServiceTypesService, ) {} async generatePrice(bookingId: string): Promise { @@ -1060,9 +1062,27 @@ export class BookingPricingService { const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1; const convert = (usd: number): number => (isEtb ? round2(usd * usdToEtb) : usd); + // An Ethiopian-side-only customs service prices off its own rate; the + // contract froze its snapshots under the matching code prefix. Resolved by + // id when the relation isn't loaded — the GL / portal shipment previews + // price a transient booking object, and a missing relation must not + // silently quote the standard fee the created booking is then billed + // differently for. + const serviceType = + booking.serviceType ?? + (booking.serviceTypeId + ? await this.serviceTypesService.findById(booking.serviceTypeId).catch(() => null) + : null); + const customsType = serviceType?.includesEthiopianCustomsOnly + ? 'ETHIOPIAN_CUSTOMS_CLEARANCE' + : 'CUSTOMS_CLEARANCE'; + const customsLabel = + customsType === 'ETHIOPIAN_CUSTOMS_CLEARANCE' + ? 'Ethiopian customs clearance service' + : 'Customs clearance service'; const onLeg = liveRates.filter( (r) => - r.rateType === 'CUSTOMS_CLEARANCE' && + r.rateType === customsType && r.currency === 'USD' && r.tradeDirection === booking.tradeDirection && r.originYardId === booking.originYardId && @@ -1070,20 +1090,20 @@ export class BookingPricingService { ); const missingRateMessage = (scope: string): string => `No customs clearance service fee is configured for ${scope} on this ` + - 'origin → destination. Ask EDR to configure the CUSTOMS_CLEARANCE rate for this route.'; + `origin → destination. Ask EDR to configure the ${customsType} rate for this route.`; if (booking.freightType === 'CONTAINER') { // Legacy short-circuit: an old contract froze one flat fee — bill it once. const hasPerSizeSnapshot = - frozenRates?.has('CUSTOMS_CLEARANCE_20FT') || - frozenRates?.has('CUSTOMS_CLEARANCE_40FT'); - const legacyFlat = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency, usdToEtb); + frozenRates?.has(`${customsType}_20FT`) || + frozenRates?.has(`${customsType}_40FT`); + const legacyFlat = this.frozenRateByCode(frozenRates, customsType, currency, usdToEtb); if (legacyFlat && !hasPerSizeSnapshot) { const amount = Number(legacyFlat.unitPrice); if (amount > 0) { lineItems.push({ - code: 'CUSTOMS_CLEARANCE', - description: 'Customs clearance service', + code: customsType, + description: customsLabel, amount, unitAmount: amount, unit: 'FLAT', @@ -1106,7 +1126,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, usdToEtb) + ? this.frozenRateByCode(frozenRates, `${customsType}_${sizeFt}FT`, currency, usdToEtb) : null; const live = onLeg.find((r) => r.containerTypeId === bc.containerTypeId); if (!frozen && !live) { @@ -1124,8 +1144,8 @@ export class BookingPricingService { const amount = unit === 'FLAT' ? unitAmount : unitAmount * billedQty; if (!(amount > 0)) continue; lineItems.push({ - code: sizeFt ? `CUSTOMS_CLEARANCE_${sizeFt}FT` : 'CUSTOMS_CLEARANCE', - description: `Customs clearance service${sizeFt ? ` (${sizeFt}ft)` : ''}`, + code: sizeFt ? `${customsType}_${sizeFt}FT` : customsType, + description: `${customsLabel}${sizeFt ? ` (${sizeFt}ft)` : ''}`, amount, unitAmount, unit, @@ -1141,7 +1161,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, usdToEtb); + const frozen = this.frozenRateByCode(frozenRates, customsType, currency, usdToEtb); const live = (booking.cargoTypeId ? onLeg.find( @@ -1172,8 +1192,8 @@ export class BookingPricingService { const amount = unit === 'FLAT' ? unitAmount : unitAmount * billedQty; if (amount > 0) { lineItems.push({ - code: 'CUSTOMS_CLEARANCE', - description: 'Customs clearance service (bulk)', + code: customsType, + description: `${customsLabel} (bulk)`, amount, unitAmount, unit, diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 805b2216f..72261627e 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -28,6 +28,7 @@ import { ContainerValidationService } from './container-validation.service'; import { BookingsRepository } from './bookings.repository'; import { assertBookingStatus } from './booking-status.util'; import { + adHocLabel, clearanceCodesForBooking, clearanceDocumentsOpen, } from './clearance.util'; @@ -643,6 +644,12 @@ export class BookingTransitionService { }>; allApproved: boolean; documentsOpen: boolean; + docRequests: Array<{ + id: string; + note: string; + byName: string | null; + at: string; + }>; phase?: string | null; milestones?: unknown[]; nextAction?: unknown; @@ -674,9 +681,14 @@ export class BookingTransitionService { bookingId, "CHANGES_REQUESTED", ); + const docRequestNotes = await this.bookingsRepository.findReviewNotes( + bookingId, + "ADDITIONAL_DOC_REQUEST", + ); const reviewerNames = await this.bookingsRepository.resolveStaffNames([ ...reviews.map((r) => r.reviewedByStaffId), ...queryNotes.map((n) => n.authorId), + ...docRequestNotes.map((n) => n.authorId), ]); const documents: Awaited< @@ -731,7 +743,9 @@ export class BookingTransitionService { const review = reviewByKey.get(`custom:${f.code}`) ?? null; documents.push({ fileKey: f.code, - label: f.name, + // What the customer called it, falling back to the filename for rows + // uploaded before the name was carried through. + label: f.title || adHocLabel(f.code) || f.name, required: false, uploadedBy: "customer", settingCode: "custom", @@ -763,9 +777,51 @@ export class BookingTransitionService { documents, allApproved, documentsOpen: clearanceDocumentsOpen(booking), + docRequests: docRequestNotes.map((n) => ({ + id: n.id, + note: n.note, + byName: n.authorId ? (reviewerNames.get(n.authorId) ?? null) : null, + at: n.createdAt.toISOString(), + })), }; } + /** + * GL asks the customer for additional clearance document(s). Stored as a + * review-note thread shown on both the GL clearance page and the customer's + * portal; the customer answers with an ad-hoc upload. Allowed for as long as + * documents are open (until the shipment is paid). + */ + async requestAdditionalDocuments( + bookingId: string, + note: string, + staffId: string, + ): Promise { + const booking = await this.bookingsService.findById(bookingId); + if (!clearanceDocumentsOpen(booking)) { + throw new ConflictException( + `Clearance documents are closed for this booking (status "${booking.status}").`, + ); + } + if (!note?.trim()) { + throw new BadRequestException("Describe the document(s) you need."); + } + await this.bookingsRepository.createReviewNote( + bookingId, + note.trim(), + "ADDITIONAL_DOC_REQUEST", + staffId, + ); + await this.clearanceEvents.record({ + bookingId, + action: "ADDITIONAL_DOCS_REQUESTED", + label: "Requested additional document(s) from the customer", + actorId: staffId, + metadata: { note: note.trim() }, + }); + this.notifier.additionalDocsRequested(booking, note.trim()); + } + /** * True when every REQUIRED field of the booking's customer-input clearance set * has an APPROVED review row. The 100% gate before clearance can be finalized. @@ -837,6 +893,10 @@ export class BookingTransitionService { resource: "bookings", code: file.fieldname, file, + // Ad-hoc uploads carry the name the customer typed (fieldname + // `custom_