Merge pull request #1279 from Tria-plc/freight_feature/usermanagement

Freight feature/usermanagement
This commit is contained in:
marshal
2026-08-13 21:58:33 +03:00
committed by GitHub
122 changed files with 14852 additions and 316 deletions

View File

@@ -40,6 +40,8 @@ import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.
import { TrainSchedulingModule } from "./modules/train-scheduling/train-scheduling.module";
import { SchedulingRescheduleModule } from "./modules/scheduling-reschedule/scheduling-reschedule.module";
import { CompaniesModule } from "./modules/companies/companies.module";
import { ShippingLineBookingCompletionModule } from "./modules/shipping-lines/shipping-line-booking-completion.module";
import { ShippingLineCompaniesModule } from "./modules/shipping-lines/shipping-line-companies.module";
import { TrackingModule } from "./modules/tracking/tracking.module";
import { BillingModule } from "./modules/billing/billing.module";
import { NotificationsModule } from "./modules/notifications/notifications.module";
@@ -201,6 +203,8 @@ if (!process.env.APPLICATION_NAME) {
TrainSchedulingModule,
SchedulingRescheduleModule,
CompaniesModule,
ShippingLineCompaniesModule,
ShippingLineBookingCompletionModule,
TrackingModule,
BillingModule,
NotificationsModule,

View File

@@ -0,0 +1,78 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Shipping lines — carriers registered by backoffice staff who sign in to the
* portal directly.
*
* Separate from `freight.companies` on purpose: a shipping line has no TIN,
* business licence, eTrade record, operational profile or onboarding state, so
* it shares none of the customer columns. `user_id` sits on the company row
* itself because the company IS the account — there is no contact-person row.
*
* No FK on `user_id`: `iam.users` belongs to the IAM service's schema, which
* this API reads but never owns.
*/
export class ShippingLineCompany3440000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DO $$ BEGIN
CREATE TYPE freight.shipping_line_companies_status_enum
AS ENUM ('active', 'suspended');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.shipping_line_companies (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL,
name varchar(200) NOT NULL,
scac_code varchar(4),
imo_number varchar(20),
bic_code varchar(20),
email varchar(150) NOT NULL,
phone_number varchar(30),
status freight.shipping_line_companies_status_enum
NOT NULL DEFAULT 'active',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
)
`);
// One login per shipping line. Partial so a soft-deleted row frees its
// account for re-registration rather than blocking it forever.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_shipping_line_companies_user"
ON freight.shipping_line_companies (user_id)
WHERE deleted_at IS NULL
`);
// SCAC identifies the carrier globally — two live lines cannot share one.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_shipping_line_companies_scac"
ON freight.shipping_line_companies (scac_code)
WHERE scac_code IS NOT NULL AND deleted_at IS NULL
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_shipping_line_companies_email"
ON freight.shipping_line_companies (lower(email))
WHERE deleted_at IS NULL
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_shipping_line_companies_status"
ON freight.shipping_line_companies (status)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP TABLE IF EXISTS freight.shipping_line_companies`,
);
await queryRunner.query(
`DROP TYPE IF EXISTS freight.shipping_line_companies_status_enum`,
);
}
}

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

@@ -0,0 +1,34 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* A train schedule can be dedicated to one shipping line.
*
* NULL = a normal train, visible and bookable to customers as before. Set =
* the departure exists for that shipping line alone: it is excluded from every
* customer-facing read (booking windows, day pools, portal home cards) and
* surfaces only in the assigned line's portal (home page + booking detail).
*/
export class TrainScheduleShippingLine3510000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules
ADD COLUMN IF NOT EXISTS shipping_line_company_id uuid
REFERENCES freight.shipping_line_companies (id)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_train_schedules_shipping_line_company_id
ON freight.train_schedules (shipping_line_company_id)
WHERE shipping_line_company_id IS NOT NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DROP INDEX IF EXISTS freight.idx_train_schedules_shipping_line_company_id
`);
await queryRunner.query(`
ALTER TABLE freight.train_schedules
DROP COLUMN IF EXISTS shipping_line_company_id
`);
}
}

View File

@@ -0,0 +1,31 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Default the daily booking desk to 24 hours: window_close_hour equal to
* window_open_hour means the desk never pauses overnight. Aligns the column
* default and the existing global-rules row; per-schedule overrides keep
* whatever staff set on them.
*/
export class DefaultDeskHours24h3520000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_scheduling_global_rules
ALTER COLUMN window_close_hour SET DEFAULT 8
`);
await queryRunner.query(`
UPDATE freight.train_scheduling_global_rules
SET window_close_hour = window_open_hour
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_scheduling_global_rules
ALTER COLUMN window_close_hour SET DEFAULT 17
`);
await queryRunner.query(`
UPDATE freight.train_scheduling_global_rules
SET window_close_hour = 17
`);
}
}

View File

@@ -0,0 +1,78 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Makerchecker for manual actions on shipping-line credit invoices.
*
* A shipping-line credit invoice is normally settled by the CBE webhook. Two
* manual paths exist for finance: recording an offline payment (MARK_PAID)
* and voiding an invoice raised in error (CANCEL, which releases its credits
* back to the unbilled pool). Both erase or move real debt, so neither is a
* single-person action: one permission raises the request, a different
* permission — held by a chief, and never the requester themselves — approves
* or rejects it. Rows are never deleted; decided requests are the audit trail.
*
* One PENDING row per invoice at a time (partial unique index): a second
* request while one is undecided is a coordination failure, not a workflow.
*/
export class ShippingLineInvoiceApprovals3530000000000
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DO $$ BEGIN
CREATE TYPE freight.shipping_line_invoice_approvals_action_enum
AS ENUM ('MARK_PAID', 'CANCEL');
EXCEPTION WHEN duplicate_object THEN NULL; END $$
`);
await queryRunner.query(`
DO $$ BEGIN
CREATE TYPE freight.shipping_line_invoice_approvals_status_enum
AS ENUM ('PENDING', 'APPROVED', 'REJECTED');
EXCEPTION WHEN duplicate_object THEN NULL; END $$
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.shipping_line_invoice_approvals (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
invoice_id uuid NOT NULL REFERENCES freight.invoices (id),
action freight.shipping_line_invoice_approvals_action_enum NOT NULL,
status freight.shipping_line_invoice_approvals_status_enum NOT NULL DEFAULT 'PENDING',
requested_by uuid NOT NULL,
reason varchar(500) NOT NULL,
payment_reference varchar(255),
decided_by uuid,
decided_at timestamptz,
decision_note varchar(500),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_sl_invoice_approvals_invoice_status
ON freight.shipping_line_invoice_approvals (invoice_id, status)
`);
// The workflow invariant, enforced where it cannot race: at most one
// undecided request per invoice.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_sl_invoice_approvals_one_pending
ON freight.shipping_line_invoice_approvals (invoice_id)
WHERE status = 'PENDING' AND deleted_at IS NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP TABLE IF EXISTS freight.shipping_line_invoice_approvals`,
);
await queryRunner.query(
`DROP TYPE IF EXISTS freight.shipping_line_invoice_approvals_status_enum`,
);
await queryRunner.query(
`DROP TYPE IF EXISTS freight.shipping_line_invoice_approvals_action_enum`,
);
}
}

View File

@@ -432,11 +432,16 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"POST /api/service-types/:id/move-order": ["Move a service type up or down in display order", "POST", "Service Type"],
"POST /api/service-types/reorder": ["Bulk reorder service types by ID list", "POST", "Service Type"],
// Shipping Line
// Shipping Line (rule-engine lookup list — a code/label bookings reference,
// not an account)
"POST /api/shipping-lines": ["Create a shipping line", "POST", "Shipping Line"],
"PATCH /api/shipping-lines/:id": ["Update a shipping line", "PATCH", "Shipping Line"],
"DELETE /api/shipping-lines/:id": ["Soft-delete a shipping line", "DELETE", "Shipping Line"],
// Shipping Line Company (carrier with a portal login, registered by staff)
"POST /api/shipping-line-companies": ["Register a shipping line company and send its activation link", "POST", "Shipping Line Company"],
"POST /api/shipping-line-companies/:id/resend-activation": ["Resend a shipping line company's activation link", "POST", "Shipping Line Company"],
// Signature
"PUT /api/me/signature": ["Create or update the reusable saved signature", "PUT", "Signature"],

View File

@@ -1,6 +1,7 @@
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { InjectRepository } from "@nestjs/typeorm";
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
import { Repository } from "typeorm";
import { ExternalProfile } from "../companies/entities/external-profile.entity";
@@ -86,7 +87,66 @@ export class CustomerResetService {
const resolved = await this.resolvePrimaryContactUser(companyId);
if (!resolved) return null;
const { user, userId } = resolved;
return this.sendResetLinkToUser(resolved.userId, channel, {
scope: `company ${companyId}`,
});
}
/**
* Mint and deliver a reset link to a specific IAM account.
*
* The delivery half of {@link sendResetLinkToCustomer}, split out so callers
* that resolve their target differently can reuse it: a customer is found via
* the company's primary contact, while a shipping line has no contact row at
* all and resolves straight off its own record. Everything below the lookup —
* active-account gating, the domestic-SMS rule, mint-before-send, the
* undelivered-link diagnostic — is identical for both and must stay that way.
*
* `scope` only labels the log line with whatever the caller resolved from.
*
* `allowWithoutCredential` relaxes the lookup for first-time activation:
* the default gate requires an existing active credential (so a reset cannot
* revive a suspended account), but an account that has never set a password
* has no credential row yet and would be excluded from its own activation
* link. Callers pass it only when the account is expected to be
* password-less — see ShippingLineCompaniesService.
*/
async sendResetLinkToUser(
userId: string,
channel: ResetChannel,
options?: { scope?: string; allowWithoutCredential?: boolean },
): Promise<SentResetLink | null> {
const user = options?.allowWithoutCredential
? await this.forgotPasswordService.resolveActivatableUserById(userId)
: await this.forgotPasswordService.resolveActiveUserById(userId);
if (!user?.id) {
this.logger.warn(
`User ${userId} is not an active account${
options?.allowWithoutCredential
? ""
: " (or has no active credential — pass allowWithoutCredential for first-time activation)"
}`,
);
return null;
}
return this.deliverResetLink(user, user.id, channel, options?.scope);
}
/**
* Shared tail: target selection → SMS reachability → mint → send → report.
* Callers have already resolved `user` to an active account.
*/
private async deliverResetLink(
user: User,
userId: string,
channel: ResetChannel,
scope?: string,
): Promise<SentResetLink | null> {
this.logger.log(
`Staff-triggered shipping line ${"link"}`,
);
const target = this.forgotPasswordService.targetFor(user, channel);
if (!target) return null;
@@ -110,6 +170,9 @@ export class CustomerResetService {
);
const link = this.buildResetLink(ticket.userId, ticket.verificationCode);
const expiresAt = new Date(Date.now() + RESET_LINK_TTL_MS);
this.logger.log(
`Staff-triggered shipping line ${link}`,
);
const { queued } = target.email
? await this.emailClient.sendEmail({
@@ -127,7 +190,22 @@ export class CustomerResetService {
});
this.logger.log(
`Staff-triggered ${channel} reset link sent to user ${userId} (company ${companyId}) queued=${queued}`,
`Staff-triggered shipping line ${channel} reset link sent to user ${userId}${
scope ? ` (${scope})` : ""
} queued=${queued}`,
);
// SECURITY: logs a live password-reset credential in cleartext. Anyone with
// read access to the log stream can set the password for the account named
// on the same line — including on sends that succeeded, not just failures.
// Kept deliberately: log aggregation is the debugging path for flaky
// email/SMS here, the same tradeoff otp.service.ts makes for OTP codes. If
// that is ever revisited, gate this on an env flag rather than deleting it,
// so dev keeps its workflow.
this.logger.warn(
`reset-link.cleartext channel=${channel} user=${userId}${
scope ? ` (${scope})` : ""
} link=${link}`,
);
if (!queued) {

View File

@@ -89,6 +89,28 @@ export class ForgotPasswordService {
.getOne();
}
/**
* Active account by id, WITHOUT requiring an existing credential.
*
* {@link activeUserQuery} inner-joins an active `user_credentials` row, which
* is right for a *reset*: it stops a staff-triggered link from reactivating a
* suspended account. But an account that has never set a password has no
* credential row yet, so that join excludes exactly the accounts a first-time
* *activation* link is for — shipping lines are created deliberately without
* one (see ShippingLineCompaniesService.register).
*
* The `isActive` gate is kept; only the credential requirement is dropped.
*/
async resolveActivatableUserById(userId: string): Promise<User | null> {
if (!userId) return null;
return await this.userRepository
.createQueryBuilder("u")
.where("u.isActive = true")
.andWhere("u.id = :userId", { userId })
.orderBy("u.createdAt", "DESC")
.getOne();
}
/**
* Base query for accounts eligible to reset. `.where()` is claimed here so
* callers must use `.andWhere()` — TypeORM's `.where()` resets the clause,
@@ -233,9 +255,22 @@ export class ForgotPasswordService {
"This password-reset link is invalid or has expired. Request a new one.",
);
const user = await this.resolveActiveUserById(userId);
// Credential-less on purpose: this resolves links for *setting* a password,
// which includes first-time activation of an account that has never had one
// (shipping lines are created without a credential row). Requiring one here
// rejected a perfectly valid activation link before its token was ever
// checked. The ticket checks below are what actually authorise the reset.
const user = await this.resolveActivatableUserById(userId);
const identifier = user && this.identifierFor(user);
if (!user || !identifier) throw invalid;
if (!user || !identifier) {
// Logged because the early return above bypasses the rejection warning
// below — without this, an account that fails the lookup produces no
// diagnostic at all and looks identical to a bad token.
this.logger.warn(
`Reset link rejected for user ${userId} — no active account or no usable identifier`,
);
throw invalid;
}
const verification = await this.dataSource
.getRepository(UserVerification)

View File

@@ -51,5 +51,8 @@ import { ListUsersService } from './list-users.service';
ForgotPasswordService,
CustomerResetService,
],
// Shipping-line registration mints activation links through the same
// staff-triggered reset path customers use.
exports: [CustomerResetService],
})
export class FreightAuthModule {}

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

@@ -13,6 +13,9 @@ import { logCtx } from "@edr/api-common";
import { DataSource, EntityManager, In } from "typeorm";
import { Booking } from "../bookings/entities/booking.entity";
// Entity-only import (no module edge): portal reads resolve shipping-line
// payers straight off the table.
import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity";
import { EimsConfig } from "../../config/eims.config";
import { CompaniesService } from "../companies/companies.service";
import { FilesService } from "../files/files.service";
@@ -114,8 +117,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. */
@@ -141,8 +152,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;
@@ -563,23 +577,61 @@ export class BillingService {
});
}
/** Invoices for the signed-in customer; empty when they have no company. */
/**
* Resolve a shipping-line company from the signed-in user (null for ordinary
* customers). Queried straight off the entity rather than through
* ShippingLineCompaniesService — that module already imports billing, so a
* service edge back would deepen the forwardRef cycle for one lookup.
*/
private async resolveShippingLineCompanyId(
userId: string,
): Promise<string | null> {
const line = await this.dataSource
.getRepository(ShippingLineCompany)
.findOne({ where: { userId } });
return line?.id ?? null;
}
/**
* Invoices for the signed-in portal user; empty when they have no company.
* A payer is either a customer company or a shipping line (enforced by the
* DB's single-payer check), so the two lookups cannot both match.
*/
async findForUser(
userId: string,
filter: { source?: string; sourceId?: string } = {},
): Promise<Invoice[]> {
const companyId = await this.resolveCompanyId(userId);
return companyId ? this.findByCompany(companyId, filter) : [];
if (companyId) return this.findByCompany(companyId, filter);
const shippingLineCompanyId =
await this.resolveShippingLineCompanyId(userId);
if (!shippingLineCompanyId) return [];
return this.invoices.findAll({
where: {
shippingLineCompanyId,
...(filter.source ? { source: filter.source } : {}),
...(filter.sourceId ? { sourceId: filter.sourceId } : {}),
},
order: { createdAt: "DESC" },
});
}
/** Company-scoped invoice detail (+ lines); 404 when not owned by the user. */
/** Payer-scoped invoice detail (+ lines); 404 when not owned by the user. */
async findByIdForUser(
id: string,
userId: string,
): Promise<Invoice & { lines: InvoiceLine[] }> {
const companyId = await this.resolveCompanyId(userId);
const invoice = await this.findById(id);
if (!companyId || invoice.companyId !== companyId) {
const ownedByCompany =
invoice.companyId != null &&
invoice.companyId === (await this.resolveCompanyId(userId));
const ownedByShippingLine =
!ownedByCompany &&
invoice.shippingLineCompanyId != null &&
invoice.shippingLineCompanyId ===
(await this.resolveShippingLineCompanyId(userId));
if (!ownedByCompany && !ownedByShippingLine) {
throw new NotFoundException(`Invoice ${id} not found`);
}
return invoice;
@@ -669,7 +721,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);
}
@@ -682,6 +733,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;
@@ -717,8 +783,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),
@@ -1048,6 +1115,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

@@ -11,6 +11,7 @@ import { Booking } from './entities/booking.entity';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { resolveCompanyNotifyContact } from '../notifications/resolve-company-phone.util';
import { resolveShippingLineNotifyTarget } from '../notifications/resolve-shipping-line-contact.util';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
/**
@@ -58,10 +59,16 @@ export class BookingLifecycleNotifierService {
// Both channels come from the same resolver: the company row's own columns
// are only half the story (see companyNotifyEmailExpr), and reading them off
// the loaded entity silently dropped every mail to a company whose address
// lives in `attributes`.
const { phone, email } = b.companyId
? await resolveCompanyNotifyContact(this.dataSource, b.companyId)
: { phone: null, email: null };
// lives in `attributes`. A shipping-line booking has NO company — its
// contact lives on the shipping_line_companies row itself.
const { phone, email } = b.shippingLineCompanyId
? await resolveShippingLineNotifyTarget(
this.dataSource,
b.shippingLineCompanyId,
)
: b.companyId
? await resolveCompanyNotifyContact(this.dataSource, b.companyId)
: { phone: null, email: null };
if (phone) {
try {
@@ -82,13 +89,44 @@ export class BookingLifecycleNotifierService {
}
}
/** Persist + push an in-app item to all portal users of the booking's company. */
/**
* Persist + push an in-app item to the booking's portal owner: every portal
* user of the company, or — for a shipping-line booking — the line's own
* account, deep-linked into the shipping-line app rather than the customer
* one (its routes live under /shipping-line/*).
*/
private inApp(
b: Booking,
title: string,
body: string,
overrides: Partial<NotifyInput> = {},
): void {
if (b.shippingLineCompanyId) {
void (async () => {
const { userId } = await resolveShippingLineNotifyTarget(
this.dataSource,
b.shippingLineCompanyId!,
);
if (!userId) return;
void this.inbox.notify({
recipients: { userIds: [userId] },
audience: NotificationAudience.PORTAL,
type: NotificationType.BOOKING_STATUS,
title,
body,
data: { bookingId: b.id, reference: b.reference },
...overrides,
// After the spread: overrides carry customer links — the bell must
// land a shipping line on ITS booking page.
link: `/shipping-line/bookings/${b.id}`,
});
})().catch((err) =>
this.logger.warn(
`shipping-line inApp failed for ${this.ref(b)}: ${(err as Error).message}`,
),
);
return;
}
if (!b.companyId) return; // government/unlinked bookings have no portal users
void this.inbox.notify({
recipients: { companyId: b.companyId },
@@ -176,13 +214,23 @@ export class BookingLifecycleNotifierService {
/** Document approval finalized → customer can proceed to request operation. */
clearanceReady(b: Booking): void {
const msg =
`Document approval for booking ${b.reference} is finalized. ` +
`You can now proceed to request operation from the portal.`;
// A shipping line's next move is BOOKING (cargo + shipment day), not the
// customer's operation-request step — say so, or the message points at a
// flow their portal does not have.
const msg = b.shippingLineCompanyId
? `Documents for booking ${b.reference} are approved. ` +
`You can now book your shipment — enter the cargo and shipment day from the portal.`
: `Document approval for booking ${b.reference} is finalized. ` +
`You can now proceed to request operation from the portal.`;
void this.notifyContact(b, msg, 'DOCUMENT APPROVAL FINALIZED');
this.inApp(b, 'Document approval finalized', msg, {
type: NotificationType.CLEARANCE_DECISION,
});
this.inApp(
b,
b.shippingLineCompanyId
? 'Documents approved — book your shipment'
: 'Document approval finalized',
msg,
{ type: NotificationType.CLEARANCE_DECISION },
);
}
/** Intercity documents approved → booking waits in the ride-along pool. */
@@ -234,11 +282,19 @@ export class BookingLifecycleNotifierService {
/** Operation accepted → invoice ready; await payment / booking window. */
operationAccepted(b: Booking): void {
const msg =
`Your operation request for booking ${b.reference} has been accepted. ` +
`An invoice has been prepared — watch for the payment window to secure your slot.`;
// No invoice and no pay window for a shipping line — the charge sits on
// its credit account and the booking boards its dedicated train directly.
const msg = b.shippingLineCompanyId
? `Your booking ${b.reference} has been accepted. The charge has been ` +
`recorded on your credit account and your shipment is being placed on its train.`
: `Your operation request for booking ${b.reference} has been accepted. ` +
`An invoice has been prepared — watch for the payment window to secure your slot.`;
void this.notifyContact(b, msg, 'OPERATION ACCEPTED');
this.inApp(b, 'Operation request accepted', msg);
this.inApp(
b,
b.shippingLineCompanyId ? 'Booking accepted' : 'Operation request accepted',
msg,
);
}
/**

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

@@ -39,6 +39,8 @@ import { ClearanceWorkflowService } from '../contracts/clearance-workflow.servic
import { ContractDocPhase } from '@edr/types';
import { BookingInvoiceService } from "./booking-invoice.service";
// Type-only: the DI edge stays event-based to keep the module graph acyclic.
import type { ShippingLineBookingAcceptedPayload } from "../shipping-lines/shipping-line-credits.service";
@Injectable()
export class BookingTransitionService {
@@ -944,6 +946,15 @@ export class BookingTransitionService {
bookingId: string,
scheduledDate: string,
requestedTrainScheduleId?: string | null,
opts?: {
/**
* Skip the customer day-pool departure/compatibility gate. Used ONLY by
* the shipping-line completion path, which has already validated the day
* against the line's own dedicated train (those trains are excluded from
* the customer pools, so the gate here would wrongly reject them).
*/
bypassDayPool?: boolean;
},
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [
@@ -971,20 +982,22 @@ export class BookingTransitionService {
// gate; quantity never blocks — oversized bookings get a partial split
// offer). The batch engine assigns the specific train within that
// (route, day) pool later.
const { hasDeparture, hasCompatible } =
await this.bookingsService.checkDayCompatibilityForBooking(
booking,
eatDay(date),
);
if (!hasDeparture) {
throw new BadRequestException(
"No departures available on the selected day for this route",
);
}
if (!hasCompatible) {
throw new BadRequestException(
"No wagon on the selected day can carry this cargo type — please choose another day",
);
if (!opts?.bypassDayPool) {
const { hasDeparture, hasCompatible } =
await this.bookingsService.checkDayCompatibilityForBooking(
booking,
eatDay(date),
);
if (!hasDeparture) {
throw new BadRequestException(
"No departures available on the selected day for this route",
);
}
if (!hasCompatible) {
throw new BadRequestException(
"No wagon on the selected day can carry this cargo type — please choose another day",
);
}
}
// Export is FCFS and never splits — a booking must ride one train whole. So
@@ -1001,7 +1014,14 @@ export class BookingTransitionService {
// The customer's train pick only exists for export rail; it rides the
// booking through the space checks below AND is persisted so the accept /
// reserve path locks onto that train (pickExportSchedule honors it).
const requestedId = isExportTrain ? (requestedTrainScheduleId ?? null) : null;
// Shipping-line completions (bypassDayPool) pick among the line's own
// dedicated trains — already validated by the caller, so the pick is
// persisted here the same way an export pick is. Customer import/domestic
// bookings still never carry one (the batch engine assigns their train).
const requestedId =
isExportTrain || opts?.bypassDayPool
? (requestedTrainScheduleId ?? null)
: null;
// Export rail rides the exact train the customer picked — never an
// auto-assigned one. Both portal flows (clearance + contract completion)
// surface a picker, so a missing id is an invalid submission, not a
@@ -1205,10 +1225,22 @@ export class BookingTransitionService {
// booking page correctly still showed it as not payable. The batch engine
// issues it in `reserve` (SELECTED_FOR_BATCH), which is where the pay window
// and the real deadline are created — matching the portal's `canPay` gate.
const invoice = await this.invoiceService.ensureInvoiceForBooking(booking);
this.logger.log(
`Generated draft invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id} — issued on batch selection`,
);
//
// Shipping-line bookings mint NO invoice at all: they have no company row
// to bill (the invoices FK requires one) and they pay on the credit ledger
// — the charge was recorded at completion, and Finance bills a batch of
// credits later through ShippingLineCreditsService.generateInvoice.
if (booking.shippingLineCompanyId) {
this.logger.log(
`Skipping invoice for shipping-line booking ${booking.reference}:${booking.id} — billed later from the credit ledger`,
);
} else {
const invoice =
await this.invoiceService.ensureInvoiceForBooking(booking);
this.logger.log(
`Generated draft invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id} — issued on batch selection`,
);
}
// TODO: road (truck) orders are an incomplete feature — they stop at the
// dead-end ROAD_DISPATCH_PENDING status below (no dispatch transition, no
// per-km pricing wired via roadKmPrice, no pay surface in the portal). They
@@ -1222,6 +1254,7 @@ export class BookingTransitionService {
lockedAt: booking.lockedAt ?? now,
} as never);
const roadFresh = await this.bookingsService.findById(booking.id);
this.emitShippingLineAccepted(roadFresh);
this.notifier.operationAccepted(roadFresh);
return roadFresh;
}
@@ -1258,11 +1291,44 @@ export class BookingTransitionService {
// batch runs after the window closes + staff document review, never at accept
// time. (Legacy pre-migration schedules with no window phase are still served
// by the periodic legacy fill.)
//
// EXCEPT shipping-line bookings: they pay later on the credit ledger, so
// no pay window exists to wait for — accept places them straight onto
// their company's dedicated train and its wagons. Non-fatal on purpose:
// the accept has committed; an allocation hiccup leaves the booking in
// the day pool for the batch engine / staff instead of failing the accept.
if (booking.shippingLineCompanyId) {
try {
await this.bookingBatchService.allocateShippingLineAccepted(booking.id);
} catch (err) {
this.logger.warn(
`Auto-allocation failed for shipping-line booking ${booking.reference}:${booking.id} — left in the day pool: ${(err as Error).message}`,
);
}
}
const trainFresh = await this.bookingsService.findById(booking.id);
this.emitShippingLineAccepted(trainFresh);
this.notifier.operationAccepted(trainFresh);
return trainFresh;
}
/**
* A shipping-line booking becomes debt at THIS moment — Operations accepted
* it — not at completion/pricing. Event, not a service call:
* ShippingLineCreditsService listens (`shipping_line_booking.accepted`), and
* importing its module here would close a module cycle. Emitted after the
* accept has fully committed (including the export-capacity path, which can
* still revert the status above), so a failed accept never creates debt.
*/
private emitShippingLineAccepted(booking: Booking): void {
if (!booking.shippingLineCompanyId) return;
this.events.emit("shipping_line_booking.accepted", {
bookingId: booking.id,
reference: booking.reference,
amount: Number(booking.totalAmount),
} satisfies ShippingLineBookingAcceptedPayload);
}
async enrichBookingResponse(booking: Booking): Promise<
Booking & {
latestChangeRequestNote?: string | null;

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

@@ -52,6 +52,11 @@ import {
} from "./entities/company-profile.entity";
import { ResponseExternalProfileDto } from "./dto/response-external-profile.dto";
import { CompanyInfoResponseDto } from "./dto/company-info-response.dto";
import {
AccountInfoResponse,
ShippingLineInfoResponseDto,
} from "./dto/account-info-response.dto";
import { ShippingLineCompaniesService } from "../shipping-lines/shipping-line-companies.service";
import { UpdateProfileDto } from "./dto/update-profile.dto";
import { ProfileResponseDto } from "./dto/profile-response.dto";
import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto";
@@ -96,6 +101,7 @@ export class CompaniesController {
constructor(
private readonly companiesService: CompaniesService,
private readonly filesService: FilesService,
private readonly shippingLineCompaniesService: ShippingLineCompaniesService,
) { }
/**
@@ -119,16 +125,31 @@ export class CompaniesController {
@Get("getInfo")
@PortalCustomer()
@ApiOperation({ summary: "Get company info for the current user" })
@ApiOperation({
summary: "Get account info for the current user (customer or shipping line)",
})
async getInfo(
@CurrentUser() user: CurrentIamUser,
): Promise<CompanyInfoResponseDto> {
): Promise<AccountInfoResponse> {
// A shipping line has no company and no external profile, so the customer
// lookup below would 404. Checked first, and reported with an explicit
// `accountKind` so the portal can skip onboarding for shipping lines
// without inferring it from a missing company.
const shippingLine = await this.shippingLineCompaniesService.findByUserId(
user.id,
);
if (shippingLine) {
return new ShippingLineInfoResponseDto(shippingLine);
}
const { profile, company } =
await this.companiesService.getCompanyInfoByUserId(user.id);
const review = await this.companiesService.getOpenChangeRequestForCompany(
company.id,
);
return new CompanyInfoResponseDto(profile, company, review);
return Object.assign(new CompanyInfoResponseDto(profile, company, review), {
accountKind: "customer" as const,
});
}
@Get("profile")

View File

@@ -17,6 +17,7 @@ import { CompanyProfile } from "./entities/company-profile.entity";
import { CompanyChangeRequest } from "./entities/company-change-request.entity";
import { CompanyRevision } from "./entities/company-revision.entity";
import { Booking } from "../bookings/entities/booking.entity";
import { ShippingLineCompaniesModule } from "../shipping-lines/shipping-line-companies.module";
import { CompanyProfileRepository } from "./company-profile.repository";
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
import { CompanyRevisionRepository } from "./company-revision.repository";
@@ -44,6 +45,10 @@ import { VerifaydaModule } from "../verifayda/verifayda.module";
forwardRef(() => NotificationInboxModule),
// 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. forwardRef
// because that module imports BillingModule, which imports this one.
forwardRef(() => ShippingLineCompaniesModule),
],
controllers: [CompaniesController],
providers: [

View File

@@ -0,0 +1,66 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { ShippingLineCompany } from "../../shipping-lines/entities/shipping-line-company.entity";
import { CompanyInfoResponseDto } from "./company-info-response.dto";
/**
* What kind of account is signed in to the portal.
*
* The portal keys its onboarding gate off this rather than off "is `company`
* missing?": a failed or slow company fetch also leaves `company` empty, and
* treating that as "no onboarding needed" would let customers skip onboarding
* whenever the request failed. A shipping line is identified positively, and
* anything else defaults to `customer`.
*/
export type AccountKind = "customer" | "shipping_line";
/** The signed-in shipping line. No company, no profile, no onboarding. */
export class ShippingLineInfoResponseDto {
@ApiProperty({ enum: ["shipping_line"] })
accountKind: "shipping_line" = "shipping_line";
@ApiProperty()
id: string;
@ApiProperty()
name: string;
@ApiProperty()
email: string;
@ApiPropertyOptional()
phoneNumber?: string | null;
@ApiPropertyOptional()
scacCode?: string | null;
@ApiProperty()
status: string;
/**
* Always null. Present so the portal can read `company` / `profile` off either
* payload shape without narrowing the union first — the fields a customer
* session carries simply have no shipping-line equivalent.
*/
@ApiProperty({ nullable: true })
company: null = null;
@ApiProperty({ nullable: true })
profile: null = null;
@ApiProperty({ nullable: true })
review: null = null;
constructor(entity: ShippingLineCompany) {
this.id = entity.id;
this.name = entity.name;
this.email = entity.email;
this.phoneNumber = entity.phoneNumber ?? null;
this.scacCode = entity.scacCode ?? null;
this.status = entity.status;
}
}
export type AccountInfoResponse =
| (CompanyInfoResponseDto & { accountKind: "customer" })
| ShippingLineInfoResponseDto;

View File

@@ -100,6 +100,7 @@ export class EimsCancellationService {
/** Best-effort — a notification failure must never mask a cancellation that already succeeded. */
private async notifyBuyer(invoice: Invoice): Promise<void> {
if (!invoice.companyId) return;
try {
await sendCompanyChannels(
this.dataSource,

View File

@@ -394,7 +394,7 @@ export class EimsInvoiceRegistrationService {
let invoiceNumber = invoiceId;
await this.dataSource.transaction(async (manager) => {
const invoice = await this.lockInvoice(manager, invoiceId);
companyId = invoice.companyId;
companyId = invoice.companyId ?? undefined;
invoiceNumber = invoice.invoiceNumber;
await manager.update(Invoice, invoiceId, {
eimsStatus: EimsInvoiceStatus.Registered,

View File

@@ -219,6 +219,7 @@ export class EimsReceiptService {
}
private async notifyBuyer(invoice: Invoice, kind: EimsReceiptKind, receiptNumber: string): Promise<void> {
if (!invoice.companyId) return;
try {
await sendCompanyChannels(
this.dataSource,

View File

@@ -0,0 +1,33 @@
import { DataSource } from "typeorm";
import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity";
/**
* Notification target for a shipping-line booking.
*
* A shipping line is NOT a `companies` row: the company IS the account — one
* IAM user (`userId`), and the contact details live on the
* `shipping_line_companies` row itself. So the customer resolvers
* (external_profiles fan-out, company attributes email) never apply; this is
* the one lookup every shipping-line notification routes through.
*
* Entity-only import — safe from any module graph: notifiers already own a
* DataSource and need no service from the shipping-lines module.
*/
export async function resolveShippingLineNotifyTarget(
dataSource: DataSource,
shippingLineCompanyId: string,
): Promise<{
userId: string | null;
phone: string | null;
email: string | null;
}> {
const line = await dataSource
.getRepository(ShippingLineCompany)
.findOne({ where: { id: shippingLineCompanyId } });
return {
userId: line?.userId ?? null,
phone: line?.phoneNumber ?? null,
email: line?.email ?? null,
};
}

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,193 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Transform, Type } from "class-transformer";
import {
IsArray,
IsBoolean,
IsDateString,
IsIn,
IsInt,
IsNumber,
IsOptional,
IsString,
IsUUID,
Min,
ValidateNested,
} from "class-validator";
import { PAYMENT_CURRENCIES } from "../../contracts/dto/create-contract.dto";
/**
* One physical container on a line — number, seal, VGM and its per-container
* handling switches. Same shape the customer shipment form submits.
*/
export class CompleteShippingLineContainerUnitDto {
@ApiProperty({ example: "MSCU1234567" })
@IsString()
containerNumber!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
sealNumber?: string;
@ApiProperty({ minimum: 0, description: "VGM of this container, tons." })
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value))
vgmTons!: number;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
isHazardous?: boolean;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
isReefer?: boolean;
}
/**
* One container line the shipping line ships — container type + count, the
* same shape the customer one-time form collects. When `units` is sent (the
* full booking page), per-container numbers/seals/VGM and handling switches
* are persisted exactly like the customer shipment form; without it (legacy
* modal shape) the line-level counts stand alone.
*/
export class CompleteShippingLineContainerLineDto {
@ApiPropertyOptional({
format: "uuid",
description:
"Container type being shipped. Optional when containerSize is sent — the server resolves the type from the size.",
})
@IsOptional()
@IsUUID()
containerTypeId?: string;
@ApiPropertyOptional({
description:
'Container size, e.g. "20ft" | "40ft". The server maps it to the configured container type (reefer variant when the line carries reefer boxes) — so the client never needs the type catalog.',
})
@IsOptional()
@IsString()
containerSize?: string;
@ApiProperty({ minimum: 1 })
@IsInt()
@Min(1)
@Transform(({ value }) => Number(value))
quantity!: number;
@ApiPropertyOptional({ minimum: 0, description: "VGM per container, tons." })
@IsOptional()
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value))
vgmPerUnitTons?: number;
@ApiPropertyOptional({ minimum: 0 })
@IsOptional()
@IsInt()
@Min(0)
@Transform(({ value }) => Number(value))
hazardousQuantity?: number;
@ApiPropertyOptional({ minimum: 0 })
@IsOptional()
@IsInt()
@Min(0)
@Transform(({ value }) => Number(value))
reeferQuantity?: number;
@ApiPropertyOptional({
type: [CompleteShippingLineContainerUnitDto],
description:
"Per-container details. When present, the handling counts and VGM are derived from these rows.",
})
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => CompleteShippingLineContainerUnitDto)
units?: CompleteShippingLineContainerUnitDto[];
}
/**
* Completion payload for a shipping-line booking whose documents Operations
* has approved (CLEARANCE_READY): the cargo and the binding shipment day —
* the two things `initiate` deliberately left empty.
*/
export class CompleteShippingLineBookingDto {
@ApiProperty({
description: "Binding shipment day (train departure day).",
example: "2026-09-01",
})
@IsDateString()
scheduledDate!: string;
@ApiPropertyOptional({
format: "uuid",
description:
"Which of the line's dedicated trains this booking rides. Required when more than one departs on the chosen day; implicit with a single departure.",
})
@IsOptional()
@IsUUID()
trainScheduleId?: string;
@ApiPropertyOptional({ enum: PAYMENT_CURRENCIES })
@IsOptional()
@IsIn([...PAYMENT_CURRENCIES])
paymentCurrency?: string;
@ApiPropertyOptional({
type: [CompleteShippingLineContainerLineDto],
description: "Container freight: what ships. Required for CONTAINER bookings.",
})
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => CompleteShippingLineContainerLineDto)
containers?: CompleteShippingLineContainerLineDto[];
@ApiPropertyOptional({
format: "uuid",
description: "Bulk freight: the cargo type. Required for BULK bookings.",
})
@IsOptional()
@IsUUID()
cargoTypeId?: string;
@ApiPropertyOptional({
minimum: 0,
description: "Bulk freight: total weight in tons. Required for BULK bookings.",
})
@IsOptional()
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value))
cargoWeightTons?: number;
@ApiPropertyOptional({
minimum: 0,
description: "Bulk freight: hazardous portion of the cargo.",
})
@IsOptional()
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value))
bulkHazardousQuantity?: number;
@ApiPropertyOptional({
minimum: 0,
description: "Bulk freight: refrigerated portion of the cargo.",
})
@IsOptional()
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value))
bulkReeferQuantity?: number;
@ApiPropertyOptional({ description: "What the containers carry." })
@IsOptional()
@IsString()
cargoFreeText?: string;
}

View File

@@ -0,0 +1,68 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import {
IsEmail,
IsNotEmpty,
IsOptional,
IsString,
Matches,
MaxLength,
} from "class-validator";
import { IsValidPhone } from "../../../common/validators/is-phone-number.validator";
export class CreateShippingLineDto {
@ApiProperty({ example: "Ethiopian Shipping Lines" })
@IsString()
@IsNotEmpty()
@MaxLength(200)
name!: string;
/**
* Becomes the IAM account's email — the activation link is sent here, so it
* is required even though the customer equivalent is optional.
*/
@ApiProperty({ example: "ops@esl.com.et" })
@IsEmail()
@MaxLength(150)
email!: string;
@ApiPropertyOptional({ example: "+251911223344" })
@IsOptional()
@IsString()
@MaxLength(30)
@IsValidPhone()
phoneNumber?: string;
@ApiPropertyOptional({
example: "ESLK",
description: "Standard Carrier Alpha Code — 2-4 letters",
})
@IsOptional()
@IsString()
@Matches(/^[A-Za-z]{2,4}$/, {
message: "SCAC must be 2-4 letters",
})
scacCode?: string;
@ApiPropertyOptional({ example: "IMO9074729" })
@IsOptional()
@IsString()
@MaxLength(20)
imoNumber?: string;
@ApiPropertyOptional({ example: "ESLU" })
@IsOptional()
@IsString()
@MaxLength(20)
bicCode?: string;
/**
* Login name. Optional — defaults to the email, which is what the line will
* naturally try first.
*/
@ApiPropertyOptional({ example: "esl-ops" })
@IsOptional()
@IsString()
@MaxLength(100)
username?: string;
}

View File

@@ -0,0 +1,47 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsDateString, 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;
@ApiProperty({
required: false,
description:
"Intended shipment day (YYYY-MM-DD). Unlike the customer flow it is picked up front — a shipping line has no later operation-request step to choose it at.",
})
@IsOptional()
@IsDateString()
scheduledDate?: string;
}

View File

@@ -0,0 +1,85 @@
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;
}
/** Finance's request for a manual action on a credit invoice (maker step). */
export class RequestInvoiceActionDto {
@ApiProperty({
description:
"Why the action is needed. Shown to the approver and kept for audit.",
example: "Paid by bank transfer, slip #TT-4491",
})
@IsString()
@MinLength(3)
@MaxLength(500)
reason!: string;
@ApiPropertyOptional({
description:
"Offline payment reference (bank slip / transfer number). MARK_PAID requests only.",
example: "TT-4491",
})
@IsOptional()
@IsString()
@MaxLength(255)
paymentReference?: string;
}
/** The decision on a pending request (approve and reject routes). */
export class DecideInvoiceActionDto {
@ApiPropertyOptional({
description: "Decision note. Required when rejecting.",
})
@IsOptional()
@IsString()
@MaxLength(500)
note?: string;
}
/** 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,64 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import {
ShippingLineCompany,
ShippingLineStatus,
} from "../entities/shipping-line-company.entity";
export class ShippingLineResponseDto {
@ApiProperty()
id: string;
@ApiProperty()
name: string;
@ApiProperty()
email: string;
@ApiPropertyOptional()
phoneNumber?: string | null;
@ApiPropertyOptional()
scacCode?: string | null;
@ApiPropertyOptional()
imoNumber?: string | null;
@ApiPropertyOptional()
bicCode?: string | null;
@ApiProperty({ enum: ShippingLineStatus })
status: ShippingLineStatus;
@ApiProperty()
createdAt: Date;
constructor(entity: ShippingLineCompany) {
this.id = entity.id;
this.name = entity.name;
this.email = entity.email;
this.phoneNumber = entity.phoneNumber ?? null;
this.scacCode = entity.scacCode ?? null;
this.imoNumber = entity.imoNumber ?? null;
this.bicCode = entity.bicCode ?? null;
this.status = entity.status;
this.createdAt = entity.createdAt;
}
}
export class RegisterShippingLineResponseDto {
@ApiProperty({ type: ShippingLineResponseDto })
shippingLine: ShippingLineResponseDto;
@ApiPropertyOptional({
description:
"Masked destination the activation link was sent to, or null if delivery failed.",
example: "o**@esl.com.et",
})
activationSentTo: string | null;
constructor(shippingLine: ShippingLineCompany, activationSentTo: string | null) {
this.shippingLine = new ShippingLineResponseDto(shippingLine);
this.activationSentTo = activationSentTo;
}
}

View File

@@ -0,0 +1,64 @@
import { BaseEntity } from "@edr/api-common";
import { Column, Entity, Index } from "typeorm";
export enum ShippingLineStatus {
Active = "active",
Suspended = "suspended",
}
/**
* A shipping line — a carrier that books rail capacity directly, registered by
* backoffice staff rather than self-signing up.
*
* Deliberately NOT a {@link Company} of a new {@link CompanyType}: a shipping
* line carries none of what `companies` exists to hold — no TIN, no business
* licence, no eTrade authenticity lookup, no operational `company_profiles`, no
* onboarding wizard state. Modelling it there would mean making all of that
* nullable for one row shape that never uses it.
*
* The company IS the account: there is no contact-person row (customers get one
* via `external_profiles`), so `user_id` lives here and the login credentials
* are the company's own. That is also why the password-reset flow resolves a
* shipping line straight off this table instead of through a primary contact.
*/
@Entity({ schema: "freight", name: "shipping_line_companies" })
@Index(["status"])
export class ShippingLineCompany extends BaseEntity {
/**
* The IAM account (`iam.users`, userType `individual`) that signs in as this
* shipping line. No FK: `iam` is a separate schema owned by the IAM service,
* and the rest of the codebase reaches it by query rather than by relation.
*/
@Column({ name: "user_id", type: "uuid", unique: true })
userId!: string;
@Column({ name: "name", type: "varchar", length: 200 })
name!: string;
/** Standard Carrier Alpha Code — 2-4 letters identifying the carrier. */
@Column({ name: "scac_code", type: "varchar", length: 4, nullable: true })
scacCode?: string | null;
/** IMO number of the vessel operator. */
@Column({ name: "imo_number", type: "varchar", length: 20, nullable: true })
imoNumber?: string | null;
/** BIC code — the container prefix the line's equipment is registered under. */
@Column({ name: "bic_code", type: "varchar", length: 20, nullable: true })
bicCode?: string | null;
/** Mirrors the IAM account's email; the activation link is sent here. */
@Column({ name: "email", type: "varchar", length: 150 })
email!: string;
@Column({ name: "phone_number", type: "varchar", length: 30, nullable: true })
phoneNumber?: string | null;
@Column({
name: "status",
type: "enum",
enum: ShippingLineStatus,
default: ShippingLineStatus.Active,
})
status!: ShippingLineStatus;
}

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,82 @@
import { BaseEntity } from "@edr/api-common";
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
import { Invoice } from "../../billing/entities/invoice.entity";
/** What finance asked to do to a shipping-line credit invoice. */
export enum ShippingLineInvoiceActionType {
/** Record a full offline settlement (paid outside the gateway). */
MarkPaid = "MARK_PAID",
/** Void the invoice; its credits return to the unbilled pool. */
Cancel = "CANCEL",
}
export enum ShippingLineInvoiceActionStatus {
Pending = "PENDING",
Approved = "APPROVED",
Rejected = "REJECTED",
}
/**
* Makerchecker for manual actions on shipping-line credit invoices.
*
* Marking an invoice paid by hand erases real debt, and cancelling one
* releases its credits back to the unbilled pool — either done unilaterally is
* a one-person fraud path. So finance REQUESTS the action (one permission)
* and a chief APPROVES or REJECTS it (a separate permission, different
* person). Every request is kept, decided or not: the table is the audit
* trail of who asked, who decided, and why.
*/
@Entity({ schema: "freight", name: "shipping_line_invoice_approvals" })
@Index(["invoiceId", "status"])
export class ShippingLineInvoiceApproval extends BaseEntity {
@Column({ name: "invoice_id", type: "uuid" })
invoiceId!: string;
@ManyToOne(() => Invoice)
@JoinColumn({ name: "invoice_id" })
invoice?: Invoice;
@Column({ name: "action", type: "enum", enum: ShippingLineInvoiceActionType })
action!: ShippingLineInvoiceActionType;
@Column({
name: "status",
type: "enum",
enum: ShippingLineInvoiceActionStatus,
default: ShippingLineInvoiceActionStatus.Pending,
})
status!: ShippingLineInvoiceActionStatus;
/** IAM user id of the finance staff who raised the request. */
@Column({ name: "requested_by", type: "uuid" })
requestedBy!: string;
/** Why the action is needed; shown to the approver, kept for audit. */
@Column({ name: "reason", type: "varchar", length: 500 })
reason!: string;
/** Offline payment reference (bank slip no. etc.) for MARK_PAID requests. */
@Column({
name: "payment_reference",
type: "varchar",
length: 255,
nullable: true,
})
paymentReference?: string | null;
/** IAM user id of the chief who approved/rejected; null while pending. */
@Column({ name: "decided_by", type: "uuid", nullable: true })
decidedBy?: string | null;
@Column({ name: "decided_at", type: "timestamptz", nullable: true })
decidedAt?: Date | null;
@Column({
name: "decision_note",
type: "varchar",
length: 500,
nullable: true,
})
decisionNote?: string | null;
}

View File

@@ -0,0 +1,109 @@
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 { PortalCustomer } from "../../common/booking-guards";
import { CompleteShippingLineBookingDto } from "./dto/complete-shipping-line-booking.dto";
import { ShippingLineBookingCompletionService } from "./shipping-line-booking-completion.service";
interface CurrentIamUser {
id: string;
}
/**
* The completion half of shipping-line bookings, sharing the
* `/shipping-line-bookings` prefix with {@link ShippingLineBookingsController}.
* Separate controller because it lives in its own module — see
* {@link ShippingLineBookingCompletionService} for why the module split exists.
*/
@ApiTags("shipping-line-bookings")
@Controller("shipping-line-bookings")
@ApiBearerAuth()
export class ShippingLineBookingCompletionController {
constructor(
private readonly completionService: ShippingLineBookingCompletionService,
) {}
@Get(":id/available-days")
@PortalCustomer()
@ApiOperation({
summary:
"Days with an open departure that can carry this booking's cargo — for the completion form's day picker.",
})
async availableDays(
@CurrentUser() user: CurrentIamUser,
@Param("id", ParseUUIDPipe) id: string,
) {
return this.completionService.availableDaysMine(user.id, id);
}
@Get(":id/trains")
@PortalCustomer()
@ApiOperation({
summary:
"The line's dedicated trains on the booking's lane for a shipment day, each with per-wagon-type free space — for the completion form's train picker. Cargo context (sizes/cargoTypeId/wagons) refines the availability.",
})
async trainsForDay(
@CurrentUser() user: CurrentIamUser,
@Param("id", ParseUUIDPipe) id: string,
@Query("date") date?: string,
@Query("sizes") sizes?: string,
@Query("cargoTypeId") cargoTypeId?: string,
@Query("wagons") wagons?: string,
) {
return this.completionService.trainsForDayMine(user.id, id, date, {
containerSizes: sizes ? sizes.split(",").filter(Boolean) : undefined,
cargoTypeId: cargoTypeId || undefined,
wagons: wagons ? Number(wagons) : undefined,
});
}
@Post(":id/price-preview")
@PortalCustomer()
@ApiOperation({
summary:
"Authoritative price quote for the completion payload — same compute as /complete, saved as the booking's breakdown + rate snapshots (refreshed on every re-preview). Persists nothing else.",
})
async pricePreview(
@CurrentUser() user: CurrentIamUser,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: CompleteShippingLineBookingDto,
) {
return this.completionService.previewPriceMine(user.id, id, dto);
}
@Get(":id/operations")
@PortalCustomer()
@ApiOperation({
summary:
"Operations view of the booking: the train it rides (assigned or requested) and the wagons allocated to it.",
})
async operations(
@CurrentUser() user: CurrentIamUser,
@Param("id", ParseUUIDPipe) id: string,
) {
return this.completionService.operationsMine(user.id, id);
}
@Post(":id/complete")
@PortalCustomer()
@ApiOperation({
summary:
"Complete an approved (CLEARANCE_READY) booking: cargo + binding shipment day. Prices off the line's rates, records the charge on the credit ledger and requests operation.",
})
async completeMine(
@CurrentUser() user: CurrentIamUser,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: CompleteShippingLineBookingDto,
) {
return this.completionService.completeMine(user.id, id, dto);
}
}

View File

@@ -0,0 +1,29 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { BookingsModule } from "../bookings/bookings.module";
import { Booking } from "../bookings/entities/booking.entity";
import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module";
import { ShippingLineBookingCompletionController } from "./shipping-line-booking-completion.controller";
import { ShippingLineBookingCompletionService } from "./shipping-line-booking-completion.service";
import { ShippingLineCompaniesModule } from "./shipping-line-companies.module";
/**
* Deliberately a LEAF module — registered in AppModule and imported by
* nothing. Completion needs BookingsModule (pricing + the operation-request
* transition), but ShippingLineCompaniesModule sits under rule-engine and
* companies, which sit under BookingsModule; importing bookings from there
* closes a module cycle Nest cannot construct. Keeping the completion flow
* here keeps the graph acyclic with no forwardRef chains.
*/
@Module({
imports: [
TypeOrmModule.forFeature([Booking]),
BookingsModule,
TrainSchedulingModule,
ShippingLineCompaniesModule,
],
controllers: [ShippingLineBookingCompletionController],
providers: [ShippingLineBookingCompletionService],
})
export class ShippingLineBookingCompletionModule {}

View File

@@ -0,0 +1,766 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { In, Repository } from "typeorm";
import { BookingPricingService } from "../bookings/booking-pricing.service";
import { BookingTransitionService } from "../bookings/booking-transition.service";
import { BookingsService } from "../bookings/bookings.service";
import { BookingContainer } from "../bookings/entities/booking-container.entity";
import { BookingContainerUnit } from "../bookings/entities/booking-container-unit.entity";
import { Booking } from "../bookings/entities/booking.entity";
import { wagonsPerUnitForSize } from "../rule-engine/container-type.util";
import { CargoType } from "../rule-engine/entities/cargo-type.entity";
import { ContainerType } from "../rule-engine/entities/container-type.entity";
import { eatDay } from "../train-scheduling/batch-window.util";
import {
BookingBatchService,
type TrainOptionCargoOverrides,
} from "../train-scheduling/booking-batch.service";
import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
import { WagonBookingAllocation } from "../train-schedules/entities/wagon-booking-allocation.entity";
import { TrainSchedulingService } from "../train-scheduling/services/train-scheduling.service";
import { CompleteShippingLineBookingDto } from "./dto/complete-shipping-line-booking.dto";
import {
ShippingLineCredit,
ShippingLineCreditStatus,
} from "./entities/shipping-line-credit.entity";
import { ShippingLineCompaniesService } from "./shipping-line-companies.service";
import { ShippingLineCreditsService } from "./shipping-line-credits.service";
/**
* Completion of a shipping-line booking — the step after Operations approves
* its documents, mirroring what a customer does at that point: cargo + binding
* shipment day go in, the booking prices off the line's negotiated rates and
* the request lands with Operations.
*
* Its own module (not part of {@link ShippingLineBookingsService}) because it
* needs BookingsModule (pricing, the operation-request transition) and
* TrainSchedulingModule — and ShippingLineCompaniesModule is imported by
* rule-engine/companies, which sit UNDER BookingsModule. Importing bookings
* from there closes a module cycle Nest cannot construct; a leaf module that
* nothing imports keeps the graph acyclic.
*/
@Injectable()
export class ShippingLineBookingCompletionService {
constructor(
@InjectRepository(Booking)
private readonly bookingsRepository: Repository<Booking>,
private readonly shippingLineCompaniesService: ShippingLineCompaniesService,
private readonly bookingsService: BookingsService,
private readonly bookingPricingService: BookingPricingService,
private readonly bookingTransitionService: BookingTransitionService,
private readonly trainSchedulingService: TrainSchedulingService,
private readonly bookingBatchService: BookingBatchService,
private readonly creditsService: ShippingLineCreditsService,
) {}
/** Same session→owner resolution every shipping-line entry point uses. */
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;
}
private async requireOwnBooking(
userId: string,
bookingId: string,
relations?: { bookingContainers?: boolean },
) {
const shippingLine = await this.requireShippingLine(userId);
const booking = await this.bookingsRepository.findOne({
where: { id: bookingId, shippingLineCompanyId: shippingLine.id },
relations,
});
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
return booking;
}
/**
* The line's dedicated departures on the booking's lane (DRAFT/SCHEDULED,
* soonest first). These trains run NO booking-window cycle — the line books
* whenever it wants until the close offset stamped in `windowClosesAt` — and
* they are excluded from every customer pool, so this is the only source
* that can offer them.
*/
private async dedicatedTrainsForBooking(booking: Booking) {
if (!booking.shippingLineCompanyId) return [];
return this.bookingsRepository.manager.getRepository(TrainSchedule).find({
where: {
shippingLineCompanyId: booking.shippingLineCompanyId,
originStationId: booking.originYardId ?? undefined,
destinationStationId: booking.destinationYardId ?? undefined,
status: In(["DRAFT", "SCHEDULED"]),
},
order: { scheduledDepartureDate: "ASC" },
});
}
/** Still bookable: the close offset before departure has not passed yet. */
private isStillOpen(schedule: TrainSchedule): boolean {
const closesAt =
schedule.windowClosesAt ?? schedule.scheduledDepartureDate;
return closesAt.getTime() > Date.now();
}
/**
* The line's dedicated trains on the booking's lane for one shipment day,
* each with per-wagon-type free space — the completion form's train picker.
* A booking rides ONE schedule, so with several departures that day the
* line picks which; the pick is validated again at complete time.
*/
async trainsForDayMine(
userId: string,
bookingId: string,
date: string | undefined,
overrides?: TrainOptionCargoOverrides,
) {
const booking = await this.requireOwnBooking(userId, bookingId);
if (!booking.shippingLineCompanyId) return [];
return this.bookingBatchService.dedicatedTrainOptionsForDay(
booking,
date ? eatDay(new Date(date)) : null,
booking.shippingLineCompanyId,
overrides,
);
}
/**
* Days the shipping line may pick as the shipment day.
*
* Lanes with trains DEDICATED to this line offer exactly those trains' days,
* open until each train's close offset — no window cycle. Lanes without a
* dedicated train fall back to the shared customer day pool, exactly as
* before. Ownership is checked first so one line cannot probe another's
* booking.
*/
async availableDaysMine(userId: string, bookingId: string) {
const booking = await this.requireOwnBooking(userId, bookingId);
const dedicated = await this.dedicatedTrainsForBooking(booking);
if (dedicated.length === 0) {
return this.bookingsService.availableDaysForBooking(bookingId);
}
const days = [
...new Set(
dedicated
.filter((s) => this.isStillOpen(s))
.map((s) => eatDay(s.scheduledDepartureDate)),
),
];
return { days };
}
/**
* Complete a bare shipping-line booking once Operations has approved its
* documents (CLEARANCE_READY), or after Operations returned the request
* (OPERATION_CHANGES_REQUESTED). The cargo and the binding shipment day go
* in, the booking is priced off the line's negotiated rates, and the request
* lands with Operations (OPERATION_REQUEST_PENDING) through the same
* transition customers use.
*
* Payment differs from customers by design: no invoice is issued here.
* Shipping lines run on the credit ledger — the priced amount is recorded as
* an UNBILLED credit and Finance bills a batch later, so the booking
* proceeds without a payment gate.
*/
async completeMine(
userId: string,
bookingId: string,
dto: CompleteShippingLineBookingDto,
) {
const booking = await this.requireOwnBooking(userId, bookingId, {
bookingContainers: true,
});
if (
!["CLEARANCE_READY", "OPERATION_CHANGES_REQUESTED"].includes(
booking.status,
)
) {
throw new BadRequestException(
"Your documents must be approved before the booking can be completed.",
);
}
// Completion is booking time. A lane with trains DEDICATED to this line
// has no window concept at all: the line books whenever it wants until the
// train's close offset. Only a lane with no dedicated train falls back to
// the customer window gate, unchanged.
const dedicated = await this.dedicatedTrainsForBooking(booking);
const pickedDay = eatDay(new Date(dto.scheduledDate));
const dedicatedOnDay = dedicated.filter(
(s) => eatDay(s.scheduledDepartureDate) === pickedDay,
);
let bypassDayPool = false;
let requestedTrainScheduleId: string | null = null;
if (dedicatedOnDay.length > 0) {
const openOnDay = dedicatedOnDay.filter((s) => this.isStillOpen(s));
if (openOnDay.length === 0) {
throw new BadRequestException(
"Booking for your train on this day has closed — the cut-off before departure has passed.",
);
}
// A booking rides ONE schedule. Several departures that day → the line
// must say which; a single one is picked implicitly. The id comes from
// the request, so it is validated against the day's own trains.
if (dto.trainScheduleId) {
const picked = openOnDay.find((s) => s.id === dto.trainScheduleId);
if (!picked) {
throw new BadRequestException(
"The selected train does not run your route on that day (or its booking cut-off has passed) — pick another train.",
);
}
requestedTrainScheduleId = picked.id;
} else if (openOnDay.length === 1) {
requestedTrainScheduleId = openOnDay[0].id;
} else {
throw new BadRequestException(
"More than one of your trains departs that day — select which train this booking rides.",
);
}
// The day is backed by the line's own train, which every customer pool
// deliberately excludes — so the day-pool gate downstream must not run.
bypassDayPool = true;
} else if (dedicated.length > 0) {
throw new BadRequestException(
"Pick one of your assigned train days for this route.",
);
} else {
await this.trainSchedulingService.assertBookingWindowOpen({
originYardId: booking.originYardId ?? null,
destinationYardId: booking.destinationYardId ?? null,
scheduledDate: dto.scheduledDate,
direction: booking.tradeDirection ?? null,
});
}
let hasCargo =
(booking.bookingContainers?.length ?? 0) > 0 ||
Number(booking.cargoTotalWeightVgm) > 0;
const restatesCargo = Boolean(
dto.containers?.length || dto.cargoTypeId || dto.cargoWeightTons,
);
// Operations may return the request asking for the CARGO to change, not
// just the day. A resubmit that restates cargo starts completion over:
// the recorded (unbilled) credit is written off and the persisted cargo
// wiped, so the fresh path below re-persists, re-prices and re-records.
// Once the credit is on an issued invoice the cargo is frozen — the
// invoice total must keep matching what it bills.
if (hasCargo && restatesCargo) {
const credit = await this.bookingsRepository.manager
.getRepository(ShippingLineCredit)
.findOne({ where: { bookingId } });
if (credit && credit.status === ShippingLineCreditStatus.Unbilled) {
await this.creditsService.cancelCredit(
credit.id,
"Cargo changed before billing — booking re-priced on completion.",
);
} else if (
credit &&
credit.status !== ShippingLineCreditStatus.Cancelled
) {
throw new BadRequestException(
"This booking's charge has already been invoiced — contact Operations to change its cargo.",
);
}
await this.wipeCargo(bookingId);
hasCargo = false;
}
// First completion persists cargo and prices the booking; a day-only
// resubmit after OPERATION_CHANGES_REQUESTED skips straight to the
// operation request with the cargo (and price) it already carries.
if (!hasCargo) {
if (booking.freightType === "CONTAINER") {
await this.persistContainerLines(booking, dto);
} else {
if (!dto.cargoTypeId || !(Number(dto.cargoWeightTons) > 0)) {
throw new BadRequestException(
"Bulk bookings need a cargo type and a total weight in tons.",
);
}
const cargoType = await this.bookingsRepository.manager
.getRepository(CargoType)
.findOne({ where: { id: dto.cargoTypeId, isActive: true } });
if (!cargoType) {
throw new NotFoundException(
`Cargo type ${dto.cargoTypeId} not found`,
);
}
}
await this.bookingsRepository.update(bookingId, {
cargoTypeId:
booking.freightType === "BULK" ? (dto.cargoTypeId ?? null) : null,
cargoFreeText: dto.cargoFreeText?.trim() || null,
cargoTotalWeightVgm:
booking.freightType === "BULK" ? Number(dto.cargoWeightTons) : 0,
bulkTotalWeightTons:
booking.freightType === "BULK" ? Number(dto.cargoWeightTons) : null,
// Bulk handling portions — sized against the cargo, billed by pricing.
...(booking.freightType === "BULK"
? {
bulkHazardousQuantity: Number(dto.bulkHazardousQuantity ?? 0),
bulkReeferQuantity: Number(dto.bulkReeferQuantity ?? 0),
}
: {}),
// Hazard is per-line for containers; the booking-level flag is what
// pricing bills the surcharge from.
isHazardous:
(dto.containers ?? []).some(
(line) =>
Number(line.hazardousQuantity ?? 0) > 0 ||
(line.units ?? []).some((u) => u.isHazardous),
) || Number(dto.bulkHazardousQuantity ?? 0) > 0,
// Same for reefer: the rule engine's REEFER trigger fires on the
// booking-level flag (or a reefer container TYPE) — a ticked reefer
// switch on a standard box only sets the per-line count, so without
// this flag the surcharge silently never bills.
isReefer:
(dto.containers ?? []).some(
(line) =>
Number(line.reeferQuantity ?? 0) > 0 ||
(line.units ?? []).some((u) => u.isReefer),
) || Number(dto.bulkReeferQuantity ?? 0) > 0,
// Shipping lines are always billed in ETB: the charge lands on the
// ETB credit ledger, so the currency is enforced here rather than
// trusted from the payload.
paymentCurrency: "ETB",
} as never);
const loaded = await this.bookingsRepository.findOne({
where: { id: bookingId },
relations: { bookingContainers: true, serviceType: true },
});
const computed = await this.bookingPricingService.computePriceForBooking(
loaded ?? booking,
);
// A zero price or hard block means no rate is configured for this line
// on this lane. Roll the cargo back so the booking stays completable —
// the approved clearance is not lost — and surface why.
if (!(computed.totalAmount > 0) || computed.hardBlocked.length > 0) {
await this.wipeCargo(bookingId);
throw new BadRequestException(
computed.hardBlocked.length > 0
? computed.hardBlocked.join("; ")
: "No rate is configured for your shipping line on this route/cargo — please contact Operations.",
);
}
await this.bookingsRepository.update(bookingId, {
totalAmount: computed.totalAmount,
priorityScore: computed.priorityScore,
pricingBreakdown: {
lineItems: computed.lineItems,
totalAmount: computed.totalAmount,
currency: computed.currency,
generatedAt: new Date().toISOString(),
},
} as never);
await this.bookingPricingService.createPricingSnapshots(
bookingId,
computed.usedRates,
computed.appliedModifiers,
);
// No credit is recorded here: completion only REQUESTS the operation.
// The charge lands on the line's ledger when Operations accepts —
// `shipping_line_booking.accepted` → ShippingLineCreditsService — so a
// request that is returned or never accepted creates no debt.
}
// Binding day, OPERATION_REQUEST_PENDING and the staff notification — the
// machine a customer booking uses. When the day is backed by a dedicated
// train, the customer day-pool gate is skipped (validated above instead).
return this.bookingTransitionService.requestOperation(
bookingId,
dto.scheduledDate,
requestedTrainScheduleId,
bypassDayPool ? { bypassDayPool: true } : undefined,
);
}
/**
* Authoritative price preview for the completion form's confirm step: the
* SAME compute the completion itself runs, over an in-memory probe shaped
* exactly like completeMine would persist the booking — so the figure the
* shipping line confirms is line-for-line what it will owe.
*
* The result is not advisory-only: the breakdown is saved on the booking and
* the rate snapshots are (re)written, so every re-preview refreshes them.
* Nothing else is persisted — no cargo rows, no credit, no transition.
*/
async previewPriceMine(
userId: string,
bookingId: string,
dto: CompleteShippingLineBookingDto,
) {
const booking = await this.requireOwnBooking(userId, bookingId, {
bookingContainers: true,
});
if (
!["CLEARANCE_READY", "OPERATION_CHANGES_REQUESTED"].includes(
booking.status,
)
) {
throw new BadRequestException(
"Your documents must be approved before the booking can be priced.",
);
}
// In-memory cargo, mirroring what completeMine persists.
let probeContainers: Partial<BookingContainer>[] = [];
let bulkFields: Record<string, unknown> = {};
if (booking.freightType === "CONTAINER") {
const lines = dto.containers ?? [];
if (!lines.length) {
throw new BadRequestException(
"At least one container line is required.",
);
}
for (const line of lines) {
const containerType = await this.resolveContainerType(line);
const figures = this.lineFigures(line);
probeContainers.push({
containerTypeId: containerType.id,
containerSize: containerType.sizeFt
? `${containerType.sizeFt}ft`
: null,
quantity: line.quantity,
hazardousQuantity: figures.hazardous,
reeferQuantity: figures.reefer,
returnQuantity: 0,
vgmPerUnitTons: figures.vgmPerUnit,
totalVgmTons: figures.totalVgm,
wagonsRequired: Math.ceil(
line.quantity * wagonsPerUnitForSize(containerType.sizeFt),
),
});
}
} else {
if (!dto.cargoTypeId || !(Number(dto.cargoWeightTons) > 0)) {
throw new BadRequestException(
"Bulk bookings need a cargo type and a total weight in tons.",
);
}
const cargoType = await this.bookingsRepository.manager
.getRepository(CargoType)
.findOne({ where: { id: dto.cargoTypeId, isActive: true } });
if (!cargoType) {
throw new NotFoundException(`Cargo type ${dto.cargoTypeId} not found`);
}
bulkFields = {
cargoTypeId: dto.cargoTypeId,
cargoTotalWeightVgm: Number(dto.cargoWeightTons),
bulkTotalWeightTons: Number(dto.cargoWeightTons),
bulkHazardousQuantity: Number(dto.bulkHazardousQuantity ?? 0),
bulkReeferQuantity: Number(dto.bulkReeferQuantity ?? 0),
};
probeContainers = [];
}
// Prototype-preserving clone so entity getters keep working — the same
// probe trick the contract preview uses.
const probe = Object.assign(
Object.create(Object.getPrototypeOf(booking)),
booking,
{
bookingContainers: probeContainers,
paymentCurrency: "ETB",
isHazardous:
(dto.containers ?? []).some(
(line) =>
Number(line.hazardousQuantity ?? 0) > 0 ||
(line.units ?? []).some((u) => u.isHazardous),
) || Number(dto.bulkHazardousQuantity ?? 0) > 0,
// Mirrors completeMine: without the booking-level flag the engine's
// REEFER trigger never fires for reefer opt-ins on standard boxes,
// and the quote would show base freight only.
isReefer:
(dto.containers ?? []).some(
(line) =>
Number(line.reeferQuantity ?? 0) > 0 ||
(line.units ?? []).some((u) => u.isReefer),
) || Number(dto.bulkReeferQuantity ?? 0) > 0,
...bulkFields,
},
) as Booking;
const computed =
await this.bookingPricingService.computePriceForBooking(probe);
if (!(computed.totalAmount > 0) || computed.hardBlocked.length > 0) {
throw new BadRequestException(
computed.hardBlocked.length > 0
? computed.hardBlocked.join("; ")
: "No rate is configured for your shipping line on this route/cargo — please contact Operations.",
);
}
// Persist the quoted figure: breakdown on the booking, snapshots of the
// rates it was built from. createPricingSnapshots clears the previous
// artifacts first, so a re-preview replaces the old quote rather than
// stacking a second one.
await this.bookingsRepository.update(bookingId, {
pricingBreakdown: {
lineItems: computed.lineItems,
totalAmount: computed.totalAmount,
currency: computed.currency,
generatedAt: new Date().toISOString(),
},
} as never);
await this.bookingPricingService.createPricingSnapshots(
bookingId,
computed.usedRates,
computed.appliedModifiers,
);
return {
totalAmount: computed.totalAmount,
currency: computed.currency,
lineItems: computed.lineItems,
warnings: computed.warnings,
};
}
/**
* What operations has done with the booking so far: the train it rides
* (assigned, or the requested one before assignment) and the wagons the
* batch engine allocated to it, with any container numbers loaded per wagon.
* Read-only, owner-scoped — feeds the detail page's Wagons & Train tab.
*/
async operationsMine(userId: string, bookingId: string) {
const booking = await this.requireOwnBooking(userId, bookingId);
const manager = this.bookingsRepository.manager;
const scheduleId =
booking.trainScheduleId ?? booking.requestedTrainScheduleId ?? null;
let train: Record<string, unknown> | null = null;
if (scheduleId) {
const schedule = await manager.getRepository(TrainSchedule).findOne({
where: { id: scheduleId },
relations: { originStation: true, destinationStation: true },
});
if (schedule) {
train = {
id: schedule.id,
reference: schedule.reference,
trainNumber: schedule.trainNumber,
status: schedule.status,
direction: schedule.direction,
scheduledDepartureDate: schedule.scheduledDepartureDate,
scheduledArrivalDate: schedule.scheduledArrivalDate,
originLabel:
schedule.originStation?.label ??
schedule.originStation?.code ??
"Origin",
destinationLabel:
schedule.destinationStation?.label ??
schedule.destinationStation?.code ??
"Destination",
// Whether this is the confirmed assignment or still the request.
assigned: Boolean(booking.trainScheduleId),
};
}
}
const allocations = await manager
.getRepository(WagonBookingAllocation)
.find({
where: { bookingId },
relations: {
trainSetWagon: { wagonType: true, physicalWagon: true },
containerItems: true,
},
order: { createdAt: "ASC" },
});
const wagons = allocations.map((allocation) => ({
id: allocation.id,
status: allocation.status,
loadType: allocation.loadType,
allocatedWeightTons: Number(allocation.allocatedWeightTons),
sequenceNo: allocation.trainSetWagon?.sequenceNo ?? null,
wagonNumber: allocation.trainSetWagon?.physicalWagon?.wagonNumber ?? null,
wagonType:
allocation.trainSetWagon?.wagonType?.name ??
allocation.trainSetWagon?.wagonType?.code ??
null,
capacityTons: Number(allocation.trainSetWagon?.capacityTons ?? 0),
containerNumbers: (allocation.containerItems ?? [])
.map((item) => item.containerNumber)
.filter((n): n is string => Boolean(n)),
}));
return { train, wagons };
}
/**
* Resolve a line's container type: by id when the payload carries one, else
* from the size string ("40ft" → the active 40ft type, preferring the reefer
* variant when the line ships reefer boxes). Resolution lives HERE, not in
* the portal, so a slow or failed catalog fetch can never block a booking
* with a phantom "type not configured" error — mirrors the customer flow's
* server-side size→type mapping.
*/
private async resolveContainerType(line: {
containerTypeId?: string;
containerSize?: string;
reeferQuantity?: number;
units?: { isReefer?: boolean }[];
}): Promise<ContainerType> {
const containerTypeRepo =
this.bookingsRepository.manager.getRepository(ContainerType);
if (line.containerTypeId) {
const byId = await containerTypeRepo.findOne({
where: { id: line.containerTypeId, isActive: true },
});
if (!byId) {
throw new NotFoundException(
`Container type ${line.containerTypeId} not found`,
);
}
return byId;
}
const sizeFt = parseInt(line.containerSize ?? "", 10);
if (!Number.isFinite(sizeFt)) {
throw new BadRequestException(
"Each container line needs a containerTypeId or a containerSize.",
);
}
const candidates = await containerTypeRepo.find({
where: { isActive: true },
});
const ofSize = candidates.filter((ct) => Number(ct.sizeFt) === sizeFt);
if (!ofSize.length) {
throw new BadRequestException(
`No ${sizeFt}ft container type is configured — please contact Operations.`,
);
}
const wantsReefer =
Number(line.reeferQuantity ?? 0) > 0 ||
(line.units ?? []).some((u) => u.isReefer);
if (wantsReefer) {
const reefer = ofSize.find((ct) => ct.isReefer);
if (reefer) return reefer;
}
return ofSize.find((ct) => !ct.isReefer) ?? ofSize[0];
}
/**
* A line's derived figures. With per-container rows (the full booking page),
* counts and VGM come FROM the rows — each container's switches are the
* source of truth. Without them, the line-level figures stand alone.
*/
private lineFigures(line: {
quantity: number;
vgmPerUnitTons?: number;
hazardousQuantity?: number;
reeferQuantity?: number;
units?: { vgmTons?: number; isHazardous?: boolean; isReefer?: boolean }[];
}) {
const units = line.units ?? [];
const hazardous = units.length
? units.filter((u) => u.isHazardous).length
: Math.min(Number(line.hazardousQuantity ?? 0), line.quantity);
const reefer = units.length
? units.filter((u) => u.isReefer).length
: Math.min(Number(line.reeferQuantity ?? 0), line.quantity);
const totalVgm = units.length
? units.reduce((s, u) => s + Number(u.vgmTons ?? 0), 0)
: Number(line.vgmPerUnitTons ?? 0) * line.quantity;
const vgmPerUnit = units.length
? totalVgm / units.length
: Number(line.vgmPerUnitTons ?? 0);
return { hazardous, reefer, totalVgm, vgmPerUnit };
}
/**
* Persist the container lines of a CONTAINER completion. Same row shape the
* customer paths write (quantity per type, VGM totals, wagon share) — the
* per-unit ISO numbers customers also skip at booking time arrive later at
* yard operations.
*/
private async persistContainerLines(
booking: Booking,
dto: CompleteShippingLineBookingDto,
): Promise<void> {
const lines = dto.containers ?? [];
if (!lines.length) {
throw new BadRequestException("At least one container line is required.");
}
const containerRepo =
this.bookingsRepository.manager.getRepository(BookingContainer);
const unitRepo =
this.bookingsRepository.manager.getRepository(BookingContainerUnit);
for (const line of lines) {
const containerType = await this.resolveContainerType(line);
// Counts and VGM derived by lineFigures — the same math the price
// preview runs, so the persisted cargo always matches the quote.
const units = line.units ?? [];
const figures = this.lineFigures(line);
const containerRow = await containerRepo.save(
containerRepo.create({
bookingId: booking.id,
containerTypeId: containerType.id,
containerSize: containerType.sizeFt
? `${containerType.sizeFt}ft`
: null,
quantity: line.quantity,
hazardousQuantity: figures.hazardous,
reeferQuantity: figures.reefer,
returnQuantity: 0,
vgmPerUnitTons: figures.vgmPerUnit,
totalVgmTons: figures.totalVgm,
wagonsRequired: Math.ceil(
line.quantity * wagonsPerUnitForSize(containerType.sizeFt),
),
}),
);
let sortOrder = 0;
for (const unit of units) {
await unitRepo.save(
unitRepo.create({
bookingContainerId: containerRow.id,
containerNumber: unit.containerNumber.trim().toUpperCase(),
sealNumber: unit.sealNumber?.trim() || null,
vgmTons: Number(unit.vgmTons ?? 0),
isHazardous: unit.isHazardous ?? false,
isReefer: unit.isReefer ?? false,
isReturn: false,
sortOrder: sortOrder++,
}),
);
}
}
}
/** Roll a failed/superseded completion back to the bare-booking shape. */
private async wipeCargo(bookingId: string): Promise<void> {
await this.bookingsRepository.manager
.getRepository(BookingContainer)
.softDelete({ bookingId });
await this.bookingsRepository.update(bookingId, {
cargoTypeId: null,
cargoTotalWeightVgm: 0,
bulkTotalWeightTons: null,
totalAmount: 0,
pricingBreakdown: null,
} as never);
}
}

View File

@@ -0,0 +1,107 @@
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);
}
// Declared before @Get(":id") so the path isn't captured as a booking id.
@Get("my-trains")
@PortalCustomer()
@ApiOperation({
summary:
"Train departures dedicated to the signed-in shipping line. These trains are hidden from customers; this is the only portal read that surfaces them.",
})
async listMyTrains(@CurrentUser() user: CurrentIamUser) {
return this.shippingLineBookingsService.listMyTrains(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,473 @@
import { insertWithGeneratedReference } from "@edr/api-common";
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { In, MoreThanOrEqual, 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 { CargoType } from "../rule-engine/entities/cargo-type.entity";
import { ContainerType } from "../rule-engine/entities/container-type.entity";
import { ServiceType } from "../rule-engine/entities/service-type.entity";
import { TrainSchedule } from "../train-schedules/entities/train-schedule.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, containerTypes, cargoTypes] =
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" },
}),
// For the completion form: what ships. Container types for CONTAINER
// bookings, cargo types for BULK ones.
this.bookingsRepository.manager.getRepository(ContainerType).find({
where: { isActive: true },
}),
this.bookingsRepository.manager.getRepository(CargoType).find({
where: { 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,
})),
containerTypes: containerTypes.map((ct) => ({
id: ct.id,
label: ct.label ?? ct.code,
sizeFt: ct.sizeFt,
isReefer: ct.isReefer,
})),
// parentGroupId lets the portal tell leaf types from grouping rows.
cargoTypes: cargoTypes.map((cargo) => ({
id: cargo.id,
name: cargo.cargoTypeName,
parentGroupId: cargo.parentGroupId ?? null,
unitOfMeasure: cargo.unitOfMeasure ?? null,
})),
};
}
/**
* 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.",
);
}
// Only a forward-looking day makes sense; train validation happens later
// when Operations schedules it, so only the past is rejected here.
let scheduledDate: Date | null = null;
if (dto.scheduledDate) {
scheduledDate = new Date(dto.scheduledDate);
const today = new Date();
today.setHours(0, 0, 0, 0);
if (scheduledDate < today) {
throw new BadRequestException(
"The scheduled date cannot be in the past.",
);
}
}
// 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",
// The shipping line picks its shipment day up front (no later
// operation-request step exists for them); cargo is still filled in
// when the booking is completed.
scheduledDate,
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. Cargo
// (container lines + units, bulk cargo type) rides along for the detail
// page's cargo tab once the booking is completed.
relations: {
originYard: true,
destinationYard: true,
serviceType: true,
cargoType: true,
bookingContainers: { containerType: true, units: 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" } });
// The note Operations wrote when returning the request — the line has to
// read it to know what to fix. Only the latest CHANGES_REQUESTED note is
// exposed; the other review-note types are staff-internal.
const changeNote =
booking.status === "OPERATION_CHANGES_REQUESTED"
? await this.bookingsRepository.manager
.getRepository(BookingReviewNote)
.findOne({
where: { bookingId, type: "CHANGES_REQUESTED" },
order: { createdAt: "DESC" },
})
: null;
return {
...booking,
hasQueriedDocuments: queriedCount > 0,
operationChangeNote: changeNote?.note ?? null,
};
}
/**
* Train departures dedicated to the signed-in shipping line: schedules whose
* `shippingLineCompanyId` is this line's. These trains are hidden from every
* customer-facing read, so this endpoint is the ONLY place they surface in
* the portal — the home page lists them and the booking detail matches them
* to a booking by lane + day.
*/
async listMyTrains(userId: string) {
const shippingLine = await this.requireShippingLine(userId);
// Recent past kept (48h) so a just-departed train is still visible while
// its cargo is on the rails; CANCELLED never shows.
const horizon = new Date(Date.now() - 48 * 60 * 60 * 1000);
const schedules = await this.bookingsRepository.manager
.getRepository(TrainSchedule)
.find({
where: {
shippingLineCompanyId: shippingLine.id,
status: In(["DRAFT", "SCHEDULED", "DISPATCHED"]),
scheduledDepartureDate: MoreThanOrEqual(horizon),
},
relations: { originStation: true, destinationStation: true },
order: { scheduledDepartureDate: "ASC" },
});
return schedules.map((s) => ({
id: s.id,
reference: s.reference,
trainNumber: s.trainNumber,
status: s.status,
direction: s.direction,
scheduledDepartureDate: s.scheduledDepartureDate,
scheduledArrivalDate: s.scheduledArrivalDate,
originYardId: s.originStationId,
originLabel: s.originStation?.label ?? s.originStation?.code ?? "Origin",
destinationYardId: s.destinationStationId,
destinationLabel:
s.destinationStation?.label ??
s.destinationStation?.code ??
"Destination",
}));
}
/**
* 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

@@ -0,0 +1,100 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
Query,
} from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { BookingStaff } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { BackofficeResetPasswordDto } from "../auth/dto/forgot-password.dto";
import { CreateShippingLineDto } from "./dto/create-shipping-line.dto";
import {
RegisterShippingLineResponseDto,
ShippingLineResponseDto,
} from "./dto/shipping-line-response.dto";
import { ShippingLineCompaniesService } from "./shipping-line-companies.service";
/**
* Shipping line *companies* — carriers with a portal login, registered by staff
* (there is no self-signup). The line receives a single-use activation link and
* sets its own password, so staff never see or handle a credential.
*
* Distinct from `freight.shipping_lines` behind `/shipping-lines`
* (rule-engine): that is a pricing lookup list — a code/label a booking points
* at via `shipping_line_id` — with no account, no user and no login. Same words,
* different concept, hence the separate route.
*/
@ApiTags("shipping-line-companies")
@Controller("shipping-line-companies")
@ApiBearerAuth()
export class ShippingLineCompaniesController {
constructor(private readonly shippingLineCompaniesService: ShippingLineCompaniesService) {}
@Post()
@BookingStaff(FREIGHT_PERMS.shippingLines.create)
@ApiOperation({
summary: "Register a shipping line and send its activation link",
})
async register(
@Body() dto: CreateShippingLineDto,
): Promise<RegisterShippingLineResponseDto> {
const { shippingLine, activationSentTo } =
await this.shippingLineCompaniesService.register(dto);
return new RegisterShippingLineResponseDto(shippingLine, activationSentTo);
}
@Get()
// OR'd: the credits view needs this list as its line picker, so holding
// shipping_line_credits:view alone is enough to read it.
@BookingStaff([
FREIGHT_PERMS.shippingLines.view,
FREIGHT_PERMS.shippingLineCredits.view,
])
@ApiOperation({ summary: "List shipping lines (paginated)" })
async list(
@Query("page") page?: string,
@Query("limit") limit?: string,
): Promise<{
items: ShippingLineResponseDto[];
total: number;
page: number;
limit: number;
}> {
const result = await this.shippingLineCompaniesService.list(
page ? Number(page) : undefined,
limit ? Number(limit) : undefined,
);
return {
...result,
items: result.items.map((item) => new ShippingLineResponseDto(item)),
};
}
@Get(":id")
@BookingStaff(FREIGHT_PERMS.shippingLines.view)
@ApiOperation({ summary: "Get a shipping line by id" })
async findOne(
@Param("id", ParseUUIDPipe) id: string,
): Promise<ShippingLineResponseDto> {
return new ShippingLineResponseDto(
await this.shippingLineCompaniesService.findById(id),
);
}
@Post(":id/resend-activation")
@BookingStaff(FREIGHT_PERMS.shippingLines.resetPassword)
@ApiOperation({
summary: "Resend a shipping line's activation / password-reset link",
})
async resendActivation(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: BackofficeResetPasswordDto,
) {
return this.shippingLineCompaniesService.resendActivation(id, dto.channel);
}
}

View File

@@ -0,0 +1,59 @@
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 { ShippingLineInvoiceApproval } from "./entities/shipping-line-invoice-approval.entity";
import { ShippingLineInvoiceApprovalsRepository } from "./shipping-line-invoice-approvals.repository";
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: [
// 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,
ShippingLineInvoiceApproval,
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,
ShippingLineBookingsController,
ShippingLineCreditsController,
],
providers: [
ShippingLineCompaniesService,
ShippingLineCompaniesRepository,
ShippingLineBookingsService,
ShippingLineCreditsService,
ShippingLineCreditsRepository,
ShippingLineInvoiceApprovalsRepository,
],
// Exported so whatever prices a shipping-line booking can record the charge.
exports: [ShippingLineCompaniesService, ShippingLineCreditsService],
})
export class ShippingLineCompaniesModule {}

View File

@@ -0,0 +1,61 @@
import { BaseRepository } from "@edr/api-common";
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { EntityManager, Repository } from "typeorm";
import { ShippingLineCompany } from "./entities/shipping-line-company.entity";
@Injectable()
export class ShippingLineCompaniesRepository extends BaseRepository<ShippingLineCompany> {
constructor(
@InjectRepository(ShippingLineCompany)
private readonly shippingLineRepo: Repository<ShippingLineCompany>,
) {
super(shippingLineRepo);
}
findByUserId(userId: string): Promise<ShippingLineCompany | null> {
return this.shippingLineRepo.findOne({ where: { userId } });
}
/** Case-insensitive, matching the `lower(email)` unique index. */
async existsByEmail(email: string): Promise<boolean> {
const count = await this.shippingLineRepo
.createQueryBuilder("sl")
.where("lower(sl.email) = lower(:email)", { email })
.getCount();
return count > 0;
}
async existsByScac(scacCode: string): Promise<boolean> {
const count = await this.shippingLineRepo
.createQueryBuilder("sl")
.where("upper(sl.scacCode) = upper(:scacCode)", { scacCode })
.getCount();
return count > 0;
}
findAllPaginated(
skip: number,
take: number,
): Promise<[ShippingLineCompany[], number]> {
return this.shippingLineRepo.findAndCount({
order: { createdAt: "DESC" },
skip,
take,
});
}
/**
* Insert inside a caller-supplied transaction, so the shipping-line row and
* the IAM user it points at commit together — a row referencing a user that
* was rolled back (or vice versa) is an account nobody can sign in to.
*/
createInTransaction(
manager: EntityManager,
data: Partial<ShippingLineCompany>,
): Promise<ShippingLineCompany> {
const repo = manager.getRepository(ShippingLineCompany);
return repo.save(repo.create(data));
}
}

View File

@@ -0,0 +1,233 @@
import { ConflictException } from "@nestjs/common";
import {
EUserStatus,
EUserType,
} from "@tria-plc/api-common/utils/enums/user.enum";
import { ResetChannel } from "../auth/dto/forgot-password.dto";
import { ShippingLineCompaniesService } from "./shipping-line-companies.service";
/**
* Registration is the whole feature: an IAM account and a carrier record
* created together, then an activation link the line uses to set its own
* password. These lock the parts that would silently break the login.
*/
describe("ShippingLineCompaniesService.register", () => {
const savedUser = { id: "user-1" };
let shippingLinesRepo: {
existsByEmail: jest.Mock;
existsByScac: jest.Mock;
createInTransaction: jest.Mock;
findById: jest.Mock;
findByUserId: jest.Mock;
};
let userRepository: { findOne: jest.Mock };
let customerResetService: { sendResetLinkToUser: jest.Mock };
let dataSource: { transaction: jest.Mock };
let userRepoInTx: { create: jest.Mock; save: jest.Mock };
let service: ShippingLineCompaniesService;
const dto = {
name: "Ethiopian Shipping Lines",
email: "Ops@ESL.com.et",
phoneNumber: "+251911223344",
scacCode: "eslk",
};
beforeEach(() => {
userRepoInTx = {
create: jest.fn((v) => v),
save: jest.fn().mockResolvedValue(savedUser),
};
shippingLinesRepo = {
existsByEmail: jest.fn().mockResolvedValue(false),
existsByScac: jest.fn().mockResolvedValue(false),
createInTransaction: jest
.fn()
.mockImplementation((_m, data) => ({ id: "sl-1", ...data })),
findById: jest.fn(),
findByUserId: jest.fn(),
};
userRepository = { findOne: jest.fn().mockResolvedValue(null) };
customerResetService = {
sendResetLinkToUser: jest
.fn()
.mockResolvedValue({ maskedTarget: "o**@esl.com.et", channel: "email" }),
};
dataSource = {
transaction: jest.fn(async (cb) =>
cb({ getRepository: () => userRepoInTx }),
),
};
service = new ShippingLineCompaniesService(
shippingLinesRepo as never,
userRepository as never,
customerResetService as never,
dataSource as never,
);
});
it("creates the IAM account with no password set", async () => {
await service.register(dto as never);
const created = userRepoInTx.create.mock.calls[0][0];
expect(created).toMatchObject({
userType: EUserType.INDIVIDUAL,
isActive: true,
status: EUserStatus.ACCEPTED,
// The line sets its own password from the activation link. Employee
// creation seeds a shared default here; a shipping line must not get one.
hasSetPassword: false,
});
});
it("never writes a credential row", async () => {
await service.register(dto as never);
// Only the User repository is touched inside the transaction — a
// UserCredential insert would mean the account has a password nobody chose.
for (const call of userRepoInTx.save.mock.calls) {
expect(call[0]).not.toHaveProperty("password");
}
});
it("normalises email and SCAC before storing", async () => {
const result = await service.register(dto as never);
expect(result.shippingLine).toMatchObject({
email: "ops@esl.com.et",
scacCode: "ESLK",
});
});
it("creates the account and the record in one transaction", async () => {
await service.register(dto as never);
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
expect(shippingLinesRepo.createInTransaction).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ userId: "user-1" }),
);
});
it("sends the activation link outside the transaction, after commit", async () => {
const order: string[] = [];
dataSource.transaction.mockImplementation(async (cb: never) => {
order.push("tx");
return (cb as unknown as (m: unknown) => Promise<unknown>)({
getRepository: () => userRepoInTx,
});
});
customerResetService.sendResetLinkToUser.mockImplementation(async () => {
order.push("send");
return { maskedTarget: "o**@esl.com.et", channel: "email" };
});
await service.register(dto as never);
expect(order[0]).toBe("tx");
expect(order).toContain("send");
});
it("emails the link, and also texts it when the number is domestic", async () => {
await service.register(dto as never);
const channels = customerResetService.sendResetLinkToUser.mock.calls.map(
(c) => c[1],
);
expect(channels).toContain(ResetChannel.Email);
expect(channels).toContain(ResetChannel.Phone);
});
it("emails only when the number is foreign — the SMS gateway is domestic-only", async () => {
await service.register({ ...dto, phoneNumber: "+441234567890" } as never);
const channels = customerResetService.sendResetLinkToUser.mock.calls.map(
(c) => c[1],
);
expect(channels).toEqual([ResetChannel.Email]);
});
it("keeps the registration when the activation link fails to send", async () => {
customerResetService.sendResetLinkToUser.mockResolvedValue(null);
const result = await service.register(dto as never);
// The account is valid without the link and the link is resendable —
// a delivery failure must not roll back the registration.
expect(result.shippingLine).toMatchObject({ id: "sl-1" });
expect(result.activationSentTo).toBeNull();
});
it("refuses a duplicate email", async () => {
shippingLinesRepo.existsByEmail.mockResolvedValue(true);
await expect(service.register(dto as never)).rejects.toBeInstanceOf(
ConflictException,
);
expect(dataSource.transaction).not.toHaveBeenCalled();
});
it("refuses a duplicate SCAC", async () => {
shippingLinesRepo.existsByScac.mockResolvedValue(true);
await expect(service.register(dto as never)).rejects.toBeInstanceOf(
ConflictException,
);
expect(dataSource.transaction).not.toHaveBeenCalled();
});
it("refuses credentials already belonging to another account", async () => {
// Reusing an existing IAM user would let one login resolve to both a
// customer and a shipping line.
userRepository.findOne.mockResolvedValue({ id: "existing" });
await expect(service.register(dto as never)).rejects.toBeInstanceOf(
ConflictException,
);
expect(dataSource.transaction).not.toHaveBeenCalled();
});
it("defaults the username to the email", async () => {
await service.register(dto as never);
expect(userRepoInTx.create.mock.calls[0][0]).toMatchObject({
username: "ops@esl.com.et",
});
});
/**
* The default reset lookup inner-joins an active `user_credentials` row so a
* reset cannot revive a suspended account. A shipping line has no credential
* until it uses the activation link, so without this flag the account is
* excluded from its own activation — the link is never minted, never logged,
* and resend answers 404.
*/
it("requests the credential-less lookup for every activation send", async () => {
await service.register(dto as never);
expect(customerResetService.sendResetLinkToUser).toHaveBeenCalled();
for (const call of customerResetService.sendResetLinkToUser.mock.calls) {
expect(call[2]).toMatchObject({ allowWithoutCredential: true });
}
});
it("requests the credential-less lookup when resending", async () => {
shippingLinesRepo.findById.mockResolvedValue({
id: "sl-1",
userId: "user-1",
phoneNumber: "+251911223344",
});
await service.resendActivation("sl-1", ResetChannel.Email);
expect(customerResetService.sendResetLinkToUser).toHaveBeenCalledWith(
"user-1",
ResetChannel.Email,
expect.objectContaining({ allowWithoutCredential: true }),
);
});
});

View File

@@ -0,0 +1,223 @@
import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
} from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import {
EUserStatus,
EUserType,
} from "@tria-plc/api-common/utils/enums/user.enum";
// Subpath import (not the package root) so ts-jest can resolve it when this
// file lands in a spec's compile graph — same reason as backoffice.service.ts.
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
import { DataSource, Repository } from "typeorm";
import { CustomerResetService } from "../auth/customer-reset.service";
import { ResetChannel } from "../auth/dto/forgot-password.dto";
import { isDomesticPhone } from "../otp/otp.service";
import { CreateShippingLineDto } from "./dto/create-shipping-line.dto";
import { ShippingLineCompany } from "./entities/shipping-line-company.entity";
import { ShippingLineCompaniesRepository } from "./shipping-line-companies.repository";
export interface RegisteredShippingLine {
shippingLine: ShippingLineCompany;
/** Masked destination of the activation link, or null if none was sent. */
activationSentTo: string | null;
activationChannel: ResetChannel | null;
}
@Injectable()
export class ShippingLineCompaniesService {
private readonly logger = new Logger(ShippingLineCompaniesService.name);
constructor(
private readonly shippingLineCompaniesRepo: ShippingLineCompaniesRepository,
@InjectRepository(User)
private readonly userRepository: Repository<User>,
private readonly customerResetService: CustomerResetService,
private readonly dataSource: DataSource,
) {}
/**
* Register a shipping line: create its IAM account and its record together,
* then send an activation link so the line sets its own password.
*
* The IAM mechanics follow `BackofficeService.createOrganizationUser` — same
* entities, same transaction shape — with one deliberate difference: no
* `UserCredential` row is written and `hasSetPassword` stays false. Staff
* creating an employee seed a shared default password; a shipping line must
* come through the activation link instead, so no credential exists until the
* line sets one.
*/
async register(dto: CreateShippingLineDto): Promise<RegisteredShippingLine> {
const email = dto.email.trim().toLowerCase();
const username = (dto.username?.trim() || email).toLowerCase();
const phoneNumber = dto.phoneNumber?.trim() || undefined;
const scacCode = dto.scacCode?.trim().toUpperCase();
if (await this.shippingLineCompaniesRepo.existsByEmail(email)) {
throw new ConflictException(
`A shipping line with email ${email} already exists`,
);
}
if (scacCode && (await this.shippingLineCompaniesRepo.existsByScac(scacCode))) {
throw new ConflictException(
`A shipping line with SCAC ${scacCode} already exists`,
);
}
// An existing IAM account means these credentials already belong to a
// customer or an employee. Reusing it would let one login resolve to two
// different account kinds, so this is refused rather than merged — unlike
// employee creation, which legitimately re-uses a person's existing user.
const existingUser = await this.userRepository.findOne({
where: [{ email }, { username }],
select: { id: true },
});
if (existingUser) {
throw new ConflictException(
"email_or_username_already_in_use",
);
}
const shippingLine = await this.dataSource.transaction(async (manager) => {
const userRepo = manager.getRepository(User);
const user = await userRepo.save(
userRepo.create({
email,
username,
phoneNumber,
name: { en: dto.name.trim() },
userType: EUserType.INDIVIDUAL,
isActive: true,
// No credential row is written: the account has no password until the
// activation link is used. `hasSetPassword` must stay false or the
// portal treats the account as ready to sign in with a password that
// does not exist.
hasSetPassword: false,
status: EUserStatus.ACCEPTED,
}),
);
return this.shippingLineCompaniesRepo.createInTransaction(manager, {
userId: user.id as string,
name: dto.name.trim(),
email,
phoneNumber: phoneNumber ?? null,
scacCode: scacCode ?? null,
imoNumber: dto.imoNumber?.trim() || null,
bicCode: dto.bicCode?.trim() || null,
});
});
// Outside the transaction on purpose: a delivery failure must not roll back
// a registered line. The link is resendable, and the account is already
// valid without it.
const activation = await this.sendActivationLink(shippingLine);
return {
shippingLine,
activationSentTo: activation?.maskedTarget ?? null,
activationChannel: activation?.channel ?? null,
};
}
/**
* Send the activation link on registration.
*
* Email always goes out — it is required at registration and is the only
* channel guaranteed to reach a foreign-registered line. SMS is sent in
* addition when the number is domestic, since the gateway silently drops
* anything else (see `CustomerResetService`). Two links are two independent
* single-use tickets; whichever the line opens first works.
*
* Reports the email send, as that is the one that is always attempted.
*/
async sendActivationLink(shippingLine: ShippingLineCompany) {
const scope = `shipping line ${shippingLine.id}`;
const emailed = await this.customerResetService.sendResetLinkToUser(
shippingLine.userId,
ResetChannel.Email,
{ scope, allowWithoutCredential: true },
);
if (!emailed) {
this.logger.error(
`Activation email not sent for shipping line ${shippingLine.id} — no reachable address`,
);
}
if (shippingLine.phoneNumber && isDomesticPhone(shippingLine.phoneNumber)) {
const texted = await this.customerResetService.sendResetLinkToUser(
shippingLine.userId,
ResetChannel.Phone,
{ scope, allowWithoutCredential: true },
);
if (!texted) {
this.logger.warn(
`Activation SMS not sent for shipping line ${shippingLine.id}`,
);
}
}
return emailed;
}
async resendActivation(id: string, channel: ResetChannel) {
const shippingLine = await this.shippingLineCompaniesRepo.findById(id);
if (!shippingLine) {
throw new NotFoundException("Shipping line not found");
}
if (
channel === ResetChannel.Phone &&
(!shippingLine.phoneNumber || !isDomesticPhone(shippingLine.phoneNumber))
) {
throw new BadRequestException(
"This shipping line has no domestic phone number — the SMS gateway cannot reach it",
);
}
const sent = await this.customerResetService.sendResetLinkToUser(
shippingLine.userId,
channel,
{ scope: `shipping line ${shippingLine.id}`, allowWithoutCredential: true },
);
if (!sent) {
throw new NotFoundException(
`No active account with ${
channel === ResetChannel.Email ? "an email address" : "a phone number"
} for this shipping line`,
);
}
return sent;
}
async findById(id: string): Promise<ShippingLineCompany> {
const shippingLine = await this.shippingLineCompaniesRepo.findById(id);
if (!shippingLine) {
throw new NotFoundException("Shipping line not found");
}
return shippingLine;
}
/** The shipping line signed in as `userId`, or null for any other account. */
findByUserId(userId: string): Promise<ShippingLineCompany | null> {
return this.shippingLineCompaniesRepo.findByUserId(userId);
}
async list(page = 1, limit = 20) {
const [items, total] = await this.shippingLineCompaniesRepo.findAllPaginated(
(page - 1) * limit,
limit,
);
return { items, total, page, limit };
}
}

View File

@@ -0,0 +1,284 @@
import { CurrentUser } from "@edr/api-common";
import { Freight } from "@edr/types";
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,
DecideInvoiceActionDto,
GenerateCreditInvoiceDto,
RequestInvoiceActionDto,
} from "./dto/shipping-line-credit.dto";
import { ShippingLineCreditStatus } from "./entities/shipping-line-credit.entity";
import { ShippingLineInvoiceActionType } from "./entities/shipping-line-invoice-approval.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) {}
@Get()
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.view)
@ApiOperation({
summary:
"The whole credit ledger across every shipping line (paginated), optionally filtered by line and/or status.",
})
async listAll(
@Query("page") page?: string,
@Query("pageSize") pageSize?: string,
@Query("status") status?: ShippingLineCreditStatus,
@Query("shippingLineId", new ParseUUIDPipe({ optional: true }))
shippingLineId?: string,
) {
return this.credits.listAll(
page ? Number(page) : 1,
pageSize ? Number(pageSize) : 20,
status,
shippingLineId,
);
}
// Declared before the parameterised staff routes so "summary" is never
// captured as a shipping-line id.
@Get("summary")
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.view)
@ApiOperation({
summary:
"Outstanding totals across every shipping line, or one line when shippingLineId is given.",
})
async summaryAll(
@Query("shippingLineId", new ParseUUIDPipe({ optional: true }))
shippingLineId?: string,
) {
return this.credits.summary(shippingLineId);
}
// Declared before ":shippingLineId" so "invoices" is never captured as an id.
@Get("invoices")
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.view)
@ApiOperation({
summary:
"Credit invoices across every shipping line (paginated), each with any pending manual-action request.",
})
async listInvoices(
@Query("page") page?: string,
@Query("pageSize") pageSize?: string,
@Query("status") status?: string,
@Query("shippingLineId", new ParseUUIDPipe({ optional: true }))
shippingLineId?: string,
) {
return this.credits.listCreditInvoices(
page ? Number(page) : 1,
pageSize ? Number(pageSize) : 20,
status as Freight.InvoiceStatus | undefined,
shippingLineId,
);
}
@Get("invoice-actions/pending")
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.view)
@ApiOperation({
summary:
"Undecided manual-action requests for a batch of invoices (one lookup for a list page).",
})
async pendingInvoiceActions(@Query("invoiceIds") invoiceIds?: string) {
const ids = (invoiceIds ?? "")
.split(",")
.map((id) => id.trim())
.filter(Boolean);
return this.credits.pendingInvoiceActions(ids);
}
// ── Makerchecker on credit invoices ──────────────────────────────────────
// Request and approve are DIFFERENT permissions, and the service refuses a
// decision by the requester — marking debt paid or voiding an invoice is
// never a one-person action.
@Post("invoices/:invoiceId/mark-paid-request")
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.invoiceMarkPaid)
@ApiOperation({
summary:
"Request recording a full offline payment against a credit invoice (awaits chief approval).",
})
async requestMarkPaid(
@Param("invoiceId", ParseUUIDPipe) invoiceId: string,
@Body() dto: RequestInvoiceActionDto,
@CurrentUser() user: CurrentIamUser,
) {
return this.credits.requestInvoiceAction(
invoiceId,
ShippingLineInvoiceActionType.MarkPaid,
user.id,
dto.reason,
dto.paymentReference,
);
}
@Post("invoices/:invoiceId/cancel-request")
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.invoiceCancel)
@ApiOperation({
summary:
"Request voiding a credit invoice — its credits return to the unbilled pool (awaits chief approval).",
})
async requestCancel(
@Param("invoiceId", ParseUUIDPipe) invoiceId: string,
@Body() dto: RequestInvoiceActionDto,
@CurrentUser() user: CurrentIamUser,
) {
return this.credits.requestInvoiceAction(
invoiceId,
ShippingLineInvoiceActionType.Cancel,
user.id,
dto.reason,
);
}
@Post("invoice-actions/:approvalId/approve")
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.invoiceApprove)
@ApiOperation({
summary:
"Approve a pending invoice request — executes the offline settlement or the cancellation.",
})
async approveInvoiceAction(
@Param("approvalId", ParseUUIDPipe) approvalId: string,
@Body() dto: DecideInvoiceActionDto,
@CurrentUser() user: CurrentIamUser,
) {
return this.credits.decideInvoiceAction(
approvalId,
user.id,
true,
dto.note,
);
}
@Post("invoice-actions/:approvalId/reject")
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.invoiceReject)
@ApiOperation({
summary: "Reject a pending invoice request — nothing is changed.",
})
async rejectInvoiceAction(
@Param("approvalId", ParseUUIDPipe) approvalId: string,
@Body() dto: DecideInvoiceActionDto,
@CurrentUser() user: CurrentIamUser,
) {
return this.credits.decideInvoiceAction(
approvalId,
user.id,
false,
dto.note,
);
}
// 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,148 @@
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. Without a line id it totals every line —
* the back-office overview figure.
*/
async outstandingFor(
shippingLineCompanyId?: string,
): Promise<OutstandingTotals> {
const qb = this.credits
.createQueryBuilder("credit")
.select("credit.status", "status")
.addSelect("COALESCE(SUM(credit.amount), 0)", "amount")
.addSelect("COUNT(*)", "count")
.where("credit.status IN (:...statuses)", {
statuses: [...OUTSTANDING_CREDIT_STATUSES],
})
.andWhere("credit.deletedAt IS NULL")
.groupBy("credit.status");
if (shippingLineCompanyId) {
qb.andWhere("credit.shippingLineCompanyId = :shippingLineCompanyId", {
shippingLineCompanyId,
});
}
const rows = await qb.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 — every credit, whatever its status. Scoped to one line
* when an id is given, across all lines otherwise.
*/
findAllPaginated(
shippingLineCompanyId: string | undefined,
skip: number,
take: number,
status?: ShippingLineCreditStatus,
): Promise<[ShippingLineCredit[], number]> {
return this.credits.findAndCount({
where: {
...(shippingLineCompanyId ? { shippingLineCompanyId } : {}),
...(status ? { status } : {}),
},
relations: { booking: true, invoice: true, shippingLineCompany: true },
order: { createdAt: "DESC" },
skip,
take,
});
}
}

View File

@@ -0,0 +1,304 @@
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,
// Approvals repo — only the invoice makerchecker paths touch it.
{
findPendingByInvoice: jest.fn(),
findPendingByInvoiceIds: jest.fn().mockResolvedValue([]),
findByIdForUpdate: jest.fn(),
create: jest.fn(),
update: jest.fn(),
} 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,780 @@
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 {
ShippingLineInvoiceApproval,
ShippingLineInvoiceActionStatus,
ShippingLineInvoiceActionType,
} from "./entities/shipping-line-invoice-approval.entity";
import { ShippingLineCreditsRepository } from "./shipping-line-credits.repository";
import { ShippingLineInvoiceApprovalsRepository } from "./shipping-line-invoice-approvals.repository";
import { ShippingLineCompany } from "./entities/shipping-line-company.entity";
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;
}
/**
* Emitted by the booking-transition accept path for shipping-line bookings.
* An event rather than a service call: BookingsModule cannot import the
* shipping-line modules without closing a module cycle.
*/
export interface ShippingLineBookingAcceptedPayload {
bookingId: string;
reference: string;
/** The booking's priced total, frozen at completion time. */
amount: 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 approvals: ShippingLineInvoiceApprovalsRepository,
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);
}
/**
* The moment a shipping-line booking becomes debt: Operations accepted it.
* Swallows its own failures with a loud log instead of throwing — the accept
* has already committed, and failing the staff response for a ledger write
* would present a succeeded accept as an error. `recordCredit` is idempotent
* per booking, so a re-accepted (previously reverted) booking cannot double
* the debt.
*/
@OnEvent("shipping_line_booking.accepted")
async onBookingAccepted(
payload: ShippingLineBookingAcceptedPayload,
): Promise<void> {
try {
await this.recordCredit({
bookingId: payload.bookingId,
amount: payload.amount,
// Shipping lines are always billed in ETB (enforced at completion).
currency: "ETB",
});
} catch (err) {
this.logger.error(
`Failed to record credit for accepted shipping-line booking ${payload.reference} (${payload.bookingId}): ${(err as Error).message} — the debt is NOT on the ledger; record it manually or re-trigger.`,
);
}
}
// ── 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);
}
/**
* Back-office overview: outstanding totals across every line, or one line
* when an id is given.
*/
async summary(shippingLineCompanyId?: string) {
if (shippingLineCompanyId) {
await this.requireShippingLine(shippingLineCompanyId);
}
return this.credits.outstandingFor(shippingLineCompanyId);
}
/**
* The whole ledger across every shipping line, newest first — finance's
* landing list. Optionally narrowed to one line and/or one status.
*/
async listAll(
page = 1,
pageSize = 20,
status?: ShippingLineCreditStatus,
shippingLineCompanyId?: string,
) {
if (shippingLineCompanyId) {
await this.requireShippingLine(shippingLineCompanyId);
}
const [items, total] = await this.credits.findAllPaginated(
shippingLineCompanyId,
(page - 1) * pageSize,
pageSize,
status,
);
return { items, total, page, pageSize };
}
/** 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,
};
}
// ── Credit invoices: list + makerchecker manual actions ─────────────────
/**
* Staff list of the invoices minted from credit batches, each with its line
* name and any undecided manual-action request attached — the data the
* back-office actions column renders from.
*/
async listCreditInvoices(
page = 1,
pageSize = 20,
status?: Freight.InvoiceStatus,
shippingLineCompanyId?: string,
) {
const [invoices, total] = await this.dataSource
.getRepository(Invoice)
.findAndCount({
where: {
source: Freight.InvoiceSource.ShippingLineCredit,
...(status ? { status } : {}),
...(shippingLineCompanyId ? { shippingLineCompanyId } : {}),
},
order: { createdAt: "DESC" },
skip: (page - 1) * pageSize,
take: pageSize,
});
const lineIds = [
...new Set(
invoices
.map((inv) => inv.shippingLineCompanyId)
.filter((id): id is string => !!id),
),
];
const lines = lineIds.length
? await this.dataSource
.getRepository(ShippingLineCompany)
.find({ where: { id: In(lineIds) } })
: [];
const nameById = new Map(lines.map((l) => [l.id, l.name]));
const pending = await this.approvals.findPendingByInvoiceIds(
invoices.map((inv) => inv.id),
);
const pendingByInvoice = new Map(pending.map((p) => [p.invoiceId, p]));
return {
items: invoices.map((inv) => ({
...inv,
shippingLineName: inv.shippingLineCompanyId
? (nameById.get(inv.shippingLineCompanyId) ?? null)
: null,
pendingAction: pendingByInvoice.get(inv.id) ?? null,
})),
total,
page,
pageSize,
};
}
/** Undecided requests for a batch of invoices — feeds any invoice list. */
async pendingInvoiceActions(
invoiceIds: string[],
): Promise<ShippingLineInvoiceApproval[]> {
// Bounded to a list page's worth of ids; anything larger is a misuse.
return this.approvals.findPendingByInvoiceIds(invoiceIds.slice(0, 100));
}
/**
* Finance raises a manual action on a credit invoice: record an offline
* payment (MARK_PAID) or void it (CANCEL). Nothing happens to the invoice
* yet — a chief with the matching approve permission decides it. One
* undecided request per invoice (backed by a partial unique index).
*/
async requestInvoiceAction(
invoiceId: string,
action: ShippingLineInvoiceActionType,
requestedBy: string,
reason: string,
paymentReference?: string,
): Promise<ShippingLineInvoiceApproval> {
const invoice = await this.dataSource
.getRepository(Invoice)
.findOne({ where: { id: invoiceId } });
if (!invoice) {
throw new NotFoundException(`Invoice ${invoiceId} not found`);
}
if (invoice.source !== Freight.InvoiceSource.ShippingLineCredit) {
throw new BadRequestException(
"Manual actions here apply only to shipping-line credit invoices.",
);
}
// Fast feedback only — the billing service re-validates authoritatively
// (under lock) when the request is approved.
if (
action === ShippingLineInvoiceActionType.MarkPaid &&
invoice.status === Freight.InvoiceStatus.Paid
) {
throw new BadRequestException("Invoice is already paid.");
}
if (invoice.status === Freight.InvoiceStatus.Cancelled) {
throw new BadRequestException("Invoice is already cancelled.");
}
if (
action === ShippingLineInvoiceActionType.Cancel &&
Number(invoice.paidAmount) > 0
) {
throw new BadRequestException(
"Cannot cancel an invoice that has payments recorded against it.",
);
}
const existing = await this.approvals.findPendingByInvoice(invoiceId);
if (existing) {
throw new BadRequestException(
`A ${existing.action} request is already awaiting decision on this invoice.`,
);
}
const approval = await this.approvals.create({
invoiceId,
action,
status: ShippingLineInvoiceActionStatus.Pending,
requestedBy,
reason,
paymentReference: paymentReference ?? null,
});
logCtx(
{
approvalId: approval.id,
invoiceId,
invoiceNumber: invoice.invoiceNumber,
action,
requestedBy,
},
{ path: "shippingLineCredit.invoiceAction.requested" },
);
return approval;
}
/**
* Decide a pending request. Gated purely by permission (the approve/reject
* grants on the controller routes) — a decider holding the grant may decide
* ANY pending request, their own included; that trade-off is deliberate.
*
* Approval executes the real action through the billing service AFTER the
* decision row commits — its settlement/cancellation events must fire from
* billing's own committed transaction (the credits listeners react to
* them). If billing then rejects the action, the decision is compensated
* back to PENDING so the request is not silently lost.
*/
async decideInvoiceAction(
approvalId: string,
decidedBy: string,
approve: boolean,
note?: string,
): Promise<ShippingLineInvoiceApproval> {
if (!approve && !note?.trim()) {
throw new BadRequestException(
"A note is required when rejecting a request.",
);
}
const decided = await this.dataSource.transaction(async (mg) => {
const approval = await this.approvals.findByIdForUpdate(mg, approvalId);
if (!approval) {
throw new NotFoundException(`Request ${approvalId} not found`);
}
if (approval.status !== ShippingLineInvoiceActionStatus.Pending) {
throw new BadRequestException(
`This request was already ${approval.status.toLowerCase()}.`,
);
}
const status = approve
? ShippingLineInvoiceActionStatus.Approved
: ShippingLineInvoiceActionStatus.Rejected;
await mg.update(
ShippingLineInvoiceApproval,
{ id: approvalId },
{
status,
decidedBy,
decidedAt: new Date(),
decisionNote: note ?? null,
},
);
return { ...approval, status, decidedBy, decisionNote: note ?? null };
});
if (!approve) {
logCtx(
{ approvalId, invoiceId: decided.invoiceId, decidedBy },
{ path: "shippingLineCredit.invoiceAction.rejected" },
);
return decided;
}
try {
if (decided.action === ShippingLineInvoiceActionType.MarkPaid) {
const invoice = await this.dataSource
.getRepository(Invoice)
.findOne({ where: { id: decided.invoiceId } });
if (!invoice) {
throw new NotFoundException(`Invoice ${decided.invoiceId} not found`);
}
// Full settlement of the outstanding balance; billing emits
// `shipping_line_credit.invoice.paid`, which marks the credits PAID.
await this.billing.recordPayment(decided.invoiceId, {
amount: Number(invoice.balanceAmount ?? invoice.totalAmount),
method: "OFFLINE",
reference: decided.paymentReference ?? undefined,
metadata: {
approvalId: decided.id,
requestedBy: decided.requestedBy,
approvedBy: decidedBy,
},
});
} else {
// Billing emits `shipping_line_credit.invoice.cancelled`;
// onInvoiceCancelled releases the credits back to the unbilled pool.
await this.billing.cancelInvoice(decided.invoiceId);
}
} catch (err) {
// The action was refused (state changed since the request — e.g. the
// line paid through CBE in the meantime). Put the request back so it is
// not recorded as approved-but-unexecuted.
await this.approvals.update(approvalId, {
status: ShippingLineInvoiceActionStatus.Pending,
decidedBy: null,
decidedAt: null,
decisionNote: null,
});
throw err;
}
logCtx(
{
approvalId,
invoiceId: decided.invoiceId,
action: decided.action,
decidedBy,
},
{ path: "shippingLineCredit.invoiceAction.approved" },
);
return decided;
}
/**
* When a credit invoice is cancelled — through the approval flow or any
* other billing path — its BILLED credits return to the unbilled pool so
* the debt can be re-billed. The debt itself never disappears on invoice
* cancellation; only {@link cancelCredit} writes debt off.
*/
@OnEvent("shipping_line_credit.invoice.cancelled")
async onInvoiceCancelled(payload: InvoiceEventPayload): Promise<void> {
const result = await this.dataSource
.getRepository(ShippingLineCredit)
.update(
{
invoiceId: payload.invoiceId,
status: ShippingLineCreditStatus.Billed,
},
{
status: ShippingLineCreditStatus.Unbilled,
invoiceId: null,
billedAt: null,
},
);
logCtx(
{
invoiceId: payload.invoiceId,
invoiceNumber: payload.invoiceNumber,
creditsReleased: result.affected ?? 0,
},
{ path: "shippingLineCredit.invoiceCancelled.released" },
);
}
// ── 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

@@ -0,0 +1,51 @@
import { BaseRepository } from "@edr/api-common";
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { EntityManager, In, Repository } from "typeorm";
import {
ShippingLineInvoiceApproval,
ShippingLineInvoiceActionStatus,
} from "./entities/shipping-line-invoice-approval.entity";
@Injectable()
export class ShippingLineInvoiceApprovalsRepository extends BaseRepository<ShippingLineInvoiceApproval> {
constructor(
@InjectRepository(ShippingLineInvoiceApproval)
private readonly approvals: Repository<ShippingLineInvoiceApproval>,
) {
super(approvals);
}
findPendingByInvoice(
invoiceId: string,
): Promise<ShippingLineInvoiceApproval | null> {
return this.approvals.findOne({
where: { invoiceId, status: ShippingLineInvoiceActionStatus.Pending },
});
}
/** Pending requests for a page of invoices — one query, no N+1. */
findPendingByInvoiceIds(
invoiceIds: string[],
): Promise<ShippingLineInvoiceApproval[]> {
if (!invoiceIds.length) return Promise.resolve([]);
return this.approvals.find({
where: {
invoiceId: In(invoiceIds),
status: ShippingLineInvoiceActionStatus.Pending,
},
});
}
/** Load one request inside the caller's transaction, locked for decision. */
findByIdForUpdate(
manager: EntityManager,
id: string,
): Promise<ShippingLineInvoiceApproval | null> {
return manager.getRepository(ShippingLineInvoiceApproval).findOne({
where: { id },
lock: { mode: "pessimistic_write" },
});
}
}

View File

@@ -7,6 +7,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } fro
import { Yard } from '../../rule-engine/entities/yard.entity';
import { Route } from '../../routes/entities/route.entity';
import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity';
import { TrainSet } from '../../train-sets/entities/train-set.entity';
import { TrainScheduleBooking } from './train-schedule-booking.entity';
@@ -81,6 +82,19 @@ export class TrainSchedule extends BaseEntity {
@Column({ name: 'direction', type: 'varchar', length: 10, nullable: true })
direction?: string | null;
/**
* Dedicates this departure to one shipping line. NULL = a normal train,
* visible to customers as today. Set = the train is HIDDEN from every
* customer-facing read (windows, day pools, home cards) and shown only to
* this shipping line in its portal.
*/
@Column({ name: 'shipping_line_company_id', type: 'uuid', nullable: true })
shippingLineCompanyId?: string | null;
@ManyToOne(() => ShippingLineCompany)
@JoinColumn({ name: 'shipping_line_company_id' })
shippingLineCompany?: ShippingLineCompany | null;
/**
* Reverse the wagon ORDER on this train: when true, the built wagon plan is
* flipped at build so the physically-last wagon sits at position 1. Only the

View File

@@ -17,6 +17,7 @@ import {
FindOptionsWhere,
ILike,
In,
IsNull,
LessThanOrEqual,
MoreThanOrEqual,
} from 'typeorm';
@@ -25,6 +26,7 @@ import { Booking } from '../bookings/entities/booking.entity';
import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingPricingService } from '../bookings/booking-pricing.service';
import { formatRouteLabel } from '../routes/entities/route.entity';
import { isRoadService } from '../bookings/road.util';
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
import { Yard } from '../rule-engine/entities/yard.entity';
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
@@ -153,6 +155,19 @@ export interface ExportTrainOption {
}>;
}
/** Form-entered cargo for a train-options probe (nothing persisted yet). */
export interface TrainOptionCargoOverrides {
/** Container types drive the per-type space. */
containerTypeIds?: string[];
/** Size labels ("20ft"/"40ft") when the form has no type ids. */
containerSizes?: string[];
/** Bulk counterparts of the container inputs. */
cargoTypeId?: string;
cargoTypeCode?: string;
/** Needed wagons estimate from the form (drives the `fits` flag). */
wagons?: number;
}
/** A train a paid-unallocated booking can board (route + capacity verified). */
export interface AllocationCandidate {
id: string;
@@ -820,8 +835,9 @@ export class BookingBatchService implements OnModuleInit {
// stop order, so we fetch the day's open trains without endpoint filters.
const corridor = await this.trainSchedulesRepository.findAll({
where: [
{ status: TrainScheduleStatusEnum.Draft },
{ status: TrainScheduleStatusEnum.Scheduled },
// Dedicated shipping-line trains are never customer-booking targets.
{ status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId: IsNull() },
{ status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId: IsNull() },
],
});
// A customer-picked train narrows the scan to that ONE schedule: export
@@ -983,8 +999,9 @@ export class BookingBatchService implements OnModuleInit {
): Promise<Array<{ scheduleId: string; departure: Date; freeWagons: number }>> {
const corridor = await this.trainSchedulesRepository.findAll({
where: [
{ status: TrainScheduleStatusEnum.Draft },
{ status: TrainScheduleStatusEnum.Scheduled },
// Dedicated shipping-line trains are never customer-booking targets.
{ status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId: IsNull() },
{ status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId: IsNull() },
],
});
const candidates = corridor
@@ -1048,19 +1065,84 @@ export class BookingBatchService implements OnModuleInit {
async exportTrainOptionsForDay(
booking: Booking,
day: string,
overrides?: {
/** Cargo the customer is entering on a form (bare contract instance —
* nothing persisted yet): container types drive the per-type space. */
containerTypeIds?: string[];
/** Size labels ("20ft"/"40ft") when the form has no type ids. */
containerSizes?: string[];
/** Bulk counterparts of the container inputs. */
cargoTypeId?: string;
cargoTypeCode?: string;
/** Needed wagons estimate from the form (drives the `fits` flag). */
wagons?: number;
},
overrides?: TrainOptionCargoOverrides,
): Promise<ExportTrainOption[]> {
booking = await this.withCargoOverrides(booking, overrides);
const corridor = await this.trainSchedulesRepository.findAll({
where: [
// Dedicated shipping-line trains are never customer-booking targets.
{ status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId: IsNull() },
{ status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId: IsNull() },
],
});
const candidates = corridor
.filter(
(s) =>
s.scheduledDepartureDate != null &&
eatDay(s.scheduledDepartureDate) === day &&
s.direction === 'EXPORT',
)
.sort(
(a, b) =>
a.scheduledDepartureDate!.getTime() -
b.scheduledDepartureDate!.getTime(),
);
return this.buildTrainOptions(booking, candidates);
}
/**
* The same per-train wagon-availability cards, but for the trains DEDICATED
* to a shipping line on the booking's lane + day. Same option shape as the
* export picker so the portal reuses the same component; `isOpen`
* additionally respects the dedicated close offset (windowClosesAt), since
* these trains run no window cycle.
*/
async dedicatedTrainOptionsForDay(
booking: Booking,
day: string | null,
shippingLineCompanyId: string,
overrides?: TrainOptionCargoOverrides,
): Promise<ExportTrainOption[]> {
booking = await this.withCargoOverrides(booking, overrides);
const dedicated = await this.trainSchedulesRepository.findAll({
where: [
{ status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId },
{ status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId },
],
});
const candidates = dedicated
.filter(
(s) =>
s.scheduledDepartureDate != null &&
// A day narrows to that departure day; without one, every upcoming
// departure on the lane is listed (the picker's full card list).
(day
? eatDay(s.scheduledDepartureDate) === day
: s.scheduledDepartureDate.getTime() > Date.now() - 3_600_000) &&
(!booking.originYardId || s.originStationId === booking.originYardId) &&
(!booking.destinationYardId ||
s.destinationStationId === booking.destinationYardId),
)
.sort(
(a, b) =>
a.scheduledDepartureDate!.getTime() -
b.scheduledDepartureDate!.getTime(),
);
const options = await this.buildTrainOptions(booking, candidates);
const now = Date.now();
return options.map((o) => ({
...o,
isOpen:
o.isOpen &&
(o.bookingClosesAt == null || o.bookingClosesAt.getTime() > now),
}));
}
/** Resolve form-entered cargo onto an (unpersisted) booking probe. */
private async withCargoOverrides(
booking: Booking,
overrides?: TrainOptionCargoOverrides,
): Promise<Booking> {
const sizeFts = (overrides?.containerSizes ?? [])
.map((s) => parseInt(s, 10))
.filter((n) => Number.isFinite(n) && n > 0);
@@ -1092,25 +1174,14 @@ export class BookingBatchService implements OnModuleInit {
if (overrides?.wagons && overrides.wagons > 0) {
booking = { ...booking, wagonsRequired: overrides.wagons } as Booking;
}
const corridor = await this.trainSchedulesRepository.findAll({
where: [
{ status: TrainScheduleStatusEnum.Draft },
{ status: TrainScheduleStatusEnum.Scheduled },
],
});
const candidates = corridor
.filter(
(s) =>
s.scheduledDepartureDate != null &&
eatDay(s.scheduledDepartureDate) === day &&
s.direction === 'EXPORT',
)
.sort(
(a, b) =>
a.scheduledDepartureDate!.getTime() -
b.scheduledDepartureDate!.getTime(),
);
return booking;
}
/** One availability card per candidate schedule — the export picker's math. */
private async buildTrainOptions(
booking: Booking,
candidates: TrainSchedule[],
): Promise<ExportTrainOption[]> {
const wagonDims = await this.loadWagonDims();
const allowed = this.allowedDimsWithTypes(booking, wagonDims);
const neededWagons = this.wagonsFor(booking, wagonDims);
@@ -1219,8 +1290,9 @@ export class BookingBatchService implements OnModuleInit {
): Promise<{ freeWagons: number; need: number; trainsForDay: boolean }> {
const corridor = await this.trainSchedulesRepository.findAll({
where: [
{ status: TrainScheduleStatusEnum.Draft },
{ status: TrainScheduleStatusEnum.Scheduled },
// Dedicated shipping-line trains are never customer-booking targets.
{ status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId: IsNull() },
{ status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId: IsNull() },
],
});
const candidates = corridor.filter(
@@ -2365,11 +2437,14 @@ export class BookingBatchService implements OnModuleInit {
originStationId: originYardId,
destinationStationId: destinationYardId,
status: TrainScheduleStatusEnum.Draft,
// Dedicated shipping-line trains never join the customer day pool.
shippingLineCompanyId: IsNull(),
},
{
originStationId: originYardId,
destinationStationId: destinationYardId,
status: TrainScheduleStatusEnum.Scheduled,
shippingLineCompanyId: IsNull(),
},
],
});
@@ -3100,8 +3175,9 @@ export class BookingBatchService implements OnModuleInit {
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
const schedules = await this.trainSchedulesRepository.findAll({
where: [
{ status: TrainScheduleStatusEnum.Draft },
{ status: TrainScheduleStatusEnum.Scheduled },
// Dedicated shipping-line trains are never customer-booking targets.
{ status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId: IsNull() },
{ status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId: IsNull() },
],
});
const today = eatDay(new Date());
@@ -3176,6 +3252,99 @@ export class BookingBatchService implements OnModuleInit {
}
}
/**
* Auto-allocate an accepted SHIPPING-LINE booking onto its company's
* dedicated train for the booking's lane and shipment day.
*
* Runs at operation-accept: shipping lines pay later on the credit ledger,
* so there is no pay window between accept and wagon placement — the
* booking boards its train immediately. Customer bookings never come here;
* they keep the batch pool → reserve → pay → allocate pipeline.
*
* Wagon shortage parks the booking WAITING_FOR_WAGON on the schedule
* (without the PAID stamps the customer hold writes — nothing was paid).
* No dedicated train on the day is not an error: the booking simply stays
* in the ordinary day pool for the batch engine.
*/
async allocateShippingLineAccepted(bookingId: string): Promise<void> {
const booking = await this.dataSource.getRepository(Booking).findOne({
where: { id: bookingId },
relations: { bookingContainers: { containerType: true }, cargoType: true },
});
if (!booking?.shippingLineCompanyId || !booking.scheduledDate) return;
if (isRoadService(booking.serviceType)) return;
const day = eatDay(booking.scheduledDate);
const dedicated = await this.dataSource.getRepository(TrainSchedule).find({
where: [
{
shippingLineCompanyId: booking.shippingLineCompanyId,
originStationId: booking.originYardId,
destinationStationId: booking.destinationYardId,
status: TrainScheduleStatusEnum.Draft,
},
{
shippingLineCompanyId: booking.shippingLineCompanyId,
originStationId: booking.originYardId,
destinationStationId: booking.destinationYardId,
status: TrainScheduleStatusEnum.Scheduled,
},
],
});
const target = dedicated.find(
(s) =>
s.scheduledDepartureDate && eatDay(s.scheduledDepartureDate) === day,
);
if (!target) {
this.logger.log(
`[BATCH] shipping-line booking ${booking.reference} has no dedicated ` +
`train on ${day} — left in the day pool for the batch engine`,
);
return;
}
// Point the booking at its train BEFORE the shortage probe — the probe
// reads the link to size the need against that schedule's wagons.
await this.dataSource.getRepository(Booking).update(booking.id, {
trainScheduleId: target.id,
} as never);
booking.trainScheduleId = target.id;
// One dedicated train carries ONE booking: the accept claims the train by
// closing its booking window on the spot. Both gates a later booking
// passes — the day picker (isStillOpen on windowClosesAt) and the
// completion's dedicated-day check — read these fields, so a second
// booking can never pick this train.
await this.dataSource.getRepository(TrainSchedule).update(target.id, {
bookingWindowStatus: "CLOSED",
windowClosesAt: new Date(),
} as never);
this.notifyBoardChanged(target.id, "shipping_line_train_claimed");
const shortage =
await this.trainSchedulingService.previewPaidBookingWagonShortage(
target.id,
booking.id,
);
if (shortage) {
// Parked for staff to attach wagons — WITHOUT the customer hold's PAID
// stamps: a shipping line has paid nothing, its debt sits on the ledger.
await this.dataSource.getRepository(Booking).update(booking.id, {
schedulingStatus: "WAITING_FOR_WAGON",
} as never);
this.logger.warn(
`Shipping-line booking ${booking.reference} WAITING FOR WAGON on its ` +
`dedicated train ${target.reference ?? target.id}: needs ` +
`${shortage.wagonsNeeded} × ${shortage.wagonTypeCodes}, ` +
`${shortage.wagonsAvailable} available (short ${shortage.wagonsShort}).`,
);
this.notifyBoardChanged(target.id, "booking_waiting_wagon");
return;
}
await this.allocate(target.id, booking, "shipping_line");
}
/**
* One reminder per hold, shortly before its pay deadline (the window tick
* calls this every pass; `payment_reminder_sent_at` dedups). Skips paid
@@ -3535,7 +3704,7 @@ export class BookingBatchService implements OnModuleInit {
private async allocate(
scheduleId: string,
booking: Booking,
reason: "paid" | "gov",
reason: "paid" | "gov" | "shipping_line",
): Promise<void> {
// Stamp the computed wagon need on the link. Several callers pass a booking
// loaded without cargo relations (ensurePaidBookingAllocated), and a NULL

View File

@@ -12,6 +12,7 @@ import { Booking } from '../bookings/entities/booking.entity';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { resolveCompanyNotifyContact } from '../notifications/resolve-company-phone.util';
import { resolveShippingLineNotifyTarget } from '../notifications/resolve-shipping-line-contact.util';
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
import { BATCH_TIMEZONE } from './booking-batch.constants';
@@ -66,10 +67,16 @@ export class BookingNotifierService {
): Promise<void> {
this.logger.log(`${logLabel}${this.ref(b)}`);
// One resolver for both channels — the company row's own email column is
// only set for a Fayda-verified owner (see companyNotifyEmailExpr).
const { phone, email } = b.companyId
? await resolveCompanyNotifyContact(this.dataSource, b.companyId)
: { phone: null, email: null };
// only set for a Fayda-verified owner (see companyNotifyEmailExpr). A
// shipping-line booking has no company; its contact is the line's row.
const { phone, email } = b.shippingLineCompanyId
? await resolveShippingLineNotifyTarget(
this.dataSource,
b.shippingLineCompanyId,
)
: b.companyId
? await resolveCompanyNotifyContact(this.dataSource, b.companyId)
: { phone: null, email: null };
if (phone) {
try {
@@ -90,13 +97,42 @@ export class BookingNotifierService {
}
}
/** Persist + push an in-app item to all portal users of the booking's company. */
/**
* Persist + push an in-app item to the booking's portal owner: every portal
* user of the company, or — for a shipping-line booking — the line's own
* account, deep-linked into the shipping-line app (/shipping-line/*).
*/
private inApp(
b: Booking,
title: string,
body: string,
overrides: Partial<NotifyInput> = {},
): void {
if (b.shippingLineCompanyId) {
void (async () => {
const { userId } = await resolveShippingLineNotifyTarget(
this.dataSource,
b.shippingLineCompanyId!,
);
if (!userId) return;
void this.inbox.notify({
recipients: { userIds: [userId] },
audience: NotificationAudience.PORTAL,
type: NotificationType.SCHEDULE_UPDATE,
title,
body,
data: { bookingId: b.id, reference: b.reference },
...overrides,
// After the spread: the bell must land the line on ITS booking page.
link: `/shipping-line/bookings/${b.id}`,
});
})().catch((err) =>
this.logger.warn(
`shipping-line inApp failed for ${this.ref(b)}: ${(err as Error).message}`,
),
);
return;
}
if (!b.companyId) return; // government/unlinked bookings have no portal users
void this.inbox.notify({
recipients: { companyId: b.companyId },
@@ -209,11 +245,19 @@ export class BookingNotifierService {
});
}
secured(b: Booking, reason: 'paid' | 'gov', scheduleId?: string | null): void {
secured(
b: Booking,
reason: 'paid' | 'gov' | 'shipping_line',
scheduleId?: string | null,
): void {
void (async () => {
const label = await this.scheduleLabel(scheduleId ?? b.trainScheduleId);
const msg = `Booking ${b.reference ?? b.id} allocated on ${label}${
reason === 'gov' ? ' (government)' : ''
reason === 'gov'
? ' (government)'
: reason === 'shipping_line'
? ' (shipping line)'
: ''
}.`;
void this.notifyContact(b, msg, 'ALLOCATED');
this.inApp(b, 'Wagon allocated', msg);

View File

@@ -172,6 +172,17 @@ export class CreateContainerTrainScheduleDto {
@IsBoolean()
reverseWagonOrder?: boolean;
@ApiPropertyOptional({
format: 'uuid',
description:
'Dedicate this departure to one shipping line. The schedule is then hidden ' +
'from every customer-facing read (windows, day pools, home cards) and shown ' +
'only to that shipping line in its portal. Omit for a normal customer train.',
})
@IsOptional()
@IsUUID()
shippingLineCompanyId?: string;
@ApiPropertyOptional({
type: CreateScheduleWindowRuleDto,
description:

View File

@@ -57,9 +57,10 @@ export class TrainSchedulingGlobalRules extends BaseEntity {
/**
* Local (Africa/Addis_Ababa) hour the booking desk shuts each day. A not-yet-full
* train whose next cycle would reopen at/after this hour pauses until the next
* morning's windowOpenHour. Set equal to windowOpenHour for a 24-hour desk.
* morning's windowOpenHour. Set equal to windowOpenHour for a 24-hour desk
* (the default).
*/
@Column({ name: 'window_close_hour', type: 'int', default: 17 })
@Column({ name: 'window_close_hour', type: 'int', default: 8 })
windowCloseHour!: number;
// Stored in hours; 4 decimals so sub-minute UI durations (4 min = 0.0667h)

View File

@@ -47,6 +47,7 @@ import { Container } from '../../container-management/entities/container.entity'
import { Locomotive } from '../../locomotives/entities/locomotive.entity';
import { LocomotivesRepository } from '../../locomotives/locomotives.repository';
import { formatRouteLabel, Route } from '../../routes/entities/route.entity';
import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity';
import { WagonMovement } from '../../wagons/entities/wagon-movement.entity';
import { Train } from '../../trains/entities/train.entity';
import { TrainSetLocomotive } from '../../train-sets/entities/train-set-locomotive.entity';
@@ -470,7 +471,11 @@ export class TrainSchedulingService {
private async emitWindowState(scheduleId: string): Promise<void> {
try {
const fresh = await this.trainSchedulesRepository.findById(scheduleId);
if (fresh) this.bookingWindowGateway.emitPhase(fresh);
// Dedicated shipping-line departures are never announced to the portal —
// the broadcast reaches every customer client.
if (fresh && !fresh.shippingLineCompanyId) {
this.bookingWindowGateway.emitPhase(fresh);
}
} catch (err) {
this.logger.warn(
`Booking-window push failed for ${scheduleId}: ${(err as Error).message}`,
@@ -511,7 +516,11 @@ export class TrainSchedulingService {
// and a newborn anchoring to it would inherit that dead window verbatim.
.andWhere('s.status != :cancelledStatus', {
cancelledStatus: TrainScheduleStatusEnum.Cancelled,
});
})
// A dedicated shipping-line departure is never a sibling either: it runs
// no window cycle, so it must neither anchor a customer group nor be
// dragged through one's open/doc-review/payment instants.
.andWhere('s.shippingLineCompanyId IS NULL');
if (excludeScheduleId) {
qb.andWhere('s.id != :excludeScheduleId', { excludeScheduleId });
}
@@ -1461,6 +1470,24 @@ export class TrainSchedulingService {
const scheduleWarnings: string[] = [];
// Dedicating the departure to a shipping line: the id comes from the
// request, so verify it is a real, active line before stamping it.
if (dto.shippingLineCompanyId) {
const line = await this.dataSource
.getRepository(ShippingLineCompany)
.findOne({ where: { id: dto.shippingLineCompanyId } });
if (!line) {
throw new NotFoundException(
`Shipping line ${dto.shippingLineCompanyId} not found`,
);
}
if (line.status !== 'active') {
throw new BadRequestException(
`Shipping line ${line.name} is suspended — it cannot be assigned a train`,
);
}
}
// The pulling set comes either from a built train (Train Builder) or from
// hand-picked locomotive ids (legacy path). A built train also links the
// schedule's train set back to it (`train_sets.train_id`) so its lifecycle
@@ -1593,8 +1620,11 @@ export class TrainSchedulingService {
// doc-review/payment phase — so there is no cross-expiry to fix, and two
// export trains departing the same day at different times must keep their
// own departure-anchored windows.
// Dedicated shipping-line departures never group either: they run no
// window cycle at all, so sharing a customer group's timeline (or
// anchoring one) would drag them into phases they must not have.
const groupAnchor =
direction === 'EXPORT'
direction === 'EXPORT' || dto.shippingLineCompanyId
? null
: await this.findGroupWindowAnchor(
manager,
@@ -1659,45 +1689,71 @@ export class TrainSchedulingService {
// only re-derives NOT-YET-OPEN schedules (see restampPendingWindows); an
// already-open schedule keeps this snapshot, and the batch board draws its
// windows from it rather than the live config.
const ruleSnapshot = windowRuleSnapshot(windowCfg);
const computedTimes =
direction === 'EXPORT'
? { ...ruleSnapshot, ...computeExportWindowTimes(departure, windowCfg) }
: {
// IMPORT and DOMESTIC share the import booking-day window cycle.
...ruleSnapshot,
...computeImportWindowTimes(departure, windowCfg, new Date()),
};
// Inside-lead departure (e.g. a huge configured lead): the raw open lands
// in the past — clamp it to `now` so the window tick opens it immediately.
if (computedTimes.windowOpensAt.getTime() < Date.now()) {
computedTimes.windowOpensAt = new Date();
let windowFields: Partial<TrainSchedule>;
if (dto.shippingLineCompanyId) {
// Dedicated shipping-line departure: NO window cycle at all. The line
// books whenever it wants from creation until the close offset before
// departure. windowPhase stays NULL, so the window engine, restamp and
// the customer window lists all skip this schedule; the close-offset
// gate is enforced by the shipping-line completion path, which reads
// windowClosesAt stamped here.
const offsetMinutes = windowCfg.importCloseOffsetMinutes ?? 0;
const closesAt = new Date(departure.getTime() - offsetMinutes * 60_000);
if (closesAt.getTime() <= Date.now()) {
throw new BadRequestException(
'With the booking-close offset applied, this departure would already be ' +
'closed for shipping-line booking — pick a later departure.',
);
}
windowFields = {
bookingWindowStatus: 'OPEN',
windowPhase: null,
windowOpensAt: new Date(),
windowClosesAt: closesAt,
ruleImportCloseOffsetMinutes: offsetMinutes || null,
windowRuleCustom: dto.windowRule != null,
};
} else {
const ruleSnapshot = windowRuleSnapshot(windowCfg);
const computedTimes =
direction === 'EXPORT'
? { ...ruleSnapshot, ...computeExportWindowTimes(departure, windowCfg) }
: {
// IMPORT and DOMESTIC share the import booking-day window cycle.
...ruleSnapshot,
...computeImportWindowTimes(departure, windowCfg, new Date()),
};
// Inside-lead departure (e.g. a huge configured lead): the raw open lands
// in the past — clamp it to `now` so the window tick opens it immediately.
if (computedTimes.windowOpensAt.getTime() < Date.now()) {
computedTimes.windowOpensAt = new Date();
}
if (
computedTimes.windowOpensAt.getTime() >= computedTimes.windowClosesAt.getTime()
) {
throw new BadRequestException(
'These booking-window settings leave no window before departure — with the ' +
'desk hours and close offset applied, the window would only open once the ' +
'train has left.',
);
}
windowFields = {
bookingWindowStatus: 'CLOSED',
windowPhase: 'PRE_WINDOW',
...(groupAnchor
? this.groupWindowFieldsFrom(groupAnchor, departure)
: computedTimes),
// `windowRuleSnapshot` never stamps the pay window (NULL = follow the
// live global value for the direction), so an explicit staff override is
// persisted here — the same field the post-creation override writes.
...(dto.windowRule?.paymentWindowMinutes !== undefined
? { rulePaymentWindowMinutes: dto.windowRule.paymentWindowMinutes }
: {}),
// Hand-configured windows opt OUT of the global re-stamp, or the next
// global-rules edit would overwrite exactly what staff chose here.
windowRuleCustom: dto.windowRule != null,
};
}
if (
computedTimes.windowOpensAt.getTime() >= computedTimes.windowClosesAt.getTime()
) {
throw new BadRequestException(
'These booking-window settings leave no window before departure — with the ' +
'desk hours and close offset applied, the window would only open once the ' +
'train has left.',
);
}
const windowFields = {
bookingWindowStatus: 'CLOSED',
windowPhase: 'PRE_WINDOW',
...(groupAnchor
? this.groupWindowFieldsFrom(groupAnchor, departure)
: computedTimes),
// `windowRuleSnapshot` never stamps the pay window (NULL = follow the
// live global value for the direction), so an explicit staff override is
// persisted here — the same field the post-creation override writes.
...(dto.windowRule?.paymentWindowMinutes !== undefined
? { rulePaymentWindowMinutes: dto.windowRule.paymentWindowMinutes }
: {}),
// Hand-configured windows opt OUT of the global re-stamp, or the next
// global-rules edit would overwrite exactly what staff chose here.
windowRuleCustom: dto.windowRule != null,
};
// A built train's own consist is the schedule's capacity: full when all
// its wagons are allocated. Trains built without wagons yet fall back to
// the configured limit.
@@ -1725,6 +1781,7 @@ export class TrainSchedulingService {
trainNumber: pairTrainNumber ?? undefined,
maxWagons,
reverseWagonOrder: dto.reverseWagonOrder ?? false,
shippingLineCompanyId: dto.shippingLineCompanyId ?? null,
...windowFields,
}),
);
@@ -4474,7 +4531,10 @@ export class TrainSchedulingService {
(b) =>
!(targetScheduleId && b.trainScheduleId === targetScheduleId) &&
!SCHEDULABLE_BOOKING_STATUSES.includes(b.status as 'PAID') &&
!b.isGovernment,
!b.isGovernment &&
// Shipping-line bookings pay later on the credit ledger — never PAID
// up front, schedulable from accept (FULLY_EXECUTED) like government.
!b.shippingLineCompanyId,
);
if (invalidStatus.length) {
const statuses = [...new Set(invalidStatus.map((b) => b.status))];
@@ -6987,6 +7047,7 @@ export class TrainSchedulingService {
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
WHERE ts.deleted_at IS NULL
AND ts.status IN ('DRAFT', 'SCHEDULED')
AND ts.shipping_line_company_id IS NULL
AND ts.window_phase IS NOT NULL
AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY')
AND ts.scheduled_departure_date >= now()
@@ -7043,6 +7104,7 @@ export class TrainSchedulingService {
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
WHERE ts.deleted_at IS NULL
AND ts.status IN ('DRAFT', 'SCHEDULED')
AND ts.shipping_line_company_id IS NULL
AND ts.window_phase IS NOT NULL
AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY')
AND ts.scheduled_departure_date >= now()
@@ -7152,6 +7214,8 @@ export class TrainSchedulingService {
const schedules = await this.trainSchedulesRepository.findAll({
where: {
bookingWindowStatus: 'OPEN',
// Dedicated shipping-line trains never surface to customer booking.
shippingLineCompanyId: IsNull(),
},
relations: {
trainSet: { locomotive: true, locomotives: { locomotive: true }, train: true },
@@ -8586,7 +8650,12 @@ export class TrainSchedulingService {
.map((sb) => sb.booking)
.filter((b): b is Booking => Boolean(b));
const eligible = linkedBookings.filter(
(b) => SCHEDULABLE_BOOKING_STATUSES.includes(b.status as 'PAID') || b.isGovernment,
(b) =>
SCHEDULABLE_BOOKING_STATUSES.includes(b.status as 'PAID') ||
b.isGovernment ||
// Shipping-line bookings board without paying up front — their charge
// sits on the credit ledger, so accept (FULLY_EXECUTED) is boardable.
Boolean(b.shippingLineCompanyId),
);
if (!eligible.length) return empty;

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

@@ -499,6 +499,30 @@ export const CUSTOMER_PERMISSIONS: FreightPermissionSeed[] = [
),
];
// C2. Shipping lines — carriers registered by staff (no self-signup).
export const SHIPPING_LINE_PERMISSIONS: FreightPermissionSeed[] = [
perm(
"d1a00002-0001-4000-8000-000000000001",
"edr_freight_app:shipping_lines:view",
"View shipping lines",
),
perm(
"d1a00002-0001-4000-8000-000000000002",
"edr_freight_app:shipping_lines:create",
"Register shipping line",
),
perm(
"d1a00002-0001-4000-8000-000000000003",
"edr_freight_app:shipping_lines:update",
"Update shipping line",
),
perm(
"d1a00002-0001-4000-8000-000000000004",
"edr_freight_app:shipping_lines:reset-password",
"Resend shipping line activation link",
),
];
// D. Finance — payments + invoices
export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [
perm(
@@ -552,6 +576,47 @@ 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",
),
// Two-step manual actions on credit invoices: request grants per action,
// decision grants that apply to any pending request.
perm(
"d2c00001-0001-4000-8000-000000000004",
"edr_freight_app:shipping_line_credits:invoice_mark_paid",
"Request marking a shipping-line credit invoice paid (offline payment)",
),
perm(
"d2c00001-0001-4000-8000-000000000005",
"edr_freight_app:shipping_line_credits:invoice_approve",
"Approve any pending shipping-line credit invoice request",
),
perm(
"d2c00001-0001-4000-8000-000000000006",
"edr_freight_app:shipping_line_credits:invoice_cancel",
"Request cancelling a shipping-line credit invoice",
),
perm(
"d2c00001-0001-4000-8000-000000000007",
"edr_freight_app:shipping_line_credits:invoice_reject",
"Reject any pending shipping-line credit invoice request",
),
];
// E. First / last mile operations
@@ -1567,6 +1632,7 @@ export const NOTIFICATION_PERMISSIONS: FreightPermissionSeed[] = [
export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [
...REPORT_PERMISSIONS,
...CUSTOMER_PERMISSIONS,
...SHIPPING_LINE_PERMISSIONS,
...FINANCE_PERMISSIONS,
...MILE_PERMISSIONS,
...FLEET_RAIL_PERMISSIONS,
@@ -1773,6 +1839,31 @@ export const FREIGHT_PERMS = {
// Notification selector, not a route guard — see NOTIFICATION_PERMISSIONS.
getNotification: "edr_freight_app:customers:get_notification",
},
shippingLines: {
view: "edr_freight_app:shipping_lines:view",
create: "edr_freight_app:shipping_lines:create",
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",
// Two-step manual actions on credit invoices, gated purely by permission:
// finance-level REQUEST grants (per action) and decision grants that apply
// to ANY pending request — including the holder's own.
/** Request recording an offline payment against a credit invoice. */
invoiceMarkPaid:
"edr_freight_app:shipping_line_credits:invoice_mark_paid",
/** Request voiding a credit invoice (credits return to unbilled). */
invoiceCancel: "edr_freight_app:shipping_line_credits:invoice_cancel",
/** Approve any pending invoice request (mark-paid or cancel). */
invoiceApprove: "edr_freight_app:shipping_line_credits:invoice_approve",
/** Reject any pending invoice request. */
invoiceReject: "edr_freight_app:shipping_line_credits:invoice_reject",
},
payments: {
view: "edr_freight_app:payments:view",
},
@@ -2320,6 +2411,13 @@ export const ROLE_PERMISSION_PRESETS = {
// exceptional operations, and are assigned to named admins rather than a role preset.
FREIGHT_PERMS.payments.view,
FREIGHT_PERMS.bookings.wagonCancellationView,
// Shipping-line credit ledger is a Finance surface: bill batches into
// invoices and RAISE manual invoice actions. Approval of those actions is
// deliberately absent — it sits with the chief (makerchecker).
FREIGHT_PERMS.shippingLineCredits.view,
FREIGHT_PERMS.shippingLineCredits.invoice,
FREIGHT_PERMS.shippingLineCredits.invoiceMarkPaid,
FREIGHT_PERMS.shippingLineCredits.invoiceCancel,
],
// Global Logistics: manages ONLY the customs-clearance queue. Scoped out of
// the general booking-request list (no bookings:view) — instead a dedicated
@@ -2422,6 +2520,11 @@ export const POSITION_PERMISSION_PRESETS = {
FREIGHT_PERMS.invoices.view,
FREIGHT_PERMS.invoices.export,
FREIGHT_PERMS.payments.view,
// Decision side of the credit-invoice two-step: finance raises
// mark-paid/cancel requests, the chief approves or rejects them.
FREIGHT_PERMS.shippingLineCredits.view,
FREIGHT_PERMS.shippingLineCredits.invoiceApprove,
FREIGHT_PERMS.shippingLineCredits.invoiceReject,
]),
// Director additionally manages train scheduling + rail fleet (same block the
// operation officer/chief hold), on top of the approval-chain role preset,

View File

@@ -36,6 +36,8 @@ import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetai
import DocumentClearanceListPage from "./pages/bookings/DocumentClearanceListPage";
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
import CustomersPage from "./pages/customers/CustomersPage";
import ShippingLineCompaniesPage from "./pages/shipping-lines/ShippingLineCompaniesPage";
import ShippingLineCreditsPage from "./pages/shipping-lines/ShippingLineCreditsPage";
import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage";
import FinanceHubPage from "./pages/invoices/FinanceHubPage";
import MyProfilePage from "./pages/dashboard/MyProfilePage";
@@ -317,6 +319,24 @@ const App = () => {
OR'd across both keys so a user with just one still gets in; each
tab hides itself if the user lacks the permission it used to be
routed on. */}
<Route
path="shipping-lines"
element={
<RequirePermission permission={FREIGHT_PERMS.shippingLines.view}>
<ShippingLineCompaniesPage />
</RequirePermission>
}
/>
<Route
path="shipping-line-credits"
element={
<RequirePermission
permission={FREIGHT_PERMS.shippingLineCredits.view}
>
<ShippingLineCreditsPage />
</RequirePermission>
}
/>
<Route
path="invoices"
element={

View File

@@ -1,5 +1,5 @@
import { Package } from "lucide-react";
import { SimpleGrid, Divider, Box, Table, Text } from "@mantine/core";
import { SimpleGrid, Divider, Box, Table, Text, Badge } from "@mantine/core";
import type { BookingDetail } from "@/types/booking";
import { cargoTonsAndItems } from "@/utils/cargoWeight";
@@ -16,6 +16,19 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
const containers = booking.bookingContainers ?? [];
const { tons, items } = cargoTonsAndItems(booking);
// Booking-level flags OR any container line carrying a count — the flag can
// lag the lines (per-line opt-ins), so either alone must light the tile.
const isHazardous =
booking.isHazardous ||
containers.some((c) => Number(c.hazardousQuantity ?? 0) > 0);
const isReefer =
booking.isReefer ||
containers.some((c) => Number(c.reeferQuantity ?? 0) > 0);
const showHandlingColumns = containers.some(
(c) =>
Number(c.hazardousQuantity ?? 0) > 0 || Number(c.reeferQuantity ?? 0) > 0,
);
return (
<SectionCard icon={Package} title="Cargo specifications" accent="orange">
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="sm">
@@ -27,11 +40,33 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
{items != null && <MetricTile label="Items" value={`${items}`} />}
<MetricTile
label="Hazardous"
value={booking.isHazardous ? "Yes" : "No"}
highlight={booking.isHazardous}
value={isHazardous ? "Yes" : "No"}
highlight={isHazardous}
/>
<MetricTile
label="Refrigerated"
value={isReefer ? "Yes" : "No"}
highlight={isReefer}
/>
</SimpleGrid>
{/* Handling that changes how the yard treats the shipment is flagged
loudly, not buried in the grid. */}
{(isHazardous || isReefer) && (
<Box mt="sm">
{isHazardous && (
<Badge color="red" variant="filled" radius="sm" mr={8}>
Hazardous cargo
</Badge>
)}
{isReefer && (
<Badge color="blue" variant="filled" radius="sm">
Refrigerated cargo
</Badge>
)}
</Box>
)}
{containers.length > 0 && (
<>
<Divider my="lg" color="var(--mantine-color-gray-2)" />
@@ -42,6 +77,8 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
<Table.Th>Container type</Table.Th>
<Table.Th>Qty</Table.Th>
<Table.Th>VGM / unit</Table.Th>
{showHandlingColumns && <Table.Th>Hazardous</Table.Th>}
{showHandlingColumns && <Table.Th>Reefer</Table.Th>}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
@@ -54,6 +91,28 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
</Table.Td>
<Table.Td>{c.quantity}</Table.Td>
<Table.Td>{c.vgmPerUnitTons} t</Table.Td>
{showHandlingColumns && (
<Table.Td>
{Number(c.hazardousQuantity ?? 0) > 0 ? (
<Text fw={700} c="red" size="sm">
{c.hazardousQuantity}
</Text>
) : (
"—"
)}
</Table.Td>
)}
{showHandlingColumns && (
<Table.Td>
{Number(c.reeferQuantity ?? 0) > 0 ? (
<Text fw={700} c="blue" size="sm">
{c.reeferQuantity}
</Text>
) : (
"—"
)}
</Table.Td>
)}
</Table.Tr>
))}
</Table.Tbody>

View File

@@ -24,6 +24,7 @@ import {
Send,
Settings,
ShieldCheck,
HandCoins,
Ship,
SlidersHorizontal,
Train,
@@ -70,6 +71,18 @@ export const buildSidebarSections = (
icon: <Building2 />,
permission: FREIGHT_PERMS.customers.view,
},
{
label: "Shipping Lines",
href: "/dashboard/shipping-lines",
icon: <Ship />,
permission: FREIGHT_PERMS.shippingLines.view,
},
{
label: "Shipping Line Credits",
href: "/dashboard/shipping-line-credits",
icon: <HandCoins />,
permission: FREIGHT_PERMS.shippingLineCredits.view,
},
{
label: "Contracts",
href: "/dashboard/contract-requests",

View File

@@ -227,6 +227,12 @@ const RuleEngineFormDialog = ({
current[name] ? { ...current, [name]: "" } : current,
);
setValues((current) => {
// Mantine fires onChange even when the same option is re-picked, and the
// cascades below clear dependent answers (yards, unit, scope). Re-picking
// an unchanged value must be a no-op, or an untouched direction silently
// wipes the yard pair and the submit fails with "origin/destination
// missing" data the admin did fill in.
if (current[name] === value) return current;
const next = { ...current, [name]: value };
// Changing what a rate applies to (or its surcharge trigger) can invalidate
// the previously-chosen unit — reset it so the admin re-picks from the new
@@ -257,6 +263,34 @@ const RuleEngineFormDialog = ({
next.cargoTypeId = "";
next.rateUnit = "";
}
// Turning the shipping-line toggle on or off swaps the entire form, so
// nothing answered under the other shape may survive into the payload.
if (name === "isShippingLineRate") {
next.shippingLineCompanyId = "";
next.shippingLineRateKind = "";
next.shippingLineCargoKind = "";
next.appliesTo = "";
next.trigger = "";
next.containerTypeId = "";
next.cargoTypeId = "";
next.originYardId = "";
next.destinationYardId = "";
next.rateUnit = "";
}
// Base-vs-surcharge and container-vs-bulk each decide the scope field and
// the legal units for a shipping-line rate, exactly as appliesTo and
// cargoKind do on the customer form.
if (name === "shippingLineRateKind" || name === "shippingLineCargoKind") {
next.containerTypeId = "";
next.cargoTypeId = "";
next.rateUnit = "";
if (name === "shippingLineRateKind") {
next.shippingLineCargoKind = "";
next.trigger = "";
next.originYardId = "";
next.destinationYardId = "";
}
}
return next;
});
};
@@ -347,12 +381,23 @@ const RuleEngineFormDialog = ({
borderRadius: "var(--mantine-radius-md)",
}}
>
<Text size="sm" fw={600}>
{field.label}
</Text>
<Stack gap={2}>
<Text size="sm" fw={600}>
{field.label}
</Text>
{field.description ? (
<Text size="xs" c="dimmed">
{field.description}
</Text>
) : null}
</Stack>
<Switch
checked={Boolean(values[field.name])}
onChange={(e) => setField(field.name, e.currentTarget.checked)}
// A toggle that re-targets what an existing record means (e.g. who
// a rate is priced for) is create-only — flipping it on a saved row
// would silently change every booking that prices off it.
disabled={field.disabled || (field.disabledOnEdit && !!initialRecord)}
size="md"
color="edr-green"
/>
@@ -493,6 +538,12 @@ const RuleEngineFormDialog = ({
// Dynamic options (e.g. rate unit) resolve from the live form values so
// the choices track the other fields the admin has picked.
const options = field.optionsFromValues ? field.optionsFromValues(values) : (field.options ?? []);
// A derived select shows (and submits) its computed value and is locked,
// matching the text-input branch — used by fields the shape decides on the
// admin's behalf, e.g. a shipping-line rate's import-only direction.
const computedSelect = field.computeValue
? String(field.computeValue(values) ?? "")
: undefined;
return (
<Select
key={field.name}
@@ -501,9 +552,18 @@ const RuleEngineFormDialog = ({
placeholder={
selectOptionsLoading ? "Loading options..." : (field.placeholder ?? "Select an option")
}
value={resolveSelectValue(field, values)}
value={
computedSelect !== undefined
? computedSelect
: resolveSelectValue(field, values)
}
onChange={(v) => setField(field.name, v === RULE_ENGINE_SELECT_NONE ? "" : v)}
disabled={selectOptionsLoading}
disabled={
selectOptionsLoading ||
field.disabled ||
(field.disabledOnEdit && !!initialRecord) ||
computedSelect !== undefined
}
// Mantine's Select is not a native input, so `required` only marks it
// visually — handleSubmit is what actually blocks an empty one.
required={field.required}

View File

@@ -0,0 +1,363 @@
import {
Badge,
Button,
Group,
Modal,
Stack,
Text,
Textarea,
TextInput,
} from "@mantine/core";
import { useMutation } from "@tanstack/react-query";
import { Ban, Check, HandCoins, X } from "lucide-react";
import { useState } from "react";
import { useAuth } from "@/auth/useAuth";
import { formatMoney } from "@/components/customers";
import { useToast } from "@/hooks/use-toast";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { api } from "@/services/api";
import type {
CreditInvoiceActionType,
CreditInvoicePendingAction,
} from "@/types/shippingLineCredit";
/** The slice of an invoice row the actions need — both list pages have it. */
export interface CreditInvoiceActionTarget {
id: string;
invoiceNumber: string;
status: string;
currency: string;
totalAmount: string | number;
paidAmount: string | number;
balanceAmount: string | number;
}
/** Statuses an offline payment can still be recorded against. */
const MARK_PAID_STATUSES = new Set([
"ISSUED",
"PENDING",
"PAYMENT_PROCESSING",
"PARTIALLY_PAID",
"OVERDUE",
]);
const ACTION_LABEL: Record<CreditInvoiceActionType, string> = {
MARK_PAID: "Mark paid",
CANCEL: "Cancel invoice",
};
export interface CreditInvoiceActionsProps {
invoice: CreditInvoiceActionTarget;
pendingAction: CreditInvoicePendingAction | null;
}
/**
* Two-step actions for ONE shipping-line credit invoice, embeddable in any
* invoice list. Gated purely by permission: the request grants raise
* mark-paid / cancel, the approve/reject grants decide ANY pending request —
* the holder's own included. Renders only the buttons the signed-in user's
* grants allow; the API enforces the same gates server-side.
*/
export default function CreditInvoiceActions({
invoice,
pendingAction,
}: CreditInvoiceActionsProps) {
const { user } = useAuth();
const { toast } = useToast();
const canRequestPaid = hasPermission(
user,
FREIGHT_PERMS.shippingLineCredits.invoiceMarkPaid,
);
const canRequestCancel = hasPermission(
user,
FREIGHT_PERMS.shippingLineCredits.invoiceCancel,
);
const canApprove = hasPermission(
user,
FREIGHT_PERMS.shippingLineCredits.invoiceApprove,
);
const canReject = hasPermission(
user,
FREIGHT_PERMS.shippingLineCredits.invoiceReject,
);
const [requestAction, setRequestActionModal] =
useState<CreditInvoiceActionType | null>(null);
const [reason, setReason] = useState("");
const [paymentReference, setPaymentReference] = useState("");
const [decideApprove, setDecideApprove] = useState<boolean | null>(null);
const [decisionNote, setDecisionNote] = useState("");
const closeRequest = () => {
setRequestActionModal(null);
setReason("");
setPaymentReference("");
};
const closeDecide = () => {
setDecideApprove(null);
setDecisionNote("");
};
const { mutate: submitRequest, isPending: isRequesting } = useMutation(
api.shippingLineCredits.requestInvoiceAction.mutationOptions({
onSuccess: (_, variables) => {
closeRequest();
toast({
title: "Request submitted",
description: `${ACTION_LABEL[variables.action]} on ${invoice.invoiceNumber} now awaits a chief's approval.`,
});
},
onError: (err) =>
toast({
title: "Could not submit request",
description: err.message,
variant: "destructive",
}),
}),
);
const { mutate: submitDecision, isPending: isDeciding } = useMutation(
api.shippingLineCredits.decideInvoiceAction.mutationOptions({
onSuccess: (_, variables) => {
closeDecide();
toast({
title: variables.approve ? "Request approved" : "Request rejected",
description: variables.approve
? pendingAction?.action === "MARK_PAID"
? "The offline payment was recorded; the invoice and its credits are now paid."
: "The invoice was cancelled; its credits returned to the unbilled pool."
: "The request was rejected and nothing was changed.",
});
},
onError: (err) =>
toast({
title: "Could not decide request",
description: err.message,
variant: "destructive",
}),
}),
);
let body = null;
if (pendingAction) {
body = (
<Stack gap={6} py={4}>
<Badge variant="light" color="orange" title={pendingAction.reason}>
{ACTION_LABEL[pendingAction.action]} awaiting approval
</Badge>
{canApprove || canReject ? (
<Group gap={6} wrap="nowrap">
{canApprove ? (
<Button
size="compact-xs"
color="edr-green"
leftSection={<Check size={12} />}
onClick={() => setDecideApprove(true)}
>
Approve
</Button>
) : null}
{canReject ? (
<Button
size="compact-xs"
variant="outline"
color="red"
leftSection={<X size={12} />}
onClick={() => setDecideApprove(false)}
>
Reject
</Button>
) : null}
</Group>
) : null}
</Stack>
);
} else {
const showMarkPaid =
canRequestPaid && MARK_PAID_STATUSES.has(invoice.status);
const showCancel =
canRequestCancel &&
invoice.status !== "CANCELLED" &&
invoice.status !== "PAID" &&
invoice.status !== "REFUNDED" &&
Number(invoice.paidAmount) === 0;
body =
!showMarkPaid && !showCancel ? (
<Text size="sm" c="dimmed">
</Text>
) : (
<Group gap={6} wrap="nowrap">
{showMarkPaid ? (
<Button
size="compact-xs"
variant="light"
color="edr-green"
leftSection={<HandCoins size={12} />}
onClick={() => setRequestActionModal("MARK_PAID")}
>
Mark paid
</Button>
) : null}
{showCancel ? (
<Button
size="compact-xs"
variant="light"
color="red"
leftSection={<Ban size={12} />}
onClick={() => setRequestActionModal("CANCEL")}
>
Cancel
</Button>
) : null}
</Group>
);
}
return (
<>
{body}
{/* Maker: raise the request. */}
<Modal
opened={requestAction !== null}
onClose={closeRequest}
title={
requestAction
? `${ACTION_LABEL[requestAction]}${invoice.invoiceNumber}`
: ""
}
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
{requestAction === "MARK_PAID"
? "Records a full offline settlement of the outstanding balance. Takes effect only after a chief approves."
: "Voids the invoice and returns its credits to the unbilled pool. Takes effect only after a chief approves."}
</Text>
{requestAction === "MARK_PAID" ? (
<TextInput
label="Payment reference"
description="Bank slip / transfer number, if any."
value={paymentReference}
onChange={(e) => setPaymentReference(e.currentTarget.value)}
/>
) : null}
<Textarea
label="Reason"
withAsterisk
minRows={2}
placeholder={
requestAction === "MARK_PAID"
? "Paid by bank transfer, slip #…"
: "Raised in error / rebilling with corrections…"
}
value={reason}
onChange={(e) => setReason(e.currentTarget.value)}
/>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
onClick={closeRequest}
disabled={isRequesting}
>
Cancel
</Button>
<Button
loading={isRequesting}
disabled={reason.trim().length < 3}
onClick={() =>
requestAction &&
submitRequest({
invoiceId: invoice.id,
action: requestAction,
reason: reason.trim(),
paymentReference: paymentReference.trim() || undefined,
})
}
>
Submit for approval
</Button>
</Group>
</Stack>
</Modal>
{/* Checker: decide the request. */}
<Modal
opened={decideApprove !== null}
onClose={closeDecide}
title={
pendingAction
? `${decideApprove ? "Approve" : "Reject"}: ${ACTION_LABEL[pendingAction.action]}${invoice.invoiceNumber}`
: ""
}
centered
>
<Stack gap="md">
{pendingAction ? (
<Stack gap={4}>
<Text size="sm">
<Text component="span" c="dimmed">
Requested reason:{" "}
</Text>
{pendingAction.reason}
</Text>
{pendingAction.paymentReference ? (
<Text size="sm">
<Text component="span" c="dimmed">
Payment reference:{" "}
</Text>
{pendingAction.paymentReference}
</Text>
) : null}
</Stack>
) : null}
{decideApprove && pendingAction ? (
<Text size="sm" c="dimmed">
{pendingAction.action === "MARK_PAID"
? `Approving records ${formatMoney(
Number(invoice.balanceAmount ?? invoice.totalAmount),
invoice.currency,
)} as paid offline and settles the invoice's credits.`
: "Approving cancels the invoice and returns its credits to the unbilled pool."}
</Text>
) : null}
<Textarea
label={decideApprove ? "Note (optional)" : "Rejection note"}
withAsterisk={!decideApprove}
minRows={2}
value={decisionNote}
onChange={(e) => setDecisionNote(e.currentTarget.value)}
/>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
onClick={closeDecide}
disabled={isDeciding}
>
Back
</Button>
<Button
color={decideApprove ? "edr-green" : "red"}
loading={isDeciding}
disabled={!decideApprove && !decisionNote.trim()}
onClick={() =>
pendingAction &&
decideApprove !== null &&
submitDecision({
approvalId: pendingAction.id,
approve: decideApprove,
note: decisionNote.trim() || undefined,
})
}
>
{decideApprove ? "Approve & execute" : "Reject request"}
</Button>
</Group>
</Stack>
</Modal>
</>
);
}

View File

@@ -0,0 +1,168 @@
import {
ActionIcon,
Alert,
Button,
Modal,
Radio,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import { useMutation } from "@tanstack/react-query";
import { Send } from "lucide-react";
import { useState } from "react";
import { useAuth } from "@/auth/useAuth";
import { useToast } from "@/hooks/use-toast";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { api } from "@/services/api";
import type {
ResetChannel,
ShippingLineCompany,
} from "@/types/shippingLineCompany";
/**
* Whether the SMS gateway can actually reach this number.
*
* The carrier integration is domestic-only: anything else is queued and
* silently lost, so a foreign number counts as unavailable rather than as a
* send that quietly fails. Mirrors `isDomesticPhone` in the API's otp.service.
*/
function isDomesticPhone(rawPhone: string): boolean {
const digits = rawPhone.trim().replace(/[^\d+]/g, "");
const normalized = digits.startsWith("+")
? digits
: /^251\d{9}$/.test(digits)
? `+${digits}`
: /^9\d{8}$|^7\d{8}$/.test(digits.replace(/^0+/, ""))
? `+251${digits.replace(/^0+/, "")}`
: digits;
return /^\+2519\d{8}$/.test(normalized);
}
export interface ResendActivationActionProps {
shippingLine: Pick<ShippingLineCompany, "id" | "name" | "email" | "phoneNumber">;
}
/**
* Resend a shipping line's activation link.
*
* The same single-use link registration sends: the carrier opens it and picks
* their own password, so no credential is ever shown to or handled by staff.
* Needed whenever the original send failed, expired (24h), or never arrived.
*/
export default function ResendActivationAction({
shippingLine,
}: ResendActivationActionProps) {
const { user } = useAuth();
const { toast } = useToast();
const [opened, setOpened] = useState(false);
const [channel, setChannel] = useState<ResetChannel>("email");
const allowed = hasPermission(user, FREIGHT_PERMS.shippingLines.resetPassword);
const { mutate, isPending } = useMutation(
api.shippingLineCompanies.resendActivation.mutationOptions({
onSuccess: (result) => {
setOpened(false);
toast({
title: "Activation link sent",
description: `The shipping line can set their password using the link sent to ${result.maskedTarget}. It expires in 24 hours.`,
});
},
onError: (error) => {
toast({
title: "Could not send activation link",
description: error.message,
variant: "destructive",
});
},
}),
);
if (!allowed) return null;
const phoneUsable =
!!shippingLine.phoneNumber && isDomesticPhone(shippingLine.phoneNumber);
const channelMissing = channel === "phone" && !phoneUsable;
return (
<>
<Tooltip label="Resend activation link" withArrow>
<ActionIcon
variant="subtle"
color="gray"
aria-label={`Resend activation link to ${shippingLine.name}`}
onClick={(event) => {
// The row itself is not clickable today, but stop here anyway so
// adding a detail-page navigation later cannot swallow this click.
event.stopPropagation();
setOpened(true);
}}
>
<Send size={16} />
</ActionIcon>
</Tooltip>
<Modal
opened={opened}
onClose={() => setOpened(false)}
title="Resend activation link"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
We&apos;ll send a single-use link to {shippingLine.name}. They choose
their own password you will not see it. The link expires in 24
hours, and sending a new one invalidates nothing they haven&apos;t
already used.
</Text>
<Radio.Group
value={channel}
onChange={(v) => setChannel(v as ResetChannel)}
label="Send the link via"
>
<Stack gap="xs" mt="xs">
<Radio
value="email"
label="Email"
description={shippingLine.email}
/>
<Radio
value="phone"
label="SMS"
disabled={!phoneUsable}
description={
!shippingLine.phoneNumber
? "No phone number on this account"
: !phoneUsable
? `${shippingLine.phoneNumber} — foreign number, SMS unavailable; use email`
: shippingLine.phoneNumber
}
/>
</Stack>
</Radio.Group>
{channelMissing ? (
<Alert color="yellow" variant="light" p="sm">
<Text size="sm">
This account has no number the SMS gateway can reach. Send the
link by email instead.
</Text>
</Alert>
) : null}
<Button
color="edr-green"
loading={isPending}
disabled={channelMissing}
onClick={() => mutate({ id: shippingLine.id, channel })}
>
Send activation link
</Button>
</Stack>
</Modal>
</>
);
}

View File

@@ -27,7 +27,8 @@ import type { UpdateScheduleWindowRulePayload } from "@/types/trainScheduling";
/** Fallbacks matching the API's global-rules defaults (used when a field is null). */
const DEFAULTS = {
windowOpenHour: 8,
windowCloseHour: 17,
// Equal to open ⇒ 24-hour desk (the default).
windowCloseHour: 8,
windowDurationHours: 3,
docReviewMinutes: 30,
paymentWindowMinutes: 60,

View File

@@ -22,7 +22,8 @@ import type { CreateScheduleWindowRulePayload } from "@/types/trainScheduling";
/** Fallbacks matching the API's global-rules defaults (used if the fetch fails). */
const DEFAULTS = {
windowOpenHour: 8,
windowCloseHour: 17,
// Equal to open ⇒ 24-hour desk (the default).
windowCloseHour: 8,
windowDurationHours: 3,
docReviewMinutes: 30,
paymentWindowMinutes: 60,

View File

@@ -28,6 +28,48 @@ export const QUERY_KEYS = {
byCode: (code: string) => ["dropdown-settings", "by-code", code] as const,
},
SHIPPING_LINE_COMPANIES: {
ROOT: ["shipping-line-companies"] as const,
list: (page: number, limit: number) =>
["shipping-line-companies", "list", page, limit] as const,
byId: (id: string) =>
["shipping-line-companies", "detail", id] as const,
},
SHIPPING_LINE_CREDITS: {
ROOT: ["shipping-line-credits"] as const,
invoices: (
page: number,
pageSize: number,
status?: string,
shippingLineId?: string,
) =>
[
"shipping-line-credits",
"invoices",
shippingLineId ?? "all",
page,
pageSize,
status ?? "all",
] as const,
summary: (shippingLineId?: string) =>
["shipping-line-credits", "summary", shippingLineId ?? "all"] as const,
list: (
page: number,
pageSize: number,
status?: string,
shippingLineId?: string,
) =>
[
"shipping-line-credits",
"list",
shippingLineId ?? "all",
page,
pageSize,
status ?? "all",
] as const,
},
CUSTOMERS: {
ROOT: ["customers"] as const,
stats: ["customers", "stats"] as const,

View File

@@ -77,6 +77,33 @@ export const URL_CONSTANTS = {
BOOKINGS: (id: string | number) => `/customers/${id}/bookings`,
},
/**
* Carriers with a portal login. Distinct from `/shipping-lines`, which is the
* rule-engine's pricing lookup list (a code/label bookings reference).
*/
SHIPPING_LINE_COMPANIES: {
BASE: "/shipping-line-companies",
BY_ID: (id: string) => `/shipping-line-companies/${id}`,
RESEND_ACTIVATION: (id: string) =>
`/shipping-line-companies/${id}/resend-activation`,
},
/** Finance's view of what shipping lines owe (use now, pay later). */
SHIPPING_LINE_CREDITS: {
BASE: "/shipping-line-credits",
SUMMARY: "/shipping-line-credits/summary",
INVOICE: "/shipping-line-credits/invoice",
INVOICES: "/shipping-line-credits/invoices",
MARK_PAID_REQUEST: (invoiceId: string) =>
`/shipping-line-credits/invoices/${invoiceId}/mark-paid-request`,
CANCEL_REQUEST: (invoiceId: string) =>
`/shipping-line-credits/invoices/${invoiceId}/cancel-request`,
APPROVE_ACTION: (approvalId: string) =>
`/shipping-line-credits/invoice-actions/${approvalId}/approve`,
REJECT_ACTION: (approvalId: string) =>
`/shipping-line-credits/invoice-actions/${approvalId}/reject`,
},
COMPANIES: {
BASE: "/companies",
STATS: "/companies/stats",

View File

@@ -3,6 +3,8 @@ import toast from "react-hot-toast";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { api } from "@/services/api";
import { shippingLineCompaniesService } from "@/services/shippingLineCompanies.service";
import type { PaginatedShippingLineCompanies } from "@/types/shippingLineCompany";
import {
ruleEngineService,
type RuleEngineListParams,
@@ -165,6 +167,28 @@ export const useContainerTypeOptions = (
select: (rows) => buildContainerTypeSelectOptions(rows, includeNone),
});
/**
* Shipping lines a rate can be scoped to. Only ACTIVE lines are offered — the
* API refuses a rate filed against a suspended one, so listing them would only
* produce an error on submit. Sorted by name so the picker is scannable.
*/
export const useShippingLineCompanyOptions = (enabled = true) =>
useQuery({
queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions("shipping-line-companies", {}),
// One page well past the number of carriers on the corridor; the picker
// needs the whole list, not a page of it.
queryFn: () => shippingLineCompaniesService.list(1, 200),
enabled,
select: (page: PaginatedShippingLineCompanies) =>
page.items
.filter((line) => line.status === "active")
.map((line) => ({
label: line.scacCode ? `${line.name} (${line.scacCode})` : line.name,
value: line.id,
}))
.sort((a, b) => a.label.localeCompare(b.label)),
});
/**
* Approval-step role options, sourced from the live IAM position types. The
* three pre-IAM role strings are appended (marked "(legacy)") so an approval

View File

@@ -123,6 +123,21 @@ export const FREIGHT_PERMS = {
verify: "edr_freight_app:customers:verify",
resetPassword: "edr_freight_app:customers:reset-password",
},
shippingLines: {
view: "edr_freight_app:shipping_lines:view",
create: "edr_freight_app:shipping_lines:create",
update: "edr_freight_app:shipping_lines:update",
resetPassword: "edr_freight_app:shipping_lines:reset-password",
},
shippingLineCredits: {
view: "edr_freight_app:shipping_line_credits:view",
invoice: "edr_freight_app:shipping_line_credits:invoice",
cancel: "edr_freight_app:shipping_line_credits:cancel",
invoiceMarkPaid: "edr_freight_app:shipping_line_credits:invoice_mark_paid",
invoiceCancel: "edr_freight_app:shipping_line_credits:invoice_cancel",
invoiceApprove: "edr_freight_app:shipping_line_credits:invoice_approve",
invoiceReject: "edr_freight_app:shipping_line_credits:invoice_reject",
},
payments: {
view: "edr_freight_app:payments:view",
},

View File

@@ -138,9 +138,20 @@ export default function DocumentClearanceDetailPage() {
clearance?.milestones?.some(
(m) => m.milestoneCode === "DOCUMENTS_APPROVED" && m.status === "COMPLETED",
) ?? false;
const queriesLocked = Boolean(
(clearance as Freight.ContractClearanceView | undefined)?.preClearanceFinalized,
);
// Querying a document is only possible while the booking is actually in
// review — the server enforces exactly that (reviewDocument asserts
// DOCUMENTS_UNDER_REVIEW), so once clearance is finalized the button could
// only ever produce a 400.
//
// `preClearanceFinalized` alone was not enough: it is a phased-customs field,
// so a non-customs booking (self-clearance, and every shipping-line booking)
// never sets it and kept offering Query after Operations had finalized.
const queriesLocked =
Boolean(
(clearance as Freight.ContractClearanceView | undefined)
?.preClearanceFinalized,
) ||
(booking?.status != null && booking.status !== "DOCUMENTS_UNDER_REVIEW");
const workflowFiles =
(clearance as Freight.ContractClearanceView | undefined)?.workflowFiles ?? [];

View File

@@ -21,6 +21,7 @@ import {
formatMoney,
humanize,
} from "@/components/customers";
import CreditInvoiceActions from "@/components/shipping-lines/CreditInvoiceActions";
import { api } from "@/services/api";
import type { Invoice } from "@/types/invoice";
import {
@@ -58,6 +59,26 @@ export default function InvoicesPanel() {
const total = data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
// Shipping-line credit invoices carry makerchecker actions (mark paid /
// cancel). One batched lookup fetches the visible rows' pending requests.
const creditInvoiceIds = useMemo(
() =>
rows
.filter((inv) => inv.source === "shipping_line_credit")
.map((inv) => inv.id),
[rows],
);
const { data: pendingActions } = useQuery(
api.shippingLineCredits.pendingInvoiceActions.queryOptions({
input: { invoiceIds: creditInvoiceIds },
enabled: creditInvoiceIds.length > 0,
}),
);
const pendingByInvoice = useMemo(
() => new Map((pendingActions ?? []).map((p) => [p.invoiceId, p])),
[pendingActions],
);
const columns: ColumnDef<Invoice>[] = useMemo(
() => [
{
@@ -122,8 +143,30 @@ export default function InvoicesPanel() {
</Text>
),
},
{
id: "actions",
header: "Actions",
cell: ({ row }) => {
const inv = row.original;
// Only shipping-line credit invoices have manual makerchecker
// actions; every other source settles through its own flow.
if (inv.source !== "shipping_line_credit") {
return (
<Text size="sm" c="dimmed">
</Text>
);
}
return (
<CreditInvoiceActions
invoice={inv}
pendingAction={pendingByInvoice.get(inv.id) ?? null}
/>
);
},
},
],
[],
[pendingByInvoice],
);
return (

View File

@@ -43,6 +43,7 @@ import {
useCargoTypeParentOptions,
useContainerTypeOptions,
useLiveRateOptions,
useShippingLineCompanyOptions,
useWagonTypeOptions,
useYardOptions,
type YardOption,
@@ -93,6 +94,20 @@ const yardOptionsForLegEnd = (
values: Record<string, unknown>,
end: "origin" | "destination",
): { label: string; value: string }[] => {
// A shipping-line rate names its shape in its own fields and is always
// import; map it onto the appliesTo/direction pair the rest of this function
// reads so the country narrowing is shared rather than duplicated.
if (values.isShippingLineRate === true) {
if (!values.shippingLineCompanyId) return [];
const isBase = values.shippingLineRateKind === "BASE";
values = {
...values,
appliesTo: isBase
? String(values.shippingLineCargoKind ?? "")
: "OTHER",
tradeDirection: "IMPORT",
};
}
const appliesTo = String(values.appliesTo ?? "");
let country: string | undefined;
if (appliesTo === "INTERCITY") {
@@ -280,6 +295,13 @@ const RuleEngineResourcePage = () => {
useContainerTypeOptions(false, usesContainerTypeField);
const { data: liveRateOptions, isLoading: liveRateOptionsLoading } =
useLiveRateOptions(usesLiveRateField);
const usesShippingLineField = Boolean(
config?.formFields.some((f) => f.name === "shippingLineCompanyId"),
);
const {
data: shippingLineOptions,
isLoading: shippingLineOptionsLoading,
} = useShippingLineCompanyOptions(usesShippingLineField);
const { data: wagonTypeOptions, isLoading: wagonTypeOptionsLoading } =
useWagonTypeOptions(usesWagonTypeField);
const usesYardField = Boolean(
@@ -390,6 +412,13 @@ const RuleEngineResourcePage = () => {
),
};
}
if (field.name === "shippingLineCompanyId") {
return {
...field,
type: "select" as const,
options: shippingLineOptions ?? [],
};
}
if (field.name === "rateId") {
return {
...field,
@@ -612,7 +641,39 @@ const RuleEngineResourcePage = () => {
const handleFormSubmit = (values: Record<string, unknown>) => {
let payload = values;
if (config.slug === "rates") {
if (config.slug === "rates" && values.isShippingLineRate === true) {
// A shipping-line rate asks its shape as "base freight vs surcharge" +
// "container vs bulk"; the API takes the same appliesTo/trigger pair as a
// customer rate, so translate here and drop the form-only fields. Always
// import (the only direction a line ships) and always USD.
const {
isShippingLineRate: _toggle,
shippingLineRateKind,
shippingLineCargoKind,
...rest
} = values;
void _toggle;
const isBase = shippingLineRateKind === "BASE";
payload = {
...rest,
appliesTo: isBase ? String(shippingLineCargoKind ?? "CONTAINER") : "OTHER",
trigger: isBase ? "ALWAYS" : values.trigger,
tradeDirection: "IMPORT",
currency: "USD",
};
if (editing?.id && editing.status === "LIVE") {
rateChangeWorkflow.submit.mutate(
{ rateId: String(editing.id), update: payload },
{
onSuccess: () => {
setFormOpen(false);
setEditing(null);
},
},
);
return;
}
} else if (config.slug === "rates") {
// Base-freight categories have no surcharge trigger field — the engine
// treats them as ALWAYS. Surcharges (Applies to = Other) keep their
// chosen trigger.
@@ -621,7 +682,11 @@ const RuleEngineResourcePage = () => {
// ton·km, container = per km + distance band) and the currency stays as
// chosen (birr or dollar). Everything else remains USD-only.
const isLastMile = values.appliesTo === "LAST_MILE";
const { lastMileMode, ...rest } = values;
// The shipping-line toggle is form-only — the API's whitelist rejects the
// whole payload if it leaks through ("property isShippingLineRate should
// not exist").
const { lastMileMode, isShippingLineRate: _toggle, ...rest } = values;
void _toggle;
payload = {
...rest,
currency: isLastMile ? (values.currency ?? "ETB") : "USD",
@@ -934,6 +999,7 @@ const RuleEngineResourcePage = () => {
(usesLiveRateField && liveRateOptionsLoading) ||
(usesWagonTypeField && wagonTypeOptionsLoading) ||
(usesYardField && yardOptionsLoading) ||
(usesShippingLineField && shippingLineOptionsLoading) ||
(usesApprovalRoleField && approvalRoleOptionsLoading)
}
positionOptions={!editing ? createPositionOptions : undefined}

View File

@@ -97,7 +97,16 @@ export interface RuleEngineOrderConfig {
export interface RuleEngineListTab {
key: string;
label: string;
filters: { appliesTo?: string; trigger?: string };
filters: {
appliesTo?: string;
trigger?: string;
/**
* "true" = only shipping-line rates, "false" = only standard customer
* rates. Sent as a string because tab filters go on the query string
* verbatim.
*/
isShippingLineRate?: string;
};
}
export interface RuleEngineResourceConfig {
@@ -205,6 +214,36 @@ const INTERCITY_KINDS = [
{ label: "Bulk", value: "BULK" },
];
/**
* A shipping-line rate: priced for one carrier's own bookings instead of for
* every customer. The toggle drives the whole form — until a line is picked
* there is nothing to configure, and the shape questions (base freight vs
* surcharge, container vs bulk) are asked only after it is.
*/
const isShippingLineRate = (values: Record<string, unknown>) =>
values.isShippingLineRate === true;
/** A shipping-line rate whose owning line has been chosen — the rest unlocks. */
const hasShippingLine = (values: Record<string, unknown>) =>
isShippingLineRate(values) && Boolean(values.shippingLineCompanyId);
/**
* What a shipping-line rate prices. Deliberately narrower than the customer
* form's `appliesTo`: a line buys base rail freight (its own containers or
* bulk) or a surcharge, and nothing else — intercity and first/last mile are
* customer products.
*/
const SHIPPING_LINE_RATE_KINDS = [
{ label: "Base freight", value: "BASE" },
{ label: "Surcharge", value: "SURCHARGE" },
];
/** Container vs bulk, asked once a shipping-line base-freight rate is chosen. */
const SHIPPING_LINE_CARGO_KINDS = [
{ label: "Container", value: "CONTAINER" },
{ label: "Bulk", value: "BULK" },
];
/** True when the rate being edited is base rail freight, which is priced per leg. */
const isBaseFreightRate = (values: Record<string, unknown>) =>
["BULK", "CONTAINER", "INTERCITY"].includes(String(values.appliesTo ?? ""));
@@ -214,7 +253,15 @@ const isBaseFreightRate = (values: Record<string, unknown>) =>
* the empty-container return surcharge (sold per route + container type).
*/
const isRouteScopedRate = (values: Record<string, unknown>) =>
isBaseFreightRate(values) ||
// A shipping line's base freight is priced per leg exactly like a customer's;
// its surcharges are route-scoped on the same triggers.
(isShippingLineRate(values)
? hasShippingLine(values) &&
(values.shippingLineRateKind === "BASE" ||
["CUSTOMS_CLEARANCE", "WITH_RETURN", "FUEL"].includes(
String(values.trigger ?? ""),
))
: isBaseFreightRate(values)) ||
(String(values.appliesTo ?? "") === "OTHER" &&
["CUSTOMS_CLEARANCE", "WITH_RETURN", "FUEL"].includes(String(values.trigger ?? "")));
@@ -303,6 +350,31 @@ export const rateUnitOptions = (
values: Record<string, unknown>,
cargoUnitOfMeasure = "",
) => {
// A shipping-line rate answers the same two questions under different names —
// map them onto the shape the unit table is keyed by. Base freight for a line
// is CONTAINER/BULK freight; a line surcharge is OTHER + its trigger.
if (isShippingLineRate(values)) {
const { shippingLineRateKind: kind, shippingLineCargoKind: cargoKind } = values;
if (kind === "BASE") {
if (cargoKind !== "CONTAINER" && cargoKind !== "BULK") return [];
return allowedRateUnits(
String(cargoKind),
"ALWAYS",
"",
cargoUnitOfMeasure,
).map(unitOption);
}
if (kind === "SURCHARGE" && values.trigger) {
return allowedRateUnits(
"OTHER",
String(values.trigger),
String(values.cargoKind ?? ""),
cargoUnitOfMeasure,
).map(unitOption);
}
return [];
}
const appliesTo = String(values.appliesTo ?? "");
const trigger = appliesTo === "OTHER" ? String(values.trigger ?? "") : "ALWAYS";
if (!appliesTo) return [];
@@ -830,36 +902,51 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
supportsSearch: true,
// Category tabs — each filters server-side by appliesTo / trigger.
listTabs: [
{ key: "all", label: "All", filters: {} },
{ key: "container", label: "Container", filters: { appliesTo: "CONTAINER" } },
{ key: "bulk", label: "Bulk", filters: { appliesTo: "BULK" } },
{ key: "intercity", label: "Intercity", filters: { appliesTo: "INTERCITY" } },
// "All" and every shape tab show customer rates only — a shipping line's
// negotiated price is its own list, not an extra row in the standard one.
{ key: "all", label: "All", filters: { isShippingLineRate: "false" } },
{
key: "shipping-line",
label: "Shipping line",
filters: { isShippingLineRate: "true" },
},
{ key: "container", label: "Container", filters: { appliesTo: "CONTAINER", isShippingLineRate: "false" } },
{ key: "bulk", label: "Bulk", filters: { appliesTo: "BULK", isShippingLineRate: "false" } },
{ key: "intercity", label: "Intercity", filters: { appliesTo: "INTERCITY", isShippingLineRate: "false" } },
{
key: "trucking",
label: "First / Last mile",
filters: { appliesTo: "FIRST_MILE,LAST_MILE" },
filters: { appliesTo: "FIRST_MILE,LAST_MILE", isShippingLineRate: "false" },
},
{
key: "customs",
label: "Customs clearance",
filters: { trigger: "CUSTOMS_CLEARANCE" },
filters: { trigger: "CUSTOMS_CLEARANCE", isShippingLineRate: "false" },
},
{
key: "return",
label: "Container return",
filters: { trigger: "WITH_RETURN" },
filters: { trigger: "WITH_RETURN", isShippingLineRate: "false" },
},
{
key: "surcharges",
label: "Surcharges",
filters: {
appliesTo: "OTHER",
isShippingLineRate: "false",
trigger:
"HAZARDOUS,OVERWEIGHT,REEFER,SHIPPING_LINE,CONSOLIDATION,LASHING,CANCELLATION,PIL_EXTRA_FEE,FUEL",
},
},
],
columns: [
// Blank on a standard customer rate; the owning carrier on a line rate.
{
id: "shippingLineCompany",
header: "Shipping line",
accessorKey: "shippingLineCompany",
format: "entityLabel",
},
{ id: "appliesTo", header: "Applies to", accessorKey: "appliesTo", format: "code" },
{ id: "trigger", header: "Trigger", accessorKey: "trigger" },
// Base freight is priced per leg, so the route is what tells two otherwise
@@ -879,6 +966,59 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
{ id: "status", header: "Status", accessorKey: "status", format: "rateStatus" },
],
formFields: [
// ── Shipping line rate ────────────────────────────────────────────────
// Flipping this on replaces the whole customer form: the only question
// is which line, and the shape questions follow once it is answered.
{
name: "isShippingLineRate",
label: "Shipping line rate",
type: "boolean",
description:
"Price this rate for one shipping line's own bookings instead of for every customer. A line rate replaces the standard rate on that lane — it does not add to it.",
// The owner is part of a rate's identity, so switching an existing rate
// between customer and line pricing would silently re-target every
// booking that prices off it. Create a new rate instead.
disabledOnEdit: true,
getInitialValue: (record) => Boolean(record.shippingLineCompanyId),
},
{
name: "shippingLineCompanyId",
label: "Shipping line",
type: "select",
required: true,
placeholder: "Which shipping line this rate is for",
description:
"Only this line's bookings price off this rate. A lane the line has no rate for is blocked at booking rather than falling back to the customer price.",
disabledOnEdit: true,
showIf: isShippingLineRate,
},
// What the line is buying. Asked only after a line is picked, so the form
// stays a single question until then.
{
name: "shippingLineRateKind",
label: "Rate type",
type: "select",
required: true,
options: SHIPPING_LINE_RATE_KINDS,
placeholder: "Base freight or a surcharge?",
showIf: hasShippingLine,
// Not stored: base freight carries trigger ALWAYS, a surcharge anything else.
getInitialValue: (record) =>
!record.trigger || record.trigger === "ALWAYS" ? "BASE" : "SURCHARGE",
},
// Container vs bulk — the line form asks this directly instead of folding
// it into `appliesTo` the way the customer form does.
{
name: "shippingLineCargoKind",
label: "Cargo kind",
type: "select",
required: true,
options: SHIPPING_LINE_CARGO_KINDS,
placeholder: "Is this rate for containers or bulk?",
showIf: (v) => hasShippingLine(v) && v.shippingLineRateKind === "BASE",
getInitialValue: (record) =>
record.appliesTo === "BULK" ? "BULK" : "CONTAINER",
},
{
name: "appliesTo",
label: "Applies to",
@@ -887,6 +1027,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
options: RATE_APPLIES_TO,
description:
"Pick what this rate is for. Bulk/Container/Intercity are base freight; Other is an auto-applied surcharge.",
// Derived from the two questions above on a shipping-line rate.
showIf: (v) => !isShippingLineRate(v),
},
// ── Surcharge trigger — only when Applies to = Other ──────────────────
{
@@ -897,6 +1039,20 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
options: RATE_TRIGGERS,
placeholder: "What makes this surcharge apply?",
showWhen: { field: "appliesTo", equals: ["OTHER"] },
showIf: (v) => !isShippingLineRate(v),
},
// The same trigger list for a shipping-line surcharge — a line incurs the
// same charges a customer does (hazard, reefer, demurrage …), just at its
// own negotiated price.
{
name: "trigger",
label: "Surcharge trigger",
type: "select",
required: true,
options: RATE_TRIGGERS,
placeholder: "What makes this surcharge apply?",
showIf: (v) =>
hasShippingLine(v) && v.shippingLineRateKind === "SURCHARGE",
},
// ── Trade direction — Bulk & Container base freight, plus the route-
// scoped surcharges (customs clearance; empty-container return, which is
@@ -915,11 +1071,31 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
? FUEL_TRADE_DIRECTIONS
: TRADE_DIRECTIONS.filter((d) => d.value !== "BOTH"),
showIf: (v) =>
["BULK", "CONTAINER"].includes(String(v.appliesTo ?? "")) ||
(String(v.appliesTo ?? "") === "OTHER" &&
["CUSTOMS_CLEARANCE", "WITH_RETURN", "LASHING", "FUEL"].includes(
String(v.trigger ?? ""),
)),
!isShippingLineRate(v) &&
(["BULK", "CONTAINER"].includes(String(v.appliesTo ?? "")) ||
(String(v.appliesTo ?? "") === "OTHER" &&
["CUSTOMS_CLEARANCE", "WITH_RETURN", "LASHING", "FUEL"].includes(
String(v.trigger ?? ""),
))),
},
// Shipping lines only ever ship import — the export leg is sold through
// the customer's contract — so the direction is stated, not asked. Shown
// as a locked field rather than hidden so the lane the yard pickers are
// filtered by is visible.
{
name: "tradeDirection",
label: "Trade direction",
type: "select",
required: true,
options: [{ label: "Import", value: "IMPORT" }],
description: "Shipping line rates are import-only.",
disabled: true,
// No defaultValue: field names repeat across form variants and the
// seeded initial value is shared, so defaulting here would pre-select
// Import on the customer form's own direction field too. computeValue
// pins IMPORT on submit and locks the input regardless.
computeValue: () => "IMPORT",
showIf: hasShippingLine,
},
// ── Cargo kind — customs clearance is priced separately for containers
// (one rate per container type) and bulk ───────────────────────────────
@@ -1089,9 +1265,24 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
optional: true,
placeholder: "Select container type (optional)",
showIf: (v) =>
v.appliesTo === "CONTAINER" ||
(v.appliesTo === "INTERCITY" && v.intercityKind === "CONTAINER") ||
(v.appliesTo === "OTHER" && v.trigger === "WITH_RETURN"),
!isShippingLineRate(v) &&
(v.appliesTo === "CONTAINER" ||
(v.appliesTo === "INTERCITY" && v.intercityKind === "CONTAINER") ||
(v.appliesTo === "OTHER" && v.trigger === "WITH_RETURN")),
},
// Container type for a shipping-line base-freight rate. Required here,
// unlike the customer form's optional catch-all: a line negotiates a
// price per box size, so an unscoped line rate has no meaning.
{
name: "containerTypeId",
label: "Container type",
type: "select",
required: true,
placeholder: "Which container type this rate covers",
showIf: (v) =>
hasShippingLine(v) &&
v.shippingLineRateKind === "BASE" &&
v.shippingLineCargoKind === "CONTAINER",
},
// ── Bulk cargo (leaf commodity) — Bulk freight, and bulk-kind intercity ─
{
@@ -1101,8 +1292,23 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
optional: true,
placeholder: "Select bulk commodity (optional)",
showIf: (v) =>
v.appliesTo === "BULK" ||
(v.appliesTo === "INTERCITY" && v.intercityKind === "BULK"),
!isShippingLineRate(v) &&
(v.appliesTo === "BULK" ||
(v.appliesTo === "INTERCITY" && v.intercityKind === "BULK")),
},
// Bulk commodity for a shipping-line base-freight rate. Its unit of
// measure decides the rate unit offered below — a counted commodity
// (PER_ITEM) prices per item where a weighed one prices per ton.
{
name: "cargoTypeId",
label: "Bulk cargo type",
type: "select",
required: true,
placeholder: "Which bulk commodity this rate covers",
showIf: (v) =>
hasShippingLine(v) &&
v.shippingLineRateKind === "BASE" &&
v.shippingLineCargoKind === "BULK",
},
// ── The leg this rate prices — base freight only ──────────────────────
// Options are narrowed to the countries the direction allows (import

View File

@@ -0,0 +1,405 @@
import {
Alert,
Badge,
Box,
Button,
Card,
Group,
Modal,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Info, Mail, Phone, Plus, Ship } from "lucide-react";
import { useMemo, useState } from "react";
import { useAuth } from "@/auth/useAuth";
import { PageContainer, PageHeader } from "@/components/page";
import ResendActivationAction from "@/components/shipping-lines/ResendActivationAction";
import { useToast } from "@/hooks/use-toast";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { api } from "@/services/api";
import type { ShippingLineCompany } from "@/types/shippingLineCompany";
import {
DataTable,
DataTableFooter,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
/** SCAC is 2-4 letters; the API enforces the same rule. */
const SCAC_PATTERN = /^[A-Za-z]{2,4}$/;
interface FormValues {
name: string;
email: string;
phoneNumber: string;
scacCode: string;
imoNumber: string;
bicCode: string;
}
const EMPTY_FORM: FormValues = {
name: "",
email: "",
phoneNumber: "",
scacCode: "",
imoNumber: "",
bicCode: "",
};
const formatDate = (iso: string) =>
new Date(iso).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
/**
* Shipping line companies — carriers with their own portal login.
*
* Registration is staff-only: there is no self-signup. Staff never set a
* password; the system emails (and texts, when the number is domestic) a
* single-use activation link that the carrier uses to choose their own.
*/
export default function ShippingLineCompaniesPage() {
const { user } = useAuth();
const { toast } = useToast();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [registerOpen, setRegisterOpen] = useState(false);
const canCreate = hasPermission(user, FREIGHT_PERMS.shippingLines.create);
const { data, isLoading, isError, error, refetch } = useQuery(
api.shippingLineCompanies.list.queryOptions({
input: {
page: pagination.pageIndex + 1,
limit: pagination.pageSize,
},
}),
);
const rows = data?.items ?? [];
const total = data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const [values, setValues] = useState<FormValues>(EMPTY_FORM);
const [touched, setTouched] = useState(false);
const setField = (field: keyof FormValues) => (value: string) =>
setValues((prev) => ({ ...prev, [field]: value }));
// Mirrors the API's own validation, so the obvious mistakes are caught before
// a round trip. The server still enforces all of it.
const errors = {
name: values.name.trim() ? null : "Company name is required",
// Required, unlike a customer's: the activation link is sent here, so an
// account without one could never be signed in to.
email: /^\S+@\S+\.\S+$/.test(values.email.trim())
? null
: "A valid email is required",
scacCode:
!values.scacCode.trim() || SCAC_PATTERN.test(values.scacCode.trim())
? null
: "SCAC must be 2-4 letters",
};
const isValid = !errors.name && !errors.email && !errors.scacCode;
const closeRegister = () => {
setRegisterOpen(false);
setValues(EMPTY_FORM);
setTouched(false);
};
const { mutate: register, isPending: isRegistering } = useMutation(
api.shippingLineCompanies.register.mutationOptions({
onSuccess: (result) => {
closeRegister();
toast({
title: "Shipping line registered",
description: result.activationSentTo
? `An activation link was sent to ${result.activationSentTo}. It expires in 24 hours.`
: // The account exists and is valid — only delivery failed, and the
// link can be resent, so this is a warning rather than an error.
"The account was created, but the activation link could not be sent. Use “Resend activation” to try again.",
variant: result.activationSentTo ? undefined : "destructive",
});
},
onError: (err) => {
toast({
title: "Could not register shipping line",
description: err.message,
variant: "destructive",
});
},
}),
);
const columns: ColumnDef<ShippingLineCompany>[] = useMemo(
() => [
{
id: "name",
header: "Shipping line",
cell: ({ row }) => {
const sl = row.original;
return (
<Group gap="sm" wrap="nowrap">
<Box
className="flex size-9 shrink-0 items-center justify-center rounded-lg"
style={{
background: "var(--mantine-color-edr-green-1)",
color: "var(--mantine-color-edr-green-7)",
}}
>
<Ship size={18} strokeWidth={1.9} />
</Box>
<div style={{ minWidth: 0 }}>
<Text fw={600} c="edr-text" truncate>
{sl.name}
</Text>
{sl.scacCode ? (
<Text size="xs" c="dimmed">
SCAC {sl.scacCode}
</Text>
) : null}
</div>
</Group>
);
},
},
{
id: "contact",
header: "Contact",
cell: ({ row }) => {
const sl = row.original;
return (
<Stack gap={2}>
<Group gap={6} wrap="nowrap">
<Mail size={13} className="shrink-0 text-gray-400" />
<Text size="sm" truncate>
{sl.email}
</Text>
</Group>
{sl.phoneNumber ? (
<Group gap={6} wrap="nowrap">
<Phone size={13} className="shrink-0 text-gray-400" />
<Text size="sm" c="dimmed">
{sl.phoneNumber}
</Text>
</Group>
) : null}
</Stack>
);
},
},
{
id: "identifiers",
header: "Identifiers",
cell: ({ row }) => {
const { imoNumber, bicCode } = row.original;
if (!imoNumber && !bicCode) {
return (
<Text size="sm" c="dimmed">
</Text>
);
}
return (
<Stack gap={2}>
{imoNumber ? <Text size="sm">IMO {imoNumber}</Text> : null}
{bicCode ? (
<Text size="sm" c="dimmed">
BIC {bicCode}
</Text>
) : null}
</Stack>
);
},
},
{
id: "status",
header: "Status",
cell: ({ row }) => (
<Badge
variant="light"
color={row.original.status === "active" ? "green" : "red"}
>
{row.original.status === "active" ? "Active" : "Suspended"}
</Badge>
),
},
{
id: "createdAt",
header: "Registered",
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{formatDate(row.original.createdAt)}
</Text>
),
},
{
id: "actions",
header: "",
cell: ({ row }) => (
<Group gap={4} justify="flex-end" wrap="nowrap">
<ResendActivationAction shippingLine={row.original} />
</Group>
),
},
],
[],
);
return (
<PageContainer>
<Stack gap="lg">
<PageHeader
title="Shipping Lines"
subtitle="Carriers with their own portal access. Registered by staff — there is no self-signup."
action={
canCreate ? (
<Button
leftSection={<Plus size={16} />}
onClick={() => setRegisterOpen(true)}
>
Register shipping line
</Button>
) : null
}
/>
<Card withBorder padding={0} radius="md">
<DataTable
columns={columns}
data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"}
emptyMessage="No shipping lines registered yet."
error={
isError
? {
message: error?.message ?? "Failed to load shipping lines.",
onRetry: () => void refetch(),
}
: undefined
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
</Card>
</Stack>
<Modal
opened={registerOpen}
onClose={closeRegister}
title="Register shipping line"
centered
>
<form
onSubmit={(event) => {
event.preventDefault();
setTouched(true);
if (!isValid) return;
register({
name: values.name.trim(),
email: values.email.trim(),
phoneNumber: values.phoneNumber.trim() || undefined,
scacCode: values.scacCode.trim() || undefined,
imoNumber: values.imoNumber.trim() || undefined,
bicCode: values.bicCode.trim() || undefined,
});
}}
>
<Stack gap="md">
<Alert
icon={<Info size={16} />}
color="blue"
variant="light"
p="sm"
>
<Text size="sm">
No password is set here. The shipping line receives a single-use
activation link and chooses their own.
</Text>
</Alert>
<TextInput
label="Company name"
placeholder="Ethiopian Shipping Lines"
withAsterisk
value={values.name}
onChange={(e) => setField("name")(e.currentTarget.value)}
error={touched ? errors.name : null}
/>
<TextInput
label="Email"
placeholder="ops@example.com"
description="The activation link is sent here."
withAsterisk
value={values.email}
onChange={(e) => setField("email")(e.currentTarget.value)}
error={touched ? errors.email : null}
/>
<TextInput
label="Phone number"
placeholder="+251911223344"
description="Ethiopian numbers also receive the link by SMS."
value={values.phoneNumber}
onChange={(e) => setField("phoneNumber")(e.currentTarget.value)}
/>
<Group grow align="flex-start">
<TextInput
label="SCAC"
placeholder="ESLK"
value={values.scacCode}
onChange={(e) => setField("scacCode")(e.currentTarget.value)}
error={touched ? errors.scacCode : null}
/>
<TextInput
label="IMO number"
placeholder="IMO9074729"
value={values.imoNumber}
onChange={(e) => setField("imoNumber")(e.currentTarget.value)}
/>
</Group>
<TextInput
label="BIC code"
placeholder="ESLU"
value={values.bicCode}
onChange={(e) => setField("bicCode")(e.currentTarget.value)}
/>
<Group justify="flex-end" gap="sm" mt="xs">
<Button
variant="default"
onClick={closeRegister}
disabled={isRegistering}
>
Cancel
</Button>
<Button type="submit" loading={isRegistering}>
Register &amp; send link
</Button>
</Group>
</Stack>
</form>
</Modal>
</PageContainer>
);
}

View File

@@ -0,0 +1,279 @@
import {
Badge,
Box,
Button,
Card,
Group,
Select,
Stack,
Text,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { Calendar, FilterX, RefreshCw, Ship } from "lucide-react";
import { useMemo, useState } from "react";
import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { formatDate, formatMoney } from "@/components/customers";
import CreditInvoiceActions from "@/components/shipping-lines/CreditInvoiceActions";
import { api } from "@/services/api";
import type { CreditInvoice } from "@/types/shippingLineCredit";
import {
DataTable,
DataTableFooter,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
const INVOICE_STATUS_META: Record<string, { label: string; color: string }> = {
DRAFT: { label: "Draft", color: "gray" },
ISSUED: { label: "Issued", color: "orange" },
PENDING: { label: "Pending", color: "orange" },
PAYMENT_PROCESSING: { label: "Processing", color: "blue" },
PARTIALLY_PAID: { label: "Partially paid", color: "yellow" },
PAID: { label: "Paid", color: "green" },
OVERDUE: { label: "Overdue", color: "red" },
CANCELLED: { label: "Cancelled", color: "gray" },
REFUNDED: { label: "Refunded", color: "blue" },
EXPIRED: { label: "Expired", color: "red" },
};
const STATUS_OPTIONS = Object.entries(INVOICE_STATUS_META).map(
([value, meta]) => ({ value, label: meta.label }),
);
/**
* Invoices minted from credit batches. The actions column is the shared
* makerchecker component (also embedded on the Finance hub's invoice list):
* finance requests mark-paid / cancel, a chief approves or rejects.
*/
export default function ShippingLineCreditInvoicesPanel() {
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [status, setStatus] = useState<string | null>(null);
const [shippingLineId, setShippingLineId] = useState<string | null>(null);
const { data: companies } = useQuery(
api.shippingLineCompanies.list.queryOptions({
input: { page: 1, limit: 100 },
}),
);
const lineOptions = useMemo(
() =>
(companies?.items ?? []).map((sl) => ({ value: sl.id, label: sl.name })),
[companies],
);
const { data, isLoading, isError, error, refetch, isFetching } = useQuery(
api.shippingLineCredits.listInvoices.queryOptions({
input: {
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
status: status ?? undefined,
shippingLineId: shippingLineId ?? undefined,
},
}),
);
const rows = data?.items ?? [];
const total = data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const activeFilterCount = (shippingLineId ? 1 : 0) + (status ? 1 : 0);
const resetPage = () =>
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
const columns: ColumnDef<CreditInvoice>[] = useMemo(
() => [
{
id: "invoice",
header: () => <span className={bookingTable.headerCell}>Invoice</span>,
cell: ({ row }) => {
const inv = row.original;
return (
<div className="flex items-center gap-3 py-1.5">
<div className={bookingTable.rowIcon}>
<Ship className="size-4" strokeWidth={1.75} />
</div>
<div className="min-w-0">
<p className="truncate font-mono text-sm font-semibold text-foreground">
{inv.invoiceNumber}
</p>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{inv.shippingLineName ?? "—"}
</p>
</div>
</div>
);
},
},
{
id: "issued",
header: () => <span className={bookingTable.headerCell}>Issued</span>,
cell: ({ row }) => (
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<Calendar className="size-3.5" />
{row.original.issuedAt ? formatDate(row.original.issuedAt) : "—"}
</span>
),
},
{
id: "due",
header: () => <span className={bookingTable.headerCell}>Due</span>,
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{row.original.dueAt ? formatDate(row.original.dueAt) : "—"}
</span>
),
},
{
id: "amount",
header: () => <span className={bookingTable.headerCell}>Amount</span>,
cell: ({ row }) => (
<span className="text-sm font-semibold text-foreground">
{formatMoney(
Number(row.original.totalAmount),
row.original.currency,
)}
</span>
),
},
{
id: "balance",
header: () => <span className={bookingTable.headerCell}>Balance</span>,
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{formatMoney(
Number(row.original.balanceAmount ?? row.original.totalAmount),
row.original.currency,
)}
</span>
),
},
{
id: "status",
header: () => <span className={bookingTable.headerCell}>Status</span>,
cell: ({ row }) => {
const meta = INVOICE_STATUS_META[row.original.status] ?? {
label: row.original.status,
color: "gray",
};
return (
<Badge variant="light" color={meta.color}>
{meta.label}
</Badge>
);
},
},
{
id: "actions",
header: () => <span className={bookingTable.headerCell}>Actions</span>,
cell: ({ row }) => (
<CreditInvoiceActions
invoice={row.original}
pendingAction={row.original.pendingAction}
/>
),
},
],
[],
);
return (
<Stack gap="lg">
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<Group justify="space-between" gap="md" wrap="wrap">
<Group gap="sm" wrap="wrap">
<Select
placeholder="All shipping lines"
data={lineOptions}
value={shippingLineId}
onChange={(v) => {
setShippingLineId(v);
resetPage();
}}
clearable
searchable
radius="lg"
style={{ minWidth: 220 }}
/>
<Select
placeholder="All statuses"
data={STATUS_OPTIONS}
value={status}
onChange={(v) => {
setStatus(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 160 }}
/>
{activeFilterCount > 0 ? (
<Button
variant="subtle"
color="gray"
radius="lg"
leftSection={<FilterX size={16} />}
onClick={() => {
setShippingLineId(null);
setStatus(null);
resetPage();
}}
>
Clear filters ({activeFilterCount})
</Button>
) : null}
</Group>
<Group gap="sm" wrap="nowrap">
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
<Button
variant="default"
size="compact-sm"
leftSection={<RefreshCw size={14} />}
loading={isFetching}
onClick={() => void refetch()}
>
Refresh
</Button>
</Group>
</Group>
</Box>
<Box style={{ overflowX: "auto" }} w="100%">
<DataTable
columns={columns}
data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"}
emptyMessage="No credit invoices yet — generate one from the Credits tab."
error={
isError
? {
message: error?.message ?? "Failed to load invoices.",
onRetry: () => void refetch(),
}
: undefined
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
</Box>
</Stack>
</Card>
</Stack>
);
}

View File

@@ -0,0 +1,65 @@
import { Stack, Tabs } from "@mantine/core";
import { HandCoins, Receipt } from "lucide-react";
import { useSearchParams } from "react-router-dom";
import { PageContainer, PageHeader } from "@/components/page";
import ShippingLineCreditInvoicesPanel from "./ShippingLineCreditInvoicesPanel";
import ShippingLineCreditsPanel from "./ShippingLineCreditsPanel";
/**
* Finance's view of what shipping lines owe. Two URL-linkable tabs (?tab=,
* FinanceHubPage convention): the credit ledger (select unbilled credits →
* generate an invoice) and the invoices minted from it (makerchecker
* mark-paid / cancel actions).
*/
export default function ShippingLineCreditsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const activeTab =
searchParams.get("tab") === "invoices" ? "invoices" : "credits";
const handleTabChange = (value: string | null) => {
if (!value) return;
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
next.set("tab", value);
return next;
},
{ replace: true },
);
};
return (
<PageContainer>
<Stack gap="lg">
<PageHeader
title="Shipping Line Credits"
subtitle={
activeTab === "invoices"
? "Invoices billed from credit batches. Manual mark-paid / cancel actions need a second approver."
: "What each line owes — outstanding totals and the full credit ledger."
}
/>
<Tabs value={activeTab} onChange={handleTabChange} keepMounted={false}>
<Tabs.List>
<Tabs.Tab value="credits" leftSection={<HandCoins size={16} />}>
Credits
</Tabs.Tab>
<Tabs.Tab value="invoices" leftSection={<Receipt size={16} />}>
Invoices
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="credits" pt="lg">
<ShippingLineCreditsPanel />
</Tabs.Panel>
<Tabs.Panel value="invoices" pt="lg">
<ShippingLineCreditInvoicesPanel />
</Tabs.Panel>
</Tabs>
</Stack>
</PageContainer>
);
}

View File

@@ -0,0 +1,553 @@
import {
Badge,
Box,
Button,
Card,
Checkbox,
Divider,
Group,
Modal,
NumberInput,
Select,
Stack,
Text,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
Calendar,
Clock,
FilterX,
HandCoins,
Receipt,
RefreshCw,
Ship,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useAuth } from "@/auth/useAuth";
import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { formatDate, formatMoney } from "@/components/customers";
import { KpiStrip } from "@/components/page";
import { useToast } from "@/hooks/use-toast";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { api } from "@/services/api";
import type {
ShippingLineCredit,
ShippingLineCreditStatus,
} from "@/types/shippingLineCredit";
import {
DataTable,
DataTableFooter,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
const STATUS_META: Record<
ShippingLineCreditStatus,
{ label: string; color: string }
> = {
UNBILLED: { label: "Unbilled", color: "orange" },
BILLED: { label: "Billed", color: "blue" },
PAID: { label: "Paid", color: "green" },
CANCELLED: { label: "Cancelled", color: "gray" },
};
const STATUS_OPTIONS = Object.entries(STATUS_META).map(([value, meta]) => ({
value,
label: meta.label,
}));
/**
* Every shipping line's credits in one list — finance's landing view, styled
* to match the booking-requests page. Summary cells total the current filter
* scope (all lines by default); the selects narrow both cells and ledger.
*/
export default function ShippingLineCreditsPanel() {
const { user } = useAuth();
const { toast } = useToast();
const [shippingLineId, setShippingLineId] = useState<string | null>(null);
const [status, setStatus] = useState<ShippingLineCreditStatus | null>(null);
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const canInvoice = hasPermission(
user,
FREIGHT_PERMS.shippingLineCredits.invoice,
);
// Selection for batch invoicing, kept as id → credit so it survives page
// changes and can total itself. One invoice has one payer, so everything
// selected must belong to the same shipping line — enforced here so the
// API's rejection is never the first time staff hears about it.
const [selected, setSelected] = useState<Map<string, ShippingLineCredit>>(
new Map(),
);
const [invoiceOpen, setInvoiceOpen] = useState(false);
const [dueInDays, setDueInDays] = useState<number | "">("");
const selectedCredits = useMemo(() => [...selected.values()], [selected]);
const selectedLineId = selectedCredits[0]?.shippingLineCompanyId ?? null;
const selectedTotal = selectedCredits.reduce(
(sum, c) => sum + Number(c.amount),
0,
);
const toggleSelected = (credit: ShippingLineCredit) =>
setSelected((prev) => {
const next = new Map(prev);
if (next.has(credit.id)) next.delete(credit.id);
else next.set(credit.id, credit);
return next;
});
const clearSelection = () => setSelected(new Map());
// ponytail: first 100 lines in the picker; server-side search when a real
// deployment outgrows that.
const { data: companies } = useQuery(
api.shippingLineCompanies.list.queryOptions({
input: { page: 1, limit: 100 },
}),
);
const lineOptions = useMemo(
() =>
(companies?.items ?? []).map((sl) => ({
value: sl.id,
label: sl.scacCode ? `${sl.name} (${sl.scacCode})` : sl.name,
})),
[companies],
);
const {
data: summary,
isLoading: summaryLoading,
refetch: refetchSummary,
} = useQuery(
api.shippingLineCredits.summary.queryOptions({
input: { shippingLineId: shippingLineId ?? undefined },
}),
);
const {
data: ledger,
isLoading,
isError,
error,
refetch,
isFetching,
} = useQuery(
api.shippingLineCredits.list.queryOptions({
input: {
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
status: status ?? undefined,
shippingLineId: shippingLineId ?? undefined,
},
}),
);
const rows = ledger?.items ?? [];
const total = ledger?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const activeFilterCount = (shippingLineId ? 1 : 0) + (status ? 1 : 0);
const resetPage = () =>
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
const clearFilters = () => {
setShippingLineId(null);
setStatus(null);
resetPage();
};
const handleRefresh = () => {
void refetch();
void refetchSummary();
};
const { mutate: generateInvoice, isPending: isInvoicing } = useMutation(
api.shippingLineCredits.generateInvoice.mutationOptions({
onSuccess: (invoice) => {
setInvoiceOpen(false);
clearSelection();
setDueInDays("");
toast({
title: `Invoice ${invoice.invoiceNumber} generated`,
description: `${formatMoney(Number(invoice.totalAmount), invoice.currency)} billed across ${selectedCredits.length} credit${selectedCredits.length === 1 ? "" : "s"}.`,
});
},
onError: (err) => {
toast({
title: "Could not generate invoice",
description: err.message,
variant: "destructive",
});
// A concurrent edit (someone else billed a selected credit) is the
// usual cause — resync so stale rows drop out of the list.
handleRefresh();
},
}),
);
const columns: ColumnDef<ShippingLineCredit>[] = useMemo(
() => [
...(canInvoice
? [
{
id: "select",
size: 40,
header: () => null,
cell: ({ row }: { row: { original: ShippingLineCredit } }) => {
const credit = row.original;
const selectable =
credit.status === "UNBILLED" &&
(selectedLineId === null ||
credit.shippingLineCompanyId === selectedLineId);
return (
<Checkbox
size="sm"
checked={selected.has(credit.id)}
disabled={!selectable}
title={
credit.status !== "UNBILLED"
? "Only unbilled credits can be invoiced"
: !selectable
? "One invoice has one payer — selection already holds another line's credits"
: undefined
}
onChange={() => toggleSelected(credit)}
aria-label="Select credit for invoicing"
/>
);
},
},
]
: []),
{
id: "shippingLine",
header: () => (
<span className={bookingTable.headerCell}>Shipping line</span>
),
cell: ({ row }) => {
const credit = row.original;
return (
<div className="flex items-center gap-3 py-1.5">
<div className={bookingTable.rowIcon}>
<Ship className="size-4" strokeWidth={1.75} />
</div>
<div className="min-w-0">
<p className="truncate font-medium text-foreground">
{credit.shippingLineCompany?.name ?? "—"}
</p>
<p className="mt-0.5 truncate font-mono text-xs text-muted-foreground">
{credit.booking?.reference ?? "—"}
</p>
</div>
</div>
);
},
},
{
id: "description",
header: () => (
<span className={bookingTable.headerCell}>Description</span>
),
cell: ({ row }) => (
<span className="block max-w-[16rem] truncate py-1 text-sm text-muted-foreground">
{row.original.description ?? "—"}
</span>
),
},
{
id: "amount",
header: () => <span className={bookingTable.headerCell}>Amount</span>,
cell: ({ row }) => (
<span className="text-sm font-semibold text-foreground">
{formatMoney(Number(row.original.amount), row.original.currency)}
</span>
),
},
{
id: "status",
header: () => <span className={bookingTable.headerCell}>Status</span>,
cell: ({ row }) => {
const meta = STATUS_META[row.original.status];
return (
<Badge variant="light" color={meta.color}>
{meta.label}
</Badge>
);
},
},
{
id: "invoice",
header: () => <span className={bookingTable.headerCell}>Invoice</span>,
cell: ({ row }) => {
const inv = row.original.invoice;
return inv ? (
<span className="truncate font-mono text-xs text-foreground">
{inv.invoiceNumber}
</span>
) : (
<span className="text-xs text-muted-foreground"></span>
);
},
},
{
id: "createdAt",
header: () => <span className={bookingTable.headerCell}>Recorded</span>,
cell: ({ row }) => (
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<Calendar className="size-3.5" />
{formatDate(row.original.createdAt)}
</span>
),
},
],
// Selection state drives the checkbox column's checked/disabled rendering.
// eslint-disable-next-line react-hooks/exhaustive-deps
[canInvoice, selected, selectedLineId],
);
return (
<Stack gap="lg">
<KpiStrip
loading={summaryLoading}
items={[
{
label: "Total outstanding",
value: summary
? formatMoney(summary.totalOutstanding, summary.currency)
: "—",
hint: "unbilled + billed",
icon: HandCoins,
color: "edr-green",
},
{
label: "Unbilled",
value: summary
? formatMoney(summary.unbilledAmount, summary.currency)
: "—",
hint: summary ? `${summary.unbilledCount} credits` : undefined,
icon: Clock,
color: "yellow",
},
{
label: "Billed",
value: summary
? formatMoney(summary.billedAmount, summary.currency)
: "—",
hint: summary ? `${summary.billedCount} on invoices` : undefined,
icon: Receipt,
color: "blue",
},
]}
/>
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<Group justify="space-between" gap="md" wrap="wrap">
<Group gap="sm" wrap="wrap">
<Select
placeholder="All shipping lines"
data={lineOptions}
value={shippingLineId}
onChange={(v) => {
setShippingLineId(v);
resetPage();
}}
clearable
searchable
radius="lg"
style={{ minWidth: 220 }}
/>
<Select
placeholder="All statuses"
data={STATUS_OPTIONS}
value={status}
onChange={(v) => {
setStatus((v as ShippingLineCreditStatus | null) ?? null);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 160 }}
/>
{activeFilterCount > 0 ? (
<Button
variant="subtle"
color="gray"
radius="lg"
leftSection={<FilterX size={16} />}
onClick={clearFilters}
>
Clear filters ({activeFilterCount})
</Button>
) : null}
</Group>
<Group gap="sm" wrap="nowrap">
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
<Button
variant="default"
size="compact-sm"
leftSection={<RefreshCw size={14} />}
loading={isFetching}
onClick={handleRefresh}
>
Refresh
</Button>
</Group>
</Group>
</Box>
{selectedCredits.length > 0 ? (
<>
<Divider />
<Group
px="md"
py="sm"
justify="space-between"
wrap="wrap"
bg="var(--mantine-color-edr-green-0)"
>
<Text size="sm" fw={600}>
{selectedCredits.length} credit
{selectedCredits.length === 1 ? "" : "s"} selected ·{" "}
{formatMoney(selectedTotal, selectedCredits[0].currency)}
{" — "}
{selectedCredits[0].shippingLineCompany?.name ?? ""}
</Text>
<Group gap="sm">
<Button
variant="subtle"
color="gray"
size="compact-sm"
onClick={clearSelection}
>
Clear selection
</Button>
<Button
size="compact-sm"
leftSection={<Receipt size={14} />}
onClick={() => setInvoiceOpen(true)}
>
Generate invoice
</Button>
</Group>
</Group>
</>
) : null}
<Box style={{ overflowX: "auto" }} w="100%">
<DataTable
columns={columns}
data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"}
emptyMessage="No credits match this filter."
error={
isError
? {
message: error?.message ?? "Failed to load credits.",
onRetry: () => void refetch(),
}
: undefined
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
</Box>
</Stack>
</Card>
<Modal
opened={invoiceOpen}
onClose={() => setInvoiceOpen(false)}
title="Generate invoice"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
One invoice for{" "}
<Text component="span" fw={600} c="edr-text">
{selectedCredits[0]?.shippingLineCompany?.name ?? "this line"}
</Text>{" "}
billing the selected credits. The line pays it at any CBE channel
there is no payment window.
</Text>
<Stack gap={6}>
{selectedCredits.map((credit) => (
<Group key={credit.id} justify="space-between" wrap="nowrap">
<Text size="sm" truncate>
{credit.booking?.reference ?? credit.description ?? credit.id}
</Text>
<Text size="sm" fw={500} style={{ whiteSpace: "nowrap" }}>
{formatMoney(Number(credit.amount), credit.currency)}
</Text>
</Group>
))}
<Divider my={4} />
<Group justify="space-between">
<Text size="sm" fw={700}>
Total
</Text>
<Text size="sm" fw={700}>
{formatMoney(
selectedTotal,
selectedCredits[0]?.currency ?? "ETB",
)}
</Text>
</Group>
</Stack>
<NumberInput
label="Due in days"
description="Optional — defaults to the standard invoice term."
placeholder="14"
min={1}
value={dueInDays}
onChange={(v) => setDueInDays(typeof v === "number" ? v : "")}
/>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
onClick={() => setInvoiceOpen(false)}
disabled={isInvoicing}
>
Cancel
</Button>
<Button
loading={isInvoicing}
onClick={() =>
generateInvoice({
creditIds: selectedCredits.map((c) => c.id),
...(typeof dueInDays === "number"
? { dueInDays }
: {}),
})
}
>
Generate &amp; issue
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
}

View File

@@ -143,6 +143,9 @@ export default function TrainScheduleV2ListPage() {
const [scheduleDate, setScheduleDate] = useState("");
const [trainId, setTrainId] = useState("");
const [reverseWagonOrder, setReverseWagonOrder] = useState(false);
// "" = a normal customer train; an id dedicates the departure to that
// shipping line and hides it from every customer-facing view.
const [shippingLineCompanyId, setShippingLineCompanyId] = useState("");
// Booking window for the schedule being created: off = inherit the live global
// rules (the default), on = the values in `windowForm` are frozen onto it.
const [configureWindow, setConfigureWindow] = useState(false);
@@ -219,6 +222,15 @@ export default function TrainScheduleV2ListPage() {
enabled: Boolean(routeId),
}),
);
// For the create modal's dedication picker. 100 covers every line EDR deals
// with; fetched only while the modal is open.
const shippingLinesQuery = useQuery(
api.shippingLineCompanies.list.queryOptions({
input: { page: 1, limit: 100 },
enabled: createOpen,
staleTime: 5 * 60_000,
}),
);
const create = useMutation(api.trainScheduling.createSchedule.mutationOptions());
const dispatchSchedule = useMutation(
api.trainScheduling.dispatchSchedule.mutationOptions(),
@@ -559,12 +571,14 @@ export default function TrainScheduleV2ListPage() {
scheduleDate: new Date(scheduleDate).toISOString(),
trainId,
reverseWagonOrder,
...(shippingLineCompanyId ? { shippingLineCompanyId } : {}),
...(windowRule ? { windowRule } : {}),
},
});
toast({ title: "Train schedule created" });
showScheduleWarnings(created.warnings);
setReverseWagonOrder(false);
setShippingLineCompanyId("");
setConfigureWindow(false);
setWindowForm(null);
setCreateOpen(false);
@@ -857,6 +871,19 @@ export default function TrainScheduleV2ListPage() {
: "Select a route first"
}
/>
<Select
label="Shipping line (optional)"
description="Dedicate this departure to one shipping line. The train is then hidden from customers and shown only in that line's portal."
placeholder="None — normal customer train"
clearable
searchable
data={(shippingLinesQuery.data?.items ?? [])
.filter((line) => line.status === "active")
.map((line) => ({ value: line.id, label: line.name }))}
value={shippingLineCompanyId || null}
onChange={(v) => setShippingLineCompanyId(v ?? "")}
comboboxProps={{ withinPortal: true }}
/>
<Checkbox
label="Reverse wagon order"
description="Place wagons on the train in reverse — the physically-last wagon becomes position 1. Composition and allocations are unchanged; only the order flips. Applies every time this schedule's wagon plan is built."

View File

@@ -44,6 +44,20 @@ import type {
PaginatedOfflineUsdInvoices,
} from "@/types/invoice";
import type { IOverviewDashboard, OverviewRange } from "@/types/overview";
import type {
CreateShippingLineCompanyDto,
PaginatedShippingLineCompanies,
RegisterShippingLineCompanyResult,
ShippingLineCompany,
} from "@/types/shippingLineCompany";
import type {
CreditInvoicePendingAction,
GeneratedCreditInvoice,
OutstandingTotals,
PaginatedCreditInvoices,
PaginatedShippingLineCredits,
ShippingLineCreditStatus,
} from "@/types/shippingLineCredit";
import {
RuleEngineListResult,
RuleEngineRecord,
@@ -156,6 +170,8 @@ import {
import { containerTypesService } from "./container-types.service";
import { containerService, type Container } from "./containerService";
import { customersService } from "./customers.service";
import { shippingLineCompaniesService } from "./shippingLineCompanies.service";
import { shippingLineCreditsService } from "./shippingLineCredits.service";
import { eimsService } from "./eims.service";
import type { EimsInvoiceStatusView, EimsVerifyResult } from "@/types/eims";
import { invoicesService } from "./invoices.service";
@@ -2792,6 +2808,160 @@ export const api = {
),
},
shippingLineCompanies: {
list: endpoint<{ page: number; limit: number }, PaginatedShippingLineCompanies>(
"shippingLineCompanies",
"list",
({ page, limit }) => shippingLineCompaniesService.list(page, limit),
({ page, limit }) => QUERY_KEYS.SHIPPING_LINE_COMPANIES.list(page, limit),
),
getById: endpoint<{ id: string }, ShippingLineCompany>(
"shippingLineCompanies",
"getById",
({ id }) => shippingLineCompaniesService.getById(id),
({ id }) => QUERY_KEYS.SHIPPING_LINE_COMPANIES.byId(id),
),
register: endpoint<
CreateShippingLineCompanyDto,
RegisterShippingLineCompanyResult
>(
"shippingLineCompanies",
"register",
(dto) => shippingLineCompaniesService.register(dto),
undefined,
() => [QUERY_KEYS.SHIPPING_LINE_COMPANIES.ROOT],
),
resendActivation: endpoint<
{ id: string; channel: ResetChannel },
ResetPasswordResult
>(
"shippingLineCompanies",
"resendActivation",
({ id, channel }) =>
shippingLineCompaniesService.resendActivation(id, channel),
),
},
shippingLineCredits: {
summary: endpoint<{ shippingLineId?: string }, OutstandingTotals>(
"shippingLineCredits",
"summary",
({ shippingLineId }) => shippingLineCreditsService.summary(shippingLineId),
({ shippingLineId }) =>
QUERY_KEYS.SHIPPING_LINE_CREDITS.summary(shippingLineId),
),
list: endpoint<
{
page: number;
pageSize: number;
status?: ShippingLineCreditStatus;
shippingLineId?: string;
},
PaginatedShippingLineCredits
>(
"shippingLineCredits",
"list",
(filter) => shippingLineCreditsService.list(filter),
({ page, pageSize, status, shippingLineId }) =>
QUERY_KEYS.SHIPPING_LINE_CREDITS.list(
page,
pageSize,
status,
shippingLineId,
),
),
generateInvoice: endpoint<
{ creditIds: string[]; dueInDays?: number },
GeneratedCreditInvoice
>(
"shippingLineCredits",
"generateInvoice",
({ creditIds, dueInDays }) =>
shippingLineCreditsService.generateInvoice(creditIds, dueInDays),
undefined,
// Billing a batch changes ledger rows, the summary totals and (via the
// draft invoice) the invoices list.
() => [QUERY_KEYS.SHIPPING_LINE_CREDITS.ROOT, QUERY_KEYS.INVOICES.ROOT],
),
listInvoices: endpoint<
{
page: number;
pageSize: number;
status?: string;
shippingLineId?: string;
},
PaginatedCreditInvoices
>(
"shippingLineCredits",
"listInvoices",
(filter) => shippingLineCreditsService.listInvoices(filter),
({ page, pageSize, status, shippingLineId }) =>
QUERY_KEYS.SHIPPING_LINE_CREDITS.invoices(
page,
pageSize,
status,
shippingLineId,
),
),
pendingInvoiceActions: endpoint<
{ invoiceIds: string[] },
CreditInvoicePendingAction[]
>(
"shippingLineCredits",
"pendingInvoiceActions",
({ invoiceIds }) =>
shippingLineCreditsService.pendingInvoiceActions(invoiceIds),
({ invoiceIds }) =>
[
"shipping-line-credits",
"pending-actions",
[...invoiceIds].sort().join(","),
] as const,
),
requestInvoiceAction: endpoint<
{
invoiceId: string;
action: "MARK_PAID" | "CANCEL";
reason: string;
paymentReference?: string;
},
CreditInvoicePendingAction
>(
"shippingLineCredits",
"requestInvoiceAction",
({ invoiceId, action, reason, paymentReference }) =>
shippingLineCreditsService.requestInvoiceAction(
invoiceId,
action,
reason,
paymentReference,
),
undefined,
() => [QUERY_KEYS.SHIPPING_LINE_CREDITS.ROOT],
),
decideInvoiceAction: endpoint<
{ approvalId: string; approve: boolean; note?: string },
CreditInvoicePendingAction
>(
"shippingLineCredits",
"decideInvoiceAction",
({ approvalId, approve, note }) =>
shippingLineCreditsService.decideInvoiceAction(approvalId, approve, note),
undefined,
// Approving executes a billing action, so both surfaces move.
() => [QUERY_KEYS.SHIPPING_LINE_CREDITS.ROOT, QUERY_KEYS.INVOICES.ROOT],
),
},
customers: {
stats: endpoint<Record<string, never>, CompanyStats>(
"customers",

View File

@@ -20,6 +20,11 @@ export interface RuleEngineListParams {
/** Rates category tabs — comma-separated appliesTo / trigger filters. */
appliesTo?: string;
trigger?: string;
/**
* Rates only: "true" lists shipping-line rates, "false" standard customer
* ones. Omitted lists both.
*/
isShippingLineRate?: string;
}
export interface RuleEngineReorderPayload {
@@ -214,6 +219,7 @@ export const ruleEngineService = {
requiresDirectorApproval: params?.requiresDirectorApproval,
appliesTo: params?.appliesTo,
trigger: params?.trigger,
isShippingLineRate: params?.isShippingLineRate,
},
});
return normalizeList<T>(response.data, page, pageSize);

View File

@@ -0,0 +1,54 @@
import { api as apiClient } from "@/auth/http";
import { URL_CONSTANTS } from "@/constants/URLS";
import type {
CreateShippingLineCompanyDto,
PaginatedShippingLineCompanies,
RegisterShippingLineCompanyResult,
ResetChannel,
ResetPasswordResult,
ShippingLineCompany,
} from "@/types/shippingLineCompany";
export const shippingLineCompaniesService = {
list(page = 1, limit = 20): Promise<PaginatedShippingLineCompanies> {
return apiClient
.get<PaginatedShippingLineCompanies>(
URL_CONSTANTS.SHIPPING_LINE_COMPANIES.BASE,
{ params: { page, limit } },
)
.then((r) => r.data);
},
getById(id: string): Promise<ShippingLineCompany> {
return apiClient
.get<ShippingLineCompany>(URL_CONSTANTS.SHIPPING_LINE_COMPANIES.BY_ID(id))
.then((r) => r.data);
},
/**
* Creates the carrier's account and record, then sends an activation link.
* Staff never set or see a password — the line chooses its own from the link.
*/
register(
dto: CreateShippingLineCompanyDto,
): Promise<RegisterShippingLineCompanyResult> {
return apiClient
.post<RegisterShippingLineCompanyResult>(
URL_CONSTANTS.SHIPPING_LINE_COMPANIES.BASE,
dto,
)
.then((r) => r.data);
},
resendActivation(
id: string,
channel: ResetChannel,
): Promise<ResetPasswordResult> {
return apiClient
.post<ResetPasswordResult>(
URL_CONSTANTS.SHIPPING_LINE_COMPANIES.RESEND_ACTIVATION(id),
{ channel },
)
.then((r) => r.data);
},
};

View File

@@ -0,0 +1,131 @@
import { api as apiClient } from "@/auth/http";
import { URL_CONSTANTS } from "@/constants/URLS";
import type {
CreditInvoicePendingAction,
GeneratedCreditInvoice,
OutstandingTotals,
PaginatedCreditInvoices,
PaginatedShippingLineCredits,
ShippingLineCreditStatus,
} from "@/types/shippingLineCredit";
export interface ShippingLineCreditListFilter {
page?: number;
pageSize?: number;
status?: ShippingLineCreditStatus;
/** Narrow to one line; omit for all lines. */
shippingLineId?: string;
}
export const shippingLineCreditsService = {
/** Outstanding totals — every line, or one line when an id is given. */
summary(shippingLineId?: string): Promise<OutstandingTotals> {
return apiClient
.get<OutstandingTotals>(URL_CONSTANTS.SHIPPING_LINE_CREDITS.SUMMARY, {
params: shippingLineId ? { shippingLineId } : {},
})
.then((r) => r.data);
},
/** The whole credit ledger, newest first, optionally filtered. */
list(
filter: ShippingLineCreditListFilter = {},
): Promise<PaginatedShippingLineCredits> {
const { page = 1, pageSize = 20, status, shippingLineId } = filter;
return apiClient
.get<PaginatedShippingLineCredits>(
URL_CONSTANTS.SHIPPING_LINE_CREDITS.BASE,
{
params: {
page,
pageSize,
...(status ? { status } : {}),
...(shippingLineId ? { shippingLineId } : {}),
},
},
)
.then((r) => r.data);
},
/**
* Bill a batch of unbilled credits as one invoice. The API enforces that all
* credits belong to one shipping line and share one currency.
*/
generateInvoice(
creditIds: string[],
dueInDays?: number,
): Promise<GeneratedCreditInvoice> {
return apiClient
.post<GeneratedCreditInvoice>(URL_CONSTANTS.SHIPPING_LINE_CREDITS.INVOICE, {
creditIds,
...(dueInDays ? { dueInDays } : {}),
})
.then((r) => r.data);
},
/** Credit invoices with any pending manual-action request attached. */
listInvoices(filter: {
page?: number;
pageSize?: number;
status?: string;
shippingLineId?: string;
} = {}): Promise<PaginatedCreditInvoices> {
const { page = 1, pageSize = 20, status, shippingLineId } = filter;
return apiClient
.get<PaginatedCreditInvoices>(URL_CONSTANTS.SHIPPING_LINE_CREDITS.INVOICES, {
params: {
page,
pageSize,
...(status ? { status } : {}),
...(shippingLineId ? { shippingLineId } : {}),
},
})
.then((r) => r.data);
},
/** Undecided manual-action requests for a batch of invoice ids. */
pendingInvoiceActions(
invoiceIds: string[],
): Promise<CreditInvoicePendingAction[]> {
if (!invoiceIds.length) return Promise.resolve([]);
return apiClient
.get<CreditInvoicePendingAction[]>(
`${URL_CONSTANTS.SHIPPING_LINE_CREDITS.BASE}/invoice-actions/pending`,
{ params: { invoiceIds: invoiceIds.join(",") } },
)
.then((r) => r.data);
},
/** Maker step: raise a mark-paid or cancel request on a credit invoice. */
requestInvoiceAction(
invoiceId: string,
action: "MARK_PAID" | "CANCEL",
reason: string,
paymentReference?: string,
): Promise<CreditInvoicePendingAction> {
const url =
action === "MARK_PAID"
? URL_CONSTANTS.SHIPPING_LINE_CREDITS.MARK_PAID_REQUEST(invoiceId)
: URL_CONSTANTS.SHIPPING_LINE_CREDITS.CANCEL_REQUEST(invoiceId);
return apiClient
.post<CreditInvoicePendingAction>(url, {
reason,
...(paymentReference ? { paymentReference } : {}),
})
.then((r) => r.data);
},
/** Decision step: approve (executes) or reject a pending request. */
decideInvoiceAction(
approvalId: string,
approve: boolean,
note?: string,
): Promise<CreditInvoicePendingAction> {
const url = approve
? URL_CONSTANTS.SHIPPING_LINE_CREDITS.APPROVE_ACTION(approvalId)
: URL_CONSTANTS.SHIPPING_LINE_CREDITS.REJECT_ACTION(approvalId);
return apiClient
.post<CreditInvoicePendingAction>(url, note ? { note } : {})
.then((r) => r.data);
},
};

View File

@@ -90,6 +90,9 @@ export interface BookingContainerLine {
containerNumber?: string | null;
quantity: number;
vgmPerUnitTons: number;
/** How many of this line are hazardous / refrigerated — 0 when none. */
hazardousQuantity?: number;
reeferQuantity?: number;
containerType?: {
id: string;
code?: string;
@@ -199,6 +202,7 @@ export interface BookingDetail {
/** Break-bulk (PER_ITEM) only: real total tons — cargoTotalWeightVgm then holds the item count. */
bulkTotalWeightTons?: number | null;
isHazardous: boolean;
isReefer?: boolean;
consolidationPartnerId?: string | null;
consolidationPartner?: BookingNamedRef & { reference?: string } | null;
priorityScore: number;

View File

@@ -0,0 +1,50 @@
import type { ResetChannel, ResetPasswordResult } from "./customer";
export type ShippingLineStatus = "active" | "suspended";
/**
* A carrier with its own portal login, registered by staff.
*
* Not to be confused with the rule-engine's `ShippingLine` (types/rule-engine):
* that is a pricing lookup — a code/label a booking points at — with no account
* and no login. This one is the account.
*/
export interface ShippingLineCompany {
id: string;
name: string;
email: string;
phoneNumber: string | null;
scacCode: string | null;
imoNumber: string | null;
bicCode: string | null;
status: ShippingLineStatus;
createdAt: string;
}
export interface CreateShippingLineCompanyDto {
name: string;
email: string;
phoneNumber?: string;
scacCode?: string;
imoNumber?: string;
bicCode?: string;
username?: string;
}
export interface RegisterShippingLineCompanyResult {
shippingLine: ShippingLineCompany;
/**
* Masked destination the activation link went to, or null when delivery
* failed. The registration still succeeded — the link is resendable.
*/
activationSentTo: string | null;
}
export interface PaginatedShippingLineCompanies {
items: ShippingLineCompany[];
total: number;
page: number;
limit: number;
}
export type { ResetChannel, ResetPasswordResult };

View File

@@ -0,0 +1,96 @@
/**
* The credit ledger for shipping lines — "use the service now, pay later".
* Mirrors `shipping-line-credits` API responses.
*/
export type ShippingLineCreditStatus =
| "UNBILLED"
| "BILLED"
| "PAID"
| "CANCELLED";
export interface ShippingLineCredit {
id: string;
shippingLineCompanyId: string;
bookingId: string;
/** Numeric column — serialized as a string by the API. */
amount: string;
currency: string;
status: ShippingLineCreditStatus;
description: string | null;
invoiceId: string | null;
billedAt: string | null;
paidAt: string | null;
cancelledAt: string | null;
cancellationReason: string | null;
createdAt: string;
booking?: { id: string; reference: string } | null;
invoice?: { id: string; invoiceNumber: string } | null;
shippingLineCompany?: { id: string; name: string } | null;
}
/** What one shipping line currently owes, split by billing stage. */
export interface OutstandingTotals {
unbilledAmount: number;
billedAmount: number;
totalOutstanding: number;
unbilledCount: number;
billedCount: number;
currency: string;
}
export interface PaginatedShippingLineCredits {
items: ShippingLineCredit[];
total: number;
page: number;
pageSize: number;
}
/** The invoice minted from a batch of unbilled credits (subset of fields). */
export interface GeneratedCreditInvoice {
id: string;
invoiceNumber: string;
totalAmount: string | number;
currency: string;
status: string;
dueDate: string | null;
}
export type CreditInvoiceActionType = "MARK_PAID" | "CANCEL";
export type CreditInvoiceActionStatus = "PENDING" | "APPROVED" | "REJECTED";
/** An undecided manual-action request attached to a credit invoice. */
export interface CreditInvoicePendingAction {
id: string;
invoiceId: string;
action: CreditInvoiceActionType;
status: CreditInvoiceActionStatus;
requestedBy: string;
reason: string;
paymentReference: string | null;
createdAt: string;
}
/** A credit invoice row in the staff list, enriched by the API. */
export interface CreditInvoice {
id: string;
invoiceNumber: string;
status: string;
currency: string;
totalAmount: string | number;
paidAmount: string | number;
balanceAmount: string | number;
issuedAt: string | null;
dueAt: string | null;
createdAt: string;
shippingLineCompanyId: string | null;
shippingLineName: string | null;
pendingAction: CreditInvoicePendingAction | null;
}
export interface PaginatedCreditInvoices {
items: CreditInvoice[];
total: number;
page: number;
pageSize: number;
}

View File

@@ -960,6 +960,11 @@ export interface CreateTrainSchedulePayload {
maxWagonsPerTrain?: number;
/** Reverse the wagon order on this train: physically-last wagon becomes position 1. */
reverseWagonOrder?: boolean;
/**
* Dedicate this departure to one shipping line — hidden from customers,
* visible only to that line in its portal. Omit for a normal customer train.
*/
shippingLineCompanyId?: string;
/**
* Configure the booking window for THIS schedule instead of inheriting the
* live global rules. Omit to follow the global rules (the default).

View File

@@ -59,6 +59,15 @@ import CheckPaymentPage from "./pages/payments/CheckPaymentPage";
import PaymentFailurePage from "./pages/payments/PaymentFailurePage";
import FaydaCallbackPage from "./pages/FaydaCallbackPage";
import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage";
import {
ShippingLineBookingDetailPage,
ShippingLineBookingsPage,
ShippingLineCompletePage,
ShippingLineHelpPage,
ShippingLineHomePage,
ShippingLineInvoicesPage,
ShippingLineSettingsPage,
} from "./pages/shipping-line";
import FaqPage from "./pages/support/FaqPage";
import HelpPage from "./pages/support/HelpPage";
import PrivacyPolicyPage from "./pages/support/PrivacyPolicyPage";
@@ -129,12 +138,20 @@ function isOnboardingAllowedPath(pathname: string): boolean {
* can be dismissed to use those pages. Visiting any other page bounces back to
* home and re-opens the wizard. New users (no company yet) are treated the same
* as users who haven't completed onboarding.
*
* Shipping lines are exempt: staff register them with their details already
* captured, so there is nothing for them to onboard — they go straight to home.
*/
function OnboardingGate() {
const { company, onboardingCompleted } = useAuth();
const { company, onboardingCompleted, isShippingLine } = useAuth();
const location = useLocation();
const needsOnboarding = !company || !onboardingCompleted;
// Keyed off a positive shipping-line identification, never off "no company":
// that is also true mid-fetch and on error, which would let customers slip
// past onboarding whenever the request failed.
const needsOnboarding = isShippingLine
? false
: !company || !onboardingCompleted;
const allowedHere = isOnboardingAllowedPath(location.pathname);
// Open by default while onboarding is pending (covers the login case).
@@ -176,21 +193,69 @@ function OnboardingGate() {
);
}
/**
* Customer-only routes. A shipping line that lands on one (an old link, a
* bookmark, a hand-typed URL) is sent to its own home rather than shown a
* contract/company-shaped page that has no meaning for it.
*/
function RequireCustomer() {
const { isShippingLine, customerQuery } = useAuth();
// RequireCompany already awaits this query, but guard anyway: a refetch can
// flip `isPending` back on, and redirecting on a half-loaded account would
// throw the user into the wrong app.
if (customerQuery.isPending) return <FullScreenSpinner />;
if (isShippingLine) return <Navigate to="/shipping-line" replace />;
return <Outlet />;
}
/** The mirror of RequireCustomer: shipping-line routes, closed to customers. */
function RequireShippingLine() {
const { isShippingLine, customerQuery } = useAuth();
if (customerQuery.isPending) return <FullScreenSpinner />;
if (!isShippingLine) return <Navigate to="/portal" replace />;
return <Outlet />;
}
/**
* Where a signed-in account belongs. Shipping lines and customers have separate
* apps, so every "you're already logged in" redirect has to pick between them.
* Waits for the company query: `isShippingLine` is false while that request is
* still in flight, which would land a shipping line on the customer home first.
*/
function useHomeRoute(): { ready: boolean; href: string } {
const { isShippingLine, customerQuery } = useAuth();
return {
ready: !customerQuery.isPending,
href: isShippingLine ? "/shipping-line" : "/portal",
};
}
/** Keeps authenticated users off the login/signup pages. */
function RedirectIfAuthed() {
const { isPending, isAuthenticated } = useAuth();
const home = useHomeRoute();
if (isPending) return <FullScreenSpinner />;
if (isAuthenticated) return <Navigate to="/portal" replace />;
if (isAuthenticated) {
if (!home.ready) return <FullScreenSpinner />;
return <Navigate to={home.href} replace />;
}
return <Outlet />;
}
/** Landing page for visitors; authenticated users go straight to the portal. */
function LandingRoute() {
const { isPending, isAuthenticated } = useAuth();
const home = useHomeRoute();
if (isPending) return <FullScreenSpinner />;
if (isAuthenticated) return <Navigate to="/portal" replace />;
if (isAuthenticated) {
if (!home.ready) return <FullScreenSpinner />;
return <Navigate to={home.href} replace />;
}
return <EDRFreightLandingPage />;
}
@@ -230,6 +295,37 @@ const sidebarItems: SidebarItem[] = [
},
];
/**
* Sidebar for shipping lines. Intentionally its own list rather than a filtered
* view of `sidebarItems`: shipping lines have no contracts, and their Home /
* Bookings / Invoices pages are different pages at different routes.
*/
const shippingLineSidebarItems: SidebarItem[] = [
{ label: "Home", href: "/shipping-line", icon: <Home size={18} /> },
{
label: "Bookings",
href: "/shipping-line/bookings",
icon: <Package size={18} />,
},
{
label: "Invoices",
href: "/shipping-line/invoices",
icon: <Receipt size={18} />,
},
{
section: "Account",
label: "Settings",
href: "/shipping-line/settings",
icon: <Settings size={18} />,
},
{
section: "Account",
label: "Help & Support",
href: "/shipping-line/help",
icon: <LifeBuoy size={18} />,
},
];
const App = () => {
const navigate = useNavigate();
const location = useLocation();
@@ -310,81 +406,152 @@ const App = () => {
<Route element={<RequireAuth />}>
<Route element={<RequireCompany />}>
<Route
element={
<AppLayout
title="EDR Freight"
sidebarItems={sidebarItems}
activeHref={location.pathname}
onNavigate={navigate}
userName={displayName}
userEmail={userEmail}
companyProfiles={companyProfiles}
companyType={companyType}
onCreateProfile={createProfile}
onReapplyProfile={reapplyProfile}
>
<OnboardingGate />
</AppLayout>
}
>
<Route path="/portal" element={<MyPortalPage />} />
{/* Bookings are created against a contract, but the full list is
{/* Shipping-line app. Its own layout and sidebar, and its own pages
at their own routes — nothing here is shared with the customer
branch below beyond the shell component itself. Contracts are
absent by design: shipping lines request bookings directly. */}
<Route element={<RequireShippingLine />}>
<Route
element={
<AppLayout
title="EDR Freight"
sidebarItems={shippingLineSidebarItems}
activeHref={location.pathname}
onNavigate={navigate}
userName={displayName}
userEmail={userEmail}
// Support chat is company-scoped; a shipping line has no
// company, so every poll would 403.
showSupportWidget={false}
>
<Outlet />
</AppLayout>
}
>
<Route
path="/shipping-line"
element={<ShippingLineHomePage />}
/>
<Route
path="/shipping-line/bookings"
element={<ShippingLineBookingsPage />}
/>
<Route
path="/shipping-line/bookings/:id"
element={<ShippingLineBookingDetailPage />}
/>
<Route
path="/shipping-line/bookings/:id/complete"
element={<ShippingLineCompletePage />}
/>
<Route
path="/shipping-line/invoices"
element={<ShippingLineInvoicesPage />}
/>
{/* Same detail component as the customer's /billing/:id — the
API scopes my-invoices to the signed-in payer either way,
and the page derives its back target from the URL. */}
<Route
path="/shipping-line/invoices/:id"
element={<InvoiceDetailPage />}
/>
<Route
path="/shipping-line/settings"
element={<ShippingLineSettingsPage />}
/>
<Route
path="/shipping-line/help"
element={<ShippingLineHelpPage />}
/>
{/* Old shared links land on the shipping-line equivalents. */}
<Route
path="/settings"
element={<Navigate to="/shipping-line/settings" replace />}
/>
</Route>
</Route>
{/* Customer app — unchanged. */}
<Route element={<RequireCustomer />}>
<Route
element={
<AppLayout
title="EDR Freight"
sidebarItems={sidebarItems}
activeHref={location.pathname}
onNavigate={navigate}
userName={displayName}
userEmail={userEmail}
companyProfiles={companyProfiles}
companyType={companyType}
onCreateProfile={createProfile}
onReapplyProfile={reapplyProfile}
>
<OnboardingGate />
</AppLayout>
}
>
<Route path="/portal" element={<MyPortalPage />} />
{/* Bookings are created against a contract, but the full list is
browsable here. New-booking entry still routes via a contract. */}
<Route path="/bookings" element={<BookingsListPage />} />
<Route
path="/bookings/new"
element={<Navigate to="/contracts/new" replace />}
/>
<Route path="/bookings/:id/edit" element={<EditBookingPage />} />
<Route path="/bookings/:id" element={<BookingDetailPage />} />
<Route
path="/bookings/:id/last-mile-confirm"
element={<LastMileConfirmPage />}
/>
<Route
path="/bookings/:id/last-mile-contract"
element={<LastMileContractPage />}
/>
<Route
path="/bookings/:id/contract"
element={<BookingContractPage />}
/>
<Route path="/contracts" element={<ContractsList />} />
<Route path="/contracts/new" element={<NewContractPage />} />
<Route
path="/contracts/:id/edit"
element={<NewContractPage mode="edit" />}
/>
<Route
path="/contracts/:id/shipment-requests/new"
element={<NewShipmentRequestPage />}
/>
<Route
path="/contracts/:id/bookings/new"
element={<NewShipmentPage />}
/>
{/* Completion of an initiated (bare) booking after per-booking
<Route path="/bookings" element={<BookingsListPage />} />
<Route
path="/bookings/new"
element={<Navigate to="/contracts/new" replace />}
/>
<Route
path="/bookings/:id/edit"
element={<EditBookingPage />}
/>
<Route path="/bookings/:id" element={<BookingDetailPage />} />
<Route
path="/bookings/:id/last-mile-confirm"
element={<LastMileConfirmPage />}
/>
<Route
path="/bookings/:id/last-mile-contract"
element={<LastMileContractPage />}
/>
<Route
path="/bookings/:id/contract"
element={<BookingContractPage />}
/>
<Route path="/contracts" element={<ContractsList />} />
<Route path="/contracts/new" element={<NewContractPage />} />
<Route
path="/contracts/:id/edit"
element={<NewContractPage mode="edit" />}
/>
<Route
path="/contracts/:id/shipment-requests/new"
element={<NewShipmentRequestPage />}
/>
<Route
path="/contracts/:id/bookings/new"
element={<NewShipmentPage />}
/>
{/* Completion of an initiated (bare) booking after per-booking
clearance — same form, submits to the complete endpoint. */}
<Route
path="/contracts/:id/bookings/:bookingId/complete"
element={<NewShipmentPage />}
/>
<Route
path="/contracts/:id/view"
element={<ContractViewPage />}
/>
<Route path="/contracts/:id" element={<ContractDetailPage />} />
<Route path="/tracking" element={<TrackingPage />} />
<Route path="/billing" element={<InvoicesList />} />
<Route path="/billing/:id" element={<InvoiceDetailPage />} />
{/* Profile was merged into Settings — keep old links working. */}
<Route
path="/profile"
element={<Navigate to="/settings" replace />}
/>
<Route path="/signature" element={<MySignaturePage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route
path="/contracts/:id/bookings/:bookingId/complete"
element={<NewShipmentPage />}
/>
<Route
path="/contracts/:id/view"
element={<ContractViewPage />}
/>
<Route path="/contracts/:id" element={<ContractDetailPage />} />
<Route path="/tracking" element={<TrackingPage />} />
<Route path="/billing" element={<InvoicesList />} />
<Route path="/billing/:id" element={<InvoiceDetailPage />} />
{/* Profile was merged into Settings — keep old links working. */}
<Route
path="/profile"
element={<Navigate to="/settings" replace />}
/>
<Route path="/signature" element={<MySignaturePage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>
</Route>
</Route>
</Route>

Some files were not shown because too many files have changed in this diff Show More