mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #942 from Tria-plc/freight_feature/usermanagement
revert back the clerance payment
This commit is contained in:
@@ -59,7 +59,7 @@ const TRIGGER_ROUTE_LABELS: Partial<Record<Rate['trigger'], string>> = {
|
|||||||
REEFER: 'Reefer (refrigerated) surcharge',
|
REEFER: 'Reefer (refrigerated) surcharge',
|
||||||
WITH_RETURN: 'Empty-container return service',
|
WITH_RETURN: 'Empty-container return service',
|
||||||
SHIPPING_LINE: 'Shipping line handling',
|
SHIPPING_LINE: 'Shipping line handling',
|
||||||
CONSOLIDATION: 'Container consolidation (extra document)',
|
CONSOLIDATION: 'Penalty (container consolidation)',
|
||||||
LASHING: 'Cargo lashing and securing',
|
LASHING: 'Cargo lashing and securing',
|
||||||
CANCELLATION: 'Booking cancellation fee',
|
CANCELLATION: 'Booking cancellation fee',
|
||||||
DEMURRAGE: 'Demurrage / wagon detention',
|
DEMURRAGE: 'Demurrage / wagon detention',
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The customs clearance service fee is no longer prepaid via its own
|
||||||
|
* `clearance`-source invoice — it is billed as a CUSTOMS_CLEARANCE line on the
|
||||||
|
* booking invoice, together with the freight (see BookingPricingService).
|
||||||
|
*
|
||||||
|
* - Contracts/bookings parked at the payment gate move straight to the
|
||||||
|
* document step (the gate no longer exists — nothing could ever pay them).
|
||||||
|
* - Open (unpaid) clearance invoices are expired; PAID ones stay as history.
|
||||||
|
* NOTE: a ONE_TIME customs contract that already PAID its prepaid fee but
|
||||||
|
* has not booked yet will be billed the fee again on its booking invoice —
|
||||||
|
* accepted for dev data; reverses the old AddClearanceFeePayment migration.
|
||||||
|
* - clearance_fee_paid_at columns are dropped from contracts and bookings.
|
||||||
|
*/
|
||||||
|
export class DropClearanceFeePrepay2860000000000 implements MigrationInterface {
|
||||||
|
name = 'DropClearanceFeePrepay2860000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.contracts
|
||||||
|
SET status = 'AWAITING_CLEARANCE_DOCUMENTS', updated_at = now()
|
||||||
|
WHERE status = 'AWAITING_CLEARANCE_PAYMENT';
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.contracts
|
||||||
|
SET clearance_status = 'AWAITING_DOCUMENTS', updated_at = now()
|
||||||
|
WHERE clearance_status = 'AWAITING_PAYMENT';
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.bookings
|
||||||
|
SET status = 'AWAITING_DOCUMENTS', updated_at = now()
|
||||||
|
WHERE status = 'AWAITING_CLEARANCE_PAYMENT';
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.invoices
|
||||||
|
SET status = 'EXPIRED', updated_at = now()
|
||||||
|
WHERE source = 'clearance'
|
||||||
|
AND status IN ('DRAFT', 'ISSUED', 'PENDING', 'PARTIALLY_PAID', 'OVERDUE');
|
||||||
|
`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.contracts DROP COLUMN IF EXISTS clearance_fee_paid_at;`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS clearance_fee_paid_at;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// Moved rows and expired invoices stay — only the columns come back.
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.contracts ADD COLUMN IF NOT EXISTS clearance_fee_paid_at timestamptz;`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS clearance_fee_paid_at timestamptz;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customs clearance fees are now sold per cargo kind: container fees name a
|
||||||
|
* container type (billed PER_CONTAINER / PER_WAGON), bulk fees carry no type
|
||||||
|
* (billed PER_TON / PER_WAGON). The old one-FLAT-fee-per-route shape cannot be
|
||||||
|
* mapped to a kind — retired (SUPERSEDED + soft-deleted) exactly like the
|
||||||
|
* base-freight and return-surcharge reshapes, kept readable for snapshot
|
||||||
|
* history. Per-kind replacements must be re-entered; a customs contract or
|
||||||
|
* booking without a matching fee hard-blocks. Contracts that already froze a
|
||||||
|
* FLAT snapshot keep billing it (legacy honoured at booking pricing).
|
||||||
|
*/
|
||||||
|
export class CustomsClearancePerKind2870000000000 implements MigrationInterface {
|
||||||
|
name = 'CustomsClearancePerKind2870000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.rates
|
||||||
|
SET status = 'SUPERSEDED',
|
||||||
|
deleted_at = now(),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND rate_type = 'CUSTOMS_CLEARANCE'
|
||||||
|
AND rate_unit = 'FLAT';
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(): Promise<void> {
|
||||||
|
// Retired rates stay retired — re-enter per-kind rates instead.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lashing is now sold per cargo kind, like the customs clearance fee:
|
||||||
|
* container rates name a container type (PER_CONTAINER / PER_WAGON), bulk
|
||||||
|
* rates carry no type (PER_TON / PER_WAGON). The old flat-per-booking shape
|
||||||
|
* cannot be mapped to a kind — retired (SUPERSEDED + soft-deleted), kept
|
||||||
|
* readable for snapshot history. Per-kind replacements must be re-entered;
|
||||||
|
* an unconfigured lashing rate simply bills nothing (lenient, like
|
||||||
|
* hazard/reefer). Matched on trigger, not rate_type — CONSOLIDATION rates
|
||||||
|
* share the LASHING rate_type and must survive.
|
||||||
|
*/
|
||||||
|
export class LashingPerKind2880000000000 implements MigrationInterface {
|
||||||
|
name = 'LashingPerKind2880000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.rates
|
||||||
|
SET status = 'SUPERSEDED',
|
||||||
|
deleted_at = now(),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND "trigger" = 'LASHING'
|
||||||
|
AND rate_unit = 'FLAT';
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(): Promise<void> {
|
||||||
|
// Retired rates stay retired — re-enter per-kind rates instead.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
InvoiceLineInput,
|
InvoiceLineInput,
|
||||||
} from "../billing/billing.service";
|
} from "../billing/billing.service";
|
||||||
import { Invoice } from "../billing/entities/invoice.entity";
|
import { Invoice } from "../billing/entities/invoice.entity";
|
||||||
import { CLEARANCE_BOOKING_INVOICE_TYPE } from "../contracts/clearance-fee.service";
|
|
||||||
import { FirstMileService } from "../first-mile/first-mile.service";
|
import { FirstMileService } from "../first-mile/first-mile.service";
|
||||||
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
|
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
|
||||||
import { PriceLineItemDto } from "./dto/generate-price-response.dto";
|
import { PriceLineItemDto } from "./dto/generate-price-response.dto";
|
||||||
@@ -121,8 +120,7 @@ export class BookingInvoiceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Expire the booking's currently-open invoices (freight PREPAID and the
|
* Expire the booking's currently-open freight (PREPAID) invoice when the booking is
|
||||||
* per-shipment clearance fee) when the booking is
|
|
||||||
* cancelled or rejected — the counterpart to the pay-window-expiry path
|
* cancelled or rejected — the counterpart to the pay-window-expiry path
|
||||||
* (which also calls {@link BillingService.expirePayable}). Stops a terminated
|
* (which also calls {@link BillingService.expirePayable}). Stops a terminated
|
||||||
* booking from leaving a payable invoice open. No-op when the booking has no
|
* booking from leaving a payable invoice open. No-op when the booking has no
|
||||||
@@ -133,15 +131,6 @@ export class BookingInvoiceService {
|
|||||||
bookingId: string,
|
bookingId: string,
|
||||||
manager?: EntityManager,
|
manager?: EntityManager,
|
||||||
): Promise<Invoice | null> {
|
): Promise<Invoice | null> {
|
||||||
// The per-shipment clearance fee (GENERAL contracts) bills this same booking
|
|
||||||
// id under its own source/type — retire it alongside the freight invoice, or
|
|
||||||
// a cancelled shipment keeps a payable clearance invoice open.
|
|
||||||
await this.billing.expirePayable(
|
|
||||||
Freight.InvoiceSource.Clearance,
|
|
||||||
bookingId,
|
|
||||||
CLEARANCE_BOOKING_INVOICE_TYPE,
|
|
||||||
manager,
|
|
||||||
);
|
|
||||||
return this.billing.expirePayable(
|
return this.billing.expirePayable(
|
||||||
Freight.InvoiceSource.Booking,
|
Freight.InvoiceSource.Booking,
|
||||||
bookingId,
|
bookingId,
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ describe('BookingPricingService — domestic corridor', () => {
|
|||||||
ratesService as never,
|
ratesService as never,
|
||||||
exchangeService as never,
|
exchangeService as never,
|
||||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||||
|
{} as never,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -240,3 +241,216 @@ describe('BookingPricingService — domestic corridor', () => {
|
|||||||
expect(result.blocked[0]).toContain('rate is configured');
|
expect(result.blocked[0]).toContain('rate is configured');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('BookingPricingService — customs clearance fee billed on the booking price', () => {
|
||||||
|
const DJ = 'yard-dj';
|
||||||
|
|
||||||
|
const containerFee20: Rate = {
|
||||||
|
id: 'rate-cc-20',
|
||||||
|
rateType: 'CUSTOMS_CLEARANCE',
|
||||||
|
trigger: 'CUSTOMS_CLEARANCE',
|
||||||
|
currency: 'USD',
|
||||||
|
rateValue: 100,
|
||||||
|
rateUnit: 'PER_CONTAINER',
|
||||||
|
status: 'LIVE',
|
||||||
|
containerTypeId: 'ct-20',
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
originYardId: DJ,
|
||||||
|
destinationYardId: DIRE,
|
||||||
|
} as Rate;
|
||||||
|
|
||||||
|
const bulkFeePerTon: Rate = {
|
||||||
|
...containerFee20,
|
||||||
|
id: 'rate-cc-bulk',
|
||||||
|
rateValue: 5,
|
||||||
|
rateUnit: 'PER_TON',
|
||||||
|
containerTypeId: null,
|
||||||
|
} as Rate;
|
||||||
|
|
||||||
|
const emptyEval = {
|
||||||
|
priorityScore: 0,
|
||||||
|
appliedModifiers: [],
|
||||||
|
containerWeightResults: [],
|
||||||
|
warnings: [],
|
||||||
|
hardBlocked: [],
|
||||||
|
requiresDirectorApproval: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeService = (opts: {
|
||||||
|
snapshots?: unknown[];
|
||||||
|
liveRates?: Rate[];
|
||||||
|
wagonCapacity?: number;
|
||||||
|
}) =>
|
||||||
|
new BookingPricingService(
|
||||||
|
{
|
||||||
|
calculateWagonCount: jest.fn().mockResolvedValue(0),
|
||||||
|
findContractRateSnapshots: jest.fn().mockResolvedValue(opts.snapshots ?? []),
|
||||||
|
} as never,
|
||||||
|
{ evaluate: jest.fn().mockResolvedValue(emptyEval) } as never,
|
||||||
|
{
|
||||||
|
findById: jest.fn(async (id: string) => ({
|
||||||
|
id,
|
||||||
|
sizeFt: id === 'ct-40' ? 40 : 20,
|
||||||
|
isReefer: false,
|
||||||
|
code: id === 'ct-40' ? 'C40' : 'C20',
|
||||||
|
})),
|
||||||
|
} as never,
|
||||||
|
{ findLiveRates: jest.fn().mockResolvedValue(opts.liveRates ?? []) } as never,
|
||||||
|
{ getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never,
|
||||||
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||||
|
{
|
||||||
|
findById: jest.fn().mockResolvedValue({
|
||||||
|
wagonTypes:
|
||||||
|
opts.wagonCapacity !== undefined
|
||||||
|
? [{ capacityTons: opts.wagonCapacity }]
|
||||||
|
: [],
|
||||||
|
}),
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
const containerBooking = (overrides: Record<string, unknown> = {}) =>
|
||||||
|
({
|
||||||
|
id: 'b-cc',
|
||||||
|
freightType: 'CONTAINER',
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
paymentCurrency: 'USD',
|
||||||
|
customsClearingEnabled: true,
|
||||||
|
originYardId: DJ,
|
||||||
|
destinationYardId: DIRE,
|
||||||
|
bookingContainers: [
|
||||||
|
{ containerTypeId: 'ct-20', quantity: 4, vgmPerUnitTons: 10, wagonsRequired: 2 },
|
||||||
|
],
|
||||||
|
...overrides,
|
||||||
|
}) as unknown as Booking;
|
||||||
|
|
||||||
|
const bulkBooking = (overrides: Record<string, unknown> = {}) =>
|
||||||
|
({
|
||||||
|
id: 'b-cc-bulk',
|
||||||
|
freightType: 'BULK',
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
paymentCurrency: 'USD',
|
||||||
|
customsClearingEnabled: true,
|
||||||
|
cargoTypeId: 'cargo-1',
|
||||||
|
cargoTotalWeightVgm: 120,
|
||||||
|
originYardId: DJ,
|
||||||
|
destinationYardId: DIRE,
|
||||||
|
bookingContainers: [],
|
||||||
|
...overrides,
|
||||||
|
}) as unknown as Booking;
|
||||||
|
|
||||||
|
it('bills a container booking per box at its own container type fee', async () => {
|
||||||
|
const service = makeService({ liveRates: [containerFee20] });
|
||||||
|
const result = await service.computePriceForBooking(containerBooking());
|
||||||
|
|
||||||
|
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE_20FT');
|
||||||
|
expect(line).toBeDefined();
|
||||||
|
expect(line!.unit).toBe('PER_CONTAINER');
|
||||||
|
expect(line!.quantity).toBe(4);
|
||||||
|
expect(line!.amount).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bills a PER_WAGON container fee on the wagons the boxes occupy (two 20ft share one)', async () => {
|
||||||
|
const service = makeService({
|
||||||
|
liveRates: [{ ...containerFee20, rateUnit: 'PER_WAGON' } as Rate],
|
||||||
|
});
|
||||||
|
const result = await service.computePriceForBooking(containerBooking());
|
||||||
|
|
||||||
|
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE_20FT');
|
||||||
|
expect(line!.unit).toBe('PER_WAGON');
|
||||||
|
expect(line!.quantity).toBe(2);
|
||||||
|
expect(line!.amount).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
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());
|
||||||
|
|
||||||
|
expect(result.lineItems.some((l) => l.code.startsWith('CUSTOMS_CLEARANCE'))).toBe(false);
|
||||||
|
expect(result.hardBlocked.some((m) => m.includes('customs clearance'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bills a bulk booking per ton at the route bulk fee', async () => {
|
||||||
|
const service = makeService({ liveRates: [bulkFeePerTon] });
|
||||||
|
const result = await service.computePriceForBooking(bulkBooking());
|
||||||
|
|
||||||
|
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE');
|
||||||
|
expect(line!.unit).toBe('PER_TON');
|
||||||
|
expect(line!.quantity).toBe(120);
|
||||||
|
expect(line!.amount).toBe(600);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bills a PER_WAGON bulk fee on ceil(tons ÷ wagon capacity)', async () => {
|
||||||
|
const service = makeService({
|
||||||
|
liveRates: [{ ...bulkFeePerTon, rateUnit: 'PER_WAGON', rateValue: 50 } as Rate],
|
||||||
|
wagonCapacity: 60,
|
||||||
|
});
|
||||||
|
const result = await service.computePriceForBooking(bulkBooking());
|
||||||
|
|
||||||
|
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE');
|
||||||
|
expect(line!.unit).toBe('PER_WAGON');
|
||||||
|
expect(line!.quantity).toBe(2); // 120 t ÷ 60 t per wagon
|
||||||
|
expect(line!.amount).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks a PER_WAGON bulk fee when no wagon capacity is configured', async () => {
|
||||||
|
const service = makeService({
|
||||||
|
liveRates: [{ ...bulkFeePerTon, rateUnit: 'PER_WAGON' } as Rate],
|
||||||
|
});
|
||||||
|
const result = await service.computePriceForBooking(bulkBooking());
|
||||||
|
|
||||||
|
expect(result.hardBlocked.some((m) => m.includes('wagon'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers the contract frozen per-size snapshot over the live rate', async () => {
|
||||||
|
const service = makeService({
|
||||||
|
liveRates: [containerFee20],
|
||||||
|
snapshots: [
|
||||||
|
{
|
||||||
|
rateCode: 'CUSTOMS_CLEARANCE_20FT',
|
||||||
|
unitPrice: 80,
|
||||||
|
currency: 'USD',
|
||||||
|
unitOfMeasure: 'per_container',
|
||||||
|
isClearance: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const result = await service.computePriceForBooking(
|
||||||
|
containerBooking({ contractId: 'c-1' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE_20FT');
|
||||||
|
expect(line!.amount).toBe(320); // 4 × frozen 80, not live 100
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honours a legacy FLAT snapshot once for the whole container booking', async () => {
|
||||||
|
const service = makeService({
|
||||||
|
liveRates: [],
|
||||||
|
snapshots: [
|
||||||
|
{
|
||||||
|
rateCode: 'CUSTOMS_CLEARANCE',
|
||||||
|
unitPrice: 500,
|
||||||
|
currency: 'USD',
|
||||||
|
unitOfMeasure: 'flat',
|
||||||
|
isClearance: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const result = await service.computePriceForBooking(
|
||||||
|
containerBooking({ contractId: 'c-legacy' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE');
|
||||||
|
expect(line!.unit).toBe('FLAT');
|
||||||
|
expect(line!.amount).toBe(500);
|
||||||
|
expect(result.hardBlocked.some((m) => m.includes('customs clearance'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds no fee line when customs clearing is disabled', async () => {
|
||||||
|
const service = makeService({ liveRates: [containerFee20] });
|
||||||
|
const result = await service.computePriceForBooking(
|
||||||
|
containerBooking({ customsClearingEnabled: false }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.lineItems.some((l) => l.code.startsWith('CUSTOMS_CLEARANCE'))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
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 { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||||
import { RatesService } from '../rule-engine/services/rates.service';
|
import { RatesService } from '../rule-engine/services/rates.service';
|
||||||
import { Rate } from '../rule-engine/entities/rate.entity';
|
import { Rate } from '../rule-engine/entities/rate.entity';
|
||||||
@@ -10,7 +11,10 @@ import {
|
|||||||
BookingEvaluationInput,
|
BookingEvaluationInput,
|
||||||
RuleEngineService,
|
RuleEngineService,
|
||||||
} from '../rule-engine/rule-engine.service';
|
} from '../rule-engine/rule-engine.service';
|
||||||
import { containersPerWagonForSize } from '../rule-engine/container-type.util';
|
import {
|
||||||
|
containersPerWagonForSize,
|
||||||
|
wagonsPerUnitForSize,
|
||||||
|
} from '../rule-engine/container-type.util';
|
||||||
import { BookingsRepository } from './bookings.repository';
|
import { BookingsRepository } from './bookings.repository';
|
||||||
import { wagonRemainder } from './consolidation.service';
|
import { wagonRemainder } from './consolidation.service';
|
||||||
import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto';
|
import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto';
|
||||||
@@ -76,6 +80,7 @@ export class BookingPricingService {
|
|||||||
private readonly ratesService: RatesService,
|
private readonly ratesService: RatesService,
|
||||||
private readonly exchangeService: ExchangeService,
|
private readonly exchangeService: ExchangeService,
|
||||||
private readonly containerValidationService: ContainerValidationService,
|
private readonly containerValidationService: ContainerValidationService,
|
||||||
|
private readonly cargoTypesService: CargoTypesService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> {
|
async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> {
|
||||||
@@ -223,6 +228,23 @@ export class BookingPricingService {
|
|||||||
if (rate) usedRatesMap.set(rate.id, rate);
|
if (rate) usedRatesMap.set(rate.id, rate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Customs clearance service fee (Path B) — billed HERE, on the booking
|
||||||
|
// invoice with the freight; no separate prepaid clearance invoice. Sold per
|
||||||
|
// cargo kind: container bookings bill each container type's own fee (per
|
||||||
|
// box or per wagon), bulk bookings the route's bulk fee (per ton or per
|
||||||
|
// wagon). Frozen contract snapshots win over live rates; a customs booking
|
||||||
|
// with nothing configured hard-blocks — clearance never ships for free.
|
||||||
|
const clearanceBlocked: string[] = [];
|
||||||
|
if (booking.customsClearingEnabled) {
|
||||||
|
const clearance = await this.customsClearanceLines(booking, frozenRates, liveRates);
|
||||||
|
for (const line of clearance.lineItems) {
|
||||||
|
lineItems.push(line);
|
||||||
|
total += line.amount;
|
||||||
|
}
|
||||||
|
for (const rate of clearance.usedRates) usedRatesMap.set(rate.id, rate);
|
||||||
|
clearanceBlocked.push(...clearance.blocked);
|
||||||
|
}
|
||||||
|
|
||||||
// Overweight detail for the customer: map the engine's per-line results back
|
// Overweight detail for the customer: map the engine's per-line results back
|
||||||
// to the booking's container lines (same order) for code + weights. maxAllowed
|
// to the booking's container lines (same order) for code + weights. maxAllowed
|
||||||
// is derived from the line total minus the excess the engine computed.
|
// is derived from the line total minus the excess the engine computed.
|
||||||
@@ -260,7 +282,7 @@ export class BookingPricingService {
|
|||||||
appliedModifiers: ruleResult.appliedModifiers,
|
appliedModifiers: ruleResult.appliedModifiers,
|
||||||
priorityScore: ruleResult.priorityScore,
|
priorityScore: ruleResult.priorityScore,
|
||||||
warnings: [...ruleResult.warnings, ...baseWarnings],
|
warnings: [...ruleResult.warnings, ...baseWarnings],
|
||||||
hardBlocked: [...ruleResult.hardBlocked, ...baseBlocked],
|
hardBlocked: [...ruleResult.hardBlocked, ...baseBlocked, ...clearanceBlocked],
|
||||||
overweightLines,
|
overweightLines,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -321,6 +343,8 @@ export class BookingPricingService {
|
|||||||
hazardousQuantity: Number(bc.hazardousQuantity ?? 0),
|
hazardousQuantity: Number(bc.hazardousQuantity ?? 0),
|
||||||
reeferQuantity: Number(bc.reeferQuantity ?? 0),
|
reeferQuantity: Number(bc.reeferQuantity ?? 0),
|
||||||
returnQuantity: Number(bc.returnQuantity ?? 0),
|
returnQuantity: Number(bc.returnQuantity ?? 0),
|
||||||
|
// Wagon share per box — a PER_WAGON empty-return rate bills on it.
|
||||||
|
wagonsPerUnit: wagonsPerUnitForSize(ct.sizeFt),
|
||||||
},
|
},
|
||||||
perWagon: containersPerWagonForSize(ct.sizeFt),
|
perWagon: containersPerWagonForSize(ct.sizeFt),
|
||||||
quantity: qty,
|
quantity: qty,
|
||||||
@@ -338,6 +362,13 @@ export class BookingPricingService {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
: 0;
|
: 0;
|
||||||
|
// Bulk wagon estimate for PER_WAGON kind-scoped surcharges (lashing).
|
||||||
|
// Deliberately NOT totalWagons — that would shift wagon-count priority
|
||||||
|
// scoring for bulk bookings.
|
||||||
|
const bulkWagons =
|
||||||
|
booking.freightType === 'BULK'
|
||||||
|
? ((await this.bulkWagonCount(booking)) ?? 0)
|
||||||
|
: 0;
|
||||||
|
|
||||||
// Consolidation is system-managed: the CONSOLIDATION surcharge fires whenever
|
// Consolidation is system-managed: the CONSOLIDATION surcharge fires whenever
|
||||||
// a container type leaves a wagon partially filled. Aggregate by type first —
|
// a container type leaves a wagon partially filled. Aggregate by type first —
|
||||||
@@ -386,6 +417,7 @@ export class BookingPricingService {
|
|||||||
booking.freightType === 'BULK'
|
booking.freightType === 'BULK'
|
||||||
? Number(booking.cargoTotalWeightVgm ?? 0)
|
? Number(booking.cargoTotalWeightVgm ?? 0)
|
||||||
: 0,
|
: 0,
|
||||||
|
bulkWagons,
|
||||||
containers,
|
containers,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -894,6 +926,184 @@ export class BookingPricingService {
|
|||||||
return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency);
|
return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customs clearance service fee lines for a customs booking (Path B), billed
|
||||||
|
* with the freight. Container bookings bill each container line at its own
|
||||||
|
* container type's fee — PER_CONTAINER × boxes or PER_WAGON × the wagons the
|
||||||
|
* line occupies (two 20ft share one). Bulk bookings bill the route's type-less
|
||||||
|
* fee — PER_TON × tonnage or PER_WAGON × wagons the bulk occupies. Frozen
|
||||||
|
* contract snapshots (CUSTOMS_CLEARANCE_20FT / _40FT / CUSTOMS_CLEARANCE)
|
||||||
|
* win over live rates; contracts frozen before the per-kind model carry one
|
||||||
|
* FLAT CUSTOMS_CLEARANCE snapshot, honoured once for the whole booking.
|
||||||
|
*/
|
||||||
|
private async customsClearanceLines(
|
||||||
|
booking: Booking,
|
||||||
|
frozenRates: Map<string, ContractRateSnapshot> | null,
|
||||||
|
liveRates: Rate[],
|
||||||
|
): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[]; blocked: string[] }> {
|
||||||
|
const lineItems: PriceLineItemDto[] = [];
|
||||||
|
const usedRates: Rate[] = [];
|
||||||
|
const blocked: string[] = [];
|
||||||
|
const currency = booking.paymentCurrency;
|
||||||
|
const isEtb = currency === 'ETB';
|
||||||
|
const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
||||||
|
const convert = (usd: number): number => (isEtb ? Math.round(usd * usdToEtb) : usd);
|
||||||
|
|
||||||
|
const onLeg = liveRates.filter(
|
||||||
|
(r) =>
|
||||||
|
r.rateType === 'CUSTOMS_CLEARANCE' &&
|
||||||
|
r.currency === 'USD' &&
|
||||||
|
r.tradeDirection === booking.tradeDirection &&
|
||||||
|
r.originYardId === booking.originYardId &&
|
||||||
|
r.destinationYardId === booking.destinationYardId,
|
||||||
|
);
|
||||||
|
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.';
|
||||||
|
|
||||||
|
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);
|
||||||
|
if (legacyFlat && !hasPerSizeSnapshot) {
|
||||||
|
const amount = Number(legacyFlat.unitPrice);
|
||||||
|
if (amount > 0) {
|
||||||
|
lineItems.push({
|
||||||
|
code: 'CUSTOMS_CLEARANCE',
|
||||||
|
description: 'Customs clearance service',
|
||||||
|
amount,
|
||||||
|
unitAmount: amount,
|
||||||
|
unit: 'FLAT',
|
||||||
|
quantity: 1,
|
||||||
|
currency,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { lineItems, usedRates, blocked };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const bc of booking.bookingContainers ?? []) {
|
||||||
|
if (!bc.containerTypeId) continue;
|
||||||
|
const qty = Number(bc.quantity || 0);
|
||||||
|
if (!(qty > 0)) continue;
|
||||||
|
let sizeFt = 0;
|
||||||
|
try {
|
||||||
|
sizeFt =
|
||||||
|
Number((await this.containerTypesService.findById(bc.containerTypeId)).sizeFt) || 0;
|
||||||
|
} catch {
|
||||||
|
// unknown type — falls through to the live per-type lookup below
|
||||||
|
}
|
||||||
|
const frozen = sizeFt
|
||||||
|
? this.frozenRateByCode(frozenRates, `CUSTOMS_CLEARANCE_${sizeFt}FT`, currency)
|
||||||
|
: null;
|
||||||
|
const live = onLeg.find((r) => r.containerTypeId === bc.containerTypeId);
|
||||||
|
if (!frozen && !live) {
|
||||||
|
blocked.push(missingRateMessage(`${sizeFt || '?'}ft containers`));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const unit = frozen
|
||||||
|
? this.rateUnitFromSnapshot(frozen.unitOfMeasure)
|
||||||
|
: live!.rateUnit;
|
||||||
|
const unitAmount = frozen
|
||||||
|
? Number(frozen.unitPrice)
|
||||||
|
: convert(Number(live!.rateValue));
|
||||||
|
const billedQty =
|
||||||
|
unit === 'PER_WAGON' ? Math.ceil(qty * wagonsPerUnitForSize(sizeFt)) : qty;
|
||||||
|
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)` : ''}`,
|
||||||
|
amount,
|
||||||
|
unitAmount,
|
||||||
|
unit,
|
||||||
|
quantity: unit === 'FLAT' ? 1 : billedQty,
|
||||||
|
currency,
|
||||||
|
});
|
||||||
|
if (live && !frozen) usedRates.push(live);
|
||||||
|
}
|
||||||
|
return { lineItems, usedRates, blocked };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bulk — one fee for the whole booking. The bulk snapshot and the legacy
|
||||||
|
// flat snapshot share the CUSTOMS_CLEARANCE code; both are the agreed fee.
|
||||||
|
const frozen = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency);
|
||||||
|
const live = onLeg.find((r) => !r.containerTypeId);
|
||||||
|
if (!frozen && !live) {
|
||||||
|
blocked.push(missingRateMessage('bulk cargo'));
|
||||||
|
return { lineItems, usedRates, blocked };
|
||||||
|
}
|
||||||
|
const unit = frozen ? this.rateUnitFromSnapshot(frozen.unitOfMeasure) : live!.rateUnit;
|
||||||
|
const unitAmount = frozen ? Number(frozen.unitPrice) : convert(Number(live!.rateValue));
|
||||||
|
let billedQty = 1;
|
||||||
|
if (unit === 'PER_TON') {
|
||||||
|
billedQty = Math.max(0, Number(booking.cargoTotalWeightVgm ?? 0));
|
||||||
|
} else if (unit === 'PER_WAGON') {
|
||||||
|
const wagons = await this.bulkWagonCount(booking);
|
||||||
|
if (wagons == null) {
|
||||||
|
blocked.push(
|
||||||
|
'The bulk customs clearance fee is per wagon, but this cargo type has ' +
|
||||||
|
'no wagon type with a capacity configured — the wagon count cannot ' +
|
||||||
|
'be derived. Ask EDR to configure the cargo type’s wagon types.',
|
||||||
|
);
|
||||||
|
return { lineItems, usedRates, blocked };
|
||||||
|
}
|
||||||
|
billedQty = wagons;
|
||||||
|
}
|
||||||
|
const amount = unit === 'FLAT' ? unitAmount : unitAmount * billedQty;
|
||||||
|
if (amount > 0) {
|
||||||
|
lineItems.push({
|
||||||
|
code: 'CUSTOMS_CLEARANCE',
|
||||||
|
description: 'Customs clearance service (bulk)',
|
||||||
|
amount,
|
||||||
|
unitAmount,
|
||||||
|
unit,
|
||||||
|
quantity: unit === 'FLAT' ? 1 : billedQty,
|
||||||
|
currency,
|
||||||
|
});
|
||||||
|
if (live && !frozen) usedRates.push(live);
|
||||||
|
}
|
||||||
|
return { lineItems, usedRates, blocked };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Snapshot unit-of-measure → the rate unit the billing math applies. */
|
||||||
|
private rateUnitFromSnapshot(unitOfMeasure: string): string {
|
||||||
|
switch (unitOfMeasure) {
|
||||||
|
case 'per_wagon':
|
||||||
|
return 'PER_WAGON';
|
||||||
|
case 'per_ton':
|
||||||
|
return 'PER_TON';
|
||||||
|
case 'per_container':
|
||||||
|
return 'PER_CONTAINER';
|
||||||
|
default:
|
||||||
|
return 'FLAT';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wagons a bulk booking occupies — ceil(tons ÷ rated capacity), using the
|
||||||
|
* largest-capacity wagon type its cargo type allows. Null when the chain is
|
||||||
|
* unconfigured (no cargo type, no wagon types, no capacity).
|
||||||
|
* ponytail: pricing-time estimate off the biggest allowed wagon; scheduling
|
||||||
|
* may stock a smaller type and use more wagons.
|
||||||
|
*/
|
||||||
|
private async bulkWagonCount(booking: Booking): Promise<number | null> {
|
||||||
|
const tons = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||||
|
if (!(tons > 0) || !booking.cargoTypeId) return null;
|
||||||
|
try {
|
||||||
|
const cargo = await this.cargoTypesService.findById(booking.cargoTypeId);
|
||||||
|
const capacity = Math.max(
|
||||||
|
0,
|
||||||
|
...(cargo.wagonTypes ?? []).map((w) => Number(w.capacityTons) || 0),
|
||||||
|
);
|
||||||
|
if (!(capacity > 0)) return null;
|
||||||
|
return Math.max(1, Math.ceil(tons / capacity));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private lineItemsSignature(items: PriceLineItemDto[]): string {
|
private lineItemsSignature(items: PriceLineItemDto[]): string {
|
||||||
return JSON.stringify(
|
return JSON.stringify(
|
||||||
[...items]
|
[...items]
|
||||||
|
|||||||
@@ -605,11 +605,6 @@ export class BookingTransitionService {
|
|||||||
files: Express.Multer.File[],
|
files: Express.Multer.File[],
|
||||||
): Promise<Booking> {
|
): Promise<Booking> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
if (booking.status === "AWAITING_CLEARANCE_PAYMENT") {
|
|
||||||
throw new ConflictException(
|
|
||||||
"The customs clearance service fee for this shipment has not been paid yet — pay it from the portal to unlock document upload.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
assertBookingStatus(booking, [
|
assertBookingStatus(booking, [
|
||||||
"AWAITING_DOCUMENTS",
|
"AWAITING_DOCUMENTS",
|
||||||
"DOCUMENTS_UNDER_REVIEW",
|
"DOCUMENTS_UNDER_REVIEW",
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ export const BOOKING_STATUSES = [
|
|||||||
'CONTRACT_ACTIVE',
|
'CONTRACT_ACTIVE',
|
||||||
'CONTRACT_CLOSED',
|
'CONTRACT_CLOSED',
|
||||||
// Post counter-sign document-clearance gate (GL workflow).
|
// Post counter-sign document-clearance gate (GL workflow).
|
||||||
'AWAITING_CLEARANCE_PAYMENT', // clearance fee invoiced, unpaid — docs locked
|
|
||||||
'AWAITING_DOCUMENTS',
|
'AWAITING_DOCUMENTS',
|
||||||
'DOCUMENTS_UNDER_REVIEW',
|
'DOCUMENTS_UNDER_REVIEW',
|
||||||
'CLEARANCE_READY',
|
'CLEARANCE_READY',
|
||||||
@@ -521,10 +520,6 @@ export class Booking extends BaseEntity {
|
|||||||
@Column({ name: 'clearance_current_phase', type: 'varchar', length: 40, nullable: true })
|
@Column({ name: 'clearance_current_phase', type: 'varchar', length: 40, nullable: true })
|
||||||
clearanceCurrentPhase?: string | null;
|
clearanceCurrentPhase?: string | null;
|
||||||
|
|
||||||
/** When the prepaid customs clearance service fee settled (GENERAL + customs). */
|
|
||||||
@Column({ name: 'clearance_fee_paid_at', type: 'timestamptz', nullable: true })
|
|
||||||
clearanceFeePaidAt?: Date | null;
|
|
||||||
|
|
||||||
@Column({ name: 'duty_required', type: 'boolean', nullable: true })
|
@Column({ name: 'duty_required', type: 'boolean', nullable: true })
|
||||||
dutyRequired?: boolean | null;
|
dutyRequired?: boolean | null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,239 +0,0 @@
|
|||||||
import { Injectable, Logger, UnprocessableEntityException } from '@nestjs/common';
|
|
||||||
import { OnEvent } from '@nestjs/event-emitter';
|
|
||||||
import { Freight } from '@edr/types';
|
|
||||||
|
|
||||||
import { BillingService, InvoiceEventPayload } from '../billing/billing.service';
|
|
||||||
import { Invoice } from '../billing/entities/invoice.entity';
|
|
||||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
|
||||||
import { ContractPricingBreakdown } from './contract-pricing.service';
|
|
||||||
import { ContractNotifierService } from './contract-notifier.service';
|
|
||||||
import { ContractsRepository } from './contracts.repository';
|
|
||||||
import { Contract } from './entities/contract.entity';
|
|
||||||
|
|
||||||
/** Invoice `type` for the contract-level fee (Path B ONE_TIME, after counter-sign). */
|
|
||||||
export const CLEARANCE_CONTRACT_INVOICE_TYPE = 'CLEARANCE_CONTRACT';
|
|
||||||
/** Invoice `type` for the per-shipment fee (Path B GENERAL, at shipment request). */
|
|
||||||
export const CLEARANCE_BOOKING_INVOICE_TYPE = 'CLEARANCE_BOOKING';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The prepaid customs clearance service fee (Path B) — the GL service charge,
|
|
||||||
* separate from both freight (booking invoice) and duty/tax (paid offline).
|
|
||||||
* Issued as its own `clearance`-source invoice and paid BEFORE the clearance
|
|
||||||
* document step opens and before GL touches the file:
|
|
||||||
* - ONE_TIME: once per contract, at staff counter-sign
|
|
||||||
* (AWAITING_CLEARANCE_PAYMENT → paid → AWAITING_CLEARANCE_DOCUMENTS);
|
|
||||||
* - GENERAL: once per shipment request, on the initiated booking instance
|
|
||||||
* (booking AWAITING_CLEARANCE_PAYMENT → paid → AWAITING_DOCUMENTS).
|
|
||||||
* The fee amount is the frozen CUSTOMS_CLEARANCE contract rate snapshot, so
|
|
||||||
* customers pay what their contract shows, not the live rate of the day.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class ClearanceFeeService {
|
|
||||||
private readonly logger = new Logger(ClearanceFeeService.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly billing: BillingService,
|
|
||||||
private readonly contractsRepository: ContractsRepository,
|
|
||||||
private readonly bookingsRepository: BookingsRepository,
|
|
||||||
private readonly notifier: ContractNotifierService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/** The frozen flat fee for a contract; falls back to the pricing breakdown. */
|
|
||||||
private async feeAmountOrNull(
|
|
||||||
contract: Contract,
|
|
||||||
): Promise<{ amount: number; currency: string } | null> {
|
|
||||||
const snapshots = await this.contractsRepository.findRateSnapshots(contract.id);
|
|
||||||
const snapshot = snapshots.find(
|
|
||||||
(s) => s.isClearance || s.rateCode === 'CUSTOMS_CLEARANCE',
|
|
||||||
);
|
|
||||||
if (snapshot && Number(snapshot.unitPrice) > 0) {
|
|
||||||
return { amount: Number(snapshot.unitPrice), currency: snapshot.currency };
|
|
||||||
}
|
|
||||||
const breakdown = contract.pricingBreakdown as ContractPricingBreakdown | null;
|
|
||||||
const line = breakdown?.lineItems?.find((l) => l.code === 'CUSTOMS_CLEARANCE');
|
|
||||||
if (line && Number(line.unitPrice) > 0) {
|
|
||||||
return { amount: Number(line.unitPrice), currency: breakdown!.currency };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async feeAmount(
|
|
||||||
contract: Contract,
|
|
||||||
): Promise<{ amount: number; currency: string }> {
|
|
||||||
const fee = await this.feeAmountOrNull(contract);
|
|
||||||
if (!fee) {
|
|
||||||
throw new UnprocessableEntityException(
|
|
||||||
`Contract ${contract.reference} has no frozen customs clearance fee — regenerate its price with a live CUSTOMS_CLEARANCE rate.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return fee;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the payment gate applies. Skipped for government/unlinked
|
|
||||||
* contracts (no company to bill — invoices require one, same rule the
|
|
||||||
* booking invoice applies) and for legacy customs contracts frozen before
|
|
||||||
* the fee existed (no CUSTOMS_CLEARANCE snapshot to bill from) — both keep
|
|
||||||
* the pre-fee flow instead of dead-ending.
|
|
||||||
*/
|
|
||||||
async gateApplies(contract: Contract): Promise<boolean> {
|
|
||||||
// Customs disabled → the prepay gate genuinely does not apply.
|
|
||||||
if (!contract.customsClearingEnabled) return false;
|
|
||||||
// No company to bill (government / unlinked) → the gate cannot raise an
|
|
||||||
// invoice, so it stays out of the flow (same rule the booking invoice uses).
|
|
||||||
if (!contract.companyId) return false;
|
|
||||||
// M26: customs IS enabled and billable. A missing frozen fee line must NOT
|
|
||||||
// silently waive the gate — that ships clearance for free. Hard-fail exactly
|
|
||||||
// as price generation does when no CUSTOMS_CLEARANCE rate is configured, so a
|
|
||||||
// missing fee blocks counter-sign / shipment instead of bypassing payment.
|
|
||||||
if ((await this.feeAmountOrNull(contract)) === null) {
|
|
||||||
throw new UnprocessableEntityException(
|
|
||||||
'No customs clearance service fee is configured. Ask the rates team to set a live CUSTOMS_CLEARANCE rate before submitting customs contracts.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Issue (idempotently) the ONE_TIME contract-level fee invoice. */
|
|
||||||
async issueForContract(contract: Contract): Promise<Invoice> {
|
|
||||||
const existing = await this.billing.findPayable(
|
|
||||||
Freight.InvoiceSource.Clearance,
|
|
||||||
contract.id,
|
|
||||||
CLEARANCE_CONTRACT_INVOICE_TYPE,
|
|
||||||
);
|
|
||||||
if (existing) return existing;
|
|
||||||
|
|
||||||
const { amount, currency } = await this.feeAmount(contract);
|
|
||||||
const invoice = await this.billing.generateInvoice({
|
|
||||||
source: Freight.InvoiceSource.Clearance,
|
|
||||||
sourceId: contract.id,
|
|
||||||
type: CLEARANCE_CONTRACT_INVOICE_TYPE,
|
|
||||||
companyId: contract.companyId!,
|
|
||||||
companyProfileId: contract.companyProfileId!,
|
|
||||||
currency,
|
|
||||||
lines: [
|
|
||||||
{
|
|
||||||
chargeType: 'CUSTOMS_CLEARANCE',
|
|
||||||
description: `Customs clearance service fee — contract ${contract.reference}`,
|
|
||||||
quantity: 1,
|
|
||||||
unitRate: amount,
|
|
||||||
amount,
|
|
||||||
currency,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
status: Freight.InvoiceStatus.Pending,
|
|
||||||
});
|
|
||||||
this.notifier.clearanceFeeDue(contract, amount, currency);
|
|
||||||
return invoice;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Issue (idempotently) the GENERAL per-shipment fee invoice on the booking. */
|
|
||||||
async issueForBooking(booking: Booking, contract: Contract): Promise<Invoice> {
|
|
||||||
const existing = await this.billing.findPayable(
|
|
||||||
Freight.InvoiceSource.Clearance,
|
|
||||||
booking.id,
|
|
||||||
CLEARANCE_BOOKING_INVOICE_TYPE,
|
|
||||||
);
|
|
||||||
if (existing) return existing;
|
|
||||||
|
|
||||||
const { amount, currency } = await this.feeAmount(contract);
|
|
||||||
const invoice = await this.billing.generateInvoice({
|
|
||||||
source: Freight.InvoiceSource.Clearance,
|
|
||||||
sourceId: booking.id,
|
|
||||||
type: CLEARANCE_BOOKING_INVOICE_TYPE,
|
|
||||||
companyId: booking.companyId ?? contract.companyId!,
|
|
||||||
companyProfileId: booking.companyProfileId ?? contract.companyProfileId!,
|
|
||||||
currency,
|
|
||||||
lines: [
|
|
||||||
{
|
|
||||||
chargeType: 'CUSTOMS_CLEARANCE',
|
|
||||||
description: `Customs clearance service fee — shipment ${booking.reference}`,
|
|
||||||
quantity: 1,
|
|
||||||
unitRate: amount,
|
|
||||||
amount,
|
|
||||||
currency,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
status: Freight.InvoiceStatus.Pending,
|
|
||||||
});
|
|
||||||
this.notifier.clearanceFeeDue(contract, amount, currency, booking.reference);
|
|
||||||
return invoice;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retire (idempotently) the unpaid contract-level fee invoice when the
|
|
||||||
* contract reaches a terminal state — a dead contract must not leave a
|
|
||||||
* payable clearance invoice open for the customer to settle. No-op when the
|
|
||||||
* fee was already paid or never invoiced (mirrors the booking cancel path,
|
|
||||||
* {@link BillingService.expirePayable}).
|
|
||||||
*/
|
|
||||||
async expireForContract(contractId: string): Promise<Invoice | null> {
|
|
||||||
return this.billing.expirePayable(
|
|
||||||
Freight.InvoiceSource.Clearance,
|
|
||||||
contractId,
|
|
||||||
CLEARANCE_CONTRACT_INVOICE_TYPE,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Settlement branch point for `clearance`-source invoices: unlock the
|
|
||||||
* document-upload step the fee was gating. Idempotent — a replayed event on
|
|
||||||
* an already-advanced contract/booking is a no-op.
|
|
||||||
*/
|
|
||||||
@OnEvent('clearance.invoice.paid')
|
|
||||||
async onClearanceInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
|
|
||||||
this.logger.log(
|
|
||||||
`clearance.invoice.paid (${payload.type}) for ${payload.sourceId} from ${payload.invoiceId}`,
|
|
||||||
);
|
|
||||||
switch (payload.type) {
|
|
||||||
case CLEARANCE_CONTRACT_INVOICE_TYPE:
|
|
||||||
await this.advanceContract(payload.sourceId);
|
|
||||||
break;
|
|
||||||
case CLEARANCE_BOOKING_INVOICE_TYPE:
|
|
||||||
await this.advanceBooking(payload.sourceId);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
this.logger.warn(
|
|
||||||
`Unhandled clearance invoice type "${payload.type}" paid (${payload.invoiceId})`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async advanceContract(contractId: string): Promise<void> {
|
|
||||||
const contract = await this.contractsRepository.findById(contractId);
|
|
||||||
if (!contract) {
|
|
||||||
this.logger.warn(`Cannot advance unknown contract ${contractId} on clearance fee payment.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (contract.status !== 'AWAITING_CLEARANCE_PAYMENT') return;
|
|
||||||
|
|
||||||
await this.contractsRepository.update(contractId, {
|
|
||||||
status: 'AWAITING_CLEARANCE_DOCUMENTS',
|
|
||||||
clearanceStatus: 'AWAITING_DOCUMENTS',
|
|
||||||
clearanceFeePaidAt: new Date(),
|
|
||||||
} as never);
|
|
||||||
const updated = await this.contractsRepository.findByIdWithRelations(contractId);
|
|
||||||
if (updated) this.notifier.clearanceFeePaid(updated);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async advanceBooking(bookingId: string): Promise<void> {
|
|
||||||
const booking = await this.bookingsRepository.findById(bookingId);
|
|
||||||
if (!booking) {
|
|
||||||
this.logger.warn(`Cannot advance unknown booking ${bookingId} on clearance fee payment.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (booking.status !== 'AWAITING_CLEARANCE_PAYMENT') return;
|
|
||||||
|
|
||||||
await this.bookingsRepository.update(bookingId, {
|
|
||||||
status: 'AWAITING_DOCUMENTS',
|
|
||||||
clearanceFeePaidAt: new Date(),
|
|
||||||
} as never);
|
|
||||||
if (booking.contractId) {
|
|
||||||
const contract = await this.contractsRepository.findByIdWithRelations(
|
|
||||||
booking.contractId,
|
|
||||||
);
|
|
||||||
if (contract) this.notifier.clearanceFeePaid(contract, booking.reference);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -26,7 +26,6 @@ describe('ContractBookingService — quantity-cap completion', () => {
|
|||||||
{} as never, // milestoneService
|
{} as never, // milestoneService
|
||||||
{} as never, // workflowService
|
{} as never, // workflowService
|
||||||
{} as never, // invoiceService
|
{} as never, // invoiceService
|
||||||
{} as never, // clearanceFeeService
|
|
||||||
{ createdToStaff: jest.fn() } as never, // bookingNotifier
|
{ createdToStaff: jest.fn() } as never, // bookingNotifier
|
||||||
{} as never, // dataSource
|
{} as never, // dataSource
|
||||||
{} as never, // trainSchedulingService
|
{} as never, // trainSchedulingService
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
|
|||||||
milestoneService as never,
|
milestoneService as never,
|
||||||
{} as never, // workflowService
|
{} as never, // workflowService
|
||||||
invoiceService as never,
|
invoiceService as never,
|
||||||
{} as never, // clearanceFeeService
|
|
||||||
{ createdToStaff: jest.fn() } as never, // bookingNotifier
|
{ createdToStaff: jest.fn() } as never, // bookingNotifier
|
||||||
{} as never, // dataSource
|
{} as never, // dataSource
|
||||||
{} as never, // trainSchedulingService
|
{} as never, // trainSchedulingService
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ import { hasFreightPermission } from '../../common/freight-permission.util';
|
|||||||
import { Contract } from './entities/contract.entity';
|
import { Contract } from './entities/contract.entity';
|
||||||
import { ContractRoute } from './entities/contract-route.entity';
|
import { ContractRoute } from './entities/contract-route.entity';
|
||||||
import { ContractsRepository } from './contracts.repository';
|
import { ContractsRepository } from './contracts.repository';
|
||||||
import { ClearanceFeeService } from './clearance-fee.service';
|
|
||||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||||
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||||
import {
|
import {
|
||||||
@@ -97,7 +96,6 @@ export class ContractBookingService {
|
|||||||
private readonly milestoneService: ClearanceMilestoneService,
|
private readonly milestoneService: ClearanceMilestoneService,
|
||||||
private readonly workflowService: ClearanceWorkflowService,
|
private readonly workflowService: ClearanceWorkflowService,
|
||||||
private readonly invoiceService: BookingInvoiceService,
|
private readonly invoiceService: BookingInvoiceService,
|
||||||
private readonly clearanceFeeService: ClearanceFeeService,
|
|
||||||
private readonly bookingNotifier: BookingLifecycleNotifierService,
|
private readonly bookingNotifier: BookingLifecycleNotifierService,
|
||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
@Inject(forwardRef(() => TrainSchedulingService))
|
@Inject(forwardRef(() => TrainSchedulingService))
|
||||||
@@ -537,11 +535,8 @@ export class ContractBookingService {
|
|||||||
|
|
||||||
const route = await this.resolveRoute(contract, opts.contractRouteId);
|
const route = await this.resolveRoute(contract, opts.contractRouteId);
|
||||||
|
|
||||||
// Prepay gate: each shipment request owes its own flat clearance service
|
// No prepay gate: the clearance service fee is billed on the booking
|
||||||
// fee before the document step opens (the paid event advances the booking
|
// invoice at completion, so the document step opens immediately.
|
||||||
// to AWAITING_DOCUMENTS). Government/unlinked contracts skip the gate.
|
|
||||||
const feeGate = await this.clearanceFeeService.gateApplies(contract);
|
|
||||||
|
|
||||||
const booking = await insertWithGeneratedReference(
|
const booking = await insertWithGeneratedReference(
|
||||||
() => this.generateReference(),
|
() => this.generateReference(),
|
||||||
(reference) =>
|
(reference) =>
|
||||||
@@ -551,7 +546,7 @@ export class ContractBookingService {
|
|||||||
companyProfileId: contract.companyProfileId ?? null,
|
companyProfileId: contract.companyProfileId ?? null,
|
||||||
isGovernment: contract.isGovernment,
|
isGovernment: contract.isGovernment,
|
||||||
governmentInstitution: contract.governmentInstitution ?? null,
|
governmentInstitution: contract.governmentInstitution ?? null,
|
||||||
status: feeGate ? 'AWAITING_CLEARANCE_PAYMENT' : 'AWAITING_DOCUMENTS',
|
status: 'AWAITING_DOCUMENTS',
|
||||||
bookingType: 'ONE_TIME',
|
bookingType: 'ONE_TIME',
|
||||||
contractId: contract.id,
|
contractId: contract.id,
|
||||||
contractRouteId: route?.id ?? null,
|
contractRouteId: route?.id ?? null,
|
||||||
@@ -590,10 +585,6 @@ export class ContractBookingService {
|
|||||||
contract.tradeDirection,
|
contract.tradeDirection,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (feeGate) {
|
|
||||||
await this.clearanceFeeService.issueForBooking(booking, contract);
|
|
||||||
}
|
|
||||||
|
|
||||||
const created =
|
const created =
|
||||||
(await this.bookingsRepository.findByIdWithFiles(booking.id)) ?? booking;
|
(await this.bookingsRepository.findByIdWithFiles(booking.id)) ?? booking;
|
||||||
this.bookingNotifier.createdToStaff(created);
|
this.bookingNotifier.createdToStaff(created);
|
||||||
|
|||||||
@@ -499,11 +499,6 @@ export class ContractClearanceService {
|
|||||||
files: Express.Multer.File[],
|
files: Express.Multer.File[],
|
||||||
): Promise<Contract> {
|
): Promise<Contract> {
|
||||||
const contract = await this.contractsService.findById(contractId);
|
const contract = await this.contractsService.findById(contractId);
|
||||||
if (contract.status === 'AWAITING_CLEARANCE_PAYMENT') {
|
|
||||||
throw new ConflictException(
|
|
||||||
'The customs clearance service fee has not been paid yet — pay it from the portal to unlock document upload.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
contract.status !== 'AWAITING_CLEARANCE_DOCUMENTS' &&
|
contract.status !== 'AWAITING_CLEARANCE_DOCUMENTS' &&
|
||||||
contract.status !== 'CLEARANCE_UNDER_REVIEW'
|
contract.status !== 'CLEARANCE_UNDER_REVIEW'
|
||||||
|
|||||||
@@ -178,26 +178,6 @@ export class ContractNotifierService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clearance service fee invoiced — customer must pay before document upload. */
|
|
||||||
clearanceFeeDue(c: Contract, amount: number, currency: string, shipmentRef?: string): void {
|
|
||||||
const scope = shipmentRef ? `shipment ${shipmentRef} under contract ${c.reference}` : `contract ${c.reference}`;
|
|
||||||
const msg =
|
|
||||||
`A customs clearance service fee of ${amount} ${currency} is due for ${scope}. ` +
|
|
||||||
`Please pay from the portal to unlock the clearance document upload.`;
|
|
||||||
void this.notifyContact(c, msg, 'CLEARANCE FEE DUE');
|
|
||||||
this.inApp(c, 'Clearance fee due', msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Clearance service fee settled — document upload is now open. */
|
|
||||||
clearanceFeePaid(c: Contract, shipmentRef?: string): void {
|
|
||||||
const scope = shipmentRef ? `shipment ${shipmentRef} under contract ${c.reference}` : `contract ${c.reference}`;
|
|
||||||
const msg =
|
|
||||||
`Your customs clearance service fee for ${scope} has been received. ` +
|
|
||||||
`You can now upload the clearance documents from the portal.`;
|
|
||||||
void this.notifyContact(c, msg, 'CLEARANCE FEE PAID');
|
|
||||||
this.inApp(c, 'Clearance fee paid', msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Clearance milestones needing customer action ──────────────────────────
|
// ── Clearance milestones needing customer action ──────────────────────────
|
||||||
|
|
||||||
/** GL advised duty & tax on the contract cycle — customer pays + uploads slip. */
|
/** GL advised duty & tax on the contract cycle — customer pays + uploads slip. */
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { Contract } from './entities/contract.entity';
|
|||||||
export interface ContractUnitRateLineItem {
|
export interface ContractUnitRateLineItem {
|
||||||
code: string;
|
code: string;
|
||||||
label: string;
|
label: string;
|
||||||
unit: 'per_container' | 'per_ton' | 'per_item' | 'per_km' | 'flat';
|
unit: 'per_container' | 'per_wagon' | 'per_ton' | 'per_item' | 'per_km' | 'flat';
|
||||||
unitPrice: number;
|
unitPrice: number;
|
||||||
containerSize?: string | null;
|
containerSize?: string | null;
|
||||||
conditionalOn?: string | null;
|
conditionalOn?: string | null;
|
||||||
@@ -37,8 +37,9 @@ function toContractUnit(rateUnit: string): ContractUnitRateLineItem['unit'] {
|
|||||||
return 'per_ton';
|
return 'per_ton';
|
||||||
case 'PER_KM':
|
case 'PER_KM':
|
||||||
return 'per_km';
|
return 'per_km';
|
||||||
case 'PER_CONTAINER':
|
|
||||||
case 'PER_WAGON':
|
case 'PER_WAGON':
|
||||||
|
return 'per_wagon';
|
||||||
|
case 'PER_CONTAINER':
|
||||||
return 'per_container';
|
return 'per_container';
|
||||||
default:
|
default:
|
||||||
return 'flat';
|
return 'flat';
|
||||||
@@ -240,18 +241,19 @@ export class ContractPricingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Customs clearance service fee (Path B) — a FLAT prepaid fee, shown on the
|
// Customs clearance service fee (Path B) — billed on the booking invoice
|
||||||
// contract and billed via its own clearance invoice: after counter-sign for
|
// together with the freight. Sold per direction + route + cargo kind:
|
||||||
// ONE_TIME, per shipment request for GENERAL. Excluded from booking totals.
|
// container contracts freeze one fee line per contract size (each size's
|
||||||
// A customs contract may not proceed without a configured live rate.
|
// own container-type rate), bulk contracts freeze the route's bulk fee.
|
||||||
|
// A customs contract may not proceed without the fee(s) configured.
|
||||||
if (contract.customsClearingEnabled) {
|
if (contract.customsClearingEnabled) {
|
||||||
// The fee is sold per direction + route — strict, no route-less fallback.
|
// Strict, no route-less fallback.
|
||||||
// ponytail: multi-route contracts bill the first lane's fee; per-lane fees need per-route snapshots.
|
// ponytail: multi-route contracts bill the first lane's fee; per-lane fees need per-route snapshots.
|
||||||
const route = [...(contract.routes ?? [])].sort(
|
const route = [...(contract.routes ?? [])].sort(
|
||||||
(a, b) => a.sortOrder - b.sortOrder,
|
(a, b) => a.sortOrder - b.sortOrder,
|
||||||
)[0];
|
)[0];
|
||||||
const clearance = route
|
const onLeg = route
|
||||||
? liveRates.find(
|
? liveRates.filter(
|
||||||
(r) =>
|
(r) =>
|
||||||
r.rateType === 'CUSTOMS_CLEARANCE' &&
|
r.rateType === 'CUSTOMS_CLEARANCE' &&
|
||||||
r.currency === 'USD' &&
|
r.currency === 'USD' &&
|
||||||
@@ -259,22 +261,55 @@ export class ContractPricingService {
|
|||||||
r.originYardId === route.originYardId &&
|
r.originYardId === route.originYardId &&
|
||||||
r.destinationYardId === route.destinationYardId,
|
r.destinationYardId === route.destinationYardId,
|
||||||
)
|
)
|
||||||
: undefined;
|
: [];
|
||||||
if (!clearance || Number(clearance.rateValue) <= 0) {
|
if (contract.freightType === 'CONTAINER') {
|
||||||
throw new UnprocessableEntityException(
|
const sizes = (contract.cargoScope ?? [])
|
||||||
'No customs clearance service fee is configured for this direction and route. Ask the rates team to set a live CUSTOMS_CLEARANCE rate for this origin → destination.',
|
.map((c) => c.containerSize)
|
||||||
);
|
.filter((s): s is string => !!s);
|
||||||
|
const { items: containerTypes } = await this.containerTypesService.findAll({
|
||||||
|
isActive: true,
|
||||||
|
pageSize: 100,
|
||||||
|
});
|
||||||
|
for (const size of sizes) {
|
||||||
|
const sizeFt = size === '40ft' ? 40 : 20;
|
||||||
|
const matchedIds = new Set(
|
||||||
|
containerTypes.filter((ct) => ct.sizeFt === sizeFt).map((ct) => ct.id),
|
||||||
|
);
|
||||||
|
const rate = onLeg.find(
|
||||||
|
(r) => r.containerTypeId && matchedIds.has(r.containerTypeId),
|
||||||
|
);
|
||||||
|
if (!rate || Number(rate.rateValue) <= 0) {
|
||||||
|
throw new UnprocessableEntityException(
|
||||||
|
`No customs clearance service fee is configured for ${size} containers on this direction and route. Ask the rates team to set a live CUSTOMS_CLEARANCE rate for this container type and origin → destination.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
lineItems.push({
|
||||||
|
// Distinct code per size so the frozen snapshots don't collide —
|
||||||
|
// booking pricing looks each size up by CUSTOMS_CLEARANCE_<FT>FT.
|
||||||
|
code: `CUSTOMS_CLEARANCE_${sizeFt}FT`,
|
||||||
|
label: `Customs clearance service (${size})`,
|
||||||
|
unit: toContractUnit(rate.rateUnit),
|
||||||
|
unitPrice: convert(Number(rate.rateValue)),
|
||||||
|
containerSize: size,
|
||||||
|
isClearance: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Bulk fee = the route's type-less customs rate (per ton / per wagon).
|
||||||
|
const rate = onLeg.find((r) => !r.containerTypeId);
|
||||||
|
if (!rate || Number(rate.rateValue) <= 0) {
|
||||||
|
throw new UnprocessableEntityException(
|
||||||
|
'No bulk customs clearance service fee is configured for this direction and route. Ask the rates team to set a live bulk CUSTOMS_CLEARANCE rate for this origin → destination.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
lineItems.push({
|
||||||
|
code: 'CUSTOMS_CLEARANCE',
|
||||||
|
label: 'Customs clearance service (bulk)',
|
||||||
|
unit: toContractUnit(rate.rateUnit),
|
||||||
|
unitPrice: convert(Number(rate.rateValue)),
|
||||||
|
isClearance: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
lineItems.push({
|
|
||||||
code: 'CUSTOMS_CLEARANCE',
|
|
||||||
label:
|
|
||||||
contract.contractKind === 'GENERAL'
|
|
||||||
? 'Customs clearance service fee (per shipment request, prepaid)'
|
|
||||||
: 'Customs clearance service fee (prepaid)',
|
|
||||||
unit: toContractUnit(clearance.rateUnit),
|
|
||||||
unitPrice: convert(Number(clearance.rateValue)),
|
|
||||||
isClearance: true,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ import { SignaturesService } from '../signatures/signatures.service';
|
|||||||
import { OtpService } from '../otp/otp.service';
|
import { OtpService } from '../otp/otp.service';
|
||||||
import { ContractTemplatesService } from '../contract-templates/contract-templates.service';
|
import { ContractTemplatesService } from '../contract-templates/contract-templates.service';
|
||||||
import { ContractPricingService } from './contract-pricing.service';
|
import { ContractPricingService } from './contract-pricing.service';
|
||||||
import { ClearanceFeeService } from './clearance-fee.service';
|
|
||||||
import { ContractNotifierService } from './contract-notifier.service';
|
import { ContractNotifierService } from './contract-notifier.service';
|
||||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||||
import { ContractsRepository } from './contracts.repository';
|
import { ContractsRepository } from './contracts.repository';
|
||||||
@@ -162,7 +161,6 @@ export class ContractTransitionService {
|
|||||||
private readonly otpService: OtpService,
|
private readonly otpService: OtpService,
|
||||||
private readonly notifier: ContractNotifierService,
|
private readonly notifier: ContractNotifierService,
|
||||||
private readonly contractTemplates: ContractTemplatesService,
|
private readonly contractTemplates: ContractTemplatesService,
|
||||||
private readonly clearanceFeeService: ClearanceFeeService,
|
|
||||||
@InjectDataSource()
|
@InjectDataSource()
|
||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
) {}
|
) {}
|
||||||
@@ -591,10 +589,6 @@ export class ContractTransitionService {
|
|||||||
actorId,
|
actorId,
|
||||||
'STAFF',
|
'STAFF',
|
||||||
);
|
);
|
||||||
// Stop the open-invoice leak: a rejected contract must not leave a payable
|
|
||||||
// clearance fee invoice open. Mirror the booking cancel path (billing.expirePayable).
|
|
||||||
await this.clearanceFeeService.expireForContract(contractId);
|
|
||||||
|
|
||||||
await this.contractsRepository.update(contractId, {
|
await this.contractsRepository.update(contractId, {
|
||||||
status: 'REJECTED',
|
status: 'REJECTED',
|
||||||
} as never);
|
} as never);
|
||||||
@@ -654,10 +648,6 @@ export class ContractTransitionService {
|
|||||||
'STAFF',
|
'STAFF',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Stop the open-invoice leak: a rejected contract must not leave a payable
|
|
||||||
// clearance fee invoice open. Mirror the booking cancel path (billing.expirePayable).
|
|
||||||
await this.clearanceFeeService.expireForContract(contractId);
|
|
||||||
|
|
||||||
await this.contractsRepository.update(contractId, {
|
await this.contractsRepository.update(contractId, {
|
||||||
status: 'REJECTED',
|
status: 'REJECTED',
|
||||||
} as never);
|
} as never);
|
||||||
@@ -669,9 +659,9 @@ export class ContractTransitionService {
|
|||||||
/**
|
/**
|
||||||
* Internal send-back branch of rejectStep: return the contract to an earlier,
|
* Internal send-back branch of rejectStep: return the contract to an earlier,
|
||||||
* already-approved stage of the chain instead of rejecting it outright.
|
* already-approved stage of the chain instead of rejecting it outright.
|
||||||
* Deliberately NOT the terminal path: no clearance-fee expiry (the contract
|
* Deliberately NOT the terminal path: the contract is still alive and there
|
||||||
* is still alive) and no customer-facing REJECTION note — the trail is a
|
* is no customer-facing REJECTION note — the trail is a staff note plus a
|
||||||
* staff note plus a backoffice inbox ping.
|
* backoffice inbox ping.
|
||||||
*/
|
*/
|
||||||
private async sendBackToStep(
|
private async sendBackToStep(
|
||||||
contract: Contract,
|
contract: Contract,
|
||||||
@@ -1169,17 +1159,11 @@ export class ContractTransitionService {
|
|||||||
const cycleNumber = (contract.clearanceCycleNumber ?? 0) + 1;
|
const cycleNumber = (contract.clearanceCycleNumber ?? 0) + 1;
|
||||||
const cycle = await this.contractsRepository.openCycle(contractId, cycleNumber);
|
const cycle = await this.contractsRepository.openCycle(contractId, cycleNumber);
|
||||||
await this.milestoneService.seedPreBookingMilestones(contract, cycle.id);
|
await this.milestoneService.seedPreBookingMilestones(contract, cycle.id);
|
||||||
// Path B prepay gate: the customs clearance service fee is invoiced here
|
// No prepay gate: the customs clearance service fee (Path B) is billed on
|
||||||
// and must settle before the document step opens (the paid event advances
|
// the booking invoice together with the freight, so the document step
|
||||||
// to AWAITING_CLEARANCE_DOCUMENTS). Path A (self-clearance) has no GL fee.
|
// opens immediately.
|
||||||
if (await this.clearanceFeeService.gateApplies(contract)) {
|
updates.status = 'AWAITING_CLEARANCE_DOCUMENTS';
|
||||||
await this.clearanceFeeService.issueForContract(contract);
|
updates.clearanceStatus = 'AWAITING_DOCUMENTS';
|
||||||
updates.status = 'AWAITING_CLEARANCE_PAYMENT';
|
|
||||||
updates.clearanceStatus = 'AWAITING_PAYMENT';
|
|
||||||
} else {
|
|
||||||
updates.status = 'AWAITING_CLEARANCE_DOCUMENTS';
|
|
||||||
updates.clearanceStatus = 'AWAITING_DOCUMENTS';
|
|
||||||
}
|
|
||||||
updates.clearanceCycleNumber = cycleNumber;
|
updates.clearanceCycleNumber = cycleNumber;
|
||||||
} else {
|
} else {
|
||||||
// No contract-level clearance gate — DOMESTIC, or any GENERAL contract
|
// No contract-level clearance gate — DOMESTIC, or any GENERAL contract
|
||||||
|
|||||||
@@ -361,8 +361,15 @@ export class ContractsController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Readable by anyone who may view the contract: the draft carries
|
||||||
|
// `editableByMe`, and the approval chain's approvers (identified by position
|
||||||
|
// type, not by staff_accept) must be able to fetch it to learn it is their
|
||||||
|
// turn. Gating this on staff_accept hid the edit dialog from every approver.
|
||||||
@Get(':id/document/draft')
|
@Get(':id/document/draft')
|
||||||
@BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept))
|
@BookingStaff([
|
||||||
|
FREIGHT_PERMS.contracts.view,
|
||||||
|
...bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept),
|
||||||
|
])
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
'Editable contract-document draft (this contract\'s snapshot, or the live template) for the accept/edit dialog',
|
'Editable contract-document draft (this contract\'s snapshot, or the live template) for the accept/edit dialog',
|
||||||
@@ -385,8 +392,15 @@ export class ContractsController {
|
|||||||
return this.documentHistory.list(id);
|
return this.documentHistory.list(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Coarse gate only. WHO may actually edit is turn-based, not a static
|
||||||
|
// permission, so `updateContractDocument` -> `assertDocumentEditable` is the
|
||||||
|
// real boundary: it admits only the approver whose step is currently pending
|
||||||
|
// (edit rights hand off down the chain on each approval).
|
||||||
@Put(':id/document/articles')
|
@Put(':id/document/articles')
|
||||||
@BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept))
|
@BookingStaff([
|
||||||
|
FREIGHT_PERMS.contracts.view,
|
||||||
|
...bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept),
|
||||||
|
])
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
'Edit this contract\'s document articles only (per-contract; never touches the six shared templates)',
|
'Edit this contract\'s document articles only (per-contract; never touches the six shared templates)',
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import { ContractsController } from './contracts.controller';
|
|||||||
import { ContractsService } from './contracts.service';
|
import { ContractsService } from './contracts.service';
|
||||||
import { ContractsRepository } from './contracts.repository';
|
import { ContractsRepository } from './contracts.repository';
|
||||||
import { ContractPricingService } from './contract-pricing.service';
|
import { ContractPricingService } from './contract-pricing.service';
|
||||||
import { ClearanceFeeService } from './clearance-fee.service';
|
|
||||||
import { ContractNotifierService } from './contract-notifier.service';
|
import { ContractNotifierService } from './contract-notifier.service';
|
||||||
import { ContractTransitionService } from './contract-transition.service';
|
import { ContractTransitionService } from './contract-transition.service';
|
||||||
import { ContractClearanceService } from './contract-clearance.service';
|
import { ContractClearanceService } from './contract-clearance.service';
|
||||||
@@ -107,7 +106,6 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
|||||||
ContractsService,
|
ContractsService,
|
||||||
ContractsRepository,
|
ContractsRepository,
|
||||||
ContractPricingService,
|
ContractPricingService,
|
||||||
ClearanceFeeService,
|
|
||||||
ContractNotifierService,
|
ContractNotifierService,
|
||||||
ContractTransitionService,
|
ContractTransitionService,
|
||||||
ContractDocumentHistoryService,
|
ContractDocumentHistoryService,
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ export class ContractRateSnapshot extends BaseEntity {
|
|||||||
conditionalOn?: string | null;
|
conditionalOn?: string | null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Customs clearance service fee line — billed up front via a clearance
|
* Customs clearance service fee line — billed on the booking invoice
|
||||||
* invoice, excluded from shipment booking totals.
|
* together with the freight (no separate prepaid clearance invoice).
|
||||||
*/
|
*/
|
||||||
@Column({ name: 'is_clearance', type: 'boolean', default: false })
|
@Column({ name: 'is_clearance', type: 'boolean', default: false })
|
||||||
isClearance!: boolean;
|
isClearance!: boolean;
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ export const CONTRACT_STATUSES = [
|
|||||||
'SIGNED_CUSTOMER',
|
'SIGNED_CUSTOMER',
|
||||||
'FULLY_EXECUTED',
|
'FULLY_EXECUTED',
|
||||||
'CONTRACT_ACTIVE',
|
'CONTRACT_ACTIVE',
|
||||||
'AWAITING_CLEARANCE_PAYMENT', // Path B — clearance fee invoiced, unpaid
|
|
||||||
'AWAITING_CLEARANCE_DOCUMENTS',
|
'AWAITING_CLEARANCE_DOCUMENTS',
|
||||||
'CLEARANCE_UNDER_REVIEW',
|
'CLEARANCE_UNDER_REVIEW',
|
||||||
'CLEARANCE_READY_FOR_BOOKING',
|
'CLEARANCE_READY_FOR_BOOKING',
|
||||||
@@ -85,7 +84,6 @@ export type ContractKindValue = (typeof CONTRACT_KINDS)[number];
|
|||||||
|
|
||||||
export const CONTRACT_CLEARANCE_STATUSES = [
|
export const CONTRACT_CLEARANCE_STATUSES = [
|
||||||
'NOT_APPLICABLE',
|
'NOT_APPLICABLE',
|
||||||
'AWAITING_PAYMENT', // Path B — clearance service fee must be paid first
|
|
||||||
'AWAITING_DOCUMENTS',
|
'AWAITING_DOCUMENTS',
|
||||||
'DOCUMENTS_UNDER_REVIEW',
|
'DOCUMENTS_UNDER_REVIEW',
|
||||||
'CLEARANCE_READY_FOR_BOOKING', // Path B — GL may create the booking
|
'CLEARANCE_READY_FOR_BOOKING', // Path B — GL may create the booking
|
||||||
@@ -217,10 +215,6 @@ export class Contract extends BaseEntity {
|
|||||||
@Column({ name: 'clearance_cycle_number', type: 'int', default: 0 })
|
@Column({ name: 'clearance_cycle_number', type: 'int', default: 0 })
|
||||||
clearanceCycleNumber!: number;
|
clearanceCycleNumber!: number;
|
||||||
|
|
||||||
/** When the prepaid customs clearance service fee settled (Path B ONE_TIME). */
|
|
||||||
@Column({ name: 'clearance_fee_paid_at', type: 'timestamptz', nullable: true })
|
|
||||||
clearanceFeePaidAt?: Date | null;
|
|
||||||
|
|
||||||
@Column({ name: 'pricing_breakdown', type: 'jsonb', nullable: true })
|
@Column({ name: 'pricing_breakdown', type: 'jsonb', nullable: true })
|
||||||
pricingBreakdown?: Record<string, unknown> | null;
|
pricingBreakdown?: Record<string, unknown> | null;
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const;
|
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const;
|
||||||
const CURRENCIES = ['USD'] as const;
|
const CURRENCIES = ['USD'] as const;
|
||||||
export const INTERCITY_KINDS = ['CONTAINER', 'BULK'] as const;
|
export const INTERCITY_KINDS = ['CONTAINER', 'BULK'] as const;
|
||||||
|
export const CARGO_KINDS = ['CONTAINER', 'BULK'] as const;
|
||||||
|
|
||||||
export class CreateRateDto {
|
export class CreateRateDto {
|
||||||
@ApiProperty({ enum: RATE_APPLIES_TO, description: 'Friendly category the rate applies to' })
|
@ApiProperty({ enum: RATE_APPLIES_TO, description: 'Friendly category the rate applies to' })
|
||||||
@@ -47,6 +48,15 @@ export class CreateRateDto {
|
|||||||
@IsIn([...INTERCITY_KINDS])
|
@IsIn([...INTERCITY_KINDS])
|
||||||
intercityKind?: string;
|
intercityKind?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
enum: CARGO_KINDS,
|
||||||
|
description:
|
||||||
|
'Whether a customs clearance / lashing rate covers containers or bulk. Required when trigger = CUSTOMS_CLEARANCE or LASHING. Not stored — container fees carry a containerTypeId, bulk fees none.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn([...CARGO_KINDS])
|
||||||
|
cargoKind?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description:
|
description:
|
||||||
'FK to yards.id — origin of the leg this rate prices. Required for base freight (bulk/container/intercity), rejected for surcharges and first/last mile.',
|
'FK to yards.id — origin of the leg this rate prices. Required for base freight (bulk/container/intercity), rejected for surcharges and first/last mile.',
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import type { RateAppliesTo, RateTrigger, RateUnit } from './rate.entity';
|
|||||||
export function allowedRateUnits(input: {
|
export function allowedRateUnits(input: {
|
||||||
appliesTo: RateAppliesTo;
|
appliesTo: RateAppliesTo;
|
||||||
trigger: RateTrigger;
|
trigger: RateTrigger;
|
||||||
|
/** CUSTOMS_CLEARANCE / LASHING only: which cargo kind the fee covers. */
|
||||||
|
cargoKind?: 'CONTAINER' | 'BULK' | null;
|
||||||
}): RateUnit[] {
|
}): RateUnit[] {
|
||||||
const { appliesTo, trigger } = input;
|
const { appliesTo, trigger } = input;
|
||||||
|
|
||||||
@@ -29,16 +31,18 @@ export function allowedRateUnits(input: {
|
|||||||
case 'DEMURRAGE':
|
case 'DEMURRAGE':
|
||||||
return ['PER_CONTAINER', 'PER_TON'];
|
return ['PER_CONTAINER', 'PER_TON'];
|
||||||
case 'WITH_RETURN':
|
case 'WITH_RETURN':
|
||||||
// Container-only empty-return service — bills per returned container.
|
// Container-only empty-return service — per returned container, per
|
||||||
return ['PER_CONTAINER', 'FLAT'];
|
// wagon the empties ride back on, or a flat fee.
|
||||||
|
return ['PER_CONTAINER', 'PER_WAGON', 'FLAT'];
|
||||||
case 'CANCELLATION':
|
case 'CANCELLATION':
|
||||||
return ['FLAT', 'PER_INVOICE'];
|
return ['FLAT', 'PER_INVOICE'];
|
||||||
case 'CUSTOMS_CLEARANCE':
|
case 'CUSTOMS_CLEARANCE':
|
||||||
// Flat per clearance (ONE_TIME contract) / per shipment request (GENERAL).
|
|
||||||
return ['FLAT'];
|
|
||||||
case 'LASHING':
|
case 'LASHING':
|
||||||
// Flat cargo-securing fee, billed once per booking.
|
// Sold per cargo kind: container fees bill per box or per wagon, bulk
|
||||||
return ['FLAT'];
|
// fees per ton or per wagon. Billed on the booking invoice.
|
||||||
|
return input.cargoKind === 'BULK'
|
||||||
|
? ['PER_TON', 'PER_WAGON']
|
||||||
|
: ['PER_CONTAINER', 'PER_WAGON'];
|
||||||
case 'CONSOLIDATION':
|
case 'CONSOLIDATION':
|
||||||
return ['PER_CONTAINER', 'FLAT'];
|
return ['PER_CONTAINER', 'FLAT'];
|
||||||
case 'SHIPPING_LINE':
|
case 'SHIPPING_LINE':
|
||||||
@@ -74,6 +78,7 @@ export function defaultRateUnit(input: { appliesTo: RateAppliesTo; trigger: Rate
|
|||||||
export function isRateUnitAllowed(input: {
|
export function isRateUnitAllowed(input: {
|
||||||
appliesTo: RateAppliesTo;
|
appliesTo: RateAppliesTo;
|
||||||
trigger: RateTrigger;
|
trigger: RateTrigger;
|
||||||
|
cargoKind?: 'CONTAINER' | 'BULK' | null;
|
||||||
unit: RateUnit;
|
unit: RateUnit;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
return allowedRateUnits(input).includes(input.unit);
|
return allowedRateUnits(input).includes(input.unit);
|
||||||
|
|||||||
@@ -249,6 +249,49 @@ describe('RuleEngineService — empty-container return per route + container typ
|
|||||||
expect(result.hardBlocked.some((m) => m.includes('return'))).toBe(true);
|
expect(result.hardBlocked.some((m) => m.includes('return'))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('PER_WAGON bills the wagons the empties ride back on, not the boxes', async () => {
|
||||||
|
// Same service, but the return rate is sold per wagon: 4× 20ft return =
|
||||||
|
// 2 wagons (two 20ft share a wagon) × 20 USD, not 4 × 20.
|
||||||
|
service = new RuleEngineService(
|
||||||
|
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||||
|
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||||
|
{
|
||||||
|
findActiveByContainerTypeId: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([{ id: 'wlr-20', maxVgmTons: 20, maxCapacityTons: null }]),
|
||||||
|
} as never,
|
||||||
|
{ findAllActive: jest.fn().mockResolvedValue([]) } as never,
|
||||||
|
{
|
||||||
|
findLiveRates: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([{ ...returnRate20, rateUnit: 'PER_WAGON' } as Rate]),
|
||||||
|
} as never,
|
||||||
|
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.evaluate(
|
||||||
|
returnInput({
|
||||||
|
containers: [
|
||||||
|
{
|
||||||
|
containerTypeId: 'ct-20',
|
||||||
|
quantity: 4,
|
||||||
|
vgmPerUnitTons: 10,
|
||||||
|
totalVgmTons: 40,
|
||||||
|
returnQuantity: 4,
|
||||||
|
wagonsPerUnit: 0.5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const ret = result.appliedModifiers.filter((m) => m.surchargeCode === 'RETURN_SURCHARGE');
|
||||||
|
expect(ret).toHaveLength(1);
|
||||||
|
expect(ret[0].triggerValue).toBe(2);
|
||||||
|
expect(ret[0].calculatedAmount).toBe(40);
|
||||||
|
expect(ret[0].billingUnit).toBe('PER_WAGON');
|
||||||
|
});
|
||||||
|
|
||||||
it('legacy booking-level flag bills every container at its type rate', async () => {
|
it('legacy booking-level flag bills every container at its type rate', async () => {
|
||||||
const result = await service.evaluate(
|
const result = await service.evaluate(
|
||||||
returnInput({
|
returnInput({
|
||||||
@@ -264,3 +307,128 @@ describe('RuleEngineService — empty-container return per route + container typ
|
|||||||
expect(ret[0].calculatedAmount).toBe(80);
|
expect(ret[0].calculatedAmount).toBe(80);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('RuleEngineService — lashing per cargo kind', () => {
|
||||||
|
const lashing20: Rate = {
|
||||||
|
id: 'rate-lash-20',
|
||||||
|
rateType: 'LASHING',
|
||||||
|
trigger: 'LASHING',
|
||||||
|
rateValue: 30,
|
||||||
|
rateUnit: 'PER_CONTAINER',
|
||||||
|
currency: 'USD',
|
||||||
|
status: 'LIVE',
|
||||||
|
containerTypeId: 'ct-20',
|
||||||
|
cargoTypeId: null,
|
||||||
|
tradeDirection: null,
|
||||||
|
originYardId: null,
|
||||||
|
destinationYardId: null,
|
||||||
|
} as Rate;
|
||||||
|
|
||||||
|
const lashingBulk: Rate = {
|
||||||
|
...lashing20,
|
||||||
|
id: 'rate-lash-bulk',
|
||||||
|
rateValue: 2,
|
||||||
|
rateUnit: 'PER_TON',
|
||||||
|
containerTypeId: null,
|
||||||
|
} as Rate;
|
||||||
|
|
||||||
|
const buildService = (rates: Rate[]): RuleEngineService =>
|
||||||
|
new RuleEngineService(
|
||||||
|
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||||
|
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||||
|
{
|
||||||
|
findActiveByContainerTypeId: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([{ id: 'wlr-20', maxVgmTons: 20, maxCapacityTons: null }]),
|
||||||
|
} as never,
|
||||||
|
{ findAllActive: jest.fn().mockResolvedValue([]) } as never,
|
||||||
|
{ findLiveRates: jest.fn().mockResolvedValue(rates) } as never,
|
||||||
|
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
const containerInput = (overrides: Partial<BookingEvaluationInput> = {}): BookingEvaluationInput => ({
|
||||||
|
serviceTypeId: 'svc-1',
|
||||||
|
paymentCurrency: 'USD',
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
isHazardous: false,
|
||||||
|
hasLashing: true,
|
||||||
|
totalWagons: 2,
|
||||||
|
containers: [
|
||||||
|
{
|
||||||
|
containerTypeId: 'ct-20',
|
||||||
|
quantity: 4,
|
||||||
|
vgmPerUnitTons: 10,
|
||||||
|
totalVgmTons: 40,
|
||||||
|
wagonsPerUnit: 0.5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const bulkInput = (overrides: Partial<BookingEvaluationInput> = {}): BookingEvaluationInput => ({
|
||||||
|
serviceTypeId: 'svc-1',
|
||||||
|
paymentCurrency: 'USD',
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
isHazardous: false,
|
||||||
|
hasLashing: true,
|
||||||
|
totalWagons: 0,
|
||||||
|
bulkTons: 100,
|
||||||
|
bulkWagons: 3,
|
||||||
|
containers: [],
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const lashingMods = (result: Awaited<ReturnType<RuleEngineService['evaluate']>>) =>
|
||||||
|
result.appliedModifiers.filter((m) => m.surchargeCode === 'LASHING');
|
||||||
|
|
||||||
|
it('bills each container line at its own container type rate', async () => {
|
||||||
|
const result = await buildService([lashing20]).evaluate(containerInput());
|
||||||
|
const mods = lashingMods(result);
|
||||||
|
expect(mods).toHaveLength(1);
|
||||||
|
expect(mods[0].triggerValue).toBe(4);
|
||||||
|
expect(mods[0].calculatedAmount).toBe(120);
|
||||||
|
expect(mods[0].billingUnit).toBe('PER_CONTAINER');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PER_WAGON container lashing bills the wagons the line occupies', async () => {
|
||||||
|
const result = await buildService([
|
||||||
|
{ ...lashing20, rateUnit: 'PER_WAGON' } as Rate,
|
||||||
|
]).evaluate(containerInput());
|
||||||
|
const mods = lashingMods(result);
|
||||||
|
expect(mods[0].triggerValue).toBe(2);
|
||||||
|
expect(mods[0].calculatedAmount).toBe(60);
|
||||||
|
expect(mods[0].billingUnit).toBe('PER_WAGON');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an unmatched container type simply bills nothing (lenient)', async () => {
|
||||||
|
const result = await buildService([
|
||||||
|
{ ...lashing20, containerTypeId: 'ct-40' } as Rate,
|
||||||
|
]).evaluate(containerInput());
|
||||||
|
expect(lashingMods(result)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bulk lashing bills per ton on the tonnage', async () => {
|
||||||
|
const result = await buildService([lashingBulk]).evaluate(bulkInput());
|
||||||
|
const mods = lashingMods(result);
|
||||||
|
expect(mods[0].triggerValue).toBe(100);
|
||||||
|
expect(mods[0].calculatedAmount).toBe(200);
|
||||||
|
expect(mods[0].billingUnit).toBe('PER_TON');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PER_WAGON bulk lashing bills the wagons the bulk occupies', async () => {
|
||||||
|
const result = await buildService([
|
||||||
|
{ ...lashingBulk, rateUnit: 'PER_WAGON', rateValue: 25 } as Rate,
|
||||||
|
]).evaluate(bulkInput());
|
||||||
|
const mods = lashingMods(result);
|
||||||
|
expect(mods[0].triggerValue).toBe(3);
|
||||||
|
expect(mods[0].calculatedAmount).toBe(75);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no lashing charge when the cargo does not need lashing', async () => {
|
||||||
|
const result = await buildService([lashing20]).evaluate(
|
||||||
|
containerInput({ hasLashing: false }),
|
||||||
|
);
|
||||||
|
expect(lashingMods(result)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -48,6 +48,12 @@ export interface BookingContainerEvalInput {
|
|||||||
hazardousQuantity?: number;
|
hazardousQuantity?: number;
|
||||||
reeferQuantity?: number;
|
reeferQuantity?: number;
|
||||||
returnQuantity?: number;
|
returnQuantity?: number;
|
||||||
|
/**
|
||||||
|
* Wagon fraction one container of this line occupies (40ft = 1, 20ft = 0.5).
|
||||||
|
* Lets a PER_WAGON empty-return rate bill the wagons the returned empties
|
||||||
|
* ride back on. Missing ⇒ one wagon per container.
|
||||||
|
*/
|
||||||
|
wagonsPerUnit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BookingEvaluationInput {
|
export interface BookingEvaluationInput {
|
||||||
@@ -86,6 +92,12 @@ export interface BookingEvaluationInput {
|
|||||||
* container freight, which is scaled by container count instead.
|
* container freight, which is scaled by container count instead.
|
||||||
*/
|
*/
|
||||||
bulkTons?: number;
|
bulkTons?: number;
|
||||||
|
/**
|
||||||
|
* Wagons a BULK booking occupies (ceil(tons ÷ wagon capacity)), resolved by
|
||||||
|
* the pricing service. Scales PER_WAGON kind-scoped surcharges (lashing);
|
||||||
|
* 0/undefined when unknown — those charges then bill nothing.
|
||||||
|
*/
|
||||||
|
bulkWagons?: number;
|
||||||
containers: BookingContainerEvalInput[];
|
containers: BookingContainerEvalInput[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,6 +324,9 @@ export class RuleEngineService {
|
|||||||
// Empty-container return is sold per route + container type — billed by
|
// Empty-container return is sold per route + container type — billed by
|
||||||
// the route-matched block below, never by this route-agnostic loop.
|
// the route-matched block below, never by this route-agnostic loop.
|
||||||
if (rate.trigger === 'WITH_RETURN') continue;
|
if (rate.trigger === 'WITH_RETURN') continue;
|
||||||
|
// Lashing is sold per cargo kind + container type — billed by the
|
||||||
|
// kind-aware block below, never by this generic loop.
|
||||||
|
if (rate.trigger === 'LASHING') continue;
|
||||||
const triggered = this.matchesTrigger(rate.trigger, {
|
const triggered = this.matchesTrigger(rate.trigger, {
|
||||||
isHazardous: input.isHazardous,
|
isHazardous: input.isHazardous,
|
||||||
hasReefer,
|
hasReefer,
|
||||||
@@ -419,6 +434,10 @@ export class RuleEngineService {
|
|||||||
appliedModifiers.push(...withReturn.modifiers);
|
appliedModifiers.push(...withReturn.modifiers);
|
||||||
hardBlocked.push(...withReturn.blocked);
|
hardBlocked.push(...withReturn.blocked);
|
||||||
|
|
||||||
|
if (hasLashing) {
|
||||||
|
appliedModifiers.push(...this.lashingCharges(input, liveRates));
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
priorityScore,
|
priorityScore,
|
||||||
appliedModifiers,
|
appliedModifiers,
|
||||||
@@ -538,12 +557,18 @@ export class RuleEngineService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rateValue = Number(rate.rateValue);
|
const rateValue = Number(rate.rateValue);
|
||||||
const amount = rate.rateUnit === 'FLAT' ? rateValue : qty * rateValue;
|
// PER_WAGON bills the wagons the returned empties occupy (two 20ft share
|
||||||
|
// one wagon), PER_CONTAINER the boxes themselves, FLAT once per line.
|
||||||
|
const billed =
|
||||||
|
rate.rateUnit === 'PER_WAGON'
|
||||||
|
? Math.ceil(qty * (container.wagonsPerUnit ?? 1))
|
||||||
|
: qty;
|
||||||
|
const amount = rate.rateUnit === 'FLAT' ? rateValue : billed * rateValue;
|
||||||
if (!(amount > 0)) continue;
|
if (!(amount > 0)) continue;
|
||||||
modifiers.push({
|
modifiers.push({
|
||||||
rateId: rate.id,
|
rateId: rate.id,
|
||||||
surchargeCode: this.surchargeCode(rate),
|
surchargeCode: this.surchargeCode(rate),
|
||||||
triggerValue: qty,
|
triggerValue: rate.rateUnit === 'FLAT' ? qty : billed,
|
||||||
calculatedAmount: amount,
|
calculatedAmount: amount,
|
||||||
currency: rate.currency,
|
currency: rate.currency,
|
||||||
unitPriceUsd: rateValue,
|
unitPriceUsd: rateValue,
|
||||||
@@ -555,6 +580,70 @@ export class RuleEngineService {
|
|||||||
return { modifiers, blocked: [...new Set(blocked)] };
|
return { modifiers, blocked: [...new Set(blocked)] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cargo securing / lashing — sold per cargo kind, like the customs clearance
|
||||||
|
* fee. Container bookings bill each container line at its own container
|
||||||
|
* type's lashing rate (PER_CONTAINER × boxes, or PER_WAGON × the wagons the
|
||||||
|
* line occupies); bulk bookings bill the type-less rate (PER_TON × tonnage,
|
||||||
|
* or PER_WAGON × the wagons the bulk occupies). An unconfigured rate simply
|
||||||
|
* bills nothing — same leniency as hazard/reefer.
|
||||||
|
*/
|
||||||
|
private lashingCharges(
|
||||||
|
input: BookingEvaluationInput,
|
||||||
|
liveRates: Rate[],
|
||||||
|
): AppliedCargoModifier[] {
|
||||||
|
const modifiers: AppliedCargoModifier[] = [];
|
||||||
|
const lashingRates = liveRates.filter(
|
||||||
|
(r) => r.trigger === 'LASHING' && r.currency === 'USD',
|
||||||
|
);
|
||||||
|
if (lashingRates.length === 0) return modifiers;
|
||||||
|
|
||||||
|
const push = (rate: Rate, billedQty: number): void => {
|
||||||
|
const rateValue = Number(rate.rateValue);
|
||||||
|
const amount =
|
||||||
|
rate.rateUnit === 'FLAT' ? rateValue : billedQty * rateValue;
|
||||||
|
if (!(amount > 0)) return;
|
||||||
|
modifiers.push({
|
||||||
|
rateId: rate.id,
|
||||||
|
surchargeCode: this.surchargeCode(rate),
|
||||||
|
triggerValue: rate.rateUnit === 'FLAT' ? 1 : billedQty,
|
||||||
|
calculatedAmount: amount,
|
||||||
|
currency: rate.currency,
|
||||||
|
unitPriceUsd: rateValue,
|
||||||
|
billingUnit: rate.rateUnit,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (input.containers.length > 0) {
|
||||||
|
for (const container of input.containers) {
|
||||||
|
const qty = Number(container.quantity || 0);
|
||||||
|
if (!(qty > 0)) continue;
|
||||||
|
const rate = lashingRates.find(
|
||||||
|
(r) => r.containerTypeId === container.containerTypeId,
|
||||||
|
);
|
||||||
|
if (!rate) continue;
|
||||||
|
const billedQty =
|
||||||
|
rate.rateUnit === 'PER_WAGON'
|
||||||
|
? Math.ceil(qty * (container.wagonsPerUnit ?? 1))
|
||||||
|
: qty;
|
||||||
|
push(rate, billedQty);
|
||||||
|
}
|
||||||
|
return modifiers;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bulk — the type-less rate covers the whole booking.
|
||||||
|
const rate = lashingRates.find((r) => !r.containerTypeId);
|
||||||
|
if (!rate) return modifiers;
|
||||||
|
const billedQty =
|
||||||
|
rate.rateUnit === 'PER_TON'
|
||||||
|
? Math.max(0, Number(input.bulkTons ?? 0))
|
||||||
|
: rate.rateUnit === 'PER_WAGON'
|
||||||
|
? Math.max(0, Number(input.bulkWagons ?? 0))
|
||||||
|
: 1;
|
||||||
|
push(rate, billedQty);
|
||||||
|
return modifiers;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Messages for container lines whose total weight exceeds the hard capacity
|
* Messages for container lines whose total weight exceeds the hard capacity
|
||||||
* ceiling (weight_limit_rules.max_capacity_tons). Non-empty ⇒ the booking
|
* ceiling (weight_limit_rules.max_capacity_tons). Non-empty ⇒ the booking
|
||||||
|
|||||||
@@ -101,6 +101,23 @@ describe('RateChangeRequestsService', () => {
|
|||||||
expect(request.payload).toEqual({ rateValue: 200 });
|
expect(request.payload).toEqual({ rateValue: 200 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('carries a re-routed leg — a yard-only edit is a real change', async () => {
|
||||||
|
const { service } = build({
|
||||||
|
rate: liveRate({ originYardId: 'yard-a', destinationYardId: 'yard-b' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = await service.submit({
|
||||||
|
rateId: 'rate-1',
|
||||||
|
update: {
|
||||||
|
rateValue: 100,
|
||||||
|
originYardId: 'yard-a',
|
||||||
|
destinationYardId: 'yard-c',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(request.payload).toEqual({ destinationYardId: 'yard-c' });
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects a no-op — 100 posted against a live 100.0000 is not a change', async () => {
|
it('rejects a no-op — 100 posted against a live 100.0000 is not a change', async () => {
|
||||||
const { service } = build();
|
const { service } = build();
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ const DIFFABLE_FIELDS = [
|
|||||||
'tradeDirection',
|
'tradeDirection',
|
||||||
'containerTypeId',
|
'containerTypeId',
|
||||||
'cargoTypeId',
|
'cargoTypeId',
|
||||||
|
// The leg a route-scoped rate prices. Missing here, a re-routed LIVE rate
|
||||||
|
// diffed to nothing and the submit was refused as "nothing changed".
|
||||||
|
'originYardId',
|
||||||
|
'destinationYardId',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -69,18 +69,19 @@ export class RatesService {
|
|||||||
appliesTo: Rate['appliesTo'],
|
appliesTo: Rate['appliesTo'],
|
||||||
trigger: Rate['trigger'],
|
trigger: Rate['trigger'],
|
||||||
requestedUnit: Rate['rateUnit'] | undefined,
|
requestedUnit: Rate['rateUnit'] | undefined,
|
||||||
|
cargoKind?: 'CONTAINER' | 'BULK' | null,
|
||||||
): Rate['rateUnit'] {
|
): Rate['rateUnit'] {
|
||||||
// Overweight is per-ton, full stop — the admin form hides the unit field
|
// Overweight is per-ton, full stop — the admin form hides the unit field
|
||||||
// for it and omits rateUnit from the payload entirely.
|
// for it and omits rateUnit from the payload entirely.
|
||||||
if (trigger === 'OVERWEIGHT') return 'PER_TON';
|
if (trigger === 'OVERWEIGHT') return 'PER_TON';
|
||||||
|
|
||||||
const allowed = allowedRateUnits({ appliesTo, trigger });
|
const allowed = allowedRateUnits({ appliesTo, trigger, cargoKind });
|
||||||
if (!requestedUnit) {
|
if (!requestedUnit) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Pick a rate unit for this rate. Allowed: ${allowed.join(', ')}.`,
|
`Pick a rate unit for this rate. Allowed: ${allowed.join(', ')}.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!isRateUnitAllowed({ appliesTo, trigger, unit: requestedUnit })) {
|
if (!isRateUnitAllowed({ appliesTo, trigger, cargoKind, unit: requestedUnit })) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Rate unit "${requestedUnit}" is not valid for this rate. Allowed: ${allowed.join(', ')}.`,
|
`Rate unit "${requestedUnit}" is not valid for this rate. Allowed: ${allowed.join(', ')}.`,
|
||||||
);
|
);
|
||||||
@@ -187,17 +188,42 @@ export class RatesService {
|
|||||||
trigger: Rate['trigger'];
|
trigger: Rate['trigger'];
|
||||||
tradeDirection: string | null;
|
tradeDirection: string | null;
|
||||||
intercityKind: string | null;
|
intercityKind: string | null;
|
||||||
|
cargoKind: string | null;
|
||||||
containerTypeId: string | null;
|
containerTypeId: string | null;
|
||||||
cargoTypeId: string | null;
|
cargoTypeId: string | null;
|
||||||
}): void {
|
}): void {
|
||||||
const { appliesTo, trigger, tradeDirection, intercityKind } = input;
|
const { appliesTo, trigger, tradeDirection, intercityKind, cargoKind } = input;
|
||||||
const { containerTypeId, cargoTypeId } = input;
|
const { containerTypeId, cargoTypeId } = input;
|
||||||
if (trigger === 'CUSTOMS_CLEARANCE') {
|
if (trigger === 'CUSTOMS_CLEARANCE' || trigger === 'LASHING') {
|
||||||
if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') {
|
const label = trigger === 'CUSTOMS_CLEARANCE' ? 'customs clearance' : 'lashing';
|
||||||
|
// Customs is sold per direction; lashing applies both ways.
|
||||||
|
if (
|
||||||
|
trigger === 'CUSTOMS_CLEARANCE' &&
|
||||||
|
tradeDirection !== 'IMPORT' &&
|
||||||
|
tradeDirection !== 'EXPORT'
|
||||||
|
) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'A customs clearance rate must say whether it covers IMPORT or EXPORT.',
|
'A customs clearance rate must say whether it covers IMPORT or EXPORT.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Sold per cargo kind: a container fee names the container type it covers
|
||||||
|
// (20ft and 40ft price differently); a bulk fee carries no type at all —
|
||||||
|
// that absence is what marks it as the bulk fee.
|
||||||
|
if (cargoKind !== 'CONTAINER' && cargoKind !== 'BULK') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`A ${label} rate must say whether it covers containers or bulk.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (cargoKind === 'CONTAINER' && !containerTypeId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`A container ${label} rate must name the container type it covers.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (cargoKind === 'BULK' && containerTypeId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`A bulk ${label} rate cannot be scoped to a container type.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (trigger === 'WITH_RETURN') {
|
if (trigger === 'WITH_RETURN') {
|
||||||
@@ -279,11 +305,17 @@ export class RatesService {
|
|||||||
const trigger = dto.trigger as Rate['trigger'];
|
const trigger = dto.trigger as Rate['trigger'];
|
||||||
// Surcharges (trigger ≠ ALWAYS) carry no direction/scope — clear them so
|
// Surcharges (trigger ≠ ALWAYS) carry no direction/scope — clear them so
|
||||||
// the engine never accidentally narrows a surcharge by container/direction.
|
// the engine never accidentally narrows a surcharge by container/direction.
|
||||||
// Exceptions: customs clearance keeps a direction, and empty-container
|
// Exceptions: customs clearance and empty-container return keep direction +
|
||||||
// return keeps direction + container type — both are sold per lane.
|
// container type — both are sold per lane (and per container type).
|
||||||
const isSurcharge = trigger !== 'ALWAYS';
|
const isSurcharge = trigger !== 'ALWAYS';
|
||||||
|
const cargoKind =
|
||||||
|
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'LASHING'
|
||||||
|
? ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? null)
|
||||||
|
: null;
|
||||||
const containerTypeId =
|
const containerTypeId =
|
||||||
trigger === 'WITH_RETURN'
|
trigger === 'WITH_RETURN' ||
|
||||||
|
((trigger === 'CUSTOMS_CLEARANCE' || trigger === 'LASHING') &&
|
||||||
|
cargoKind === 'CONTAINER')
|
||||||
? (dto.containerTypeId ?? null)
|
? (dto.containerTypeId ?? null)
|
||||||
: isSurcharge
|
: isSurcharge
|
||||||
? null
|
? null
|
||||||
@@ -304,6 +336,7 @@ export class RatesService {
|
|||||||
trigger,
|
trigger,
|
||||||
tradeDirection,
|
tradeDirection,
|
||||||
intercityKind,
|
intercityKind,
|
||||||
|
cargoKind,
|
||||||
containerTypeId,
|
containerTypeId,
|
||||||
cargoTypeId,
|
cargoTypeId,
|
||||||
});
|
});
|
||||||
@@ -325,6 +358,7 @@ export class RatesService {
|
|||||||
appliesTo,
|
appliesTo,
|
||||||
trigger,
|
trigger,
|
||||||
dto.rateUnit as Rate['rateUnit'] | undefined,
|
dto.rateUnit as Rate['rateUnit'] | undefined,
|
||||||
|
cargoKind,
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.assertNoDuplicatePattern({
|
await this.assertNoDuplicatePattern({
|
||||||
@@ -419,7 +453,19 @@ export class RatesService {
|
|||||||
if (dto.appliesTo) updates.appliesTo = appliesTo;
|
if (dto.appliesTo) updates.appliesTo = appliesTo;
|
||||||
if (dto.trigger) updates.trigger = trigger;
|
if (dto.trigger) updates.trigger = trigger;
|
||||||
|
|
||||||
const keepsContainerType = !isSurcharge || trigger === 'WITH_RETURN';
|
// A patch that leaves the cargo kind unsaid keeps the one the rate already
|
||||||
|
// has — read back off its container scope (container fees carry the type).
|
||||||
|
const cargoKind =
|
||||||
|
trigger !== 'CUSTOMS_CLEARANCE' && trigger !== 'LASHING'
|
||||||
|
? null
|
||||||
|
: ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ??
|
||||||
|
(existing.containerTypeId ? 'CONTAINER' : 'BULK'));
|
||||||
|
|
||||||
|
const keepsContainerType =
|
||||||
|
!isSurcharge ||
|
||||||
|
trigger === 'WITH_RETURN' ||
|
||||||
|
((trigger === 'CUSTOMS_CLEARANCE' || trigger === 'LASHING') &&
|
||||||
|
cargoKind === 'CONTAINER');
|
||||||
const containerTypeId = !keepsContainerType
|
const containerTypeId = !keepsContainerType
|
||||||
? null
|
? null
|
||||||
: dto.containerTypeId !== undefined
|
: dto.containerTypeId !== undefined
|
||||||
@@ -455,6 +501,7 @@ export class RatesService {
|
|||||||
trigger,
|
trigger,
|
||||||
tradeDirection: updates.tradeDirection,
|
tradeDirection: updates.tradeDirection,
|
||||||
intercityKind,
|
intercityKind,
|
||||||
|
cargoKind,
|
||||||
containerTypeId: updates.containerTypeId,
|
containerTypeId: updates.containerTypeId,
|
||||||
cargoTypeId: updates.cargoTypeId,
|
cargoTypeId: updates.cargoTypeId,
|
||||||
});
|
});
|
||||||
@@ -486,7 +533,7 @@ export class RatesService {
|
|||||||
// Re-validate the unit against the (possibly changed) shape; overweight is
|
// Re-validate the unit against the (possibly changed) shape; overweight is
|
||||||
// forced to PER_TON.
|
// forced to PER_TON.
|
||||||
const requestedUnit = (dto.rateUnit as Rate['rateUnit']) ?? existing.rateUnit;
|
const requestedUnit = (dto.rateUnit as Rate['rateUnit']) ?? existing.rateUnit;
|
||||||
updates.rateUnit = this.resolveRateUnit(appliesTo, trigger, requestedUnit);
|
updates.rateUnit = this.resolveRateUnit(appliesTo, trigger, requestedUnit, cargoKind);
|
||||||
|
|
||||||
// Guard the pattern uniqueness for the new identity, ignoring this row.
|
// Guard the pattern uniqueness for the new identity, ignoring this row.
|
||||||
await this.assertNoDuplicatePattern({
|
await this.assertNoDuplicatePattern({
|
||||||
|
|||||||
@@ -416,9 +416,10 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
|||||||
{ appliesTo: "OTHER", trigger: "WITH_RETURN", rateType: "RETURN_SURCHARGE", rateValue: 20, rateUnit: "PER_CONTAINER" },
|
{ appliesTo: "OTHER", trigger: "WITH_RETURN", rateType: "RETURN_SURCHARGE", rateValue: 20, rateUnit: "PER_CONTAINER" },
|
||||||
{ appliesTo: "OTHER", trigger: "SHIPPING_LINE", rateType: "DOUBLE_HANDLING", rateValue: 100, rateUnit: "PER_CONTAINER" },
|
{ appliesTo: "OTHER", trigger: "SHIPPING_LINE", rateType: "DOUBLE_HANDLING", rateValue: 100, rateUnit: "PER_CONTAINER" },
|
||||||
{ appliesTo: "OTHER", trigger: "CONSOLIDATION", rateType: "LASHING", rateValue: 50, rateUnit: "PER_CONTAINER" },
|
{ appliesTo: "OTHER", trigger: "CONSOLIDATION", rateType: "LASHING", rateValue: 50, rateUnit: "PER_CONTAINER" },
|
||||||
// Cargo-securing / lashing — flat fee, billed once per booking whose
|
// Cargo-securing / lashing — fires when the cargo type has hasLashing.
|
||||||
// cargo type has hasLashing = true.
|
// Bulk lashing seeds per ton; container lashing is per container type
|
||||||
{ appliesTo: "OTHER", trigger: "LASHING", rateType: "LASHING", rateValue: 40, rateUnit: "FLAT" },
|
// and is configured by the rates team (no catch-all container seed).
|
||||||
|
{ appliesTo: "OTHER", trigger: "LASHING", rateType: "LASHING", rateValue: 40, rateUnit: "PER_TON" },
|
||||||
// ── First/last-mile road haulage (per km) — drives the mile invoices ──
|
// ── First/last-mile road haulage (per km) — drives the mile invoices ──
|
||||||
{ appliesTo: "OTHER", trigger: "ALWAYS", rateType: "FIRST_MILE", rateValue: 20, rateUnit: "PER_KM" },
|
{ appliesTo: "OTHER", trigger: "ALWAYS", rateType: "FIRST_MILE", rateValue: 20, rateUnit: "PER_KM" },
|
||||||
{ appliesTo: "OTHER", trigger: "ALWAYS", rateType: "LAST_MILE", rateValue: 25, rateUnit: "PER_KM" },
|
{ appliesTo: "OTHER", trigger: "ALWAYS", rateType: "LAST_MILE", rateValue: 25, rateUnit: "PER_KM" },
|
||||||
|
|||||||
@@ -65,8 +65,12 @@ export function computeGlShipmentTotal(
|
|||||||
(i) =>
|
(i) =>
|
||||||
i.containerSize === line.containerSize &&
|
i.containerSize === line.containerSize &&
|
||||||
i.unit === "per_container" &&
|
i.unit === "per_container" &&
|
||||||
!i.conditionalOn,
|
!i.conditionalOn &&
|
||||||
) ?? rateFor((i) => i.containerSize === line.containerSize);
|
!i.isClearance,
|
||||||
|
) ??
|
||||||
|
rateFor(
|
||||||
|
(i) => i.containerSize === line.containerSize && !i.isClearance,
|
||||||
|
);
|
||||||
if (rate) {
|
if (rate) {
|
||||||
lines.push({
|
lines.push({
|
||||||
label: rate.label,
|
label: rate.label,
|
||||||
@@ -123,7 +127,9 @@ export function computeGlShipmentTotal(
|
|||||||
} else {
|
} else {
|
||||||
const qty = q.bulkQuantity;
|
const qty = q.bulkQuantity;
|
||||||
const rate =
|
const rate =
|
||||||
rateFor((i) => i.unit === "per_ton" || i.unit === "per_item") ?? items[0];
|
rateFor(
|
||||||
|
(i) => (i.unit === "per_ton" || i.unit === "per_item") && !i.isClearance,
|
||||||
|
) ?? items[0];
|
||||||
if (rate && qty > 0) {
|
if (rate && qty > 0) {
|
||||||
lines.push({
|
lines.push({
|
||||||
label: rate.label,
|
label: rate.label,
|
||||||
@@ -159,6 +165,36 @@ export function computeGlShipmentTotal(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Customs clearance service fee — billed on the booking invoice with the
|
||||||
|
// freight. Container fees estimate per size (per box, or per wagon: two 20ft
|
||||||
|
// share one); bulk per-ton scales by tonnage. Bulk per-wagon fees depend on
|
||||||
|
// the wagon capacity the train stocks — shown at real pricing, not estimated.
|
||||||
|
for (const cl of items.filter((i) => i.isClearance)) {
|
||||||
|
let qty = 0;
|
||||||
|
if (q.isContainer) {
|
||||||
|
const boxes = q.containers
|
||||||
|
.filter((c) => c.containerSize === cl.containerSize)
|
||||||
|
.reduce((s, c) => s + Number(c.quantity || 0), 0);
|
||||||
|
qty =
|
||||||
|
cl.unit === "per_wagon"
|
||||||
|
? Math.ceil(boxes * (cl.containerSize === "40ft" ? 1 : 0.5))
|
||||||
|
: boxes;
|
||||||
|
} else if (cl.unit === "per_ton") {
|
||||||
|
qty = q.bulkQuantity;
|
||||||
|
} else if (cl.unit === "flat") {
|
||||||
|
qty = 1;
|
||||||
|
}
|
||||||
|
if (qty > 0) {
|
||||||
|
lines.push({
|
||||||
|
label: cl.label,
|
||||||
|
unitPrice: cl.unitPrice,
|
||||||
|
unit: cl.unit,
|
||||||
|
quantity: qty,
|
||||||
|
amount: cl.unitPrice * qty,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const total = lines.reduce((s, l) => s + l.amount, 0);
|
const total = lines.reduce((s, l) => s + l.amount, 0);
|
||||||
return { currency, lines, total };
|
return { currency, lines, total };
|
||||||
}
|
}
|
||||||
@@ -167,6 +203,7 @@ export function computeGlShipmentTotal(
|
|||||||
export function formatRateUnit(unit: Freight.ContractRateUnit | string): string {
|
export function formatRateUnit(unit: Freight.ContractRateUnit | string): string {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
per_container: "container",
|
per_container: "container",
|
||||||
|
per_wagon: "wagon",
|
||||||
per_ton: "ton",
|
per_ton: "ton",
|
||||||
per_item: "item",
|
per_item: "item",
|
||||||
per_km: "km",
|
per_km: "km",
|
||||||
|
|||||||
@@ -209,6 +209,12 @@ const RuleEngineFormDialog = ({
|
|||||||
next.containerTypeId = "";
|
next.containerTypeId = "";
|
||||||
next.cargoTypeId = "";
|
next.cargoTypeId = "";
|
||||||
}
|
}
|
||||||
|
// Cargo kind (customs / lashing) decides both the container-type scope
|
||||||
|
// and the legal units (container → per box/wagon, bulk → per ton/wagon).
|
||||||
|
if (name === "cargoKind") {
|
||||||
|
next.containerTypeId = "";
|
||||||
|
next.rateUnit = "";
|
||||||
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -286,7 +286,6 @@ export const BOOKING_LIST_TABS = [
|
|||||||
key: "clearance",
|
key: "clearance",
|
||||||
label: "Clearance",
|
label: "Clearance",
|
||||||
statuses: [
|
statuses: [
|
||||||
"AWAITING_CLEARANCE_PAYMENT",
|
|
||||||
"AWAITING_DOCUMENTS",
|
"AWAITING_DOCUMENTS",
|
||||||
"DOCUMENTS_UNDER_REVIEW",
|
"DOCUMENTS_UNDER_REVIEW",
|
||||||
"CLEARANCE_READY",
|
"CLEARANCE_READY",
|
||||||
|
|||||||
@@ -51,10 +51,6 @@ export const CONTRACT_STATUS_STYLES: Record<string, StatusStyle> = {
|
|||||||
label: "Active",
|
label: "Active",
|
||||||
color: "bg-[color:var(--freight-brand-muted)] text-[color:var(--freight-brand)] border-[color:var(--freight-brand-border)]",
|
color: "bg-[color:var(--freight-brand-muted)] text-[color:var(--freight-brand)] border-[color:var(--freight-brand-border)]",
|
||||||
},
|
},
|
||||||
AWAITING_CLEARANCE_PAYMENT: {
|
|
||||||
label: "Clearance Fee Due",
|
|
||||||
color: "bg-orange-50 text-orange-700 border-orange-200",
|
|
||||||
},
|
|
||||||
AWAITING_CLEARANCE_DOCUMENTS: {
|
AWAITING_CLEARANCE_DOCUMENTS: {
|
||||||
label: "Awaiting Documents",
|
label: "Awaiting Documents",
|
||||||
color: "bg-amber-50 text-amber-700 border-amber-200",
|
color: "bg-amber-50 text-amber-700 border-amber-200",
|
||||||
@@ -122,7 +118,6 @@ export const CONTRACT_STATUS_COLOR: Record<string, string> = {
|
|||||||
SIGNED_CUSTOMER: "cyan",
|
SIGNED_CUSTOMER: "cyan",
|
||||||
FULLY_EXECUTED: "indigo",
|
FULLY_EXECUTED: "indigo",
|
||||||
CONTRACT_ACTIVE: "edr-green",
|
CONTRACT_ACTIVE: "edr-green",
|
||||||
AWAITING_CLEARANCE_PAYMENT: "orange",
|
|
||||||
AWAITING_CLEARANCE_DOCUMENTS: "yellow",
|
AWAITING_CLEARANCE_DOCUMENTS: "yellow",
|
||||||
CLEARANCE_UNDER_REVIEW: "yellow",
|
CLEARANCE_UNDER_REVIEW: "yellow",
|
||||||
CLEARANCE_READY_FOR_BOOKING: "edr-green",
|
CLEARANCE_READY_FOR_BOOKING: "edr-green",
|
||||||
@@ -212,13 +207,6 @@ export const CONTRACT_STATUS_META: Record<string, StatusMeta> = {
|
|||||||
color: "text-[color:var(--freight-brand)]",
|
color: "text-[color:var(--freight-brand)]",
|
||||||
stage: 3,
|
stage: 3,
|
||||||
},
|
},
|
||||||
AWAITING_CLEARANCE_PAYMENT: {
|
|
||||||
title: "Clearance Fee Due",
|
|
||||||
description:
|
|
||||||
"Customer must pay the prepaid clearance service fee before uploading documents.",
|
|
||||||
color: "text-orange-600",
|
|
||||||
stage: 3,
|
|
||||||
},
|
|
||||||
AWAITING_CLEARANCE_DOCUMENTS: {
|
AWAITING_CLEARANCE_DOCUMENTS: {
|
||||||
title: "Awaiting Documents",
|
title: "Awaiting Documents",
|
||||||
description: "Customer is uploading pre-booking clearance documents.",
|
description: "Customer is uploading pre-booking clearance documents.",
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
tradeDirection: "Direction",
|
tradeDirection: "Direction",
|
||||||
containerTypeId: "Container type",
|
containerTypeId: "Container type",
|
||||||
cargoTypeId: "Cargo type",
|
cargoTypeId: "Cargo type",
|
||||||
|
originYardId: "Origin yard",
|
||||||
|
destinationYardId: "Destination yard",
|
||||||
};
|
};
|
||||||
|
|
||||||
const fmtDateTime = (iso: string) =>
|
const fmtDateTime = (iso: string) =>
|
||||||
@@ -36,12 +38,20 @@ const fmtDateTime = (iso: string) =>
|
|||||||
hour12: false,
|
hour12: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const fmtValue = (field: string, value: unknown): string => {
|
const fmtValue = (
|
||||||
|
field: string,
|
||||||
|
value: unknown,
|
||||||
|
labels?: Record<string, string>,
|
||||||
|
): string => {
|
||||||
if (value === null || value === undefined || value === "") return "—";
|
if (value === null || value === undefined || value === "") return "—";
|
||||||
if (field === "rateValue") {
|
if (field === "rateValue") {
|
||||||
const num = Number(value);
|
const num = Number(value);
|
||||||
return Number.isNaN(num) ? String(value) : num.toLocaleString();
|
return Number.isNaN(num) ? String(value) : num.toLocaleString();
|
||||||
}
|
}
|
||||||
|
// Yard ids are unreadable — an approver decides on the route, not a UUID.
|
||||||
|
if (field === "originYardId" || field === "destinationYardId") {
|
||||||
|
return labels?.[String(value)] ?? String(value);
|
||||||
|
}
|
||||||
return String(value).replace(/_/g, " ");
|
return String(value).replace(/_/g, " ");
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -77,6 +87,8 @@ interface RateApprovalsSectionProps {
|
|||||||
canDecide: boolean;
|
canDecide: boolean;
|
||||||
approve: Decide;
|
approve: Decide;
|
||||||
reject: Decide;
|
reject: Decide;
|
||||||
|
/** yardId → label, so a re-routed rate reads as yards, not UUIDs. */
|
||||||
|
yardLabels?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -89,6 +101,7 @@ const RateApprovalsSection = ({
|
|||||||
canDecide,
|
canDecide,
|
||||||
approve,
|
approve,
|
||||||
reject,
|
reject,
|
||||||
|
yardLabels,
|
||||||
}: RateApprovalsSectionProps) => {
|
}: RateApprovalsSectionProps) => {
|
||||||
const [openId, setOpenId] = useState<string | null>(null);
|
const [openId, setOpenId] = useState<string | null>(null);
|
||||||
const [notes, setNotes] = useState<Record<string, string>>({});
|
const [notes, setNotes] = useState<Record<string, string>>({});
|
||||||
@@ -212,11 +225,11 @@ const RateApprovalsSection = ({
|
|||||||
{FIELD_LABELS[field] ?? field}
|
{FIELD_LABELS[field] ?? field}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" c="dimmed" td="line-through">
|
<Text size="sm" c="dimmed" td="line-through">
|
||||||
{fmtValue(field, r.previousValues[field])}
|
{fmtValue(field, r.previousValues[field], yardLabels)}
|
||||||
</Text>
|
</Text>
|
||||||
<ArrowRight size={13} />
|
<ArrowRight size={13} />
|
||||||
<Text size="sm" fw={600}>
|
<Text size="sm" fw={600}>
|
||||||
{fmtValue(field, r.payload[field])}
|
{fmtValue(field, r.payload[field], yardLabels)}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -269,6 +269,10 @@ const RuleEngineResourcePage = () => {
|
|||||||
);
|
);
|
||||||
const { data: yardOptions, isLoading: yardOptionsLoading } =
|
const { data: yardOptions, isLoading: yardOptionsLoading } =
|
||||||
useYardOptions(usesYardField);
|
useYardOptions(usesYardField);
|
||||||
|
const yardLabelById = useMemo(
|
||||||
|
() => Object.fromEntries((yardOptions ?? []).map((y) => [y.value, y.label])),
|
||||||
|
[yardOptions],
|
||||||
|
);
|
||||||
const usesApprovalRoleField = Boolean(
|
const usesApprovalRoleField = Boolean(
|
||||||
config?.formFields.some(
|
config?.formFields.some(
|
||||||
(f) => f.name === "requiredRole" || f.name === "blocksRole",
|
(f) => f.name === "requiredRole" || f.name === "blocksRole",
|
||||||
@@ -661,6 +665,7 @@ const RuleEngineResourcePage = () => {
|
|||||||
canDecide={canApproveRates}
|
canDecide={canApproveRates}
|
||||||
approve={rateChangeWorkflow.approve}
|
approve={rateChangeWorkflow.approve}
|
||||||
reject={rateChangeWorkflow.reject}
|
reject={rateChangeWorkflow.reject}
|
||||||
|
yardLabels={yardLabelById}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -171,12 +171,11 @@ const RATE_TRIGGERS = [
|
|||||||
value: "WITH_RETURN",
|
value: "WITH_RETURN",
|
||||||
},
|
},
|
||||||
{ label: "Shipping line mapped", value: "SHIPPING_LINE" },
|
{ label: "Shipping line mapped", value: "SHIPPING_LINE" },
|
||||||
{ label: "Consolidation", value: "CONSOLIDATION" },
|
{ label: "Penalty", value: "CONSOLIDATION" },
|
||||||
{ label: "Lashing (flat, per booking)", value: "LASHING" },
|
{ label: "Lashing (per container type / bulk)", value: "LASHING" },
|
||||||
{ label: "Cancellation", value: "CANCELLATION" },
|
{ label: "Cancellation", value: "CANCELLATION" },
|
||||||
{ label: "Demurrage", value: "DEMURRAGE" },
|
|
||||||
{ label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" },
|
{ label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" },
|
||||||
{ label: "Customs clearance service fee (prepaid)", value: "CUSTOMS_CLEARANCE" },
|
{ label: "Customs clearance service fee (billed with the booking)", value: "CUSTOMS_CLEARANCE" },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -211,7 +210,11 @@ const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value
|
|||||||
* bill per container, bulk per ton, overweight always per excess ton, etc. Kept
|
* bill per container, bulk per ton, overweight always per excess ton, etc. Kept
|
||||||
* in sync with apps/edr-freight-api/.../entities/rate-unit.util.ts.
|
* in sync with apps/edr-freight-api/.../entities/rate-unit.util.ts.
|
||||||
*/
|
*/
|
||||||
const allowedRateUnits = (appliesTo: string, trigger: string): string[] => {
|
const allowedRateUnits = (
|
||||||
|
appliesTo: string,
|
||||||
|
trigger: string,
|
||||||
|
cargoKind = "",
|
||||||
|
): string[] => {
|
||||||
if (appliesTo === "OTHER") {
|
if (appliesTo === "OTHER") {
|
||||||
switch (trigger) {
|
switch (trigger) {
|
||||||
case "OVERWEIGHT":
|
case "OVERWEIGHT":
|
||||||
@@ -221,16 +224,16 @@ const allowedRateUnits = (appliesTo: string, trigger: string): string[] => {
|
|||||||
case "DEMURRAGE":
|
case "DEMURRAGE":
|
||||||
return ["PER_CONTAINER", "PER_TON"];
|
return ["PER_CONTAINER", "PER_TON"];
|
||||||
case "WITH_RETURN":
|
case "WITH_RETURN":
|
||||||
// Container-only service — bills per returned container.
|
// Container-only service — per returned container, per wagon, or flat.
|
||||||
return ["PER_CONTAINER", "FLAT"];
|
return ["PER_CONTAINER", "PER_WAGON", "FLAT"];
|
||||||
case "CANCELLATION":
|
case "CANCELLATION":
|
||||||
return ["FLAT", "PER_INVOICE"];
|
return ["FLAT", "PER_INVOICE"];
|
||||||
case "CUSTOMS_CLEARANCE":
|
case "CUSTOMS_CLEARANCE":
|
||||||
// Flat per clearance (ONE_TIME) / per shipment request (GENERAL).
|
|
||||||
return ["FLAT"];
|
|
||||||
case "LASHING":
|
case "LASHING":
|
||||||
// Flat cargo-securing fee, billed once per booking.
|
// Per cargo kind: container fees per box/wagon, bulk per ton/wagon.
|
||||||
return ["FLAT"];
|
return cargoKind === "BULK"
|
||||||
|
? ["PER_TON", "PER_WAGON"]
|
||||||
|
: ["PER_CONTAINER", "PER_WAGON"];
|
||||||
case "CONSOLIDATION":
|
case "CONSOLIDATION":
|
||||||
case "SHIPPING_LINE":
|
case "SHIPPING_LINE":
|
||||||
case "PIL_EXTRA_FEE":
|
case "PIL_EXTRA_FEE":
|
||||||
@@ -258,7 +261,11 @@ const rateUnitOptions = (values: Record<string, unknown>) => {
|
|||||||
const appliesTo = String(values.appliesTo ?? "");
|
const appliesTo = String(values.appliesTo ?? "");
|
||||||
const trigger = appliesTo === "OTHER" ? String(values.trigger ?? "") : "ALWAYS";
|
const trigger = appliesTo === "OTHER" ? String(values.trigger ?? "") : "ALWAYS";
|
||||||
if (!appliesTo) return [];
|
if (!appliesTo) return [];
|
||||||
return allowedRateUnits(appliesTo, trigger).map(unitOption);
|
return allowedRateUnits(
|
||||||
|
appliesTo,
|
||||||
|
trigger,
|
||||||
|
String(values.cargoKind ?? ""),
|
||||||
|
).map(unitOption);
|
||||||
};
|
};
|
||||||
|
|
||||||
const CURRENCIES = [
|
const CURRENCIES = [
|
||||||
@@ -659,7 +666,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
filters: {
|
filters: {
|
||||||
appliesTo: "OTHER",
|
appliesTo: "OTHER",
|
||||||
trigger:
|
trigger:
|
||||||
"HAZARDOUS,OVERWEIGHT,REEFER,SHIPPING_LINE,CONSOLIDATION,LASHING,CANCELLATION,DEMURRAGE,PIL_EXTRA_FEE",
|
"HAZARDOUS,OVERWEIGHT,REEFER,SHIPPING_LINE,CONSOLIDATION,LASHING,CANCELLATION,PIL_EXTRA_FEE",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -717,6 +724,37 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
(String(v.appliesTo ?? "") === "OTHER" &&
|
(String(v.appliesTo ?? "") === "OTHER" &&
|
||||||
["CUSTOMS_CLEARANCE", "WITH_RETURN"].includes(String(v.trigger ?? ""))),
|
["CUSTOMS_CLEARANCE", "WITH_RETURN"].includes(String(v.trigger ?? ""))),
|
||||||
},
|
},
|
||||||
|
// ── Cargo kind — customs clearance and lashing are priced separately
|
||||||
|
// for containers (one rate per container type) and bulk ────────────────
|
||||||
|
{
|
||||||
|
name: "cargoKind",
|
||||||
|
label: "Cargo kind",
|
||||||
|
type: "select",
|
||||||
|
required: true,
|
||||||
|
options: INTERCITY_KINDS,
|
||||||
|
placeholder: "Is this fee for containers or bulk?",
|
||||||
|
description:
|
||||||
|
"Container fees bill per box or wagon (one rate per container type); bulk fees bill per ton or wagon.",
|
||||||
|
showIf: (v) =>
|
||||||
|
v.appliesTo === "OTHER" &&
|
||||||
|
["CUSTOMS_CLEARANCE", "LASHING"].includes(String(v.trigger ?? "")),
|
||||||
|
// Not a stored column: a container fee carries its containerTypeId, a
|
||||||
|
// bulk fee carries none.
|
||||||
|
getInitialValue: (record) =>
|
||||||
|
record.containerTypeId ? "CONTAINER" : "BULK",
|
||||||
|
},
|
||||||
|
// ── Container type — a container fee names the type it covers ─────────
|
||||||
|
{
|
||||||
|
name: "containerTypeId",
|
||||||
|
label: "Container type",
|
||||||
|
type: "select",
|
||||||
|
required: true,
|
||||||
|
placeholder: "Which container type this fee covers",
|
||||||
|
showIf: (v) =>
|
||||||
|
v.appliesTo === "OTHER" &&
|
||||||
|
["CUSTOMS_CLEARANCE", "LASHING"].includes(String(v.trigger ?? "")) &&
|
||||||
|
v.cargoKind === "CONTAINER",
|
||||||
|
},
|
||||||
// ── Cargo kind — Intercity only (import/export get it from appliesTo) ─
|
// ── Cargo kind — Intercity only (import/export get it from appliesTo) ─
|
||||||
{
|
{
|
||||||
name: "intercityKind",
|
name: "intercityKind",
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ export const BOOKING_STATUSES = [
|
|||||||
"CONTRACT_ACTIVE",
|
"CONTRACT_ACTIVE",
|
||||||
"CONTRACT_CLOSED",
|
"CONTRACT_CLOSED",
|
||||||
// Post counter-sign document-clearance gate.
|
// Post counter-sign document-clearance gate.
|
||||||
"AWAITING_CLEARANCE_PAYMENT",
|
|
||||||
"AWAITING_DOCUMENTS",
|
"AWAITING_DOCUMENTS",
|
||||||
"DOCUMENTS_UNDER_REVIEW",
|
"DOCUMENTS_UNDER_REVIEW",
|
||||||
"CLEARANCE_READY",
|
"CLEARANCE_READY",
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import { useNavigate } from "react-router-dom";
|
|||||||
|
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
import { PayClearanceFeeButton } from "@/pages/bookings/payments/PayClearanceFeeButton";
|
|
||||||
import { PayNowButton } from "@/pages/bookings/payments/PayNowButton";
|
import { PayNowButton } from "@/pages/bookings/payments/PayNowButton";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { ContractClearanceAction } from "./ContractClearanceAction";
|
import { ContractClearanceAction } from "./ContractClearanceAction";
|
||||||
@@ -70,17 +69,6 @@ export function ContractCustomerAction({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action.type === "pay-clearance") {
|
|
||||||
return (
|
|
||||||
<PayClearanceFeeButton
|
|
||||||
sourceId={action.contractId}
|
|
||||||
currency={contract.paymentCurrency}
|
|
||||||
label={action.label}
|
|
||||||
size={size}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action.type === "initiate") {
|
if (action.type === "initiate") {
|
||||||
return (
|
return (
|
||||||
<InitiateBookingButton
|
<InitiateBookingButton
|
||||||
|
|||||||
@@ -80,14 +80,6 @@ export type ContractCustomerAction =
|
|||||||
label: string;
|
label: string;
|
||||||
primary: boolean;
|
primary: boolean;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
}
|
|
||||||
| {
|
|
||||||
/** Prepaid customs clearance service fee (contract-level, ONE_TIME Path B). */
|
|
||||||
type: "pay-clearance";
|
|
||||||
contractId: string;
|
|
||||||
label: string;
|
|
||||||
primary: boolean;
|
|
||||||
icon: LucideIcon;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function findPayableBookingForContract(
|
function findPayableBookingForContract(
|
||||||
@@ -145,17 +137,6 @@ export function deriveContractCustomerAction(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepaid clearance service fee gate — must settle before document upload.
|
|
||||||
if (contract.status === "AWAITING_CLEARANCE_PAYMENT") {
|
|
||||||
return {
|
|
||||||
type: "pay-clearance",
|
|
||||||
contractId: id,
|
|
||||||
label: "Pay clearance fee",
|
|
||||||
primary: true,
|
|
||||||
icon: CreditCard,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const payable = findPayableBookingForContract(id, bookings);
|
const payable = findPayableBookingForContract(id, bookings);
|
||||||
if (payable) {
|
if (payable) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { contractNeedsClearanceAction } from "@/components/customer-actions/deri
|
|||||||
export interface ActionItem {
|
export interface ActionItem {
|
||||||
id: string;
|
id: string;
|
||||||
/** What the customer must do — drives the icon, label and modal. */
|
/** What the customer must do — drives the icon, label and modal. */
|
||||||
kind: "clearance" | "duty" | "sign" | "book" | "pay" | "clearance-fee";
|
kind: "clearance" | "duty" | "sign" | "book" | "pay";
|
||||||
/** The contract/booking reference for display. */
|
/** The contract/booking reference for display. */
|
||||||
reference: string;
|
reference: string;
|
||||||
/** Short human description of the action. */
|
/** Short human description of the action. */
|
||||||
@@ -40,18 +40,6 @@ export function deriveActionItems(
|
|||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Prepaid clearance service fee (Path B) — blocks the document step.
|
|
||||||
if (c.status === "AWAITING_CLEARANCE_PAYMENT") {
|
|
||||||
items.push({
|
|
||||||
id: `clearance-fee-${c.id}`,
|
|
||||||
kind: "clearance-fee",
|
|
||||||
reference: c.reference,
|
|
||||||
description: "Clearance service fee due — pay to unlock document upload",
|
|
||||||
targetId: c.id,
|
|
||||||
urgent: true,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const clr = contractNeedsClearanceAction(c);
|
const clr = contractNeedsClearanceAction(c);
|
||||||
if (clr.show) {
|
if (clr.show) {
|
||||||
items.push({
|
items.push({
|
||||||
@@ -82,19 +70,6 @@ export function deriveActionItems(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const b of bookings) {
|
for (const b of bookings) {
|
||||||
// Per-shipment clearance service fee (GENERAL + customs shipment request).
|
|
||||||
if (b.status === "AWAITING_CLEARANCE_PAYMENT") {
|
|
||||||
items.push({
|
|
||||||
id: `clearance-fee-${b.id}`,
|
|
||||||
kind: "clearance-fee",
|
|
||||||
reference: b.reference,
|
|
||||||
description:
|
|
||||||
"Clearance service fee due for this shipment — pay to unlock document upload",
|
|
||||||
targetId: b.id,
|
|
||||||
urgent: true,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const isGeneral = b.bookingType === "GENERAL_CONTRACT";
|
const isGeneral = b.bookingType === "GENERAL_CONTRACT";
|
||||||
const canPay =
|
const canPay =
|
||||||
b.paymentStatus !== "PAID" &&
|
b.paymentStatus !== "PAID" &&
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ const KIND_META: Record<
|
|||||||
sign: { icon: FileSignature, label: "Sign", color: "blue" },
|
sign: { icon: FileSignature, label: "Sign", color: "blue" },
|
||||||
book: { icon: PackagePlus, label: "Book", color: "violet" },
|
book: { icon: PackagePlus, label: "Book", color: "violet" },
|
||||||
pay: { icon: CreditCard, label: "Payment", color: "orange" },
|
pay: { icon: CreditCard, label: "Payment", color: "orange" },
|
||||||
"clearance-fee": { icon: CreditCard, label: "Clearance fee", color: "orange" },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ActionNeededSectionProps {
|
export interface ActionNeededSectionProps {
|
||||||
@@ -138,14 +137,10 @@ export function ActionNeededSection({
|
|||||||
|
|
||||||
// Billing is invoice-centric — resolve the booking's currently payable
|
// Billing is invoice-centric — resolve the booking's currently payable
|
||||||
// invoice before paying it (mirrors ReadonlyBookingView).
|
// invoice before paying it (mirrors ReadonlyBookingView).
|
||||||
// A "pay" item settles the booking invoice; a "clearance-fee" item settles the
|
|
||||||
// prepaid clearance-fee invoice (source `clearance`, keyed by contract or
|
|
||||||
// booking id depending on where the gate sits).
|
|
||||||
const payItemSource = payItem?.kind === "clearance-fee" ? "clearance" : "booking";
|
|
||||||
const { data: payItemInvoices = [] } = useQuery({
|
const { data: payItemInvoices = [] } = useQuery({
|
||||||
queryKey: [`${payItemSource}-invoices`, payItem?.targetId],
|
queryKey: ["booking-invoices", payItem?.targetId],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
invoicesService.listForSource(payItemSource, payItem!.targetId),
|
invoicesService.listForSource("booking", payItem!.targetId),
|
||||||
enabled: payItem !== null,
|
enabled: payItem !== null,
|
||||||
});
|
});
|
||||||
const payableInvoiceId = payItemInvoices.find((inv) =>
|
const payableInvoiceId = payItemInvoices.find((inv) =>
|
||||||
@@ -188,7 +183,6 @@ export function ActionNeededSection({
|
|||||||
navigate(`/contracts/${item.targetId}`);
|
navigate(`/contracts/${item.targetId}`);
|
||||||
break;
|
break;
|
||||||
case "pay":
|
case "pay":
|
||||||
case "clearance-fee":
|
|
||||||
setPayItem(item);
|
setPayItem(item);
|
||||||
break;
|
break;
|
||||||
case "sign":
|
case "sign":
|
||||||
@@ -284,9 +278,7 @@ export function ActionNeededSection({
|
|||||||
>
|
>
|
||||||
{item.kind === "pay"
|
{item.kind === "pay"
|
||||||
? "Pay now"
|
? "Pay now"
|
||||||
: item.kind === "clearance-fee"
|
: item.kind === "duty"
|
||||||
? "Pay clearance fee"
|
|
||||||
: item.kind === "duty"
|
|
||||||
? "Pay duty & upload slip"
|
? "Pay duty & upload slip"
|
||||||
: item.kind === "sign"
|
: item.kind === "sign"
|
||||||
? "Sign"
|
? "Sign"
|
||||||
|
|||||||
@@ -165,19 +165,6 @@ export const STATUS_CONFIG: Record<string, StageConfig> = {
|
|||||||
badgeDot: "edr-green.5",
|
badgeDot: "edr-green.5",
|
||||||
action: { label: "View", kind: "outline" },
|
action: { label: "View", kind: "outline" },
|
||||||
},
|
},
|
||||||
AWAITING_CLEARANCE_PAYMENT: {
|
|
||||||
stage: 3,
|
|
||||||
icon: Wallet,
|
|
||||||
iconColor: "edr-amber-text",
|
|
||||||
tile: "edr-amber-soft",
|
|
||||||
hint: "Clearance service fee due · pay to unlock document upload",
|
|
||||||
step: "edr-accent",
|
|
||||||
badgeLabel: "Clearance fee due",
|
|
||||||
badgeBg: "edr-amber-soft",
|
|
||||||
badgeText: "edr-amber-text",
|
|
||||||
badgeDot: "edr-accent",
|
|
||||||
action: { label: "Pay clearance fee", kind: "amber", icon: ArrowRight },
|
|
||||||
},
|
|
||||||
AWAITING_DOCUMENTS: {
|
AWAITING_DOCUMENTS: {
|
||||||
stage: 3,
|
stage: 3,
|
||||||
icon: FileUp,
|
icon: FileUp,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Group, Paper, Tabs, Text } from "@mantine/core";
|
import { Group, Tabs } from "@mantine/core";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { CreditCard, FileText, LayoutGrid } from "lucide-react";
|
import { CreditCard, FileText, LayoutGrid } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@@ -12,7 +12,6 @@ import { isPayable } from "@/pages/billing/invoice-ui";
|
|||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton";
|
import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton";
|
||||||
import { PayClearanceFeeButton } from "../payments/PayClearanceFeeButton";
|
|
||||||
import { ActivityCard } from "./components/ActivityCard";
|
import { ActivityCard } from "./components/ActivityCard";
|
||||||
import { ClearanceCard } from "./components/ClearanceCard";
|
import { ClearanceCard } from "./components/ClearanceCard";
|
||||||
import { DocumentsTab } from "./components/DocumentsTab";
|
import { DocumentsTab } from "./components/DocumentsTab";
|
||||||
@@ -143,8 +142,6 @@ export function ReadonlyBookingView({
|
|||||||
const isCustoms = Boolean(booking.customsClearingEnabled);
|
const isCustoms = Boolean(booking.customsClearingEnabled);
|
||||||
const canSelfRebook = !isCustoms;
|
const canSelfRebook = !isCustoms;
|
||||||
const isPendingConsolidation = status === "PENDING_CONSOLIDATION";
|
const isPendingConsolidation = status === "PENDING_CONSOLIDATION";
|
||||||
// Prepaid clearance service fee gate — document upload stays locked until paid.
|
|
||||||
const isAwaitingClearanceFee = status === "AWAITING_CLEARANCE_PAYMENT";
|
|
||||||
const isClearance = [
|
const isClearance = [
|
||||||
"AWAITING_DOCUMENTS",
|
"AWAITING_DOCUMENTS",
|
||||||
"DOCUMENTS_UNDER_REVIEW",
|
"DOCUMENTS_UNDER_REVIEW",
|
||||||
@@ -243,28 +240,6 @@ export function ReadonlyBookingView({
|
|||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<ContractCard booking={booking} />
|
<ContractCard booking={booking} />
|
||||||
|
|
||||||
{isAwaitingClearanceFee && (
|
|
||||||
<Paper withBorder radius="lg" p="lg" style={{ borderColor: "#FDE68A", background: "#FFFBEB" }}>
|
|
||||||
<Group justify="space-between" align="center" wrap="wrap" gap="md">
|
|
||||||
<div>
|
|
||||||
<Text fw={700} fz={15} c="#92400E">
|
|
||||||
Customs clearance service fee due
|
|
||||||
</Text>
|
|
||||||
<Text fz={13} c="#B45309" mt={4}>
|
|
||||||
Pay the clearance service fee to unlock the clearance
|
|
||||||
document upload. Global Logistics starts working on your
|
|
||||||
shipment once the fee is settled.
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
<PayClearanceFeeButton
|
|
||||||
sourceId={booking.id}
|
|
||||||
currency={booking.paymentCurrency}
|
|
||||||
size="md"
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
</Paper>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isClearance && <ClearanceCard booking={booking} />}
|
{isClearance && <ClearanceCard booking={booking} />}
|
||||||
|
|
||||||
<BodyGrid
|
<BodyGrid
|
||||||
|
|||||||
@@ -51,17 +51,10 @@ export function buildJourneySteps(
|
|||||||
if (isCustoms && milestones.length > 0) {
|
if (isCustoms && milestones.length > 0) {
|
||||||
const sorted = [...milestones].sort((a, b) => a.sortOrder - b.sortOrder);
|
const sorted = [...milestones].sort((a, b) => a.sortOrder - b.sortOrder);
|
||||||
const firstPendingId = sorted.find((m) => m.status === "PENDING")?.id;
|
const firstPendingId = sorted.find((m) => m.status === "PENDING")?.id;
|
||||||
const feeActive = status === "AWAITING_CLEARANCE_PAYMENT";
|
|
||||||
const delivered = ["COMPLETED", "DELIVERED"].includes(status);
|
const delivered = ["COMPLETED", "DELIVERED"].includes(status);
|
||||||
|
|
||||||
const steps: JourneyStep[] = [
|
const steps: JourneyStep[] = [
|
||||||
{ key: "booked", label: "Booking initiated", state: "done" },
|
{ key: "booked", label: "Booking initiated", state: "done" },
|
||||||
{
|
|
||||||
key: "fee",
|
|
||||||
label: "Clearance fee paid",
|
|
||||||
state: feeActive ? "active" : "done",
|
|
||||||
owner: "CUST",
|
|
||||||
},
|
|
||||||
...sorted.map<JourneyStep>((m) => ({
|
...sorted.map<JourneyStep>((m) => ({
|
||||||
key: m.id,
|
key: m.id,
|
||||||
label: m.milestoneLabel,
|
label: m.milestoneLabel,
|
||||||
@@ -71,7 +64,7 @@ export function buildJourneySteps(
|
|||||||
? "done"
|
? "done"
|
||||||
: m.status === "SKIPPED"
|
: m.status === "SKIPPED"
|
||||||
? "skipped"
|
? "skipped"
|
||||||
: !feeActive && m.id === firstPendingId
|
: m.id === firstPendingId
|
||||||
? "active"
|
? "active"
|
||||||
: "idle",
|
: "idle",
|
||||||
})),
|
})),
|
||||||
@@ -79,7 +72,7 @@ export function buildJourneySteps(
|
|||||||
];
|
];
|
||||||
// Every known milestone is done but the booking hasn't closed yet — the
|
// Every known milestone is done but the booking hasn't closed yet — the
|
||||||
// delivery step is what's in progress.
|
// delivery step is what's in progress.
|
||||||
if (!feeActive && !firstPendingId && !delivered) {
|
if (!firstPendingId && !delivered) {
|
||||||
steps[steps.length - 1].state = "active";
|
steps[steps.length - 1].state = "active";
|
||||||
}
|
}
|
||||||
return steps;
|
return steps;
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { useDisclosure } from "@mantine/hooks";
|
|||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
CreditCard,
|
|
||||||
PackagePlus,
|
PackagePlus,
|
||||||
PencilLine,
|
PencilLine,
|
||||||
Upload,
|
Upload,
|
||||||
@@ -12,7 +11,6 @@ import { useNavigate } from "react-router-dom";
|
|||||||
|
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
import { PayClearanceFeeButton } from "@/pages/bookings/payments/PayClearanceFeeButton";
|
|
||||||
import { ResubmitBookingModal } from "@/pages/bookings/resubmit/ResubmitBookingModal";
|
import { ResubmitBookingModal } from "@/pages/bookings/resubmit/ResubmitBookingModal";
|
||||||
|
|
||||||
import { BookingActionModal } from "./BookingActionModal";
|
import { BookingActionModal } from "./BookingActionModal";
|
||||||
@@ -25,7 +23,6 @@ const ICON_BY_KIND: Record<
|
|||||||
BookingActionKind,
|
BookingActionKind,
|
||||||
typeof Upload
|
typeof Upload
|
||||||
> = {
|
> = {
|
||||||
PAY_CLEARANCE: CreditCard,
|
|
||||||
UPLOAD_DOCUMENTS: Upload,
|
UPLOAD_DOCUMENTS: Upload,
|
||||||
FIX_DOCUMENTS: AlertCircle,
|
FIX_DOCUMENTS: AlertCircle,
|
||||||
SCHEDULE_OPERATION: ArrowRight,
|
SCHEDULE_OPERATION: ArrowRight,
|
||||||
@@ -59,19 +56,6 @@ export function BookingActionButton({
|
|||||||
|
|
||||||
if (!isChangesRequested && !action) return null;
|
if (!isChangesRequested && !action) return null;
|
||||||
|
|
||||||
// The prepaid clearance service fee has its own payment flow (method modal +
|
|
||||||
// provider redirect) — delegate to the self-contained pay button.
|
|
||||||
if (action?.kind === "PAY_CLEARANCE") {
|
|
||||||
return (
|
|
||||||
<PayClearanceFeeButton
|
|
||||||
sourceId={booking.id}
|
|
||||||
currency={booking.paymentCurrency}
|
|
||||||
label={action.label}
|
|
||||||
size={size}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const Icon = action ? ICON_BY_KIND[action.kind] : PencilLine;
|
const Icon = action ? ICON_BY_KIND[action.kind] : PencilLine;
|
||||||
const label = action ? action.label : "Update & resubmit";
|
const label = action ? action.label : "Update & resubmit";
|
||||||
// BOOK navigates to the booking form (cargo + day + window check) — the
|
// BOOK navigates to the booking form (cargo + day + window check) — the
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import type { Freight } from "@edr/types";
|
|||||||
* to operation.
|
* to operation.
|
||||||
*/
|
*/
|
||||||
export type BookingActionKind =
|
export type BookingActionKind =
|
||||||
| "PAY_CLEARANCE" // AWAITING_CLEARANCE_PAYMENT — pay the prepaid clearance service fee
|
|
||||||
| "UPLOAD_DOCUMENTS" // AWAITING_DOCUMENTS — upload the required clearance docs
|
| "UPLOAD_DOCUMENTS" // AWAITING_DOCUMENTS — upload the required clearance docs
|
||||||
| "FIX_DOCUMENTS" // DOCUMENTS_UNDER_REVIEW — some docs queried, re-upload them
|
| "FIX_DOCUMENTS" // DOCUMENTS_UNDER_REVIEW — some docs queried, re-upload them
|
||||||
| "SCHEDULE_OPERATION" // CLEARANCE_READY (legacy with cargo) — pick a day and proceed
|
| "SCHEDULE_OPERATION" // CLEARANCE_READY (legacy with cargo) — pick a day and proceed
|
||||||
@@ -24,11 +23,6 @@ export interface BookingNextAction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ACTION_BY_STATUS: Record<string, BookingNextAction> = {
|
const ACTION_BY_STATUS: Record<string, BookingNextAction> = {
|
||||||
AWAITING_CLEARANCE_PAYMENT: {
|
|
||||||
kind: "PAY_CLEARANCE",
|
|
||||||
label: "Pay clearance fee",
|
|
||||||
title: "Pay the clearance service fee",
|
|
||||||
},
|
|
||||||
AWAITING_DOCUMENTS: {
|
AWAITING_DOCUMENTS: {
|
||||||
kind: "UPLOAD_DOCUMENTS",
|
kind: "UPLOAD_DOCUMENTS",
|
||||||
label: "Upload documents",
|
label: "Upload documents",
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
import { Button, type ButtonProps } from "@mantine/core";
|
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
||||||
import { CreditCard } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
import { ModalSafeWrapper } from "@/components/customer-actions/ModalSafeWrapper";
|
|
||||||
import { isPayable } from "@/pages/billing/invoice-ui";
|
|
||||||
import { api } from "@/services/api";
|
|
||||||
import { invoicesService } from "@/services/invoices.service";
|
|
||||||
import {
|
|
||||||
paymentsService,
|
|
||||||
type PaymentMethod,
|
|
||||||
} from "@/services/payments.service";
|
|
||||||
import { PaymentMethodModal } from "../BookingDetailPage/components/PaymentMethodModal";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Payment flow for the prepaid customs clearance service fee. The fee is its
|
|
||||||
* own `clearance`-source invoice — sourceId is the contract id (ONE_TIME,
|
|
||||||
* contract status AWAITING_CLEARANCE_PAYMENT) or the booking id (GENERAL
|
|
||||||
* shipment request, booking status AWAITING_CLEARANCE_PAYMENT). Paying it
|
|
||||||
* unlocks the clearance document upload; same modal + provider redirect as
|
|
||||||
* booking payment.
|
|
||||||
*/
|
|
||||||
export function useClearanceFeePayment(sourceId: string) {
|
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
|
||||||
|
|
||||||
const { data: invoices = [] } = useQuery({
|
|
||||||
queryKey: ["clearance-invoices", sourceId],
|
|
||||||
queryFn: () => invoicesService.listForSource("clearance", sourceId),
|
|
||||||
enabled: Boolean(sourceId),
|
|
||||||
});
|
|
||||||
const payableInvoice = invoices.find((inv) => isPayable(inv.status)) ?? null;
|
|
||||||
|
|
||||||
const mutation = useMutation({
|
|
||||||
mutationFn: (method: PaymentMethod) => {
|
|
||||||
if (!payableInvoice) {
|
|
||||||
throw new Error(
|
|
||||||
"No payable clearance-fee invoice found yet. Please refresh or contact support.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return api.invoices.pay.call({
|
|
||||||
id: payableInvoice.id,
|
|
||||||
payload: { method, platform: "web" },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onSuccess: (data, method) => {
|
|
||||||
const redirectUrl =
|
|
||||||
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
|
|
||||||
? data.clientAction.url
|
|
||||||
: paymentsService.checkoutUrlForInvoice({
|
|
||||||
invoiceId: payableInvoice!.id,
|
|
||||||
method,
|
|
||||||
});
|
|
||||||
window.location.href = redirectUrl;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const close = () => {
|
|
||||||
if (!mutation.isPending) {
|
|
||||||
setModalOpen(false);
|
|
||||||
mutation.reset();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
invoice: payableInvoice,
|
|
||||||
modalOpen,
|
|
||||||
open: () => setModalOpen(true),
|
|
||||||
close,
|
|
||||||
processing: mutation.isPending,
|
|
||||||
error: mutation.isError
|
|
||||||
? mutation.error instanceof Error
|
|
||||||
? mutation.error.message
|
|
||||||
: "Could not start payment. Please try again."
|
|
||||||
: null,
|
|
||||||
confirm: (method: PaymentMethod) => mutation.mutate(method),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PayClearanceFeeButtonProps {
|
|
||||||
/** Contract id (ONE_TIME) or booking id (GENERAL shipment) the fee bills. */
|
|
||||||
sourceId: string;
|
|
||||||
/** Fallback currency while the invoice is loading. */
|
|
||||||
currency?: string;
|
|
||||||
label?: string;
|
|
||||||
size?: ButtonProps["size"];
|
|
||||||
fullWidth?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Self-contained "Pay clearance fee" action — modal in place, no navigation. */
|
|
||||||
export function PayClearanceFeeButton({
|
|
||||||
sourceId,
|
|
||||||
currency,
|
|
||||||
label = "Pay clearance fee",
|
|
||||||
size = "xs",
|
|
||||||
fullWidth,
|
|
||||||
}: PayClearanceFeeButtonProps) {
|
|
||||||
const pay = useClearanceFeePayment(sourceId);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ModalSafeWrapper>
|
|
||||||
<Button
|
|
||||||
size={size}
|
|
||||||
radius="md"
|
|
||||||
fw={700}
|
|
||||||
fz={13}
|
|
||||||
color="edr-green"
|
|
||||||
fullWidth={fullWidth}
|
|
||||||
leftSection={<CreditCard size={14} />}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
pay.open();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<PaymentMethodModal
|
|
||||||
opened={pay.modalOpen}
|
|
||||||
onClose={pay.close}
|
|
||||||
amountLabel={
|
|
||||||
pay.invoice
|
|
||||||
? `${Number(
|
|
||||||
pay.invoice.balanceAmount ?? pay.invoice.totalAmount,
|
|
||||||
).toLocaleString()} ${pay.invoice.currency}`
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
currency={pay.invoice?.currency ?? currency}
|
|
||||||
processing={pay.processing}
|
|
||||||
error={pay.error}
|
|
||||||
onConfirm={pay.confirm}
|
|
||||||
/>
|
|
||||||
</ModalSafeWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -72,7 +72,6 @@ import { ContractClearancePanel } from "./ContractClearancePanel";
|
|||||||
import { ContractClearanceWorkflowBanner } from "./ContractClearanceWorkflowBanner";
|
import { ContractClearanceWorkflowBanner } from "./ContractClearanceWorkflowBanner";
|
||||||
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
|
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
|
||||||
import { InitiateBookingButton } from "@/components/customer-actions/ContractCustomerAction";
|
import { InitiateBookingButton } from "@/components/customer-actions/ContractCustomerAction";
|
||||||
import { PayClearanceFeeButton } from "@/pages/bookings/payments/PayClearanceFeeButton";
|
|
||||||
import { formatRateUnit } from "./new-contract-form/unit-rates";
|
import { formatRateUnit } from "./new-contract-form/unit-rates";
|
||||||
import { getContractBookingAction } from "./contract-booking-action";
|
import { getContractBookingAction } from "./contract-booking-action";
|
||||||
import { closedWindowMessage, hasOpenWindow } from "./booking-window";
|
import { closedWindowMessage, hasOpenWindow } from "./booking-window";
|
||||||
@@ -438,9 +437,6 @@ export default function ContractDetailPage() {
|
|||||||
// clearance is finalized.
|
// clearance is finalized.
|
||||||
const canUploadClearance =
|
const canUploadClearance =
|
||||||
CLEARANCE_UPLOAD_STATUSES.includes(contract.status) && !clearanceFinalized;
|
CLEARANCE_UPLOAD_STATUSES.includes(contract.status) && !clearanceFinalized;
|
||||||
// Prepaid clearance service fee gate (Path B) — the document step stays
|
|
||||||
// locked until the fee invoice settles.
|
|
||||||
const awaitingClearanceFee = contract.status === "AWAITING_CLEARANCE_PAYMENT";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box style={{ padding: "28px 32px 40px" }}>
|
<Box style={{ padding: "28px 32px 40px" }}>
|
||||||
@@ -574,13 +570,6 @@ export default function ContractDetailPage() {
|
|||||||
Global Logistics is creating your booking
|
Global Logistics is creating your booking
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{awaitingClearanceFee && (
|
|
||||||
<PayClearanceFeeButton
|
|
||||||
sourceId={contract.id}
|
|
||||||
currency={contract.paymentCurrency}
|
|
||||||
size="md"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{canUploadClearance && (
|
{canUploadClearance && (
|
||||||
<Button
|
<Button
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
|
|||||||
@@ -125,10 +125,6 @@ export const CONTRACT_STATUS_CONFIG: Record<
|
|||||||
FULLY_EXECUTED: { label: "Fully Executed", ...TONE.success },
|
FULLY_EXECUTED: { label: "Fully Executed", ...TONE.success },
|
||||||
CONTRACT_ACTIVE: { label: "Active", ...TONE.success },
|
CONTRACT_ACTIVE: { label: "Active", ...TONE.success },
|
||||||
// ── Path B pre-booking clearance (contract-level) ──
|
// ── Path B pre-booking clearance (contract-level) ──
|
||||||
AWAITING_CLEARANCE_PAYMENT: {
|
|
||||||
label: "Clearance Fee Due",
|
|
||||||
...TONE.warning,
|
|
||||||
},
|
|
||||||
AWAITING_CLEARANCE_DOCUMENTS: {
|
AWAITING_CLEARANCE_DOCUMENTS: {
|
||||||
label: "Upload Clearance Docs",
|
label: "Upload Clearance Docs",
|
||||||
...TONE.warning,
|
...TONE.warning,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { Freight } from "@edr/types";
|
|||||||
export function formatRateUnit(unit: Freight.ContractRateUnit | string): string {
|
export function formatRateUnit(unit: Freight.ContractRateUnit | string): string {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
per_container: "container",
|
per_container: "container",
|
||||||
|
per_wagon: "wagon",
|
||||||
per_ton: "ton",
|
per_ton: "ton",
|
||||||
per_item: "item",
|
per_item: "item",
|
||||||
per_km: "km",
|
per_km: "km",
|
||||||
|
|||||||
@@ -48,8 +48,12 @@ export function computeShipmentTotal(
|
|||||||
(i) =>
|
(i) =>
|
||||||
i.containerSize === line.containerSize &&
|
i.containerSize === line.containerSize &&
|
||||||
i.unit === "per_container" &&
|
i.unit === "per_container" &&
|
||||||
!i.conditionalOn,
|
!i.conditionalOn &&
|
||||||
) ?? rateFor((i) => i.containerSize === line.containerSize);
|
!i.isClearance,
|
||||||
|
) ??
|
||||||
|
rateFor(
|
||||||
|
(i) => i.containerSize === line.containerSize && !i.isClearance,
|
||||||
|
);
|
||||||
if (rate) {
|
if (rate) {
|
||||||
lines.push({
|
lines.push({
|
||||||
label: rate.label,
|
label: rate.label,
|
||||||
@@ -106,7 +110,9 @@ export function computeShipmentTotal(
|
|||||||
} else {
|
} else {
|
||||||
const qty = Number(values.cargoWeightTons || values.itemCount || 0);
|
const qty = Number(values.cargoWeightTons || values.itemCount || 0);
|
||||||
const rate =
|
const rate =
|
||||||
rateFor((i) => i.unit === "per_ton" || i.unit === "per_item") ?? items[0];
|
rateFor(
|
||||||
|
(i) => (i.unit === "per_ton" || i.unit === "per_item") && !i.isClearance,
|
||||||
|
) ?? items[0];
|
||||||
if (rate && qty > 0) {
|
if (rate && qty > 0) {
|
||||||
lines.push({
|
lines.push({
|
||||||
label: rate.label,
|
label: rate.label,
|
||||||
@@ -144,6 +150,36 @@ export function computeShipmentTotal(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Customs clearance service fee — billed on the booking invoice with the
|
||||||
|
// freight. Container fees estimate per size (per box, or per wagon: two 20ft
|
||||||
|
// share one); bulk per-ton scales by tonnage. Bulk per-wagon fees depend on
|
||||||
|
// the wagon capacity the train stocks — shown at real pricing, not estimated.
|
||||||
|
for (const cl of items.filter((i) => i.isClearance)) {
|
||||||
|
let qty = 0;
|
||||||
|
if (isContainer) {
|
||||||
|
const boxes = (values.containers ?? [])
|
||||||
|
.filter((c) => c.containerSize === cl.containerSize)
|
||||||
|
.reduce((s, c) => s + Number(c.quantity || 0), 0);
|
||||||
|
qty =
|
||||||
|
cl.unit === "per_wagon"
|
||||||
|
? Math.ceil(boxes * (cl.containerSize === "40ft" ? 1 : 0.5))
|
||||||
|
: boxes;
|
||||||
|
} else if (cl.unit === "per_ton") {
|
||||||
|
qty = Number(values.cargoWeightTons || 0);
|
||||||
|
} else if (cl.unit === "flat") {
|
||||||
|
qty = 1;
|
||||||
|
}
|
||||||
|
if (qty > 0) {
|
||||||
|
lines.push({
|
||||||
|
label: cl.label,
|
||||||
|
unitPrice: cl.unitPrice,
|
||||||
|
unit: cl.unit,
|
||||||
|
quantity: qty,
|
||||||
|
amount: cl.unitPrice * qty,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const total = lines.reduce((s, l) => s + l.amount, 0);
|
const total = lines.reduce((s, l) => s + l.amount, 0);
|
||||||
return { currency, lines, total };
|
return { currency, lines, total };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,6 +165,64 @@ WHERE NOT EXISTS (
|
|||||||
SELECT 1 FROM freight.wagons w WHERE w.wagon_number = 'ECW' || lpad(g::text, 4, '0')
|
SELECT 1 FROM freight.wagons w WHERE w.wagon_number = 'ECW' || lpad(g::text, 4, '0')
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- 5b3. Ledger-day rolling stock: wheat may also ride PW2 box wagons, and the
|
||||||
|
-- GRAIN train is a BUILT Train-Builder consist — 37 PW2 wagons coupled at
|
||||||
|
-- KALITY behind two dedicated locos. A built train's physical wagon count IS
|
||||||
|
-- its schedule capacity (37), immune to the loco-length slot recompute.
|
||||||
|
INSERT INTO freight.cargo_type_wagon_types (cargo_type_id, wagon_type_id)
|
||||||
|
SELECT ct.id, wt.id
|
||||||
|
FROM freight.cargo_types ct
|
||||||
|
JOIN freight.wagon_types wt ON wt.code = 'PW2'
|
||||||
|
WHERE ct.code IN ('E2E_IMP_GRAINS', 'E2E_IMP_WHEAT')
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM freight.cargo_type_wagon_types x
|
||||||
|
WHERE x.cargo_type_id = ct.id AND x.wagon_type_id = wt.id
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO freight.locomotives
|
||||||
|
(id, code, max_pull_weight_tons, max_train_length_meters, current_yard_id)
|
||||||
|
SELECT gen_random_uuid(), v.code, 9000, 760, y.id
|
||||||
|
FROM (VALUES ('LOCO-LED-1'), ('LOCO-LED-2')) AS v(code)
|
||||||
|
JOIN freight.yards y ON y.code = 'KALITY'
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM freight.locomotives l WHERE l.code = v.code);
|
||||||
|
|
||||||
|
INSERT INTO freight.trains
|
||||||
|
(id, code, train_name, capacity_tons, current_yard_id,
|
||||||
|
import_train_number, export_train_number)
|
||||||
|
SELECT gen_random_uuid(), 'TRN-LEDGER-PW2', 'Ledger Grain Carrier', 2600, y.id,
|
||||||
|
'9102', '9101'
|
||||||
|
FROM freight.yards y
|
||||||
|
WHERE y.code = 'KALITY'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM freight.trains t WHERE t.code = 'TRN-LEDGER-PW2');
|
||||||
|
|
||||||
|
INSERT INTO freight.train_locomotives (id, train_id, locomotive_id, sequence_no)
|
||||||
|
SELECT gen_random_uuid(), t.id, l.id,
|
||||||
|
row_number() OVER (ORDER BY l.code) - 1
|
||||||
|
FROM freight.trains t
|
||||||
|
JOIN freight.locomotives l ON l.code IN ('LOCO-LED-1', 'LOCO-LED-2')
|
||||||
|
WHERE t.code = 'TRN-LEDGER-PW2'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM freight.train_locomotives tl
|
||||||
|
WHERE tl.train_id = t.id AND tl.locomotive_id = l.id
|
||||||
|
);
|
||||||
|
|
||||||
|
UPDATE freight.wagons w
|
||||||
|
SET train_id = t.id,
|
||||||
|
sequence_number = sub.rn,
|
||||||
|
current_yard_id = (SELECT id FROM freight.yards WHERE code = 'KALITY')
|
||||||
|
FROM freight.trains t,
|
||||||
|
LATERAL (
|
||||||
|
SELECT w2.id, row_number() OVER (ORDER BY w2.wagon_number) AS rn
|
||||||
|
FROM freight.wagons w2
|
||||||
|
JOIN freight.wagon_types wt ON wt.id = w2.wagon_type_id AND wt.code = 'PW2'
|
||||||
|
WHERE w2.train_id IS NULL AND w2.deleted_at IS NULL
|
||||||
|
ORDER BY w2.wagon_number
|
||||||
|
LIMIT 37
|
||||||
|
) sub
|
||||||
|
WHERE t.code = 'TRN-LEDGER-PW2'
|
||||||
|
AND w.id = sub.id
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM freight.wagons wx WHERE wx.train_id = t.id);
|
||||||
|
|
||||||
-- 5c. Approved exporter profile — export contracts bill against it
|
-- 5c. Approved exporter profile — export contracts bill against it
|
||||||
-- (seed-company.sql only creates the importer).
|
-- (seed-company.sql only creates the importer).
|
||||||
INSERT INTO freight.company_profiles (id, company_id, type, status, reference)
|
INSERT INTO freight.company_profiles (id, company_id, type, status, reference)
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ export const CONTRACT_STATUSES = [
|
|||||||
"FULLY_EXECUTED", // ONE_TIME
|
"FULLY_EXECUTED", // ONE_TIME
|
||||||
"CONTRACT_ACTIVE", // GENERAL
|
"CONTRACT_ACTIVE", // GENERAL
|
||||||
// customs clearance execution (Path B, pre-booking)
|
// customs clearance execution (Path B, pre-booking)
|
||||||
"AWAITING_CLEARANCE_PAYMENT", // clearance service fee invoiced, unpaid
|
|
||||||
"AWAITING_CLEARANCE_DOCUMENTS",
|
"AWAITING_CLEARANCE_DOCUMENTS",
|
||||||
"CLEARANCE_UNDER_REVIEW",
|
"CLEARANCE_UNDER_REVIEW",
|
||||||
"CLEARANCE_READY_FOR_BOOKING",
|
"CLEARANCE_READY_FOR_BOOKING",
|
||||||
@@ -68,7 +67,6 @@ export type ContractStatus = (typeof CONTRACT_STATUSES)[number];
|
|||||||
*/
|
*/
|
||||||
export const CONTRACT_CLEARANCE_STATUSES = [
|
export const CONTRACT_CLEARANCE_STATUSES = [
|
||||||
"NOT_APPLICABLE",
|
"NOT_APPLICABLE",
|
||||||
"AWAITING_PAYMENT", // Path B — clearance service fee must be paid first
|
|
||||||
"AWAITING_DOCUMENTS",
|
"AWAITING_DOCUMENTS",
|
||||||
"DOCUMENTS_UNDER_REVIEW",
|
"DOCUMENTS_UNDER_REVIEW",
|
||||||
"CLEARANCE_READY_FOR_BOOKING", // Path B — GL may create the booking
|
"CLEARANCE_READY_FOR_BOOKING", // Path B — GL may create the booking
|
||||||
@@ -89,6 +87,7 @@ export const CONTRACT_CUSTOMER_EDITABLE_STATUSES: ContractStatus[] = [
|
|||||||
|
|
||||||
export type ContractRateUnit =
|
export type ContractRateUnit =
|
||||||
| "per_container"
|
| "per_container"
|
||||||
|
| "per_wagon"
|
||||||
| "per_ton"
|
| "per_ton"
|
||||||
| "per_item"
|
| "per_item"
|
||||||
| "per_km"
|
| "per_km"
|
||||||
|
|||||||
@@ -103,8 +103,6 @@ export enum BookingStatus {
|
|||||||
PendingConsolidation = "PENDING_CONSOLIDATION",
|
PendingConsolidation = "PENDING_CONSOLIDATION",
|
||||||
Consolidated = "CONSOLIDATED",
|
Consolidated = "CONSOLIDATED",
|
||||||
// Post counter-sign document-clearance gate (GL workflow).
|
// Post counter-sign document-clearance gate (GL workflow).
|
||||||
/** Clearance service fee invoiced; docs + GL work locked until paid. */
|
|
||||||
AwaitingClearancePayment = "AWAITING_CLEARANCE_PAYMENT",
|
|
||||||
AwaitingDocuments = "AWAITING_DOCUMENTS",
|
AwaitingDocuments = "AWAITING_DOCUMENTS",
|
||||||
DocumentsUnderReview = "DOCUMENTS_UNDER_REVIEW",
|
DocumentsUnderReview = "DOCUMENTS_UNDER_REVIEW",
|
||||||
ClearanceReady = "CLEARANCE_READY",
|
ClearanceReady = "CLEARANCE_READY",
|
||||||
|
|||||||
Reference in New Issue
Block a user