feat: implement shipping line bookings management

- Add ShippingLineBookingsPage for listing and managing shipping line bookings.
- Create ShippingLineDocumentsModal for document uploads related to bookings.
- Introduce ShippingLineInitiateModal for initiating new shipping line bookings.
- Implement booking document state management with booking-doc-state utility.
- Add shipping line bookings service for API interactions.
- Update index to export new components and services.
- Enhance types for freight to include shipping line credits.
This commit is contained in:
marshalyordanos
2026-08-13 15:54:40 +03:00
parent 9aae132dd4
commit 9fff469ffa
50 changed files with 4485 additions and 77 deletions

View File

@@ -0,0 +1,117 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Shipping lines book rail capacity directly, without a contract.
*
* A booking has always been owned by `company_id` (a customer `companies` row),
* but a shipping line is a `shipping_line_companies` row and deliberately NOT a
* company — it carries no TIN, licence or operational profiles. So it gets its
* own nullable owner column rather than a synthetic company row.
*
* Exactly one of the two is set: `company_id` for a customer booking,
* `shipping_line_company_id` for a shipping-line one. Existing rows keep
* `company_id` and a NULL `shipping_line_company_id`, so nothing needs
* backfilling and every customer query filtering on `company_id` behaves
* exactly as before. Government bookings already bill to a seeded government
* company, so they satisfy the CHECK unchanged.
*
* NOTE: not to be confused with the existing `bookings.shipping_line_id`, which
* is cargo metadata naming the carrier line that moves the goods
* (`freight.shipping_lines`, reference data). This column points at
* `freight.shipping_line_companies` — the portal account — and is unrelated.
*/
export class BookingShippingLine3450000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS shipping_line_company_id uuid
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_bookings_shipping_line_company_id
ON freight.bookings (shipping_line_company_id)
`);
// `company_id` / `company_profile_id` are NOT NULL and point at the customer
// tables, so a shipping-line booking could not be inserted at all. Relax
// them to nullable; their foreign keys are left in place and keep validating
// every non-NULL value, so a customer booking is constrained exactly as
// before. The CHECK below is what now guarantees an owner is present.
await queryRunner.query(`
ALTER TABLE freight.bookings ALTER COLUMN company_id DROP NOT NULL
`);
await queryRunner.query(`
ALTER TABLE freight.bookings ALTER COLUMN company_profile_id DROP NOT NULL
`);
// Route and service are inherited from the contract on a customer booking.
// A shipping line initiates before any of that is known — the bare booking
// exists only to hang documents off — so these are relaxed too and filled
// in when the booking is completed. Existing rows all have values, and the
// customer paths still always set them.
await queryRunner.query(`
ALTER TABLE freight.bookings ALTER COLUMN origin_yard_id DROP NOT NULL
`);
await queryRunner.query(`
ALTER TABLE freight.bookings ALTER COLUMN destination_yard_id DROP NOT NULL
`);
await queryRunner.query(`
ALTER TABLE freight.bookings ALTER COLUMN service_type_id DROP NOT NULL
`);
await queryRunner.query(`
ALTER TABLE freight.bookings ALTER COLUMN freight_type DROP NOT NULL
`);
// No FK: kept consistent with how the column is populated at the service
// layer, and avoids a lock on shipping_line_companies during deploy.
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP CONSTRAINT IF EXISTS chk_bookings_single_owner
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD CONSTRAINT chk_bookings_single_owner
CHECK (
(company_id IS NOT NULL AND shipping_line_company_id IS NULL)
OR (company_id IS NULL AND shipping_line_company_id IS NOT NULL)
)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP CONSTRAINT IF EXISTS chk_bookings_single_owner
`);
// Only reinstate NOT NULL if no shipping-line booking exists; those rows
// have a NULL company_id by design and would make the ALTER fail. Leaving
// the columns nullable is the safe outcome — the constraint is additive.
const [{ count }] = (await queryRunner.query(`
SELECT COUNT(*)::int AS count FROM freight.bookings
WHERE shipping_line_company_id IS NOT NULL
`)) as Array<{ count: number }>;
if (count === 0) {
for (const column of [
"company_id",
"company_profile_id",
"origin_yard_id",
"destination_yard_id",
"service_type_id",
"freight_type",
]) {
await queryRunner.query(`
ALTER TABLE freight.bookings ALTER COLUMN ${column} SET NOT NULL
`);
}
}
await queryRunner.query(`
DROP INDEX IF EXISTS freight.idx_bookings_shipping_line_company_id
`);
await queryRunner.query(`
ALTER TABLE freight.bookings DROP COLUMN IF EXISTS shipping_line_company_id
`);
}
}

View File

@@ -0,0 +1,201 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Shipping lines consume services before paying for them.
*
* A shipping line books rail capacity and the booking proceeds with no payment
* gate at all — unlike a customer booking, which cannot advance until its
* PREPAID invoice settles. What the line owes is instead recorded here as a
* credit: one row per booking, priced once and never recalculated. Finance
* later selects a batch of unbilled credits, generates a single invoice for
* them, and the line pays that invoice through the normal CBE flow. When the
* invoice settles, its credits are marked paid and stop counting as debt.
*
* This is deliberately NOT a wallet or a stored balance. There is no money in
* the system to draw down: a credit is a debt the line already incurred, so
* the outstanding figure is always derived (`SUM(amount) WHERE status <>
* 'PAID'`) rather than kept in a column that UPDATEs can drift out of sync.
*
* `invoices.company_id` / `company_profile_id` are relaxed to nullable for the
* same reason `bookings` was in {@link BookingShippingLine3450000000000}: a
* shipping line is not a `companies` row and never will be, so an invoice
* billed to one has no customer to point at. Both FKs stay in place and keep
* validating every non-NULL value, so a customer invoice is constrained
* exactly as before; the CHECK below is what now guarantees a payer exists.
*/
export class ShippingLineCredits3460000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// ── Invoices: allow a shipping-line payer ────────────────────────────────
await queryRunner.query(`
ALTER TABLE freight.invoices
ADD COLUMN IF NOT EXISTS shipping_line_company_id uuid
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_invoices_shipping_line_company_id
ON freight.invoices (shipping_line_company_id)
`);
await queryRunner.query(`
ALTER TABLE freight.invoices ALTER COLUMN company_id DROP NOT NULL
`);
await queryRunner.query(`
ALTER TABLE freight.invoices ALTER COLUMN company_profile_id DROP NOT NULL
`);
// Exactly one payer. Mirrors chk_bookings_single_owner so the two tables
// answer "who owes this?" the same way. Existing rows all have company_id
// and a NULL shipping_line_company_id, so nothing needs backfilling.
await queryRunner.query(`
ALTER TABLE freight.invoices
DROP CONSTRAINT IF EXISTS chk_invoices_single_payer
`);
await queryRunner.query(`
ALTER TABLE freight.invoices
ADD CONSTRAINT chk_invoices_single_payer
CHECK (
(company_id IS NOT NULL AND shipping_line_company_id IS NULL)
OR (company_id IS NULL AND shipping_line_company_id IS NOT NULL)
)
`);
// ── The credit ledger ────────────────────────────────────────────────────
await queryRunner.query(`
DO $$ BEGIN
CREATE TYPE freight.shipping_line_credits_status_enum AS ENUM (
'UNBILLED', 'BILLED', 'PAID', 'CANCELLED'
);
EXCEPTION WHEN duplicate_object THEN NULL; END $$
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.shipping_line_credits (
id uuid DEFAULT gen_random_uuid() NOT NULL,
shipping_line_company_id uuid NOT NULL,
booking_id uuid NOT NULL,
amount numeric(14,2) NOT NULL,
currency character varying(8) DEFAULT 'ETB'::character varying NOT NULL,
status freight.shipping_line_credits_status_enum
DEFAULT 'UNBILLED'::freight.shipping_line_credits_status_enum NOT NULL,
description character varying(255),
invoice_id uuid,
billed_at timestamp with time zone,
paid_at timestamp with time zone,
cancelled_at timestamp with time zone,
cancellation_reason character varying(255),
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
deleted_at timestamp with time zone,
CONSTRAINT pk_shipping_line_credits PRIMARY KEY (id),
CONSTRAINT chk_shipping_line_credits_amount CHECK (amount >= 0),
-- The state machine, enforced in the DB rather than trusted to the
-- service: an UNBILLED credit has no invoice, and anything past
-- UNBILLED must name the invoice it was billed on. Without this a
-- half-applied batch could leave BILLED rows with a NULL invoice_id
-- and silently vanish from both the unbilled list and the invoice.
CONSTRAINT chk_shipping_line_credits_invoice_link CHECK (
(status = 'UNBILLED' AND invoice_id IS NULL)
OR (status IN ('BILLED', 'PAID') AND invoice_id IS NOT NULL)
OR status = 'CANCELLED'
)
)
`);
await queryRunner.query(`
ALTER TABLE freight.shipping_line_credits
DROP CONSTRAINT IF EXISTS fk_shipping_line_credits_shipping_line
`);
await queryRunner.query(`
ALTER TABLE freight.shipping_line_credits
ADD CONSTRAINT fk_shipping_line_credits_shipping_line
FOREIGN KEY (shipping_line_company_id)
REFERENCES freight.shipping_line_companies(id) ON DELETE RESTRICT
`);
await queryRunner.query(`
ALTER TABLE freight.shipping_line_credits
DROP CONSTRAINT IF EXISTS fk_shipping_line_credits_booking
`);
await queryRunner.query(`
ALTER TABLE freight.shipping_line_credits
ADD CONSTRAINT fk_shipping_line_credits_booking
FOREIGN KEY (booking_id)
REFERENCES freight.bookings(id) ON DELETE RESTRICT
`);
// SET NULL rather than CASCADE: deleting an invoice must never delete the
// record of what was owed. The row would then violate the link CHECK, so a
// credit whose invoice is removed has to be walked back to UNBILLED
// explicitly — which is the correct, visible outcome.
await queryRunner.query(`
ALTER TABLE freight.shipping_line_credits
DROP CONSTRAINT IF EXISTS fk_shipping_line_credits_invoice
`);
await queryRunner.query(`
ALTER TABLE freight.shipping_line_credits
ADD CONSTRAINT fk_shipping_line_credits_invoice
FOREIGN KEY (invoice_id)
REFERENCES freight.invoices(id) ON DELETE SET NULL
`);
// One live credit per booking. Partial so a soft-deleted or cancelled row
// does not block re-pricing a booking that was voided and rebooked.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_shipping_line_credits_booking
ON freight.shipping_line_credits (booking_id)
WHERE deleted_at IS NULL AND status <> 'CANCELLED'
`);
// Drives the two hot reads: finance's unbilled worklist per line, and the
// outstanding total on the shipping-line detail page.
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_shipping_line_credits_line_status
ON freight.shipping_line_credits (shipping_line_company_id, status)
WHERE deleted_at IS NULL
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_shipping_line_credits_invoice_id
ON freight.shipping_line_credits (invoice_id)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DROP TABLE IF EXISTS freight.shipping_line_credits
`);
await queryRunner.query(`
DROP TYPE IF EXISTS freight.shipping_line_credits_status_enum
`);
await queryRunner.query(`
ALTER TABLE freight.invoices
DROP CONSTRAINT IF EXISTS chk_invoices_single_payer
`);
// Only reinstate NOT NULL if no shipping-line invoice exists; those rows
// have a NULL company_id by design and would make the ALTER fail. Leaving
// the columns nullable is the safe outcome — the constraint is additive.
const [{ count }] = (await queryRunner.query(`
SELECT COUNT(*)::int AS count FROM freight.invoices
WHERE shipping_line_company_id IS NOT NULL
`)) as Array<{ count: number }>;
if (count === 0) {
await queryRunner.query(`
ALTER TABLE freight.invoices ALTER COLUMN company_id SET NOT NULL
`);
await queryRunner.query(`
ALTER TABLE freight.invoices ALTER COLUMN company_profile_id SET NOT NULL
`);
}
await queryRunner.query(`
DROP INDEX IF EXISTS freight.idx_invoices_shipping_line_company_id
`);
await queryRunner.query(`
ALTER TABLE freight.invoices
DROP COLUMN IF EXISTS shipping_line_company_id
`);
}
}

View File

@@ -0,0 +1,127 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Per-shipping-line rates.
*
* A shipping line books rail capacity directly (see BookingShippingLine3450000000000)
* and negotiates its own prices, so the rate table gains an owner column:
* `shipping_line_company_id` NULL = the standard rate every customer pays,
* NOT NULL = a rate that only that line's bookings resolve.
*
* Points at `freight.shipping_line_companies` (the portal account that owns the
* booking), NOT `freight.shipping_lines` — the latter is carrier reference data
* naming who physically moves the goods, and the existing SHIPPING_LINE trigger
* already keys off it. Both stay independent.
*
* Line rates OVERRIDE rather than stack: a booking owned by a line prices off
* that line's rate for the lane, and is hard-blocked when none exists (the
* standard rate is deliberately not a fallback — see RuleEngineService).
*
* Every existing row keeps a NULL owner, so nothing needs backfilling and the
* standard-rate lookups behave exactly as before.
*/
export class ShippingLineRates3470000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.rates
ADD COLUMN IF NOT EXISTS shipping_line_company_id uuid
`);
await queryRunner.query(`
ALTER TABLE freight.rates
DROP CONSTRAINT IF EXISTS "FK_rates_shipping_line_company"
`);
await queryRunner.query(`
ALTER TABLE freight.rates
ADD CONSTRAINT "FK_rates_shipping_line_company"
FOREIGN KEY (shipping_line_company_id)
REFERENCES freight.shipping_line_companies (id)
ON DELETE RESTRICT
`);
// Rate resolution always filters by owner, so the lookups this column
// participates in are (owner, lane) — indexed together with rate_type,
// which every lookup also pins.
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_rates_shipping_line_company_id
ON freight.rates (shipping_line_company_id)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_rates_shipping_line_lane
ON freight.rates (shipping_line_company_id, rate_type, origin_yard_id, destination_yard_id)
WHERE shipping_line_company_id IS NOT NULL
`);
// A shipping line sells import freight only — the export leg is contracted
// through the customer, not the carrier. Enforced here so a line rate can
// never be filed against an export lane regardless of which API path wrote
// it. Surcharges carry no direction and are unaffected.
await queryRunner.query(`
ALTER TABLE freight.rates
DROP CONSTRAINT IF EXISTS "CK_rates_shipping_line_import_only"
`);
await queryRunner.query(`
ALTER TABLE freight.rates
ADD CONSTRAINT "CK_rates_shipping_line_import_only" CHECK (
deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR
shipping_line_company_id IS NULL OR
trade_direction IS NULL OR trade_direction = 'IMPORT'
)
`);
// The owner joins the rate's identity. Without it MSC's 20ft Djibouti→Modjo
// rate collides with the standard rate for the same lane — same rate_type,
// same scope, same unit — and the insert fails on UQ_rates_pattern. NULL
// (the standard rate) collapses to the zero uuid like every other nullable
// scope column, so existing rows keep their current uniqueness exactly.
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern"`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern" ON freight.rates USING btree (
rate_type,
COALESCE(shipping_line_company_id, '00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(trade_direction, ''::character varying),
COALESCE(origin_yard_id, '00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(destination_yard_id, '00000000-0000-0000-0000-000000000000'::uuid),
rate_unit,
COALESCE(min_km, '-1'::numeric)
) WHERE ((deleted_at IS NULL) AND ((status)::text <> 'SUPERSEDED'::text))
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Restore the pre-owner pattern index (as left by LastMileRateBands).
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern"`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern" ON freight.rates USING btree (
rate_type,
COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(trade_direction, ''::character varying),
COALESCE(origin_yard_id, '00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(destination_yard_id, '00000000-0000-0000-0000-000000000000'::uuid),
rate_unit,
COALESCE(min_km, '-1'::numeric)
) WHERE ((deleted_at IS NULL) AND ((status)::text <> 'SUPERSEDED'::text))
`);
await queryRunner.query(`
ALTER TABLE freight.rates
DROP CONSTRAINT IF EXISTS "CK_rates_shipping_line_import_only"
`);
await queryRunner.query(
`DROP INDEX IF EXISTS freight.idx_rates_shipping_line_lane`,
);
await queryRunner.query(
`DROP INDEX IF EXISTS freight.idx_rates_shipping_line_company_id`,
);
await queryRunner.query(`
ALTER TABLE freight.rates
DROP CONSTRAINT IF EXISTS "FK_rates_shipping_line_company"
`);
await queryRunner.query(`
ALTER TABLE freight.rates DROP COLUMN IF EXISTS shipping_line_company_id
`);
}
}

View File

@@ -19,7 +19,8 @@ import { FilesModule } from "../files/files.module";
imports: [
TypeOrmModule.forFeature([Invoice, InvoiceLine]),
forwardRef(() => PaymentModule),
CompaniesModule,
// Cycles back via ShippingLineCompaniesModule, which imports this module.
forwardRef(() => CompaniesModule),
DocumentsModule,
UserTradeAccessModule,
FilesModule,
@@ -29,3 +30,4 @@ import { FilesModule } from "../files/files.module";
exports: [BillingService],
})
export class BillingModule {}

View File

@@ -112,8 +112,16 @@ export interface GenerateInvoiceInput {
sourceId: string;
/** What the invoice is for (e.g. "prepaid", "credit"). */
type: string;
companyId: string;
companyProfileId: string;
/** The customer billed. Omit only when billing a shipping line instead. */
companyId?: string | null;
companyProfileId?: string | null;
/**
* The shipping line billed, for an invoice covering batched shipping-line
* credits. Mutually exclusive with `companyId` — the DB enforces this via
* `chk_invoices_single_payer`, and {@link createInvoice} rejects a payload
* setting both or neither before it ever reaches the constraint.
*/
shippingLineCompanyId?: string | null;
lines: InvoiceLineInput[];
currency?: string;
/** Explicit pre-tax subtotal; defaults to the sum of line amounts. */
@@ -139,8 +147,11 @@ export interface InvoiceEventPayload {
source: Freight.InvoiceSource;
sourceId: string;
type: string;
companyId: string;
companyProfileId: string;
/** Null when the payer is a shipping line rather than a customer company. */
companyId: string | null;
companyProfileId: string | null;
/** Set only on shipping-line invoices; mutually exclusive with `companyId`. */
shippingLineCompanyId?: string | null;
totalAmount: number;
currency: string;
status: Freight.InvoiceStatus;
@@ -609,7 +620,6 @@ export class BillingService {
input: GenerateInvoiceInput,
manager?: EntityManager,
): Promise<Invoice & { lines: InvoiceLine[] }> {
console.log("oooooooooo", input);
const run = (mg: EntityManager) => this.createInvoice(input, mg);
return manager ? run(manager) : this.dataSource.transaction(run);
}
@@ -622,6 +632,21 @@ export class BillingService {
const status = input.status ?? Freight.InvoiceStatus.Pending;
const issued = status !== Freight.InvoiceStatus.Draft;
// Exactly one payer, checked here so a bad payload fails with a clear
// message instead of a raw `chk_invoices_single_payer` violation.
const billsCompany = Boolean(input.companyId);
const billsShippingLine = Boolean(input.shippingLineCompanyId);
if (billsCompany === billsShippingLine) {
throw new BadRequestException(
"An invoice must be billed to exactly one payer: either companyId or shippingLineCompanyId.",
);
}
if (billsCompany && !input.companyProfileId) {
throw new BadRequestException(
"companyProfileId is required when billing a company.",
);
}
const lines = input.lines.map((l) => {
const quantity = l.quantity ?? 1;
const unitRate = l.unitRate ?? 0;
@@ -657,8 +682,9 @@ export class BillingService {
source: input.source,
sourceId: input.sourceId,
type: input.type,
companyId: input.companyId,
companyProfileId: input.companyProfileId,
companyId: input.companyId ?? null,
companyProfileId: input.companyProfileId ?? null,
shippingLineCompanyId: input.shippingLineCompanyId ?? null,
subtotalAmount: round2(subtotalAmount),
taxAmount: round2(taxAmount),
totalAmount: round2(totalAmount),
@@ -988,6 +1014,7 @@ export class BillingService {
type: invoice.type,
companyId: invoice.companyId,
companyProfileId: invoice.companyProfileId,
shippingLineCompanyId: invoice.shippingLineCompanyId ?? null,
totalAmount: invoice.totalAmount,
currency: invoice.currency,
status: invoice.status,

View File

@@ -23,22 +23,37 @@ export class Invoice extends BaseEntity {
@Column({ name: "invoice_number", type: "varchar", length: 64, unique: true })
invoiceNumber!: string;
/** The customer (company) this invoice is billed to. */
@Column({ name: "company_id", type: "uuid" })
companyId!: string;
/**
* The customer (company) this invoice is billed to. Null on a shipping-line
* invoice, which is billed to `shippingLineCompanyId` instead — a shipping
* line is deliberately not a `companies` row. A DB CHECK
* (`chk_invoices_single_payer`) guarantees exactly one of the two is set.
*/
@Column({ name: "company_id", type: "uuid", nullable: true })
companyId!: string | null;
@ManyToOne(() => Company)
@JoinColumn({ name: "company_id" })
company?: Company;
/** The specific company profile (importer/exporter/forwarder/...) billed. */
@Column({ name: "company_profile_id", type: "uuid" })
companyProfileId!: string;
@Column({ name: "company_profile_id", type: "uuid", nullable: true })
companyProfileId!: string | null;
@ManyToOne(() => CompanyProfile)
@JoinColumn({ name: "company_profile_id" })
companyProfile?: CompanyProfile;
/**
* The shipping line billed, when this invoice bills batched shipping-line
* credits rather than a customer booking. Mutually exclusive with
* `companyId`. No relation is declared: `ShippingLineCredit` already owns
* that edge, and importing the shipping-lines module here would close an
* import cycle (shipping-lines already depends on billing).
*/
@Column({ name: "shipping_line_company_id", type: "uuid", nullable: true })
shippingLineCompanyId?: string | null;
/** Sum of line amounts before tax; defaults to `totalAmount` for tax-free invoices. */
@Column({ name: "subtotal_amount", type: "numeric", precision: 14, scale: 2, default: 0 })
subtotalAmount!: number;

View File

@@ -165,7 +165,7 @@ export class BookingPricingService {
total += line.amount;
}
const liveRates = await this.ratesService.findLiveRates();
const liveRates = await this.liveRatesForBooking(booking);
const rateById = new Map(liveRates.map((r) => [r.id, r]));
const usedRatesMap = new Map([...baseRates, ...mileRates].map((r) => [r.id, r]));
@@ -414,6 +414,9 @@ export class BookingPricingService {
isGovernment: booking.isGovernment,
allowConsolidation,
shippingLineId: booking.shippingLineId,
// A shipping line's own booking prices off that line's negotiated rates
// instead of the standard customer ones (see RuleEngineService.ratesForOwner).
shippingLineCompanyId: booking.shippingLineCompanyId,
originYardId: booking.originYardId,
destinationYardId: booking.destinationYardId,
totalWagons,
@@ -428,6 +431,22 @@ export class BookingPricingService {
};
}
/**
* LIVE rates this booking may price off.
*
* A shipping-line booking sees only its own line's rates; a customer booking
* only the standard ones. Line rates override rather than stack, and the
* standard rate is not a fallback — a lane the line has no rate for falls
* through to the existing "no rate configured" hard block, which is the
* intended outcome rather than silently billing the customer price.
*/
private async liveRatesForBooking(booking: Booking): Promise<Rate[]> {
const rates = await this.ratesService.findLiveRates();
return booking.shippingLineCompanyId
? rates.filter((r) => r.shippingLineCompanyId === booking.shippingLineCompanyId)
: rates.filter((r) => !r.shippingLineCompanyId);
}
private async requireBooking(id: string): Promise<Booking> {
const booking = await this.bookingsRepository.findByIdWithFiles(id);
if (!booking) throw new NotFoundException(`Booking ${id} not found`);
@@ -512,7 +531,7 @@ export class BookingPricingService {
warnings: string[];
blocked: string[];
}> {
const liveRates = await this.ratesService.findLiveRates();
const liveRates = await this.liveRatesForBooking(booking);
const paymentCurrency = booking.paymentCurrency;
const isEtbBooking = paymentCurrency === 'ETB';
const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1;
@@ -713,7 +732,7 @@ export class BookingPricingService {
return { lineItems: [], usedRates: [] };
}
const liveRates = await this.ratesService.findLiveRates();
const liveRates = await this.liveRatesForBooking(booking);
const paymentCurrency = booking.paymentCurrency;
const isEtbBooking = paymentCurrency === 'ETB';
const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1;

View File

@@ -16,6 +16,16 @@ type Freight = 'container' | 'bulk';
*/
export const INTERCITY_DOCUMENTS_SETTING_CODE = 'intercity_documents';
/**
* The document set a shipping line uploads on a booking it initiated.
*
* Shipping lines book without a contract, so none of the trade-direction /
* freight / customs matrix below applies to them — this one admin-configured
* set is what Operations reviews before the booking may be completed.
*/
export const SHIPPING_LINE_DOCUMENTS_SETTING_CODE =
'shipping_line_booking_documents';
/** Trade direction → clearance operation. DOMESTIC has no customs clearance. */
function operationFor(tradeDirection: string): Op | null {
if (tradeDirection === 'IMPORT') return 'import';
@@ -67,6 +77,20 @@ export function clearanceCodesForBooking(booking: Booking): {
outputCode: string | null;
includesCustoms: boolean;
} {
// Shipping-line bookings resolve to their own single set and never reach the
// matrix below: they have no contract, and their trade direction / freight
// type are placeholders until the booking is completed, so the customer codes
// would resolve to a set that was never meant for them. Keyed off the owner
// column, which is NULL on every customer booking — so no customer booking
// can take this branch.
if (booking.shippingLineCompanyId) {
return {
inputCode: SHIPPING_LINE_DOCUMENTS_SETTING_CODE,
outputCode: null,
includesCustoms: false,
};
}
// Customs applies when EITHER the service type bundles it OR the booking was
// created with customsClearingEnabled (copied from the contract). Contract
// bookings carry customsClearingEnabled even when the serviceType relation

View File

@@ -108,15 +108,34 @@ export class Booking extends BaseEntity {
// @JoinColumn({ name: 'customer_id' })
// customer?: Customer;
// Every booking is billed to a company — government bookings bill to a seeded
// government company (companies.kind = 'government'). Enforced NOT NULL.
@Column({ name: 'company_id', type: 'uuid' })
// Every CUSTOMER booking is billed to a company — government bookings bill to
// a seeded government company (companies.kind = 'government'). NULL only on a
// shipping-line booking, owned by `shippingLineCompanyId` instead; a DB CHECK
// enforces that exactly one of the two is set.
@Column({ name: 'company_id', type: 'uuid', nullable: true })
companyId!: string;
@ManyToOne(() => Company, { nullable: true })
@JoinColumn({ name: 'company_id' })
company?: Company | null;
/**
* The shipping-line ACCOUNT that owns this booking, when it is not a
* customer's. Shipping lines book without a contract and are not `companies`
* rows (no TIN, licence or operational profiles), so they get their own owner
* column rather than a synthetic company. NULL on every customer booking.
*
* Deliberately NOT `shippingLineId` above: that is cargo metadata naming the
* carrier line that moves the goods (`freight.shipping_lines`, reference data
* set on customer bookings too). This points at `shipping_line_companies` —
* the portal account — and the two are unrelated.
*
* No relation is declared: `ShippingLineCompany` lives in its own module and
* the column is read by id, matching how the migration leaves it FK-free.
*/
@Column({ name: 'shipping_line_company_id', type: 'uuid', nullable: true })
shippingLineCompanyId?: string | null;
/**
* The operational profile (importer/exporter/forwarder) this booking belongs
* to. Stamped at creation from the booking's trade direction (IMPORT→importer,
@@ -125,7 +144,9 @@ export class Booking extends BaseEntity {
* commercial bookings resolve it from trade direction / active mode;
* government bookings carry the explicitly-picked government profile.
*/
@Column({ name: 'company_profile_id', type: 'uuid' })
// NULL only on a shipping-line booking — shipping lines have no operational
// profiles. Always set on a customer booking, as before.
@Column({ name: 'company_profile_id', type: 'uuid', nullable: true })
companyProfileId!: string;
@ManyToOne(() => CompanyProfile, { nullable: true })
@@ -259,7 +280,7 @@ export class Booking extends BaseEntity {
@Column({ name: 'contract_type', type: 'varchar', length: 20 })
contractType!: string;
@Column({ name: 'service_type_id', type: 'uuid' })
@Column({ name: 'service_type_id', type: 'uuid', nullable: true })
serviceTypeId!: string;
@ManyToOne(() => ServiceType)
@@ -337,14 +358,14 @@ export class Booking extends BaseEntity {
@Column({ name: 'equipment_return', type: 'varchar', length: 20 })
equipmentReturn!: string;
@Column({ name: 'origin_yard_id', type: 'uuid' })
@Column({ name: 'origin_yard_id', type: 'uuid', nullable: true })
originYardId!: string;
@ManyToOne(() => Yard)
@JoinColumn({ name: 'origin_yard_id' })
originYard?: Yard;
@Column({ name: 'destination_yard_id', type: 'uuid' })
@Column({ name: 'destination_yard_id', type: 'uuid', nullable: true })
destinationYardId!: string;
@ManyToOne(() => Yard)
@@ -354,7 +375,7 @@ export class Booking extends BaseEntity {
@Column({ name: 'trade_direction', type: 'varchar', length: 10 })
tradeDirection!: string;
@Column({ name: 'freight_type', type: 'varchar', length: 20 })
@Column({ name: 'freight_type', type: 'varchar', length: 20, nullable: true })
freightType!: string;
@Column({ name: 'cargo_type_id', type: 'uuid', nullable: true })

View File

@@ -46,8 +46,9 @@ import { VerifaydaModule } from "../verifayda/verifayda.module";
// Fayda identity verification for the company's owner and PoA.
VerifaydaModule,
// `GET /companies/getInfo` serves both portal audiences: it must recognise a
// shipping-line session, which has no company row to look up.
ShippingLineCompaniesModule,
// shipping-line session, which has no company row to look up. forwardRef
// because that module imports BillingModule, which imports this one.
forwardRef(() => ShippingLineCompaniesModule),
],
controllers: [CompaniesController],
providers: [

View File

@@ -75,6 +75,14 @@ export class CreateRateDto {
@IsUUID()
destinationYardId?: string;
@ApiPropertyOptional({
description:
'FK to shipping_line_companies.id — set to price this rate for one shipping line only. Omitted/null = the standard rate every customer pays. A line rate overrides the standard one for that line\'s bookings.',
})
@IsOptional()
@IsUUID()
shippingLineCompanyId?: string;
@ApiPropertyOptional({ enum: CURRENCIES })
@IsOptional()
@IsIn([...CURRENCIES])

View File

@@ -141,6 +141,22 @@ export class ListRatesQueryDto extends PaginationQueryDto {
@IsString()
@MaxLength(200)
trigger?: string;
@ApiPropertyOptional({
description: 'Filter to one shipping line\'s rates.',
})
@IsOptional()
@IsUUID()
shippingLineCompanyId?: string;
@ApiPropertyOptional({
description:
'true = only shipping-line rates (any line), false = only standard customer rates. Omitted = both. Powers the Shipping line tab.',
})
@IsOptional()
@Transform(toOptionalBoolean)
@IsBoolean()
isShippingLineRate?: boolean;
}
export class ListWeightLimitRulesQueryDto extends PaginationQueryDto {

View File

@@ -1,5 +1,6 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity';
import { CargoType } from './cargo-type.entity';
import { ContainerType } from './container-type.entity';
import { Yard } from './yard.entity';
@@ -108,6 +109,7 @@ export type RateTrigger = typeof RATE_TRIGGERS[number];
@Index(['trigger'])
@Index(['originYardId'])
@Index(['destinationYardId'])
@Index(['shippingLineCompanyId'])
export class Rate extends BaseEntity {
@Column({ name: 'rate_type', type: 'varchar', length: 50 })
rateType!: RateType;
@@ -155,6 +157,23 @@ export class Rate extends BaseEntity {
@JoinColumn({ name: 'destination_yard_id' })
destinationYard?: Yard | null;
/**
* The shipping line this rate belongs to, or NULL for the standard rate every
* customer pays. A booking owned by a shipping line prices exclusively off
* that line's rates — the standard rate is NOT a fallback, so a missing line
* rate hard-blocks the booking rather than quietly billing the customer price.
*
* Points at `shipping_line_companies` (the portal account that books capacity),
* not `shipping_lines` (carrier reference data behind the SHIPPING_LINE
* trigger). The two are unrelated despite the similar names.
*/
@Column({ name: 'shipping_line_company_id', type: 'uuid', nullable: true })
shippingLineCompanyId?: string | null;
@ManyToOne(() => ShippingLineCompany, { nullable: true, eager: false })
@JoinColumn({ name: 'shipping_line_company_id' })
shippingLineCompany?: ShippingLineCompany | null;
@Column({ name: 'currency', type: 'varchar', length: 5 })
currency!: string;

View File

@@ -16,6 +16,8 @@ export interface IRatesRepository {
rateType: string;
/** Omitted for singly-resolved rates — see the repository implementation. */
rateUnit?: string;
/** Owning shipping line; null/omitted = the standard customer rate. */
shippingLineCompanyId?: string | null;
containerTypeId?: string | null;
cargoTypeId?: string | null;
tradeDirection?: string | null;

View File

@@ -69,6 +69,7 @@ export class RatesRepository implements IRatesRepository {
findByPattern(pattern: {
rateType: string;
rateUnit?: string;
shippingLineCompanyId?: string | null;
containerTypeId?: string | null;
cargoTypeId?: string | null;
tradeDirection?: string | null;
@@ -85,6 +86,16 @@ export class RatesRepository implements IRatesRepository {
qb.andWhere('rate.rate_unit = :rateUnit', { rateUnit: pattern.rateUnit });
}
// The owner is part of the identity: a line's rate for a lane is a
// different rate from the standard one, not a duplicate of it.
if (pattern.shippingLineCompanyId) {
qb.andWhere('rate.shipping_line_company_id = :shippingLineCompanyId', {
shippingLineCompanyId: pattern.shippingLineCompanyId,
});
} else {
qb.andWhere('rate.shipping_line_company_id IS NULL');
}
if (pattern.containerTypeId) {
qb.andWhere('rate.container_type_id = :containerTypeId', { containerTypeId: pattern.containerTypeId });
} else {
@@ -139,8 +150,22 @@ export class RatesRepository implements IRatesRepository {
// yards joined the route columns have only ids to render.
.leftJoinAndSelect('rate.originYard', 'originYard')
.leftJoinAndSelect('rate.destinationYard', 'destinationYard')
// The shipping-line tab renders the owning line's name, not its id.
.leftJoinAndSelect('rate.shippingLineCompany', 'shippingLineCompany')
.orderBy('rate.createdAt', query.sortOrder ?? 'DESC');
if (query.shippingLineCompanyId) {
qb.andWhere('rate.shippingLineCompanyId = :shippingLineCompanyId', {
shippingLineCompanyId: query.shippingLineCompanyId,
});
} else if (query.isShippingLineRate !== undefined) {
// Tab filter: shipping-line rates (any line) vs standard customer rates.
qb.andWhere(
query.isShippingLineRate
? 'rate.shippingLineCompanyId IS NOT NULL'
: 'rate.shippingLineCompanyId IS NULL',
);
}
if (query.status) {
qb.andWhere('rate.status = :status', { status: query.status });
}
@@ -164,7 +189,7 @@ export class RatesRepository implements IRatesRepository {
}
if (query.search) {
qb.andWhere(
'(rate.rateType ILIKE :search OR rate.status ILIKE :search OR rate.rateUnit ILIKE :search OR rate.currency ILIKE :search)',
'(rate.rateType ILIKE :search OR rate.status ILIKE :search OR rate.rateUnit ILIKE :search OR rate.currency ILIKE :search OR shippingLineCompany.name ILIKE :search)',
{ search: `%${query.search}%` },
);
}

View File

@@ -69,6 +69,7 @@ import { YardFacilitiesService } from './services/yard-facilities.service';
import { RuleEngineService } from './rule-engine.service';
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
import { ShippingLineCompaniesModule } from '../shipping-lines/shipping-line-companies.module';
import { WagonTypesModule } from '../wagon-types/wagon-types.module';
import { BookingCargoModifier } from '../bookings/entities/booking-cargo-modifier.entity';
@@ -102,6 +103,9 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
// Rated wagon capacities — cargo types validate their per-wagon tonnage cap
// against them (a cap above the rating is a typo, not a policy).
WagonTypesModule,
// Rates may be scoped to one shipping line; creating such a rate validates
// the line exists and is active.
ShippingLineCompaniesModule,
],
controllers: [
CargoTypesController,

View File

@@ -527,3 +527,145 @@ describe('RuleEngineService — fuel surcharge (per lane + cargo type)', () => {
expect(fuelMods(result)).toHaveLength(0);
});
});
describe('RuleEngineService — shipping-line rates override the standard ones', () => {
const LINE = 'slc-msc';
/** Standard customer container-import rate on the lane. */
const standardBase: Rate = {
id: 'rate-standard-20',
rateType: 'CONTAINER_IMPORT',
trigger: 'ALWAYS',
rateValue: 1000,
rateUnit: 'PER_CONTAINER',
currency: 'USD',
status: 'LIVE',
containerTypeId: 'ct-20',
cargoTypeId: null,
shippingLineCompanyId: null,
originYardId: 'yard-dj',
destinationYardId: 'yard-adama',
} as Rate;
/** The same lane, priced for one shipping line. */
const lineBase: Rate = {
...standardBase,
id: 'rate-line-20',
rateValue: 1200,
shippingLineCompanyId: LINE,
} as Rate;
const standardHazard: Rate = {
id: 'rate-hazard-standard',
rateType: 'HAZARD_SURCHARGE',
trigger: 'HAZARDOUS',
rateValue: 50,
rateUnit: 'PER_CONTAINER',
currency: 'USD',
status: 'LIVE',
containerTypeId: null,
cargoTypeId: null,
shippingLineCompanyId: null,
} as Rate;
const lineHazard: Rate = {
...standardHazard,
id: 'rate-hazard-line',
rateValue: 80,
shippingLineCompanyId: LINE,
} as Rate;
const buildService = (rates: Rate[]) =>
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,
);
// One 20ft at 25 t against a 20 t limit → 5 t excess.
const bookingInput = (
overrides: Partial<BookingEvaluationInput> = {},
): BookingEvaluationInput => ({
serviceTypeId: 'svc-1',
paymentCurrency: 'USD',
tradeDirection: 'IMPORT',
isHazardous: false,
totalWagons: 1,
originYardId: 'yard-dj',
destinationYardId: 'yard-adama',
containers: [
{ containerTypeId: 'ct-20', quantity: 1, vgmPerUnitTons: 25, totalVgmTons: 25 },
],
...overrides,
});
const overweightOf = (result: { appliedModifiers: Array<{ surchargeCode: string }> }) =>
result.appliedModifiers.filter((m) => m.surchargeCode === 'OVERWEIGHT_PER_TON');
it('derives a line booking\'s overweight from the LINE\'s base rate, not the standard one', async () => {
const result = await buildService([standardBase, lineBase]).evaluate(
bookingInput({ shippingLineCompanyId: LINE }),
);
const ow = overweightOf(result);
expect(ow).toHaveLength(1);
// The line's 1200 / (2 × 20) = 30 USD/t, not the standard 1000 → 25 USD/t.
expect(ow[0]).toMatchObject({
rateId: lineBase.id,
unitPriceUsd: 30,
calculatedAmount: 150,
});
});
it('keeps a customer booking on the standard rate even when a line rate exists', async () => {
const result = await buildService([standardBase, lineBase]).evaluate(bookingInput());
const ow = overweightOf(result);
expect(ow).toHaveLength(1);
expect(ow[0]).toMatchObject({
rateId: standardBase.id,
unitPriceUsd: 25,
calculatedAmount: 125,
});
});
it('does not fall back to the standard rate when the line has none for the lane', async () => {
const result = await buildService([standardBase]).evaluate(
bookingInput({ shippingLineCompanyId: LINE }),
);
// No line rate on the lane → nothing to derive from. Base freight is what
// hard-blocks the booking; the standard 1000 must never be borrowed here.
expect(overweightOf(result)).toHaveLength(0);
});
it('bills the line\'s own surcharge and never the standard one alongside it', async () => {
const result = await buildService([
standardBase,
lineBase,
standardHazard,
lineHazard,
]).evaluate(bookingInput({ shippingLineCompanyId: LINE, isHazardous: true }));
const hazard = result.appliedModifiers.filter(
(m) => m.surchargeCode === 'HAZARD_SURCHARGE',
);
expect(hazard).toHaveLength(1);
expect(hazard[0]).toMatchObject({ rateId: lineHazard.id, calculatedAmount: 80 });
});
it('hard-blocks a requested service the line has no surcharge rate for', async () => {
const result = await buildService([standardBase, lineBase, standardHazard]).evaluate(
bookingInput({ shippingLineCompanyId: LINE, isHazardous: true }),
);
// The standard hazard rate exists but belongs to customers, so the line's
// hazardous booking must block rather than borrow it.
expect(result.hardBlocked).toHaveLength(1);
expect(result.hardBlocked[0]).toContain('hazardous');
});
});

View File

@@ -74,6 +74,16 @@ export interface BookingEvaluationInput {
isGovernment?: boolean;
allowConsolidation?: boolean;
shippingLineId?: string | null;
/**
* The shipping line that OWNS this booking (`bookings.shipping_line_company_id`),
* when it is a shipping-line booking rather than a customer one. Such a booking
* prices exclusively off that line's own rates — see {@link ratesForOwner}.
*
* Not to be confused with `shippingLineId` above, which is cargo metadata
* naming the carrier that physically moves the goods and only feeds the
* SHIPPING_LINE double-handling trigger.
*/
shippingLineCompanyId?: string | null;
/**
* The booking's rail leg. Import overweight derives its per-ton price from
* this route's own container freight rate, so the engine needs the yards.
@@ -282,7 +292,10 @@ export class RuleEngineService {
// scope) must contribute exactly ONE line. Duplicate LIVE rate rows — e.g.
// from a non-idempotent seeder — would otherwise repeat the same surcharge
// many times and inflate the total, so we collapse them to one row each.
const liveRates = await this.ratesRepo.findLiveRates();
const liveRates = this.ratesForOwner(
await this.ratesRepo.findLiveRates(),
input.shippingLineCompanyId,
);
const surchargeRates = this.dedupeRatesBySignature(
liveRates.filter((r) => r.trigger && r.trigger !== 'ALWAYS'),
);
@@ -812,6 +825,28 @@ export class RuleEngineService {
return rate.rateType ?? rate.trigger;
}
/**
* Narrow the LIVE rate pool to the ones this booking's owner may price off.
*
* A customer booking sees only standard rates (no owner) — a shipping line's
* negotiated price must never leak into a customer quote. A shipping-line
* booking sees only that line's own rates: line rates OVERRIDE the standard
* ones rather than stacking on them, and the standard rate is deliberately
* NOT a fallback, so a lane the line has no rate for hard-blocks downstream
* (base freight already blocks on "no rate for this route") instead of
* quietly billing the line at the customer price.
*
* Filtering once, here, is what makes the override apply uniformly: every
* downstream lookup (base freight, derived overweight, empty return, lashing,
* fuel, and the additive surcharges) reads from this same pool, so none of
* them needs its own owner check.
*/
private ratesForOwner(rates: Rate[], shippingLineCompanyId?: string | null): Rate[] {
return shippingLineCompanyId
? rates.filter((r) => r.shippingLineCompanyId === shippingLineCompanyId)
: rates.filter((r) => !r.shippingLineCompanyId);
}
/**
* Collapse rates that describe the same charge to a single representative.
*

View File

@@ -61,6 +61,8 @@ describe('RatesService — one rate per pattern', () => {
})),
} as never,
{ findById: jest.fn().mockResolvedValue(null) } as never,
// Shipping line companies — these rates carry no owner, so it is never hit.
{ findById: jest.fn() } as never,
);
});

View File

@@ -8,6 +8,8 @@ import {
} from '@nestjs/common';
import { PaginatedResponse, YardCountry } from '@edr/types';
import { IsNull, Not } from 'typeorm';
import { ShippingLineStatus } from '../../shipping-lines/entities/shipping-line-company.entity';
import { ShippingLineCompaniesService } from '../../shipping-lines/shipping-line-companies.service';
import { CreateRateDto } from '../dto/create-rate.dto';
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
import { UpdateRateDto } from '../dto/update-rate.dto';
@@ -39,6 +41,7 @@ export class RatesService {
private readonly yardsRepository: IYardsRepository,
@Inject(CARGO_TYPES_REPOSITORY)
private readonly cargoTypesRepository: ICargoTypesRepository,
private readonly shippingLineCompaniesService: ShippingLineCompaniesService,
) {}
/** List rates — standard paginated envelope with server-side search. */
@@ -491,6 +494,7 @@ export class RatesService {
rateType: string;
/** Passed only for additive surcharges — see {@link resolvesSingleRate}. */
rateUnit?: string;
shippingLineCompanyId: string | null;
containerTypeId: string | null;
cargoTypeId: string | null;
tradeDirection: string | null;
@@ -508,6 +512,35 @@ export class RatesService {
}
}
/**
* Validate the shipping line a rate is scoped to, when any.
*
* A shipping line only ever ships import — the export leg is sold through the
* customer's contract — so a line rate carrying an EXPORT direction is
* rejected here as well as by `CK_rates_shipping_line_import_only`.
* Returns the owner id to store (null = the standard customer rate).
*/
private async resolveShippingLineScope(
shippingLineCompanyId: string | null | undefined,
tradeDirection: string | null,
): Promise<string | null> {
if (!shippingLineCompanyId) return null;
// Throws NotFoundException when the line does not exist.
const line = await this.shippingLineCompaniesService.findById(shippingLineCompanyId);
if (line.status !== ShippingLineStatus.Active) {
throw new BadRequestException(
`${line.name} is ${line.status} — rates can only be configured for an active shipping line.`,
);
}
if (tradeDirection && tradeDirection !== 'IMPORT') {
throw new BadRequestException(
'Shipping line rates are import-only — the export leg is priced through the customer contract.',
);
}
return shippingLineCompanyId;
}
/** Create a rate in DRAFT status. */
async create(dto: CreateRateDto, proposedByStaffId: string): Promise<Rate> {
const appliesTo = dto.appliesTo as Rate['appliesTo'];
@@ -568,6 +601,11 @@ export class RatesService {
destinationYardId: dto.destinationYardId,
});
const shippingLineCompanyId = await this.resolveShippingLineScope(
dto.shippingLineCompanyId,
tradeDirection,
);
const rateType = deriveRateType({
appliesTo,
trigger,
@@ -602,6 +640,7 @@ export class RatesService {
await this.assertNoDuplicatePattern({
rateType,
...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }),
shippingLineCompanyId,
containerTypeId,
cargoTypeId,
tradeDirection,
@@ -614,6 +653,7 @@ export class RatesService {
appliesTo,
trigger,
rateType,
shippingLineCompanyId,
containerTypeId,
cargoTypeId,
tradeDirection,
@@ -791,6 +831,17 @@ export class RatesService {
updates.originYardId = yardScope.originYardId;
updates.destinationYardId = yardScope.destinationYardId;
// The owning line is re-validated on every edit: a patch that flips the
// direction to EXPORT has to be refused for a line rate, and a patch that
// moves the rate to a suspended line too.
const shippingLineCompanyId = await this.resolveShippingLineScope(
dto.shippingLineCompanyId !== undefined
? dto.shippingLineCompanyId
: existing.shippingLineCompanyId,
updates.tradeDirection,
);
updates.shippingLineCompanyId = shippingLineCompanyId;
// Keep the derived rateType in sync with whatever changed.
const rateType = deriveRateType({
appliesTo,
@@ -839,6 +890,7 @@ export class RatesService {
await this.assertNoDuplicatePattern({
rateType,
...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }),
shippingLineCompanyId,
containerTypeId: updates.containerTypeId,
cargoTypeId: updates.cargoTypeId,
tradeDirection: updates.tradeDirection,

View File

@@ -0,0 +1,15 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsOptional, IsString, MaxLength } from "class-validator";
/** Payload for a shipping line cancelling its own booking. */
export class CancelShippingLineBookingDto {
@ApiProperty({
required: false,
description:
"Why the booking is being cancelled. Recorded on the booking's review-note log.",
})
@IsOptional()
@IsString()
@MaxLength(500)
reason?: string;
}

View File

@@ -0,0 +1,38 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsIn, IsOptional, IsUUID } from "class-validator";
import { FREIGHT_TYPES } from "../../bookings/entities/booking.entity";
/**
* Payload for initiating a bare shipping-line booking.
*
* A customer's bare booking inherits its lane from the contract it is initiated
* under. Shipping lines have no contract, so the lane comes from a route the
* caller picks — one choice that yields origin, destination and trade direction
* together, rather than three fields that can contradict each other.
*/
export class InitiateShippingLineBookingDto {
@ApiProperty({
description:
"The lane being booked. Supplies the booking's origin yard, destination yard and trade direction.",
})
@IsUUID()
routeId!: string;
@ApiProperty({
required: false,
description: "Service type being booked.",
})
@IsOptional()
@IsUUID()
serviceTypeId?: string;
@ApiProperty({
required: false,
enum: FREIGHT_TYPES,
description: "Freight type. Defaults to CONTAINER.",
})
@IsOptional()
@IsIn(FREIGHT_TYPES)
freightType?: string;
}

View File

@@ -0,0 +1,51 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
ArrayNotEmpty,
IsArray,
IsInt,
IsOptional,
IsString,
IsUUID,
MaxLength,
Min,
MinLength,
} from "class-validator";
/** Finance's request to bill a batch of unbilled credits as one invoice. */
export class GenerateCreditInvoiceDto {
@ApiProperty({
description:
"The unbilled credits to bill. All must belong to the same shipping line and share one currency.",
type: [String],
format: "uuid",
})
@IsArray()
@ArrayNotEmpty()
@IsUUID("4", { each: true })
creditIds!: string[];
@ApiPropertyOptional({
description:
"Pay window in days from issue. Defaults to the standard invoice term.",
minimum: 1,
example: 14,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
dueInDays?: number;
}
/** Write-off of a single unbilled credit. */
export class CancelCreditDto {
@ApiProperty({
description: "Why the credit is being written off. Recorded on the row.",
example: "Booking voided before departure",
})
@IsString()
@MinLength(3)
@MaxLength(255)
reason!: string;
}

View File

@@ -0,0 +1,110 @@
import { BaseEntity } from "@edr/api-common";
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
import { Booking } from "../../bookings/entities/booking.entity";
import { Invoice } from "../../billing/entities/invoice.entity";
import { ShippingLineCompany } from "./shipping-line-company.entity";
/** Where a credit sits between "service used" and "money received". */
export enum ShippingLineCreditStatus {
/** Service used, priced, not yet on any invoice. Counts as debt. */
Unbilled = "UNBILLED",
/** Finance put it on an invoice; awaiting payment. Still counts as debt. */
Billed = "BILLED",
/** The invoice settled. Terminal — no longer debt, and never re-billed. */
Paid = "PAID",
/** Written off / booking voided. Terminal, excluded from every total. */
Cancelled = "CANCELLED",
}
/** Statuses a shipping line still owes money for. */
export const OUTSTANDING_CREDIT_STATUSES = [
ShippingLineCreditStatus.Unbilled,
ShippingLineCreditStatus.Billed,
] as const;
/**
* What a shipping line owes for one booking.
*
* Shipping lines get the service first and pay later, so a booking of theirs
* raises no invoice and passes no payment gate — it raises one of these. The
* amount is frozen when the booking is priced and is never recalculated, so a
* later rate change cannot silently alter a debt already incurred.
*
* Finance batches unbilled credits into one invoice (see
* `ShippingLineCreditsService.generateInvoice`); the line pays that invoice
* through the ordinary CBE flow; settlement flips the batch to PAID and the
* debt disappears. The outstanding figure is always derived by summing
* {@link OUTSTANDING_CREDIT_STATUSES} rows — there is no balance column,
* because a stored balance is one missed UPDATE away from being a lie.
*/
@Entity({ schema: "freight", name: "shipping_line_credits" })
@Index(["shippingLineCompanyId", "status"])
@Index(["invoiceId"])
export class ShippingLineCredit extends BaseEntity {
/** The line that owes this. */
@Column({ name: "shipping_line_company_id", type: "uuid" })
shippingLineCompanyId!: string;
@ManyToOne(() => ShippingLineCompany)
@JoinColumn({ name: "shipping_line_company_id" })
shippingLineCompany?: ShippingLineCompany;
/**
* The booking that incurred the charge. Unique among live rows (partial
* index excludes soft-deleted and CANCELLED), so one booking can never be
* billed twice.
*/
@Column({ name: "booking_id", type: "uuid" })
bookingId!: string;
@ManyToOne(() => Booking)
@JoinColumn({ name: "booking_id" })
booking?: Booking;
/** Frozen at pricing time. Never recalculated. */
@Column({ name: "amount", type: "numeric", precision: 14, scale: 2 })
amount!: number;
@Column({ name: "currency", type: "varchar", length: 8, default: "ETB" })
currency!: string;
@Column({
name: "status",
type: "enum",
enum: ShippingLineCreditStatus,
default: ShippingLineCreditStatus.Unbilled,
})
status!: ShippingLineCreditStatus;
/** What the charge is for; becomes the invoice line description. */
@Column({ name: "description", type: "varchar", length: 255, nullable: true })
description?: string | null;
/** The invoice this credit was billed on; null while UNBILLED. */
@Column({ name: "invoice_id", type: "uuid", nullable: true })
invoiceId?: string | null;
@ManyToOne(() => Invoice)
@JoinColumn({ name: "invoice_id" })
invoice?: Invoice;
/** When finance put it on an invoice. */
@Column({ name: "billed_at", type: "timestamptz", nullable: true })
billedAt?: Date | null;
/** When that invoice settled. */
@Column({ name: "paid_at", type: "timestamptz", nullable: true })
paidAt?: Date | null;
@Column({ name: "cancelled_at", type: "timestamptz", nullable: true })
cancelledAt?: Date | null;
@Column({
name: "cancellation_reason",
type: "varchar",
length: 255,
nullable: true,
})
cancellationReason?: string | null;
}

View File

@@ -0,0 +1,96 @@
import { CurrentUser } from "@edr/api-common";
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
} from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { PortalCustomer } from "../../common/booking-guards";
import { CancelShippingLineBookingDto } from "./dto/cancel-shipping-line-booking.dto";
import { InitiateShippingLineBookingDto } from "./dto/initiate-shipping-line-booking.dto";
import { ShippingLineBookingsService } from "./shipping-line-bookings.service";
interface CurrentIamUser {
id: string;
}
/**
* Bookings a shipping line makes for itself, from the portal.
*
* Separate from `/bookings` (customers) on purpose — see
* {@link ShippingLineBookingsService} for why the two flows are not merged.
* `PortalCustomer` only proves a valid portal session; the service resolves the
* shipping-line account from that session and rejects anyone else, so the owner
* is never taken from the request body.
*/
@ApiTags("shipping-line-bookings")
@Controller("shipping-line-bookings")
@ApiBearerAuth()
export class ShippingLineBookingsController {
constructor(
private readonly shippingLineBookingsService: ShippingLineBookingsService,
) {}
@Post("initiate")
@PortalCustomer()
@ApiOperation({
summary:
"Initiate a bare booking (no contract). Starts at AWAITING_DOCUMENTS so the shipping line can upload its documents for Operations to approve.",
})
async initiate(
@CurrentUser() user: CurrentIamUser,
@Body() dto: InitiateShippingLineBookingDto,
) {
return this.shippingLineBookingsService.initiate(user.id, dto);
}
// Declared before @Get(":id") so the path isn't captured as a booking id.
@Get("reference-data")
@PortalCustomer()
@ApiOperation({
summary:
"Catalog for the initiate form: bookable routes (each carrying its trade direction) and service types.",
})
async referenceData(@CurrentUser() user: CurrentIamUser) {
return this.shippingLineBookingsService.referenceData(user.id);
}
@Get("my")
@PortalCustomer()
@ApiOperation({ summary: "List the signed-in shipping line's bookings." })
async listMine(@CurrentUser() user: CurrentIamUser) {
return this.shippingLineBookingsService.listMine(user.id);
}
@Get(":id")
@PortalCustomer()
@ApiOperation({ summary: "Get one of the signed-in shipping line's bookings." })
async findMine(
@CurrentUser() user: CurrentIamUser,
@Param("id", ParseUUIDPipe) id: string,
) {
return this.shippingLineBookingsService.findMine(user.id, id);
}
@Post(":id/cancel")
@PortalCustomer()
@ApiOperation({
summary:
"Cancel one of the signed-in shipping line's own bookings. Allowed only before the booking is priced.",
})
async cancelMine(
@CurrentUser() user: CurrentIamUser,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: CancelShippingLineBookingDto,
) {
return this.shippingLineBookingsService.cancelMine(
user.id,
id,
dto.reason,
);
}
}

View File

@@ -0,0 +1,363 @@
import { insertWithGeneratedReference } from "@edr/api-common";
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { In, Repository } from "typeorm";
import { BookingDocumentReview } from "../bookings/entities/booking-document-review.entity";
import { BookingReviewNote } from "../bookings/entities/booking-review-note.entity";
import { Booking } from "../bookings/entities/booking.entity";
import { formatRouteLabel, Route } from "../routes/entities/route.entity";
import { ServiceType } from "../rule-engine/entities/service-type.entity";
import { InitiateShippingLineBookingDto } from "./dto/initiate-shipping-line-booking.dto";
import { ShippingLineCompaniesService } from "./shipping-line-companies.service";
/**
* The only trade direction a shipping line books.
*
* Their cargo arrives by sea at Djibouti and moves inland to Ethiopia, which is
* IMPORT by the rule routes are stamped with (DJ→ET = IMPORT, ET→DJ = EXPORT,
* same country = DOMESTIC). Export and intercity lanes are therefore neither
* offered nor accepted.
*/
const SHIPPING_LINE_DIRECTION = "IMPORT";
/**
* Statuses a shipping line may cancel its own booking from — everything before
* the booking is priced. Past this point cancelling has billing consequences
* (fees, credit notes) and belongs with Operations.
*/
const SHIPPING_LINE_CANCELLABLE_STATUSES: string[] = [
"AWAITING_DOCUMENTS",
"DOCUMENTS_UNDER_REVIEW",
"CLEARANCE_READY",
"CHANGES_REQUESTED",
];
/**
* Booking creation for shipping lines.
*
* Deliberately separate from `BookingsService` / `ContractBookingService`
* rather than a branch inside them. Those are built end to end around a
* customer: a `companies` row, an approved operational `company_profile`, a
* contract supplying route/quantities, and contract-capacity accounting. A
* shipping line has none of that — it books directly, without a contract — so
* branching there would mean threading "no company, no profile, no contract"
* through every method a customer booking passes through. Keeping it here means
* the customer paths are not touched at all.
*
* What IS shared is the table and the downstream lifecycle: the row lands in
* `freight.bookings` at `AWAITING_DOCUMENTS`, the shipping line uploads its
* documents against the `shipping_line_booking_documents` file-upload setting,
* and Operations reviews and finalizes them through the same clearance flow
* customers already use.
*/
@Injectable()
export class ShippingLineBookingsService {
constructor(
@InjectRepository(Booking)
private readonly bookingsRepository: Repository<Booking>,
private readonly shippingLineCompaniesService: ShippingLineCompaniesService,
) {}
/**
* Resolve the shipping-line account for a signed-in user, or reject. Every
* entry point goes through this: the owner is taken from the session, never
* from the request body, so one shipping line cannot book as another.
*/
private async requireShippingLine(userId: string) {
const shippingLine =
await this.shippingLineCompaniesService.findByUserId(userId);
if (!shippingLine) {
throw new ForbiddenException("This account is not a shipping line.");
}
if (shippingLine.status !== "active") {
throw new ForbiddenException(
"This shipping-line account is suspended and cannot create bookings.",
);
}
return shippingLine;
}
/**
* The catalog the initiate form needs: the lanes EDR actually runs, and the
* services that can be booked on their own.
*
* Routes are offered instead of two loose yard pickers so a shipping line
* cannot invent a lane that does not exist — and because the route already
* carries its trade direction, which is otherwise guesswork.
*
* Read-only and scoped to bookable rows, which is why it lives here rather
* than reusing the staff `/routes` controller (gated behind fleet
* permissions a shipping line does not and should not hold).
*/
async referenceData(userId: string) {
await this.requireShippingLine(userId);
const [routes, serviceTypes] = await Promise.all([
this.bookingsRepository.manager.getRepository(Route).find({
// Shipping lines only move inbound cargo: it lands at the Djibouti port
// and runs inland to Ethiopia. Filtering here rather than in the portal
// means an export or intercity lane is never offered AND never
// accepted — `initiate` re-checks the same rule below.
where: { status: "AVAILABLE", direction: SHIPPING_LINE_DIRECTION },
relations: { originYard: true, destinationYard: true },
}),
// Customs-bundled services are excluded: those run the phased ET/DJ
// customs workflow, which is a contract-backed flow a shipping line has
// no part in. Their clearance is the single document set Operations
// reviews on the booking itself.
this.bookingsRepository.manager.getRepository(ServiceType).find({
where: {
canBeBookedAlone: true,
includesCustoms: false,
isActive: true,
},
order: { displayOrder: "ASC" },
}),
]);
return {
routes: routes.map((route) => ({
id: route.id,
label: formatRouteLabel(route),
direction: route.direction,
originYardId: route.originYardId,
// Per-yard labels so the portal can offer origin and destination as two
// separate pickers (the shape the customer form uses) while still
// resolving the pair back to one of these routes.
originLabel:
route.originYard?.label ?? route.originYard?.code ?? "Origin",
destinationYardId: route.destinationYardId,
destinationLabel:
route.destinationYard?.label ??
route.destinationYard?.code ??
"Destination",
})),
serviceTypes: serviceTypes.map((service) => ({
id: service.id,
name: service.serviceName,
})),
};
}
/**
* Create a BARE booking for a shipping line — no contract, no cargo, no date
* and no price. It exists so documents have something to hang off: the
* shipping line uploads them next, Operations approves, and only then is the
* booking completed with its cargo and shipment day.
*/
async initiate(userId: string, dto: InitiateShippingLineBookingDto) {
const shippingLine = await this.requireShippingLine(userId);
// The route is the single source of origin, destination AND direction —
// resolved server-side so the three can never disagree, and so a caller
// cannot post a lane EDR does not run.
const route = await this.bookingsRepository.manager
.getRepository(Route)
.findOne({ where: { id: dto.routeId } });
if (!route) {
throw new NotFoundException(`Route ${dto.routeId} not found`);
}
if (route.status !== "AVAILABLE") {
throw new BadRequestException(
"This route is not currently available for booking.",
);
}
// Enforced here too, not just by filtering the picker: the route id comes
// from the request, so an export or intercity lane could otherwise be
// posted directly.
if (route.direction !== SHIPPING_LINE_DIRECTION) {
throw new BadRequestException(
"Shipping lines can only book inbound (Djibouti to Ethiopia) routes.",
);
}
// Same reasoning as the picker filter: a customs-bundled service would put
// the booking into the phased customs workflow, which has no contract to
// hang off here. Checked server-side because the id comes from the request.
if (dto.serviceTypeId) {
const serviceType = await this.bookingsRepository.manager
.getRepository(ServiceType)
.findOne({ where: { id: dto.serviceTypeId } });
if (!serviceType) {
throw new NotFoundException(
`Service type ${dto.serviceTypeId} not found`,
);
}
if (serviceType.includesCustoms) {
throw new BadRequestException(
"Shipping lines cannot book a service that bundles customs clearance.",
);
}
}
return insertWithGeneratedReference(
() => this.generateReference(),
(reference) =>
this.bookingsRepository.save({
reference,
// The owner columns: a shipping-line booking has no company and no
// operational profile, which is exactly what the `chk_bookings_
// single_owner` CHECK expects alongside a set shippingLineCompanyId.
companyId: null,
companyProfileId: null,
shippingLineCompanyId: shippingLine.id,
status: "AWAITING_DOCUMENTS",
bookingType: "ONE_TIME",
contractId: null,
contractType: "NEW",
createdByRole: "SHIPPING_LINE",
createdByUserId: userId,
// Taken from the chosen route, never from the request body: the
// direction is frozen on the route from its yard countries, so
// deriving it here keeps it consistent with scheduling and booking
// windows, which read the same field.
originYardId: route.originYardId,
destinationYardId: route.destinationYardId,
tradeDirection: route.direction,
serviceTypeId: dto.serviceTypeId ?? null,
freightType: dto.freightType ?? "CONTAINER",
// Bare instance — filled in when the booking is completed.
scheduledDate: null,
cargoTypeId: null,
cargoTotalWeightVgm: 0,
} as never),
);
}
/**
* List the bookings belonging to the signed-in shipping line, newest first.
*
* Each row carries `hasQueriedDocuments`: a reviewer querying a document sets
* that document's review status but leaves the BOOKING on
* DOCUMENTS_UNDER_REVIEW, so status alone cannot tell the list which bookings
* need the shipping line to act. Resolved in one grouped query rather than a
* clearance call per row.
*/
async listMine(userId: string) {
const shippingLine = await this.requireShippingLine(userId);
const bookings = await this.bookingsRepository.find({
where: { shippingLineCompanyId: shippingLine.id },
relations: { originYard: true, destinationYard: true },
order: { createdAt: "DESC" },
});
if (bookings.length === 0) return [];
const queried = await this.bookingsRepository.manager
.getRepository(BookingDocumentReview)
.find({
where: {
bookingId: In(bookings.map((b) => b.id)),
status: "QUERIED",
},
select: { bookingId: true },
});
const queriedIds = new Set(queried.map((row) => row.bookingId));
return bookings.map((booking) => ({
...booking,
hasQueriedDocuments: queriedIds.has(booking.id),
}));
}
/**
* Fetch one of the signed-in shipping line's own bookings. Scoped by owner so
* an id belonging to a customer (or another shipping line) reads as missing.
*/
async findMine(userId: string, bookingId: string) {
const shippingLine = await this.requireShippingLine(userId);
const booking = await this.bookingsRepository.findOne({
where: { id: bookingId, shippingLineCompanyId: shippingLine.id },
// Yards are loaded so the portal can render the lane without a second
// lookup — they are set at initiate time from the chosen route.
relations: { originYard: true, destinationYard: true, serviceType: true },
});
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
// Same flag as the list — see listMine for why booking status alone is not
// enough to tell whether the shipping line has something to fix.
const queriedCount = await this.bookingsRepository.manager
.getRepository(BookingDocumentReview)
.count({ where: { bookingId, status: "QUERIED" } });
return { ...booking, hasQueriedDocuments: queriedCount > 0 };
}
/**
* Cancel one of the signed-in shipping line's own bookings.
*
* Its own method rather than the customer `customerCancel`: that path routes
* into `BookingTransitionService.cancel`, whose status whitelist covers the
* contract-backed lifecycle (DRAFT, SUBMITTED, PENDING_APPROVAL…) and does
* not include the document-clearance statuses a shipping-line booking lives
* in — so it would reject every one of them.
*
* Only allowed before the booking is priced and paid. Once it carries a
* charge, cancelling is a billing decision (fees, credit notes) that belongs
* with Operations, not a self-service button.
*/
async cancelMine(userId: string, bookingId: string, reason?: string) {
const shippingLine = await this.requireShippingLine(userId);
const booking = await this.bookingsRepository.findOne({
where: { id: bookingId, shippingLineCompanyId: shippingLine.id },
});
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
if (booking.status === "CANCELLED") {
throw new BadRequestException("This booking is already cancelled.");
}
if (!SHIPPING_LINE_CANCELLABLE_STATUSES.includes(booking.status)) {
throw new BadRequestException(
"This booking can no longer be cancelled — please contact Operations.",
);
}
// Belt and braces: the statuses above are all pre-pricing, so a charge here
// would mean the booking moved on in a way this guard did not anticipate.
if (Number(booking.totalAmount ?? 0) > 0) {
throw new BadRequestException(
"This booking has already been priced — please contact Operations to cancel it.",
);
}
// The reason lives on the booking's review-note log, the same place the
// customer cancel path records it — there is no column for it.
await this.bookingsRepository.manager
.getRepository(BookingReviewNote)
.save({
bookingId,
note: reason?.trim() || "Cancelled by the shipping line",
type: "REJECTION",
authorId: userId,
} as never);
await this.bookingsRepository.update(bookingId, {
status: "CANCELLED",
} as never);
return this.findMine(userId, bookingId);
}
/** Mirrors the customer reference format — one booking sequence per year. */
private async generateReference(): Promise<string> {
const year = new Date().getFullYear();
const { max } = (await this.bookingsRepository
.createQueryBuilder("b")
.select(
`COALESCE(MAX(NULLIF(regexp_replace(b.reference, '^BK-${year}-', ''), b.reference)::int), 0)`,
"max",
)
.where("b.reference LIKE :prefix", { prefix: `BK-${year}-%` })
.getRawOne<{ max: number }>()) ?? { max: 0 };
return `BK-${year}-${String(Number(max) + 1).padStart(6, "0")}`;
}
}

View File

@@ -1,24 +1,55 @@
import { Module } from "@nestjs/common";
import { Module, forwardRef } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
import { FreightAuthModule } from "../auth/freight-auth.module";
import { BillingModule } from "../billing/billing.module";
import { Booking } from "../bookings/entities/booking.entity";
import { OtpModule } from "../otp/otp.module";
import { ShippingLineCompany } from "./entities/shipping-line-company.entity";
import { ShippingLineCredit } from "./entities/shipping-line-credit.entity";
import { ShippingLineBookingsController } from "./shipping-line-bookings.controller";
import { ShippingLineBookingsService } from "./shipping-line-bookings.service";
import { ShippingLineCompaniesController } from "./shipping-line-companies.controller";
import { ShippingLineCompaniesRepository } from "./shipping-line-companies.repository";
import { ShippingLineCompaniesService } from "./shipping-line-companies.service";
import { ShippingLineCreditsController } from "./shipping-line-credits.controller";
import { ShippingLineCreditsRepository } from "./shipping-line-credits.repository";
import { ShippingLineCreditsService } from "./shipping-line-credits.service";
@Module({
imports: [
TypeOrmModule.forFeature([ShippingLineCompany, User]),
// Booking is registered here only so this module can create shipping-line
// rows in `freight.bookings`; the customer BookingsModule is untouched.
TypeOrmModule.forFeature([
ShippingLineCompany,
ShippingLineCredit,
User,
Booking,
]),
// CustomerResetService — activation links reuse the staff-triggered reset path.
FreightAuthModule,
OtpModule,
// Credits are billed by generating an ordinary invoice. Billing still knows
// nothing about credits and hears about settlement only by emitting its own
// `shipping_line_credit.invoice.paid` event, but the module graph now cycles
// (billing -> companies -> here -> billing), so this edge needs forwardRef.
forwardRef(() => BillingModule),
],
controllers: [ShippingLineCompaniesController],
providers: [ShippingLineCompaniesService, ShippingLineCompaniesRepository],
exports: [ShippingLineCompaniesService],
controllers: [
ShippingLineCompaniesController,
ShippingLineBookingsController,
ShippingLineCreditsController,
],
providers: [
ShippingLineCompaniesService,
ShippingLineCompaniesRepository,
ShippingLineBookingsService,
ShippingLineCreditsService,
ShippingLineCreditsRepository,
],
// Exported so whatever prices a shipping-line booking can record the charge.
exports: [ShippingLineCompaniesService, ShippingLineCreditsService],
})
export class ShippingLineCompaniesModule {}

View File

@@ -0,0 +1,127 @@
import { CurrentUser } from "@edr/api-common";
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
Query,
} from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { BookingStaff, PortalCustomer } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import {
CancelCreditDto,
GenerateCreditInvoiceDto,
} from "./dto/shipping-line-credit.dto";
import { ShippingLineCreditStatus } from "./entities/shipping-line-credit.entity";
import { ShippingLineCreditsService } from "./shipping-line-credits.service";
interface CurrentIamUser {
id: string;
}
/**
* Finance's view of what shipping lines owe.
*
* A shipping line books and ships without paying — the charge is recorded as a
* credit instead. Finance reads the unbilled list here, batches it into an
* invoice, and the line then pays that invoice through the ordinary
* `/billing` + CBE routes; nothing in this controller touches money directly.
*/
@ApiTags("shipping-line-credits")
@Controller("shipping-line-credits")
@ApiBearerAuth()
export class ShippingLineCreditsController {
constructor(private readonly credits: ShippingLineCreditsService) {}
// Declared before the parameterised staff routes so "me" is never captured
// as a shipping-line id.
@Get("me")
@PortalCustomer()
@ApiOperation({
summary:
"The signed-in shipping line's own statement: outstanding balance plus its credit ledger.",
})
async myStatement(
@CurrentUser() user: CurrentIamUser,
@Query("page") page?: string,
@Query("pageSize") pageSize?: string,
) {
return this.credits.myStatement(
user.id,
page ? Number(page) : 1,
pageSize ? Number(pageSize) : 20,
);
}
@Get(":shippingLineId/outstanding")
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.view)
@ApiOperation({
summary:
"What one shipping line owes: unbilled + billed totals, derived from the ledger.",
})
async outstanding(
@Param("shippingLineId", ParseUUIDPipe) shippingLineId: string,
) {
return this.credits.outstanding(shippingLineId);
}
@Get(":shippingLineId/unbilled")
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.view)
@ApiOperation({
summary:
"Credits that can go on an invoice for this line, oldest first. This is the selection list.",
})
async listUnbilled(
@Param("shippingLineId", ParseUUIDPipe) shippingLineId: string,
) {
return this.credits.listUnbilled(shippingLineId);
}
@Get(":shippingLineId")
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.view)
@ApiOperation({
summary: "Full credit ledger for one shipping line (paginated).",
})
async listCredits(
@Param("shippingLineId", ParseUUIDPipe) shippingLineId: string,
@Query("page") page?: string,
@Query("pageSize") pageSize?: string,
@Query("status") status?: ShippingLineCreditStatus,
) {
return this.credits.listCredits(
shippingLineId,
page ? Number(page) : 1,
pageSize ? Number(pageSize) : 20,
status,
);
}
@Post("invoice")
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.invoice)
@ApiOperation({
summary:
"Bill a batch of unbilled credits as one invoice. All credits must belong to the same shipping line.",
})
async generateInvoice(@Body() dto: GenerateCreditInvoiceDto) {
return this.credits.generateInvoice(dto.creditIds, {
dueInDays: dto.dueInDays,
});
}
@Post(":creditId/cancel")
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.cancel)
@ApiOperation({
summary:
"Write off an unbilled credit. Once billed, cancel the invoice instead.",
})
async cancel(
@Param("creditId", ParseUUIDPipe) creditId: string,
@Body() dto: CancelCreditDto,
) {
return this.credits.cancelCredit(creditId, dto.reason);
}
}

View File

@@ -0,0 +1,138 @@
import { BaseRepository } from "@edr/api-common";
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { EntityManager, In, Repository } from "typeorm";
import {
OUTSTANDING_CREDIT_STATUSES,
ShippingLineCredit,
ShippingLineCreditStatus,
} from "./entities/shipping-line-credit.entity";
/** What one shipping line currently owes, split by billing stage. */
export interface OutstandingTotals {
/** Priced but not yet on an invoice. */
unbilledAmount: number;
/** On an issued invoice, awaiting payment. */
billedAmount: number;
/** `unbilledAmount + billedAmount` — the full debt. */
totalOutstanding: number;
unbilledCount: number;
billedCount: number;
currency: string;
}
@Injectable()
export class ShippingLineCreditsRepository extends BaseRepository<ShippingLineCredit> {
constructor(
@InjectRepository(ShippingLineCredit)
private readonly credits: Repository<ShippingLineCredit>,
) {
super(credits);
}
findByBookingId(bookingId: string): Promise<ShippingLineCredit | null> {
return this.credits.findOne({ where: { bookingId } });
}
/**
* Finance's worklist: everything for one line that can go on an invoice,
* oldest first so the longest-standing debt is billed before newer charges.
*/
findUnbilled(shippingLineCompanyId: string): Promise<ShippingLineCredit[]> {
return this.credits.find({
where: {
shippingLineCompanyId,
status: ShippingLineCreditStatus.Unbilled,
},
relations: { booking: true },
order: { createdAt: "ASC" },
});
}
findByInvoiceId(
invoiceId: string,
manager?: EntityManager,
): Promise<ShippingLineCredit[]> {
const repo = manager
? manager.getRepository(ShippingLineCredit)
: this.credits;
return repo.find({ where: { invoiceId } });
}
/**
* Load a specific batch inside the caller's transaction and lock it, so two
* concurrent invoice generations cannot both claim the same credits.
*/
findByIdsForUpdate(
manager: EntityManager,
ids: string[],
): Promise<ShippingLineCredit[]> {
return manager.getRepository(ShippingLineCredit).find({
where: { id: In(ids) },
lock: { mode: "pessimistic_write" },
});
}
/**
* Derived debt — never a stored column. Grouped in one query so the detail
* page does not fan out per status.
*/
async outstandingFor(
shippingLineCompanyId: string,
): Promise<OutstandingTotals> {
const rows = await this.credits
.createQueryBuilder("credit")
.select("credit.status", "status")
.addSelect("COALESCE(SUM(credit.amount), 0)", "amount")
.addSelect("COUNT(*)", "count")
.where("credit.shippingLineCompanyId = :shippingLineCompanyId", {
shippingLineCompanyId,
})
.andWhere("credit.status IN (:...statuses)", {
statuses: [...OUTSTANDING_CREDIT_STATUSES],
})
.andWhere("credit.deletedAt IS NULL")
.groupBy("credit.status")
.getRawMany<{ status: string; amount: string; count: string }>();
const totals = (status: ShippingLineCreditStatus) => {
const row = rows.find((r) => r.status === status);
return {
amount: row ? Number(row.amount) : 0,
count: row ? Number(row.count) : 0,
};
};
const unbilled = totals(ShippingLineCreditStatus.Unbilled);
const billed = totals(ShippingLineCreditStatus.Billed);
return {
unbilledAmount: unbilled.amount,
billedAmount: billed.amount,
totalOutstanding: unbilled.amount + billed.amount,
unbilledCount: unbilled.count,
billedCount: billed.count,
currency: "ETB",
};
}
/** Paginated ledger for one line — every credit, whatever its status. */
findAllPaginated(
shippingLineCompanyId: string,
skip: number,
take: number,
status?: ShippingLineCreditStatus,
): Promise<[ShippingLineCredit[], number]> {
return this.credits.findAndCount({
where: {
shippingLineCompanyId,
...(status ? { status } : {}),
},
relations: { booking: true, invoice: true },
order: { createdAt: "DESC" },
skip,
take,
});
}
}

View File

@@ -0,0 +1,296 @@
import { BadRequestException, NotFoundException } from "@nestjs/common";
import { Freight } from "@edr/types";
import { Booking } from "../bookings/entities/booking.entity";
import {
ShippingLineCredit,
ShippingLineCreditStatus,
} from "./entities/shipping-line-credit.entity";
import { ShippingLineCreditsService } from "./shipping-line-credits.service";
/**
* The money path: a shipping line ships without paying, so the debt lives
* entirely in these three transitions. Each test below locks one way the debt
* could be lost or double-counted.
*/
describe("ShippingLineCreditsService", () => {
let creditsRepo: {
findByIdsForUpdate: jest.Mock;
findUnbilled: jest.Mock;
outstandingFor: jest.Mock;
findAllPaginated: jest.Mock;
};
let billing: { generateInvoice: jest.Mock };
let shippingLines: { findById: jest.Mock; findByUserId: jest.Mock };
let dataSource: { transaction: jest.Mock; getRepository: jest.Mock };
let mg: {
findOne: jest.Mock;
getRepository: jest.Mock;
update: jest.Mock;
};
let txRepo: { findOne: jest.Mock; save: jest.Mock; create: jest.Mock };
let updateResult: { affected: number };
let service: ShippingLineCreditsService;
const booking = {
id: "booking-1",
reference: "BK-2026-000001",
shippingLineCompanyId: "sl-1",
} as Booking;
beforeEach(() => {
txRepo = {
findOne: jest.fn().mockResolvedValue(null),
create: jest.fn((v) => v),
save: jest.fn(async (v) => ({ id: "credit-1", ...v })),
};
mg = {
findOne: jest.fn().mockResolvedValue(booking),
getRepository: jest.fn(() => txRepo),
update: jest.fn().mockResolvedValue({ affected: 1 }),
};
updateResult = { affected: 2 };
dataSource = {
transaction: jest.fn(async (cb) => cb(mg)),
getRepository: jest.fn(() => ({
update: jest.fn().mockResolvedValue(updateResult),
})),
};
creditsRepo = {
findByIdsForUpdate: jest.fn(),
findUnbilled: jest.fn(),
outstandingFor: jest.fn(),
findAllPaginated: jest.fn(),
};
billing = {
generateInvoice: jest.fn().mockResolvedValue({
id: "inv-1",
invoiceNumber: "INV-20260813-00001",
totalAmount: 50000,
}),
};
shippingLines = {
findById: jest.fn().mockResolvedValue({ id: "sl-1", name: "ESL" }),
findByUserId: jest.fn(),
};
service = new ShippingLineCreditsService(
dataSource as never,
creditsRepo as never,
billing as never,
shippingLines as never,
);
});
describe("recordCredit", () => {
it("records the charge against the booking's own shipping line", async () => {
const credit = await service.recordCredit({
bookingId: "booking-1",
amount: 20000,
});
expect(txRepo.save).toHaveBeenCalledWith(
expect.objectContaining({
// Taken from the booking, never from the caller.
shippingLineCompanyId: "sl-1",
bookingId: "booking-1",
amount: 20000,
status: ShippingLineCreditStatus.Unbilled,
}),
);
expect(credit.id).toBe("credit-1");
});
it("is idempotent per booking — a retried pricing step cannot double the debt", async () => {
const existing = {
id: "credit-existing",
status: ShippingLineCreditStatus.Unbilled,
amount: 20000,
currency: "ETB",
};
txRepo.findOne.mockResolvedValue(existing);
const credit = await service.recordCredit({
bookingId: "booking-1",
amount: 20000,
});
expect(credit).toBe(existing);
expect(txRepo.save).not.toHaveBeenCalled();
});
it("refuses a customer booking — those are paid up front, not on credit", async () => {
mg.findOne.mockResolvedValue({
...booking,
shippingLineCompanyId: null,
});
await expect(
service.recordCredit({ bookingId: "booking-1", amount: 100 }),
).rejects.toBeInstanceOf(BadRequestException);
});
it("rejects a negative amount", async () => {
await expect(
service.recordCredit({ bookingId: "booking-1", amount: -1 }),
).rejects.toBeInstanceOf(BadRequestException);
});
});
describe("generateInvoice", () => {
const unbilled = (id: string, amount: number) => ({
id,
shippingLineCompanyId: "sl-1",
bookingId: `booking-${id}`,
amount,
currency: "ETB",
status: ShippingLineCreditStatus.Unbilled,
description: `Freight service — ${id}`,
});
it("bills the batch as one invoice and flips the credits to BILLED", async () => {
creditsRepo.findByIdsForUpdate.mockResolvedValue([
unbilled("c1", 20000),
unbilled("c2", 30000),
]);
const invoice = await service.generateInvoice(["c1", "c2"]);
expect(billing.generateInvoice).toHaveBeenCalledWith(
expect.objectContaining({
source: Freight.InvoiceSource.ShippingLineCredit,
// The payer, not a customer — invoices.company_id stays null.
shippingLineCompanyId: "sl-1",
sourceId: "sl-1",
status: Freight.InvoiceStatus.Issued,
lines: [
expect.objectContaining({ amount: 20000 }),
expect.objectContaining({ amount: 30000 }),
],
}),
mg,
);
expect(mg.update).toHaveBeenCalledWith(
ShippingLineCredit,
expect.anything(),
expect.objectContaining({
status: ShippingLineCreditStatus.Billed,
invoiceId: "inv-1",
}),
);
expect(invoice.id).toBe("inv-1");
});
it("refuses to bill a credit that is already on an invoice", async () => {
creditsRepo.findByIdsForUpdate.mockResolvedValue([
{ ...unbilled("c1", 20000), status: ShippingLineCreditStatus.Billed },
]);
await expect(service.generateInvoice(["c1"])).rejects.toBeInstanceOf(
BadRequestException,
);
expect(billing.generateInvoice).not.toHaveBeenCalled();
});
it("refuses to mix two shipping lines on one invoice", async () => {
creditsRepo.findByIdsForUpdate.mockResolvedValue([
unbilled("c1", 20000),
{ ...unbilled("c2", 30000), shippingLineCompanyId: "sl-2" },
]);
await expect(
service.generateInvoice(["c1", "c2"]),
).rejects.toBeInstanceOf(BadRequestException);
expect(billing.generateInvoice).not.toHaveBeenCalled();
});
it("refuses to mix currencies", async () => {
creditsRepo.findByIdsForUpdate.mockResolvedValue([
unbilled("c1", 20000),
{ ...unbilled("c2", 300), currency: "USD" },
]);
await expect(
service.generateInvoice(["c1", "c2"]),
).rejects.toBeInstanceOf(BadRequestException);
});
it("reports ids that do not exist rather than silently billing the rest", async () => {
creditsRepo.findByIdsForUpdate.mockResolvedValue([unbilled("c1", 20000)]);
await expect(
service.generateInvoice(["c1", "missing"]),
).rejects.toBeInstanceOf(NotFoundException);
});
it("rejects an empty selection", async () => {
await expect(service.generateInvoice([])).rejects.toBeInstanceOf(
BadRequestException,
);
});
});
describe("onInvoicePaid", () => {
it("clears every billed credit on the settled invoice", async () => {
const update = jest.fn().mockResolvedValue({ affected: 2 });
dataSource.getRepository = jest.fn(() => ({ update }));
await service.onInvoicePaid({
invoiceId: "inv-1",
invoiceNumber: "INV-20260813-00001",
} as never);
expect(update).toHaveBeenCalledWith(
// Scoped to BILLED so a redelivered webhook cannot re-stamp paidAt.
{ invoiceId: "inv-1", status: ShippingLineCreditStatus.Billed },
expect.objectContaining({ status: ShippingLineCreditStatus.Paid }),
);
});
it("is a no-op on webhook redelivery", async () => {
const update = jest.fn().mockResolvedValue({ affected: 0 });
dataSource.getRepository = jest.fn(() => ({ update }));
await expect(
service.onInvoicePaid({
invoiceId: "inv-1",
invoiceNumber: "INV-1",
} as never),
).resolves.toBeUndefined();
});
});
describe("cancelCredit", () => {
it("writes off an unbilled credit", async () => {
creditsRepo.findByIdsForUpdate.mockResolvedValue([
{ id: "c1", status: ShippingLineCreditStatus.Unbilled },
]);
const result = await service.cancelCredit("c1", "Booking voided");
expect(result.status).toBe(ShippingLineCreditStatus.Cancelled);
expect(mg.update).toHaveBeenCalledWith(
ShippingLineCredit,
{ id: "c1" },
expect.objectContaining({
status: ShippingLineCreditStatus.Cancelled,
cancellationReason: "Booking voided",
}),
);
});
it("refuses to write off a credit already on an invoice", async () => {
creditsRepo.findByIdsForUpdate.mockResolvedValue([
{
id: "c1",
status: ShippingLineCreditStatus.Billed,
invoiceId: "inv-1",
},
]);
await expect(
service.cancelCredit("c1", "oops"),
).rejects.toBeInstanceOf(BadRequestException);
});
});
});

View File

@@ -0,0 +1,420 @@
import { logCtx } from "@edr/api-common";
import { Freight } from "@edr/types";
import {
BadRequestException,
ForbiddenException,
Injectable,
Logger,
NotFoundException,
} from "@nestjs/common";
import { OnEvent } from "@nestjs/event-emitter";
import { DataSource, EntityManager, In } from "typeorm";
import {
BillingService,
InvoiceEventPayload,
InvoiceLineInput,
} from "../billing/billing.service";
import { Invoice } from "../billing/entities/invoice.entity";
import { Booking } from "../bookings/entities/booking.entity";
import {
ShippingLineCredit,
ShippingLineCreditStatus,
} from "./entities/shipping-line-credit.entity";
import { ShippingLineCreditsRepository } from "./shipping-line-credits.repository";
import { ShippingLineCompaniesService } from "./shipping-line-companies.service";
/** A charge to record against a shipping line's booking. */
export interface RecordCreditInput {
bookingId: string;
/** Frozen at this value; never recalculated afterwards. */
amount: number;
currency?: string;
description?: string;
}
/** Payment terms for a generated shipping-line invoice. */
export interface GenerateCreditInvoiceOptions {
/** Pay window in days; defaults to the billing module's own default. */
dueInDays?: number;
}
/**
* The credit ledger for shipping lines — "use the service now, pay later".
*
* Three moments, in order:
*
* 1. **Charge.** A shipping line's booking is priced, and
* {@link recordCredit} writes an UNBILLED credit. No invoice, no payment
* intent, no gate on the booking — it proceeds regardless.
* 2. **Bill.** Finance picks a batch of unbilled credits for ONE line and
* {@link generateInvoice} turns them into a single invoice, one line per
* credit. The credits become BILLED.
* 3. **Settle.** The line pays that invoice through the ordinary CBE flow.
* Billing emits `shipping_line_credit.invoice.paid`, {@link onInvoicePaid}
* marks the batch PAID, and the debt disappears.
*
* Nothing here decrements a balance: the amount owed is always
* `SUM(amount)` over non-terminal credits. Payment is settled by the gateway
* webhook alone — no manual approval step — so a credit only ever leaves debt
* because real money arrived.
*/
@Injectable()
export class ShippingLineCreditsService {
private readonly logger = new Logger(ShippingLineCreditsService.name);
constructor(
private readonly dataSource: DataSource,
private readonly credits: ShippingLineCreditsRepository,
private readonly billing: BillingService,
private readonly shippingLines: ShippingLineCompaniesService,
) {}
// ── 1. Charge ──────────────────────────────────────────────────────────────
/**
* Record what a shipping line owes for one booking.
*
* Called when the booking is priced. The owner is read off the booking
* itself rather than passed in, so a credit can never be filed against the
* wrong line. Idempotent per booking: a second call returns the existing
* credit untouched rather than doubling the debt — safe against a retried
* pricing step, and the partial unique index backs it at the DB level.
*
* Pass `manager` to enlist in the caller's transaction, so the credit and
* whatever priced the booking commit together.
*/
async recordCredit(
input: RecordCreditInput,
manager?: EntityManager,
): Promise<ShippingLineCredit> {
if (!(input.amount >= 0)) {
throw new BadRequestException("Credit amount cannot be negative.");
}
const run = async (mg: EntityManager): Promise<ShippingLineCredit> => {
const booking = await mg.findOne(Booking, {
where: { id: input.bookingId },
});
if (!booking) {
throw new NotFoundException(`Booking ${input.bookingId} not found`);
}
if (!booking.shippingLineCompanyId) {
throw new BadRequestException(
`Booking ${booking.reference} is not a shipping-line booking — customer bookings are billed up front, not on credit.`,
);
}
const repo = mg.getRepository(ShippingLineCredit);
const existing = await repo.findOne({
where: { bookingId: input.bookingId },
});
if (existing && existing.status !== ShippingLineCreditStatus.Cancelled) {
this.logger.warn(
`Credit already exists for booking ${booking.reference} (${existing.status}, ${existing.amount} ${existing.currency}) — leaving it unchanged.`,
);
return existing;
}
const credit = await repo.save(
repo.create({
shippingLineCompanyId: booking.shippingLineCompanyId,
bookingId: input.bookingId,
amount: input.amount,
currency: input.currency ?? "ETB",
description:
input.description ?? `Freight service — booking ${booking.reference}`,
status: ShippingLineCreditStatus.Unbilled,
}),
);
logCtx(
{
creditId: credit.id,
bookingId: credit.bookingId,
shippingLineCompanyId: credit.shippingLineCompanyId,
amount: credit.amount,
},
{ path: "shippingLineCredit.recorded" },
);
return credit;
};
return manager ? run(manager) : this.dataSource.transaction(run);
}
// ── 2. Bill ────────────────────────────────────────────────────────────────
/**
* Turn a batch of unbilled credits into one invoice.
*
* Every credit must belong to the SAME shipping line — one invoice has one
* payer, so a mixed batch is rejected rather than silently split. The whole
* thing runs in one transaction with the credits locked FOR UPDATE, so two
* finance users clicking at once cannot bill the same credit twice: the
* second transaction blocks, then finds the rows already BILLED and fails.
*/
async generateInvoice(
creditIds: string[],
options: GenerateCreditInvoiceOptions = {},
): Promise<Invoice> {
if (creditIds.length === 0) {
throw new BadRequestException(
"Select at least one credit to invoice.",
);
}
const uniqueIds = [...new Set(creditIds)];
return this.dataSource.transaction(async (mg) => {
const credits = await this.credits.findByIdsForUpdate(mg, uniqueIds);
const missing = uniqueIds.filter(
(id) => !credits.some((c) => c.id === id),
);
if (missing.length > 0) {
throw new NotFoundException(
`Credit(s) not found: ${missing.join(", ")}`,
);
}
const alreadyBilled = credits.filter(
(c) => c.status !== ShippingLineCreditStatus.Unbilled,
);
if (alreadyBilled.length > 0) {
throw new BadRequestException(
`These credits are no longer unbilled and cannot be invoiced: ${alreadyBilled
.map((c) => `${c.id} (${c.status})`)
.join(", ")}`,
);
}
const lineIds = new Set(credits.map((c) => c.shippingLineCompanyId));
if (lineIds.size > 1) {
throw new BadRequestException(
"All selected credits must belong to the same shipping line — one invoice has one payer.",
);
}
const shippingLineCompanyId = credits[0].shippingLineCompanyId;
const currencies = new Set(credits.map((c) => c.currency));
if (currencies.size > 1) {
throw new BadRequestException(
`Cannot mix currencies on one invoice: ${[...currencies].join(", ")}.`,
);
}
const currency = credits[0].currency;
const shippingLine = await this.shippingLines.findById(
shippingLineCompanyId,
);
if (!shippingLine) {
throw new NotFoundException(
`Shipping line ${shippingLineCompanyId} not found`,
);
}
const lines: InvoiceLineInput[] = credits.map((credit) => ({
chargeType: "SHIPPING_LINE_SERVICE",
description: credit.description ?? undefined,
quantity: 1,
unitRate: Number(credit.amount),
amount: Number(credit.amount),
currency: credit.currency,
metadata: { creditId: credit.id, bookingId: credit.bookingId },
}));
const invoice = await this.billing.generateInvoice(
{
source: Freight.InvoiceSource.ShippingLineCredit,
// Unlike other sources this is the payer, not a single billed
// record: the invoice spans many bookings, and each credit keeps its
// own booking link.
sourceId: shippingLineCompanyId,
type: "SHIPPING_LINE_CREDIT",
shippingLineCompanyId,
currency,
lines,
dueInDays: options.dueInDays,
status: Freight.InvoiceStatus.Issued,
},
mg,
);
const billedAt = new Date();
await mg.update(
ShippingLineCredit,
{ id: In(credits.map((c) => c.id)) },
{
status: ShippingLineCreditStatus.Billed,
invoiceId: invoice.id,
billedAt,
},
);
logCtx(
{
invoiceId: invoice.id,
invoiceNumber: invoice.invoiceNumber,
shippingLineCompanyId,
creditCount: credits.length,
totalAmount: invoice.totalAmount,
},
{ path: "shippingLineCredit.invoiced" },
);
return invoice;
});
}
// ── 3. Settle ──────────────────────────────────────────────────────────────
/**
* Clear the batch once its invoice is paid.
*
* Driven by the billing event rather than a call inside the payment path, so
* the CBE webhook flow needs no knowledge of credits: whatever settles the
* invoice — gateway webhook, or a finance-recorded offline payment — this
* fires. Idempotent, because a redelivered webhook re-emits the event.
*/
@OnEvent("shipping_line_credit.invoice.paid")
async onInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
const result = await this.dataSource
.getRepository(ShippingLineCredit)
.update(
{
invoiceId: payload.invoiceId,
status: ShippingLineCreditStatus.Billed,
},
{ status: ShippingLineCreditStatus.Paid, paidAt: new Date() },
);
logCtx(
{
invoiceId: payload.invoiceId,
invoiceNumber: payload.invoiceNumber,
creditsCleared: result.affected ?? 0,
},
{ path: "shippingLineCredit.settled" },
);
// Zero is the ordinary idempotent no-op on webhook redelivery. It is only
// worth a line in the log, not an error: the invoice is paid either way.
if (!result.affected) {
this.logger.log(
`Invoice ${payload.invoiceNumber} paid — no BILLED credits left to clear (already settled).`,
);
}
}
// ── Reads ──────────────────────────────────────────────────────────────────
/** Finance's worklist: what can go on an invoice for this line right now. */
async listUnbilled(shippingLineCompanyId: string) {
await this.requireShippingLine(shippingLineCompanyId);
const credits = await this.credits.findUnbilled(shippingLineCompanyId);
return {
items: credits,
totalAmount: credits.reduce((sum, c) => sum + Number(c.amount), 0),
currency: credits[0]?.currency ?? "ETB",
};
}
/** The debt figure shown on the shipping-line detail page. */
async outstanding(shippingLineCompanyId: string) {
await this.requireShippingLine(shippingLineCompanyId);
return this.credits.outstandingFor(shippingLineCompanyId);
}
/** Full ledger for one line, newest first. */
async listCredits(
shippingLineCompanyId: string,
page = 1,
pageSize = 20,
status?: ShippingLineCreditStatus,
) {
await this.requireShippingLine(shippingLineCompanyId);
const [items, total] = await this.credits.findAllPaginated(
shippingLineCompanyId,
(page - 1) * pageSize,
pageSize,
status,
);
return { items, total, page, pageSize };
}
/**
* The signed-in shipping line's own statement: what it owes and why.
* Resolves the line from the session, so one line can never read another's.
*/
async myStatement(userId: string, page = 1, pageSize = 20) {
const shippingLine = await this.shippingLines.findByUserId(userId);
if (!shippingLine) {
throw new ForbiddenException("This account is not a shipping line.");
}
const [outstanding, ledger] = await Promise.all([
this.credits.outstandingFor(shippingLine.id),
this.credits.findAllPaginated(
shippingLine.id,
(page - 1) * pageSize,
pageSize,
),
]);
return {
outstanding,
items: ledger[0],
total: ledger[1],
page,
pageSize,
};
}
// ── Cancellation ───────────────────────────────────────────────────────────
/**
* Write off an unbilled credit (booking voided, charge raised in error).
* Only UNBILLED credits can be cancelled — once a credit is on an issued
* invoice, the invoice is what has to be cancelled or credited, otherwise
* the invoice total would stop matching the sum of its lines.
*/
async cancelCredit(
creditId: string,
reason: string,
): Promise<ShippingLineCredit> {
return this.dataSource.transaction(async (mg) => {
const [credit] = await this.credits.findByIdsForUpdate(mg, [creditId]);
if (!credit) {
throw new NotFoundException(`Credit ${creditId} not found`);
}
if (credit.status !== ShippingLineCreditStatus.Unbilled) {
throw new BadRequestException(
`Only an unbilled credit can be cancelled; this one is ${credit.status}. Cancel or credit invoice ${credit.invoiceId} instead.`,
);
}
await mg.update(
ShippingLineCredit,
{ id: creditId },
{
status: ShippingLineCreditStatus.Cancelled,
cancelledAt: new Date(),
cancellationReason: reason,
},
);
return { ...credit, status: ShippingLineCreditStatus.Cancelled };
});
}
private async requireShippingLine(shippingLineCompanyId: string) {
const shippingLine = await this.shippingLines.findById(
shippingLineCompanyId,
);
if (!shippingLine) {
throw new NotFoundException(
`Shipping line ${shippingLineCompanyId} not found`,
);
}
return shippingLine;
}
}

View File

@@ -99,7 +99,9 @@ interface InventoryContext {
interface ViewSource {
id: string;
invoiceNumber: string;
companyId: string;
/** Nullable on the entity (shipping-line invoices have no company); every
* warehouse invoice is customer-billed, so in practice this is always set. */
companyId: string | null;
sourceId: string;
type: string;
status: Freight.InvoiceStatus | string;

View File

@@ -643,6 +643,21 @@ const INTERCITY_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [
},
];
// ── Shipping line booking documents ─────────────────────────────────────────
// Collected on a shipping line's booking right after it is initiated. Shipping
// lines book without a contract, so this set — not a contract — is what
// Operations reviews before the booking may be completed. Fields start empty
// and are configured in the backoffice file-settings editor, like the sets
// above. `entity: "booking"` puts it alongside the other per-booking sets.
const SHIPPING_LINE_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [
{
code: "shipping_line_booking_documents",
label: "Shipping line booking documents",
entity: "booking",
fields: [],
},
];
// ── Hazardous cargo documents ───────────────────────────────────────────────
// Asked for in the contract wizard the moment the customer flags the cargo as
// hazardous (ONE_TIME contracts only). Fields start empty and are configured in
@@ -701,6 +716,11 @@ export class FileUploadSettingsSeeder {
description:
"Intercity shipment documents — contract-level for ONE_TIME (after both signatures), per booking for GENERAL; reviewed by Operations.",
})),
...SHIPPING_LINE_DOCUMENT_SETTINGS.map((s) => ({
...s,
description:
"Documents a shipping line uploads on a booking it initiated. Reviewed by Operations; the booking can only be completed once they are approved.",
})),
];
const missing = allSettings.filter((s) => !existingCodes.has(s.code));

View File

@@ -513,6 +513,25 @@ export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [
"edr_freight_app:invoices:confirm_offline",
"Confirm offline (bank transfer) invoice payment",
),
// Shipping lines consume services on credit and are invoiced after the fact,
// so what they owe is its own Finance surface, separate from invoices:view —
// an unbilled credit is not an invoice yet.
perm(
"d2c00001-0001-4000-8000-000000000001",
"edr_freight_app:shipping_line_credits:view",
"View shipping-line credits and outstanding balance",
),
perm(
"d2c00001-0001-4000-8000-000000000002",
"edr_freight_app:shipping_line_credits:invoice",
"Generate an invoice from shipping-line credits",
),
// Erases a debt outright, which is why it is not folded into :invoice.
perm(
"d2c00001-0001-4000-8000-000000000003",
"edr_freight_app:shipping_line_credits:cancel",
"Cancel (write off) an unbilled shipping-line credit",
),
];
// E. First / last mile operations
@@ -1730,6 +1749,13 @@ export const FREIGHT_PERMS = {
update: "edr_freight_app:shipping_lines:update",
resetPassword: "edr_freight_app:shipping_lines:reset-password",
},
shippingLineCredits: {
view: "edr_freight_app:shipping_line_credits:view",
/** Turn a batch of unbilled credits into an invoice. */
invoice: "edr_freight_app:shipping_line_credits:invoice",
/** Write off an unbilled credit — separate grant: it erases a debt. */
cancel: "edr_freight_app:shipping_line_credits:cancel",
},
payments: {
view: "edr_freight_app:payments:view",
},