Merge branch 'dev' into freight/nati-2

# Conflicts:
#	apps/edr-freight-api/src/app.module.ts
#	apps/edr-freight-api/src/seed/freight-permissions.registry.ts
#	apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx
#	apps/edr-freight-web/backoffice/src/constants/URLS.ts
#	apps/edr-freight-web/backoffice/src/lib/permissions.ts
This commit is contained in:
Nathnael
2026-08-20 11:29:21 +00:00
287 changed files with 25453 additions and 2339 deletions

View File

@@ -43,6 +43,8 @@ jobs:
"passenger-portal"
"passenger-backoffice"
"payment-api"
"synapse"
"element-web"
)
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
@@ -84,6 +86,10 @@ jobs:
echo "$CHANGED" | grep -q "^apps/edr-passenger-web/portal/" && SERVICES+=("passenger-portal")
echo "$CHANGED" | grep -q "^apps/edr-passenger-web/backoffice/" && SERVICES+=("passenger-backoffice")
echo "$CHANGED" | grep -q "^apps/edr-payment-api/" && SERVICES+=("payment-api")
# synapse / element-web have no per-service filter line: their only
# source is infrastructure/matrix/, already caught by GLOBAL_PATTERN
# above (which redeploys every service), so a dedicated line here
# would never fire.
SERVICES=($(printf '%s\n' "${SERVICES[@]}" | sort -u))
@@ -119,7 +125,7 @@ jobs:
- name: Resolve project and build env file
run: |
case "${{ matrix.service }}" in
freight-api|freight-portal|freight-backoffice|gps-tracker)
freight-api|freight-portal|freight-backoffice|gps-tracker|synapse|element-web)
echo "PROJECT=edr-freight" >> "$GITHUB_ENV"
echo "BUILD_ENV_FILE=freight-web.build.env" >> "$GITHUB_ENV"
;;

View File

@@ -219,3 +219,22 @@ EIMS_AUTO_SUBMIT=false
EIMS_AUTO_SUBMIT_CRON=0 */5 * * * *
# MoR rejects documents older than 3 days; the sweep will not attempt those.
EIMS_AUTO_SUBMIT_MAX_AGE_DAYS=3
# ── Internal chat (Matrix/Element) ──────────────────────────────────────────
# Disabled by default; /chat/sso and the nightly room/membership reconcile are
# no-ops until enabled. See infrastructure/matrix/.
MATRIX_ENABLED=false
# Synapse URL reachable from this container (docker-compose service DNS in
# prod, e.g. http://synapse:8008 — NOT the public https://matrix.edr.et).
MATRIX_BASE_URL=http://localhost:8008
# Synapse's own public_baseurl — what Element itself is configured to call.
# Only used to seed the sso.html handoff page's localStorage.
MATRIX_PUBLIC_BASE_URL=https://matrix.edr.et
MATRIX_CHAT_WEB_URL=https://chat.edr.et
MATRIX_SERVER_NAME=matrix.edr.et
# Must exactly match infrastructure/matrix/synapse/.env's MATRIX_JWT_SECRET —
# this is the whole trust boundary for the SSO handoff.
MATRIX_JWT_SECRET=
# access_token of a Synapse server-admin account. Bootstrap it once via
# infrastructure/matrix/synapse's MATRIX_REGISTRATION_SHARED_SECRET (see that
# file's comments) — this app never touches the shared secret itself.
MATRIX_ADMIN_TOKEN=

View File

@@ -23,6 +23,7 @@ import telebirrConfig from "./config/telebirr.config";
import rabbitmqConfig from "./config/rabbitmq.config";
import faydaConfig from "./config/fayda.config";
import eimsConfig from "./config/eims.config";
import chatConfig from "./config/chat.config";
import { BookingsModule } from "./modules/bookings/bookings.module";
import { ContractsModule } from "./modules/contracts/contracts.module";
@@ -51,6 +52,7 @@ import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-up
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
import { ExchangeSettingsModule } from "./modules/exchange-settings/exchange-settings.module";
import { OperationsReportingModule } from "./modules/operations-reporting/operations-reporting.module";
import { PaymentSettingsModule } from "./modules/payment-settings/payment-settings.module";
import { StampSettingsModule } from "./modules/stamp-settings/stamp-settings.module";
import { LogoSettingsModule } from "./modules/logo-settings/logo-settings.module";
import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module";
@@ -118,7 +120,10 @@ import { InterchangeDocumentsModule } from "./modules/interchange-documents/inte
import { ImportOperationsModule } from "./modules/import-operations/import-operations.module";
import { AiModule } from "./modules/ai/ai.module";
import { AuditModule } from "./modules/audit/audit.module";
// dev replaced the local LoggerMiddleware with the shared RequestLogMiddleware
// and deleted ./logger.middleware, so the branch's import is dropped here.
import { RequestLogMiddleware } from "@edr/api-common";
import { ChatModule } from "./modules/chat/chat.module";
import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware";
import { PositionTypePermissionsCache } from "./common/position-type-permissions.cache";
@@ -137,6 +142,7 @@ if (!process.env.APPLICATION_NAME) {
rabbitmqConfig,
faydaConfig,
eimsConfig,
chatConfig,
],
}),
ScheduleModule.forRoot(),
@@ -216,6 +222,7 @@ if (!process.env.APPLICATION_NAME) {
DropdownSettingsModule,
ExchangeSettingsModule,
OperationsReportingModule,
PaymentSettingsModule,
StampSettingsModule,
LogoSettingsModule,
ContractTemplatesModule,
@@ -256,6 +263,7 @@ if (!process.env.APPLICATION_NAME) {
FleetHistoryModule,
AiModule,
AuditModule,
ChatModule,
],
providers: [
EdrOrgSeeder,

View File

@@ -49,6 +49,8 @@ export const MixedAudience = (permission: string | string[]) =>
export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view);
export const ChatSync = () => BookingStaff(FREIGHT_PERMS.chat.sync);
/**
* The document-review countdown in the backoffice header. Its own permission so
* it can be granted to exactly the position types that decide operation

View File

@@ -0,0 +1,59 @@
import { registerAs } from '@nestjs/config';
export interface ChatConfig {
enabled: boolean;
/** Synapse base URL reachable from this container (client + admin APIs). */
baseUrl: string;
/** Synapse's public_baseurl — what Element itself is configured to call. Only
* used to seed the sso.html handoff; server-to-server calls use {@link baseUrl}. */
publicBaseUrl: string;
/** Public Element Web origin — the SSO handoff link points here. */
webUrl: string;
/** Matrix server_name — the `:domain` half of every MXID. */
serverName: string;
/** HS256 secret. Must exactly match Synapse's jwt_config.secret. */
jwtSecret: string;
/** Bearer token for a Synapse server admin account (room/user provisioning). */
adminToken: string;
}
const REQUIRED_VARS = [
'MATRIX_BASE_URL',
'MATRIX_PUBLIC_BASE_URL',
'MATRIX_CHAT_WEB_URL',
'MATRIX_SERVER_NAME',
'MATRIX_JWT_SECRET',
'MATRIX_ADMIN_TOKEN',
] as const;
export default registerAs('chat', (): ChatConfig => {
const enabled = (process.env.MATRIX_ENABLED ?? 'false').toLowerCase() === 'true';
if (!enabled) {
return {
enabled: false,
baseUrl: '',
publicBaseUrl: '',
webUrl: '',
serverName: '',
jwtSecret: '',
adminToken: '',
};
}
const missing = REQUIRED_VARS.filter((name) => !process.env[name]);
if (missing.length > 0) {
throw new Error(
`Internal chat is enabled (MATRIX_ENABLED=true) but the following env vars are missing: ${missing.join(', ')}`,
);
}
return {
enabled: true,
baseUrl: process.env.MATRIX_BASE_URL!.replace(/\/$/, ''),
publicBaseUrl: process.env.MATRIX_PUBLIC_BASE_URL!.replace(/\/$/, ''),
webUrl: process.env.MATRIX_CHAT_WEB_URL!.replace(/\/$/, ''),
serverName: process.env.MATRIX_SERVER_NAME!,
jwtSecret: process.env.MATRIX_JWT_SECRET!,
adminToken: process.env.MATRIX_ADMIN_TOKEN!,
};
});

View File

@@ -70,3 +70,62 @@ describe("eims.config — private key / certificate resolution", () => {
);
});
});
describe("eims.config — baked-in Ethiopia region/zone/woreda codes", () => {
it("resolves a known region/wereda/zone with no env var set at all", () => {
withEnv(
{ ...REQUIRED, EIMS_PRIVATE_KEY: "x", EIMS_CERTIFICATE_PATH: "/dev/null" },
() => {
const cfg = eimsConfigFactory();
expect(cfg.invoice.buyerRegionCodes.Somali).toBe("05");
expect(cfg.invoice.buyerWeredaCodes["Jijiga Town"]).toBe("02");
expect(cfg.invoice.buyerCityCodes.Fafan).toBe("01");
},
);
});
it("an env var entry overrides the baked-in code for the same name", () => {
withEnv(
{
...REQUIRED,
EIMS_PRIVATE_KEY: "x",
EIMS_CERTIFICATE_PATH: "/dev/null",
EIMS_BUYER_REGION_CODES: "Somali=99",
},
() => {
expect(eimsConfigFactory().invoice.buyerRegionCodes.Somali).toBe("99");
},
);
});
it("an env var still adds a name the baked-in table doesn't have (a spelling variant)", () => {
withEnv(
{
...REQUIRED,
EIMS_PRIVATE_KEY: "x",
EIMS_CERTIFICATE_PATH: "/dev/null",
EIMS_BUYER_CITY_CODES: "Fafen=01",
},
() => {
const codes = eimsConfigFactory().invoice.buyerCityCodes;
expect(codes.Fafen).toBe("01");
expect(codes.Fafan).toBe("01"); // baked-in entry still present alongside it
},
);
});
it("resolves the bare Addis Ababa sub-city name a buyer profile actually stores, not the CSV's example-woreda name", () => {
withEnv(
{ ...REQUIRED, EIMS_PRIVATE_KEY: "x", EIMS_CERTIFICATE_PATH: "/dev/null" },
() => {
const codes = eimsConfigFactory().invoice.buyerWeredaCodes;
expect(codes.Bole).toBe("01");
expect(codes.Arada).toBe("01");
expect(codes.Kirkos).toBe("01");
expect(codes.Yeka).toBe("01");
expect(codes["Nifas Silk Lafto"]).toBe("13");
expect(codes["Nefas Silk-Lafto"]).toBe("13");
},
);
});
});

View File

@@ -1,5 +1,7 @@
import { registerAs } from "@nestjs/config";
import { ETHIOPIA_REGION_CODES, ETHIOPIA_WOREDA_CODES, ETHIOPIA_ZONE_CODES } from "./ethiopia-geo-codes";
/**
* Ethiopian MoR EIMS e-invoicing gateway.
*
@@ -258,9 +260,11 @@ export default registerAs("eims", (): EimsConfig => {
unitDefault: process.env.EIMS_UNIT_DEFAULT ?? "",
buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null,
buyerCountryCodes: parseCodeMap(process.env.EIMS_BUYER_COUNTRY_CODES),
buyerRegionCodes: parseCodeMap(process.env.EIMS_BUYER_REGION_CODES),
buyerWeredaCodes: parseCodeMap(process.env.EIMS_BUYER_WEREDA_CODES),
buyerCityCodes: parseCodeMap(process.env.EIMS_BUYER_CITY_CODES),
// Baked-in Ethiopia reference table first, env var entries win on a name collision — lets a
// deployment override or add to it without a redeploy. See ethiopia-geo-codes.ts.
buyerRegionCodes: { ...ETHIOPIA_REGION_CODES, ...parseCodeMap(process.env.EIMS_BUYER_REGION_CODES) },
buyerWeredaCodes: { ...ETHIOPIA_WOREDA_CODES, ...parseCodeMap(process.env.EIMS_BUYER_WEREDA_CODES) },
buyerCityCodes: { ...ETHIOPIA_ZONE_CODES, ...parseCodeMap(process.env.EIMS_BUYER_CITY_CODES) },
taxCodeByChargeType: parseCodeMap(process.env.EIMS_TAX_CODE_BY_CHARGE_TYPE),
taxRateByChargeType: parseCodeMap(process.env.EIMS_TAX_RATE_BY_CHARGE_TYPE),
exciseByChargeType: parseCodeMap(process.env.EIMS_EXCISE_BY_CHARGE_TYPE),

View File

@@ -0,0 +1,160 @@
/**
* MoR EIMS region/zone/woreda codes, by name — the baked-in fallback under
* `EIMS_BUYER_REGION_CODES`/`EIMS_BUYER_WEREDA_CODES`/`EIMS_BUYER_CITY_CODES` (zone is the closest
* match to EIMS's "City", per `eims-invoice.mapper.ts`).
*
* Before this existed, every buyer from a not-yet-seen region/zone/woreda crashed EIMS filing until
* someone hunted down the code and added it to an env var by hand — happened three times in one
* afternoon (2026-08-17: Somali region, Fafan zone, Jigjiga woreda, even the Ethiopia country code
* itself were all unset). Ethiopia's administrative divisions are fixed, known, reference data, not
* something that should be maintained reactively per buyer. Source: `ethiopia_administrative_
* hierarchy_master.csv`, supplied 2026-08-17 — NOT exhaustive (a representative sample per region,
* not all ~1000 real woredas), extend as new gaps surface.
*
* The env vars stay wired in ahead of this table (see `eims.config.ts`) — for a quick correction
* without a redeploy, or a name spelled differently in a buyer's profile than in this table (already
* hit live: DB has zone "Fafen", this table's official spelling is "Fafan" — same zone, matching is
* case/space-insensitive but not spelling-tolerant, so the env var override is still how that buyer
* actually resolves; this table mainly helps the *next* buyer whose profile spelling matches).
*
* ponytail: region names are unique nationwide (only ~15), safe as a flat map. Zone and woreda names
* are not always unique across different regions (e.g. "North Shewa" is both an Amhara zone and an
* Oromia zone, different codes) — `Company` stores region/zone/woreda as three independent strings,
* no parent linkage, so a flat name lookup can't disambiguate. First occurrence in the source data
* wins on a collision. Only affects the optional `City` field (zone) — never blocks filing, unlike
* Region/Wereda. A correct fix needs `Company` to store a linked hierarchy, not just three strings;
* out of scope here. Upgrade path: key this by `${region}/${zone}` once that linkage exists.
*/
const ROWS: Array<[region: string, zone: string, woreda: string, regionCode: string, zoneCode: string, woredaCode: string]> = [
["Tigray", "Western Tigray", "Humera", "01", "01", "01"],
["Tigray", "Western Tigray", "Kafta Humera", "01", "01", "02"],
["Tigray", "Western Tigray", "Tsegede", "01", "01", "03"],
["Tigray", "North Western Tigray", "Shire Endaselassie", "01", "02", "01"],
["Tigray", "North Western Tigray", "Sheraro", "01", "02", "02"],
["Tigray", "Central Tigray", "Axum", "01", "03", "01"],
["Tigray", "Central Tigray", "Adwa", "01", "03", "02"],
["Tigray", "Eastern Tigray", "Adigrat", "01", "04", "01"],
["Tigray", "Southern Tigray", "Maychew", "01", "05", "01"],
["Tigray", "Mekelle Special Zone", "Mekelle City", "01", "06", "01"],
["Afar", "Awusi Rasu (Zone 1)", "Asayita", "02", "01", "01"],
["Afar", "Awusi Rasu (Zone 1)", "Semera-Logiya", "02", "01", "02"],
["Afar", "Kilbet Rasu (Zone 2)", "Abala", "02", "02", "01"],
["Afar", "Gabi Rasu (Zone 3)", "Awash Fentale", "02", "03", "01"],
["Afar", "Fantena Rasu (Zone 4)", "Yalo", "02", "04", "01"],
["Afar", "Hari Rasu (Zone 5)", "Telalak", "02", "05", "01"],
["Amhara", "North Gondar", "Debark", "03", "01", "01"],
["Amhara", "South Gondar", "Debre Tabor", "03", "02", "01"],
["Amhara", "North Wollo", "Woldiya", "03", "03", "01"],
["Amhara", "South Wollo", "Dessie Town", "03", "04", "01"],
["Amhara", "North Shewa", "Debre Berhan", "03", "05", "01"],
["Amhara", "East Gojjam", "Debre Markos", "03", "06", "01"],
["Amhara", "West Gojjam", "Finote Selam", "03", "07", "01"],
["Amhara", "Wag Hemra", "Sekota", "03", "08", "01"],
["Amhara", "Awi", "Injibara", "03", "09", "01"],
["Amhara", "Oromia Special Zone", "Kemise", "03", "10", "01"],
["Amhara", "Bahir Dar Special Zone", "Bahir Dar City", "03", "11", "01"],
["Amhara", "Gondar Special Zone", "Gondar City", "03", "12", "01"],
["Oromia", "North Shewa", "Fiche", "04", "01", "01"],
["Oromia", "South West Shewa", "Waliso", "04", "02", "01"],
["Oromia", "East Shewa", "Adama Town", "04", "03", "01"],
["Oromia", "East Shewa", "Bishoftu Town", "04", "03", "02"],
["Oromia", "West Shewa", "Ambo", "04", "04", "01"],
["Oromia", "Arsi", "Asella", "04", "05", "01"],
["Oromia", "West Arsi", "Shashemene", "04", "06", "01"],
["Oromia", "Bale", "Robe", "04", "07", "01"],
["Oromia", "East Hararghe", "Harar Outskirts", "04", "08", "01"],
["Oromia", "West Hararghe", "Chiro", "04", "09", "01"],
["Oromia", "Jimma", "Jimma Town", "04", "10", "01"],
["Oromia", "Illubabor", "Mettu", "04", "11", "01"],
["Oromia", "Buno Bedele", "Bedele", "04", "12", "01"],
["Oromia", "Welega (West)", "Gimbi", "04", "13", "01"],
["Oromia", "Welega (East)", "Nekemte", "04", "14", "01"],
["Oromia", "Horo Guduru Welega", "Shambu", "04", "15", "01"],
["Oromia", "Kelam Welega", "Dembidolo", "04", "16", "01"],
["Oromia", "Borena", "Yabelo", "04", "17", "01"],
["Oromia", "Guji", "Negele Borana", "04", "18", "01"],
["Oromia", "West Guji", "Bule Hora", "04", "19", "01"],
["Oromia", "East Bale", "Ginir", "04", "20", "01"],
["Oromia", "Sheger City", "Sululta", "04", "21", "01"],
["Somali", "Fafan", "Jijiga Woreda", "05", "01", "01"],
["Somali", "Fafan", "Jijiga Town", "05", "01", "02"],
["Somali", "Fafan", "Awbare", "05", "01", "03"],
["Somali", "Sitti", "Shinile", "05", "02", "01"],
["Somali", "Erer", "Fiq", "05", "03", "01"],
["Somali", "Jarar", "Degehabur", "05", "04", "01"],
["Somali", "Nogob", "Segeg", "05", "05", "01"],
["Somali", "Korahe", "Kebridehar", "05", "06", "01"],
["Somali", "Shabelle", "Gode", "05", "07", "01"],
["Somali", "Afder", "Afder Woreda", "05", "08", "01"],
["Somali", "Liben", "Filtu", "05", "09", "01"],
["Somali", "Dhawa", "Mubarak", "05", "10", "01"],
["Somali", "Dollo", "Warder", "05", "11", "01"],
["Benishangul-Gumuz", "Asosa", "Asosa Woreda", "06", "01", "01"],
["Benishangul-Gumuz", "Kamasashi", "Kamasashi Woreda", "06", "02", "01"],
["Benishangul-Gumuz", "Metekel", "Gilgel Beles", "06", "03", "01"],
["Southern Ethiopia", "Wolayta", "Sodo Zuria", "07", "01", "01"],
["Southern Ethiopia", "Wolayta", "Sodo Town", "07", "01", "02"],
["Southern Ethiopia", "Gamo", "Arba Minch Town", "07", "02", "01"],
["Southern Ethiopia", "Gofa", "Sawla", "07", "03", "01"],
["Southern Ethiopia", "Konso", "Konso Woreda", "07", "04", "01"],
["Southern Ethiopia", "South Omo", "Jinka", "07", "05", "01"],
["Gambela", "Anywaa", "Gambela Zuria", "08", "01", "01"],
["Gambela", "Nuer", "Lare", "08", "02", "01"],
["Gambela", "Majang", "Metu Zuria part", "08", "03", "01"],
["Harari", "Harar Hundanee", "Amir Nur Woreda", "09", "01", "01"],
["Harari", "Harar Hundanee", "Abadir Woreda", "09", "01", "02"],
["Addis Ababa", "Bole Sub-City", "Bole Woreda 01", "10", "01", "01"],
["Addis Ababa", "Kirkos Sub-City", "Kirkos Woreda 01", "10", "02", "01"],
["Addis Ababa", "Nifas Silk Lafto", "NSL Woreda 13", "10", "03", "13"],
["Addis Ababa", "Yeka Sub-City", "Yeka Woreda 01", "10", "04", "01"],
["Addis Ababa", "Arada Sub-City", "Arada Woreda 01", "10", "05", "01"],
["Dire Dawa", "Dire Dawa Urban", "Melka Jebdu", "11", "01", "01"],
["Dire Dawa", "Dire Dawa Rural", "Gurgura", "11", "02", "01"],
["Sidama", "Hawassa City Admin", "Hayek Chereka", "12", "01", "01"],
["Sidama", "Sidama Zuria", "Yirgalem Town", "12", "02", "01"],
["Sidama", "Sidama Zuria", "Aleta Wendo", "12", "02", "02"],
["Southwest Ethiopia", "Keffa", "Bonga Town", "13", "01", "01"],
["Southwest Ethiopia", "Sheka", "Mappi Zuria", "13", "02", "01"],
["Southwest Ethiopia", "Bench Sheko", "Mizan Aman", "13", "03", "01"],
["Central Ethiopia", "Gurage", "Wolkite", "14", "01", "01"],
["Central Ethiopia", "Hadiya", "Hosaina", "14", "02", "01"],
["Central Ethiopia", "Silte", "Worabe", "14", "03", "01"],
["Gedeo State", "Gedeo Zone", "Dilla Zuria", "15", "01", "01"],
["Gedeo State", "Gedeo Zone", "Yirgacheffe", "15", "01", "02"],
];
/** First occurrence wins on a name collision — see the class comment. */
const buildMap = (pick: (row: (typeof ROWS)[number]) => [string, string]): Record<string, string> => {
const map: Record<string, string> = {};
for (const row of ROWS) {
const [name, code] = pick(row);
if (!(name in map)) map[name] = code;
}
return map;
};
export const ETHIOPIA_REGION_CODES: Record<string, string> = buildMap((r) => [r[0], r[3]]);
/** Zone name → code. Fed into `buyerCityCodes` — EIMS's "City" is really the buyer's zone. */
export const ETHIOPIA_ZONE_CODES: Record<string, string> = buildMap((r) => [r[1], r[4]]);
export const ETHIOPIA_WOREDA_CODES: Record<string, string> = buildMap((r) => [r[2], r[5]]);
/**
* Buyer records commonly store just the bare Addis Ababa sub-city name ("Bole", "Arada") as their
* woreda, not the source CSV's specific example-woreda name ("Bole Woreda 01") — confirmed live
* 2026-08-17 across three different buyers before any of them actually got past this check. Since
* the CSV lists exactly one representative woreda per Addis sub-city, alias the bare name to that
* same code rather than wait on a fuller table.
*/
const ADDIS_SUBCITY_ALIASES: Array<[bareName: string, csvZoneName: string]> = [
["Bole", "Bole Sub-City"],
["Kirkos", "Kirkos Sub-City"],
["Nifas Silk Lafto", "Nifas Silk Lafto"],
// Matches EIMS_BUYER_WEREDA_CODES' own existing spelling in .env — same zone, different hyphenation.
["Nefas Silk-Lafto", "Nifas Silk Lafto"],
["Yeka", "Yeka Sub-City"],
["Arada", "Arada Sub-City"],
];
for (const [bareName, csvZoneName] of ADDIS_SUBCITY_ALIASES) {
const row = ROWS.find((r) => r[1] === csvZoneName);
if (row && !(bareName in ETHIOPIA_WOREDA_CODES)) ETHIOPIA_WOREDA_CODES[bareName] = row[5];
}

View File

@@ -0,0 +1,36 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Single-row table controlling whether Finance may settle invoices by hand,
* per currency (see ManualPaymentSettingsService). Defaults preserve the
* pre-toggle behaviour: USD was always bank-transfer-only (ON), ETB manual
* settlement is the new capability and must be switched on deliberately (OFF).
*/
export class ManualPaymentSettings3560000000000 implements MigrationInterface {
name = "ManualPaymentSettings3560000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.manual_payment_settings (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
etb_enabled boolean NOT NULL DEFAULT false,
usd_enabled boolean NOT NULL DEFAULT true,
updated_by_id uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
`);
await queryRunner.query(`
INSERT INTO freight.manual_payment_settings (etb_enabled, usd_enabled)
SELECT false, true
WHERE NOT EXISTS (SELECT 1 FROM freight.manual_payment_settings);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP TABLE IF EXISTS freight.manual_payment_settings;`,
);
}
}

View File

@@ -0,0 +1,47 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Which desks work at which yard — the input to yard access scoping.
*
* Many-to-many: a position (what the user-management tree calls a department)
* can cover several yards, and a yard is staffed by several positions. The
* scope resolver reads it to answer "which yards may this caller touch?".
*
* `yard_id` carries a real FK; `position_id` deliberately does NOT. Positions
* live in `iam`, which is owned by the vendored @tria-plc/iamapi-common package
* and shared with the passenger app: a hard FK would let freight block an IAM
* delete, and would have to be dropped the day IAM moves to its own database.
* Reads join `iam.positions … WHERE deleted_at IS NULL` instead, so a
* soft-deleted position silently drops out of scope rather than granting it.
*
* The unique index is PARTIAL — soft-deleted rows must not block re-adding the
* same pair later.
*/
export class YardPositions3560000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.yard_positions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
yard_id uuid NOT NULL REFERENCES freight.yards(id) ON DELETE CASCADE,
position_id uuid NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS ux_yard_positions_pair
ON freight.yard_positions (yard_id, position_id)
WHERE deleted_at IS NULL
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS ix_yard_positions_position
ON freight.yard_positions (position_id)
WHERE deleted_at IS NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.yard_positions`);
}
}

View File

@@ -0,0 +1,89 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Approval gate for consolidated (shared-wagon) bookings.
*
* A booking that fills its own wagons goes straight from GL completion to the
* operations queue. A CONSOLIDATED booking does not: it shares one physical
* wagon with another customer's booking, which means two customers' cargo, two
* invoices and two liabilities riding the same wagon. That pairing is a
* commercial decision, so it is reviewed by a person before Operations sees it.
*
* The pair is approved as a UNIT — one row covers both halves (booking_id +
* partner_booking_id) so an approver can never approve one side of a shared
* wagon and leave the other pending. Rows are never deleted; decided rows are
* the audit trail of who approved which pairing and when.
*
* One PENDING row per booking at a time (partial unique index on each side of
* the pair): a second request while one is undecided is a coordination failure,
* not a workflow.
*/
export class ConsolidationApprovals3570000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DO $$ BEGIN
CREATE TYPE freight.consolidation_approvals_status_enum
AS ENUM ('PENDING', 'APPROVED', 'REJECTED');
EXCEPTION WHEN duplicate_object THEN NULL; END $$
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.consolidation_approvals (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
booking_id uuid NOT NULL REFERENCES freight.bookings (id),
partner_booking_id uuid NOT NULL REFERENCES freight.bookings (id),
status freight.consolidation_approvals_status_enum NOT NULL DEFAULT 'PENDING',
-- Who put the pairing up for review (the GL user who completed it) and
-- who decided it. Both are recorded: the point of the gate is that they
-- are different people.
requested_by uuid,
requested_at timestamptz NOT NULL DEFAULT now(),
decided_by uuid,
decided_at timestamptz,
decision_note varchar(500),
-- Snapshot of what was approved, so the audit trail still reads
-- correctly after the bookings themselves move on.
scheduled_date timestamptz,
booking_reference varchar(50),
partner_booking_reference varchar(50),
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_consolidation_approvals_booking_status
ON freight.consolidation_approvals (booking_id, status)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_consolidation_approvals_status
ON freight.consolidation_approvals (status)
`);
// The workflow invariant, enforced where it cannot race: at most one
// undecided request per booking — on EITHER side of the pair, so the same
// wagon can never collect two pending requests from its two halves.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_consolidation_approvals_one_pending
ON freight.consolidation_approvals (booking_id)
WHERE status = 'PENDING' AND deleted_at IS NULL
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_consolidation_approvals_one_pending_partner
ON freight.consolidation_approvals (partner_booking_id)
WHERE status = 'PENDING' AND deleted_at IS NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP TABLE IF EXISTS freight.consolidation_approvals`,
);
await queryRunner.query(
`DROP TYPE IF EXISTS freight.consolidation_approvals_status_enum`,
);
}
}

View File

@@ -0,0 +1,54 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Seed `edr_freight_app:yards:view_all` — the cross-yard bypass for yard access
* scoping.
*
* The permission catalog is otherwise written by `EdrOrgSeeder`, which skips
* itself unless `SEED_EDR_ORG` is set. That flag is off in normal environments,
* so a key added to the registry never reaches `iam.permissions` and cannot be
* granted to anyone — the bypass would exist in code and be unusable in the
* database. A migration is the one path that runs everywhere.
*
* Idempotent on `key`, which is the identity every consumer resolves by (the
* registry's uuid is only used where a seed row needs one). Skips silently when
* the freight application row is absent, since there is nothing to attach to.
*/
export class YardViewAllPermission3570000000000 implements MigrationInterface {
private static readonly KEY = 'edr_freight_app:yards:view_all';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`INSERT INTO iam.permissions (id, key, name, application_id)
SELECT gen_random_uuid(),
$1::varchar,
'{"am": "Access every yard (bypass yard scoping)", "en": "Access every yard (bypass yard scoping)"}'::jsonb,
a.id
FROM iam.application a
WHERE a.key = 'edr_freight_app'
AND NOT EXISTS (SELECT 1 FROM iam.permissions p WHERE p.key = $1::varchar)`,
[YardViewAllPermission3570000000000.KEY],
);
}
/**
* Removes only the permission row itself. Any grant of it goes first, or the
* delete trips the position/role permission foreign keys — and a half-removed
* permission is worse than one left in place.
*/
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DELETE FROM iam.position_permissions
WHERE permission_id IN (SELECT id FROM iam.permissions WHERE key = $1)`,
[YardViewAllPermission3570000000000.KEY],
);
await queryRunner.query(
`DELETE FROM iam.role_permissions
WHERE permission_id IN (SELECT id FROM iam.permissions WHERE key = $1)`,
[YardViewAllPermission3570000000000.KEY],
);
await queryRunner.query(`DELETE FROM iam.permissions WHERE key = $1`, [
YardViewAllPermission3570000000000.KEY,
]);
}
}

View File

@@ -0,0 +1,35 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Columns for `POST /v1/bulkRegister` — see `EimsBulkRegistrationService`.
*
* `eims_system_state.in_flight_conversation_id` is the bulk equivalent of `in_flight_invoice_id`:
* a whole batch, not one invoice, is what's outstanding while MoR processes it asynchronously.
* `invoices.eims_bulk_conversation_id` tags which batch an invoice was submitted in, so a stuck
* batch (webhook never arrived) can be found and reconciled by conversation id.
*/
export class EimsBulkRegistration3580000000000 implements MigrationInterface {
name = "EimsBulkRegistration3580000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.eims_system_state
ADD COLUMN IF NOT EXISTS in_flight_conversation_id text
`);
await queryRunner.query(`
ALTER TABLE freight.invoices
ADD COLUMN IF NOT EXISTS eims_bulk_conversation_id text
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.eims_system_state
DROP COLUMN IF EXISTS in_flight_conversation_id
`);
await queryRunner.query(`
ALTER TABLE freight.invoices
DROP COLUMN IF EXISTS eims_bulk_conversation_id
`);
}
}

View File

@@ -0,0 +1,46 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Post-finalization clearance charges billed to the customer: one PORT_CHARGES
* and one MISCELLANEOUS row max per booking, each carrying a document, amount,
* currency and its own payable invoice.
*/
export class BookingClearanceCharge3590000000000 implements MigrationInterface {
name = 'BookingClearanceCharge3590000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "freight"."booking_clearance_charge" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
"deleted_at" timestamptz,
"booking_id" uuid NOT NULL,
"type" character varying(20) NOT NULL,
"status" character varying(20) NOT NULL DEFAULT 'DOC_UPLOADED',
"file_record_id" uuid,
"amount" numeric(14,2),
"currency" character varying(8),
"invoice_id" uuid,
"uploaded_by_staff_id" uuid,
"uploaded_at" timestamptz,
"billed_by_staff_id" uuid,
"billed_at" timestamptz,
"paid_at" timestamptz,
CONSTRAINT "pk_booking_clearance_charge" PRIMARY KEY ("id"),
CONSTRAINT "fk_booking_clearance_charge_booking" FOREIGN KEY ("booking_id")
REFERENCES "freight"."bookings"("id") ON DELETE CASCADE
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_booking_clearance_charge_booking_type"
ON "freight"."booking_clearance_charge" ("booking_id", "type")
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP TABLE IF EXISTS "freight"."booking_clearance_charge"`,
);
}
}

View File

@@ -0,0 +1,37 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/** Per-booking clearance action history — drives the History tab. */
export class BookingClearanceEvent3600000000000 implements MigrationInterface {
name = 'BookingClearanceEvent3600000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "freight"."booking_clearance_event" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
"deleted_at" timestamptz,
"booking_id" uuid NOT NULL,
"action" character varying(64) NOT NULL,
"label" character varying(500) NOT NULL,
"actor_type" character varying(16) NOT NULL DEFAULT 'STAFF',
"actor_id" uuid,
"actor_name" character varying(150),
"metadata" jsonb,
CONSTRAINT "pk_booking_clearance_event" PRIMARY KEY ("id"),
CONSTRAINT "fk_booking_clearance_event_booking" FOREIGN KEY ("booking_id")
REFERENCES "freight"."bookings"("id") ON DELETE CASCADE
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_booking_clearance_event_booking_created"
ON "freight"."booking_clearance_event" ("booking_id", "created_at")
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP TABLE IF EXISTS "freight"."booking_clearance_event"`,
);
}
}

View File

@@ -12,7 +12,7 @@
* humanized handler name where a route has none.
*
* Excludes the AI Assist and Account entities.
* Generated from the controllers under src/ — 488 endpoints.
* Generated from the controllers under src/ — 517 endpoints.
*/
/** [title, method, entity] for one auditable route. */
export type AuditEndpointMeta = readonly [title: string, method: string, entity: string];
@@ -47,6 +47,10 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"POST /api/bookings/:id/clearance/proceed": ["Customer requests operation with a schedule day", "POST", "Booking"],
"POST /api/bookings/:id/clearance/release-order": ["Upload Booking Release Order", "POST", "Booking"],
"POST /api/bookings/:id/clearance/review": ["GL reviews a clearance document (Approve | Query)", "POST", "Booking"],
"POST /api/bookings/:id/clearance/charges/port-document": ["GL Djibouti uploads the port-charges document", "POST", "Booking"],
"PATCH /api/bookings/:id/clearance/charges/:chargeId/bill": ["GL Ethiopia sets or revises a clearance charge's amount + currency", "PATCH", "Booking"],
"POST /api/bookings/:id/clearance/charges/:chargeId/send": ["GL Ethiopia issues the clearance charge invoice to the customer", "POST", "Booking"],
"POST /api/bookings/:id/clearance/charges/miscellaneous": ["GL Ethiopia creates the miscellaneous clearance charge", "POST", "Booking"],
"POST /api/bookings/:id/clearance/ro-amendment": ["Request Booking RO Amendment", "POST", "Booking"],
"POST /api/bookings/:id/clearance/transit-assignee/assign": ["GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns", "POST", "Booking"],
"POST /api/bookings/:id/clearance/transit-assignee/request": ["GL ET asks GL Djibouti to name the transit officer — required before the import customs declaration", "POST", "Booking"],
@@ -83,6 +87,9 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"POST /api/bookings/:id/wagon-cancellations/preview": ["Preview the fee/credit of a partial wagon cancellation (no writes)", "POST", "Booking"],
"POST /api/bookings/wagon-cancellations/:cancellationId/rebook": ["Rebook a wagon-cancellation credit: pick a shipment day only — the new booking is created under the contract and marked PAID (freight already paid; contract must still be valid)", "POST", "Booking"],
"POST /api/bookings/wagon-cancellations/:cancellationId/withdraw": ["Withdraw a fee-pending wagon cancellation (owner, or staff with the void permission)", "POST", "Booking"],
"POST /api/bookings/consolidation-approvals/:approvalId/approve": ["Approve a shared wagon: both bookings leave the gate and continue to Operations together.", "POST", "Booking"],
"POST /api/bookings/consolidation-approvals/:approvalId/reject": ["Reject a shared wagon: both bookings go back to GL for changes with the reason.", "POST", "Booking"],
"POST /api/bookings/:id/paired-decision": ["Apply a staff decision (accept / cancel / operationAccept / requestChanges) to BOTH halves of a consolidated pair, all-or-nothing.", "POST", "Booking"],
// Cargo
"POST /api/cargoes": ["Create a new cargo", "POST", "Cargo"],
@@ -99,6 +106,9 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"POST /api/cargo-types/:id/move-order": ["Move a cargo type up or down in display order", "POST", "Cargo Type"],
"POST /api/cargo-types/reorder": ["Bulk reorder cargo types by ID list", "POST", "Cargo Type"],
// Chat
"POST /api/chat/sync": ["Re-run the chat room/membership reconcile immediately (normally nightly)", "POST", "Chat"],
// Company
"POST /api/companies": ["Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter)", "POST", "Company"],
"POST /api/companies/:companyId/documents": ["Upload documents for a company (onboarding)", "POST", "Company"],
@@ -119,17 +129,14 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"POST /api/companies/documents/:fileId/request-change": ["Ask the customer to correct one uploaded document", "POST", "Company"],
"POST /api/companies/fetch-etrade-info": ["Fetch company info from eTrade by TIN", "POST", "Company"],
"POST /api/companies/identity/fayda/complete": ["Bind a completed Fayda verification to the company's owner or Power of Attorney", "POST", "Company"],
"DELETE /api/companies/identity/fayda/poa": ["Remove the company's Power of Attorney — the verified identity, its details and the delegation paper together", "DELETE", "Company"],
"DELETE /api/companies/identity/gm": ["Clear the General Manager's identity — the \\\"same as owner\\\" declaration or a verification, and the details either wrote", "DELETE", "Company"],
"POST /api/companies/identity/gm/same-as-owner": ["Declare the General Manager is the company's owner, copying the owner's verified identity across", "POST", "Company"],
"POST /api/companies/identity/poa/same-as-owner": ["Declare the Power of Attorney is the company's owner, copying the owner's identity across", "POST", "Company"],
"DELETE /api/companies/identity/poa/same-as-owner": ["Undo the Power of Attorney \\\"same as owner\\\" declaration and the identity it copied, leaving the representative open to be verified in their own right", "DELETE", "Company"],
"PATCH /api/companies/onboarding-step": ["Persist the user's current onboarding wizard step", "PATCH", "Company"],
"POST /api/companies/onboarding/complete": ["Mark the current user's onboarding as complete", "POST", "Company"],
"POST /api/companies/onboarding/start": ["Begin onboarding: create a draft company + profile + role(s) so later steps can save incrementally", "POST", "Company"],
"POST /api/companies/poa-delegation": ["Upload the Power of Attorney delegation letter, replacing any existing one", "POST", "Company"],
"DELETE /api/companies/poa-delegation/:fileId": ["Remove the Power of Attorney delegation letter (staged for review on an approved company)", "DELETE", "Company"],
"PATCH /api/companies/profile": ["Update profile (flattened settings page)", "PATCH", "Company"],
"PATCH /api/companies/identity/poa-declared": ["Answer whether anyone holds power of attorney for this company — the question that decides whose identity is verified.", "PATCH", "Company"],
"POST /api/companies/onboarding/revert-to-etrade": ["Drop the manual-registration route (co-operative or foreign investment licence): clear the typed registration and reopen onboarding so the TIN is verified against eTrade", "POST", "Company"],
// Compliance
"POST /api/compliance": ["Create a compliance record", "POST", "Compliance"],
@@ -222,6 +229,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"POST /api/gl-exchange/:entityId": ["Share a document with the other GL desk", "POST", "Contract"],
"PATCH /api/gl-exchange/documents/:documentId": ["Uploader edits a shared document (title, visibility, file)", "PATCH", "Contract"],
"DELETE /api/gl-exchange/documents/:documentId": ["Uploader removes a shared document", "DELETE", "Contract"],
"POST /api/contracts/:id/bookings/:bookingId/complete-consolidated": ["Complete this booking and its chosen shared-wagon partner together (all-or-nothing). Each booking is priced and invoiced separately — only the wagon is shared.", "POST", "Contract"],
// Contract Template
"POST /api/contract-templates": ["Create a bulk contract template for a (cargo type, customs option) pair", "POST", "Contract Template"],
@@ -253,6 +261,10 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"POST /api/invoices/:id/eims/register": ["Register the invoice with MoR EIMS. Idempotent — an invoice that already has an IRN is returned unchanged", "POST", "EIMS Invoice"],
"POST /api/invoices/:id/eims/resolve": ["Resolve an unacknowledged submission: record the IRN confirmed with MoR, or discard it. Clears the system-wide block", "POST", "EIMS Invoice"],
"POST /api/invoices/:id/eims/verify": ["Verify the invoice's stored IRN against EIMS", "POST", "EIMS Invoice"],
"POST /api/invoices/:id/eims/cancel": ["Cancel the invoice", "POST", "EIMS Invoice"],
"POST /api/invoices/:id/eims/receipt/sales": ["Register a sales receipt with MoR EIMS against a registered invoice", "POST", "EIMS Invoice"],
"POST /api/invoices/:id/eims/receipt/withholding": ["Register a withholding receipt with MoR EIMS against a registered invoice", "POST", "EIMS Invoice"],
"POST /api/invoices/eims/bulk-cancel": ["Cancel multiple invoices", "POST", "EIMS Invoice"],
// Exchange Setting
"PATCH /api/exchange-settings": ["Set the USD→ETB fallback by hand (used only while CBE is unreachable)", "PATCH", "Exchange Setting"],
@@ -301,6 +313,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"POST /api/import-operations/djibouti-incidents": ["Batch 8: report a Djibouti import incident / exception", "POST", "Import Operation"],
"POST /api/import-operations/empty-container-returns": ["Batch 16: create an empty container return record", "POST", "Import Operation"],
"POST /api/import-operations/empty-container-returns/:id/status": ["Batch 16: advance empty container return workflow", "POST", "Import Operation"],
"POST /api/import-operations/empty-container-returns/load-on-train": ["Load returned empties onto an export train (1×40ft or 2×20ft per wagon)", "POST", "Import Operation"],
// Incident
"POST /api/incidents": ["Report an incident", "POST", "Incident"],
@@ -336,6 +349,10 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"POST /api/locomotives/:id/decommission": ["Decommission a locomotive", "POST", "Locomotive"],
"DELETE /api/locomotives/:id/permanent": ["Permanently delete a locomotive (irreversible; refused if any train references it)", "DELETE", "Locomotive"],
// Logo Setting
"PUT /api/logo-settings": ["Replace the company logo", "PUT", "Logo Setting"],
"DELETE /api/logo-settings": ["Clear the company logo (documents fall back to their text mark)", "DELETE", "Logo Setting"],
// Maintenance
"POST /api/maintenance/costs": ["Record maintenance cost", "POST", "Maintenance"],
"POST /api/maintenance/intervals": ["Define/adjust a service interval (e.g. oil change every 10,000 km)", "POST", "Maintenance"],
@@ -377,6 +394,10 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"POST /api/internal/payments/mark-paid": ["Apply a payment.succeeded / payment.failed event from the payment service (idempotent)", "POST", "Payment"],
"POST /api/payments/initiate": ["Initiate payment for an invoice", "POST", "Payment"],
"POST /api/payments/redirect-success/:bookingId": ["Success-redirect ack: mark payment processing + invoice PAYMENT_PROCESSING (webhook remains source of truth)", "POST", "Payment"],
"POST /api/billing/invoices/:id/memo": ["Issue a credit or debit memo against a registered invoice (MoR DEB/CRE). Filing-equivalent — the auto-submit sweep picks it up like any other issued invoice.", "POST", "Payment"],
// Payment Setting
"PATCH /api/payment-settings/manual": ["Enable or disable manual invoice settlement for ETB and/or USD", "PATCH", "Payment Setting"],
// Priority Config
"POST /api/priority-configs": ["Create a priority config", "POST", "Priority Config"],
@@ -438,6 +459,20 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"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 Booking
"POST /api/shipping-line-bookings/initiate": ["Initiate a bare booking (no contract). Starts at AWAITING_DOCUMENTS so the shipping line can upload its documents for Operations to approve.", "POST", "Shipping Line Booking"],
"POST /api/shipping-line-bookings/:id/cancel": ["Cancel one of the signed-in shipping line's own bookings. Allowed only before the booking is priced.", "POST", "Shipping Line Booking"],
"POST /api/shipping-line-bookings/:id/price-preview": ["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.", "POST", "Shipping Line Booking"],
"POST /api/shipping-line-bookings/:id/complete": ["Complete an approved (CLEARANCE_READY) booking: cargo + binding shipment day.", "POST", "Shipping Line Booking"],
// Shipping Line Credit
"POST /api/shipping-line-credits/invoice": ["Bill a batch of unbilled credits as one invoice. All credits must belong to the same shipping line.", "POST", "Shipping Line Credit"],
"POST /api/shipping-line-credits/:creditId/cancel": ["Write off an unbilled credit. Once billed, cancel the invoice instead.", "POST", "Shipping Line Credit"],
"POST /api/shipping-line-credits/invoices/:invoiceId/mark-paid-request": ["Request recording a full offline payment against a credit invoice (awaits chief approval).", "POST", "Shipping Line Credit"],
"POST /api/shipping-line-credits/invoices/:invoiceId/cancel-request": ["Request voiding a credit invoice — its credits return to the unbilled pool (awaits chief approval).", "POST", "Shipping Line Credit"],
"POST /api/shipping-line-credits/invoice-actions/:approvalId/approve": ["Approve a pending invoice request — executes the offline settlement or the cancellation.", "POST", "Shipping Line Credit"],
"POST /api/shipping-line-credits/invoice-actions/:approvalId/reject": ["Reject a pending invoice request — nothing is changed.", "POST", "Shipping Line Credit"],
// 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"],
@@ -445,6 +480,10 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
// Signature
"PUT /api/me/signature": ["Create or update the reusable saved signature", "PUT", "Signature"],
// Stamp Setting
"PUT /api/stamp-settings": ["Replace the company stamp", "PUT", "Stamp Setting"],
"DELETE /api/stamp-settings": ["Clear the company stamp (invoices fall back to the plain seal)", "DELETE", "Stamp Setting"],
// Support Chat
"POST /api/support/agent/conversations": ["Start chatting with a company (returns the thread if one exists)", "POST", "Support Chat"],
"POST /api/support/agent/conversations/:id/messages": ["Reply as an agent, optionally with attachments", "POST", "Support Chat"],
@@ -515,9 +554,6 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"POST /api/train-scheduling/schedules/:id/intercity/:bookingId/unload": ["Confirm intercity cargo unloaded at the booking's destination yard (completes the booking)", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/intercity/accept": ["Accept intercity bookings onto this train (opens their pay window; capacity re-checked per booking)", "POST", "Train Schedule"],
"PATCH /api/train-scheduling/schedules/:id/loading-status": ["Mark bookings loaded/unloaded on this schedule (any direction, pre-dispatch only)", "PATCH", "Train Schedule"],
// NOTE: duplicate route — also declared in modules/train-scheduling/controllers/train-scheduling.controller.ts:798.
// Two controllers register this same path; Nest serves whichever module loads first.
"POST /api/train-scheduling/schedules/:id/maintenance [modules/train-scheduling/controllers/train-scheduling.controller.ts]": ["Maintenance reschedule: move the train to a new departure with every allocated booking aboard — links, wagons and window settings unchanged", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/pin-wagons": ["Pin physical wagons to train set slots", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/run-allocation": ["Run wagon-level allocation for all eligible linked bookings", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/run-batch": ["Manually run the batch fill for a schedule", "POST", "Train Schedule"],
@@ -527,6 +563,8 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"DELETE /api/train-scheduling/schedules/:id/wagons/:trainSetWagonId": ["Remove an empty wagon slot from a train", "DELETE", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/wagons/:wagonId/move-load": ["Move a wagon's whole load to another wagon (empty → move/repin, loaded → swap loads)", "POST", "Train Schedule"],
"PATCH /api/train-scheduling/schedules/:id/window-rule": ["Override the booking-window rule for one schedule (open/close hour, duration, doc-review, payment, lead days) — only before the window opens", "PATCH", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/merge": ["Merge another train into this schedule: its wagons join this consist, a same-day schedule on it is absorbed, and the emptied train is deactivated", "POST", "Train Schedule"],
"PATCH /api/train-scheduling/schedules/:id/checkpoints/:sequenceNo": ["Edit a logged leg", "PATCH", "Train Schedule"],
// Transit Agent
"POST /api/transit-agents": ["Create a transit agent", "POST", "Transit Agent"],
@@ -633,6 +671,11 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"DELETE /api/weight-limit-rules/:id": ["Soft-delete a weight limit rule", "DELETE", "Weight Limit Rule"],
// Yard
// Yard Position (desk↔yard mapping — an input to yard access scoping, so
// every change to it is evidence of who widened or narrowed someone's reach)
"PUT /api/yard-positions/yard/:yardId": ["Replace a yard's whole position set", "PUT", "Yard Position"],
"PUT /api/yard-positions/position/:positionId": ["Replace a position's whole yard set", "PUT", "Yard Position"],
"POST /api/yards": ["Create a yard", "POST", "Yard"],
"PATCH /api/yards/:id": ["Update a yard", "PATCH", "Yard"],
"DELETE /api/yards/:id": ["Soft-delete a yard", "DELETE", "Yard"],

View File

@@ -81,6 +81,7 @@ describe("BillingService.generateInvoice", () => {
{} as never, // invoiceDocuments
{} as never, // files
{ get: () => undefined } as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
);
});
@@ -163,6 +164,7 @@ describe("BillingService.issueMemo", () => {
{} as never,
{} as never,
{ get: () => undefined } as never,
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
);
return { service, manager, savedLines };
}
@@ -297,6 +299,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
{} as never, // invoiceDocuments
{} as never, // files
{ get: () => undefined } as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
);
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
@@ -352,6 +355,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
{} as never, // invoiceDocuments
{} as never, // files
{ get: () => undefined } as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
);
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
@@ -397,6 +401,7 @@ describe("BillingService.settleByPaymentId", () => {
{} as never, // invoiceDocuments
{} as never, // files
{ get: () => undefined } as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
);
return { service, mg, events };
}
@@ -510,6 +515,7 @@ describe("BillingService.recordPayment", () => {
{} as never, // invoiceDocuments
{} as never, // files
{ get: () => undefined } as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
);
return { service, mg, events };
}
@@ -627,6 +633,7 @@ describe("BillingService.expirePayable — locked write runs in a transaction",
{} as never,
{} as never,
{} as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
);
return { service, defaultManager, txManager, transaction };
};
@@ -700,6 +707,7 @@ describe("BillingService.issuePayable", () => {
{} as never,
{} as never,
{} as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
);
return { service, manager };
};
@@ -791,6 +799,7 @@ describe("BillingService — CAC Bank (OTP debit)", () => {
{} as never,
{} as never,
{} as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
);
return { service, repo };
};
@@ -874,6 +883,7 @@ describe("BillingService — CBE bill amounts carry cents, never rounded", () =>
{} as never,
{} as never,
{} as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
);
return { service, repo };
};
@@ -943,6 +953,7 @@ describe("BillingService.document", () => {
? { tin: "0053481357", invoice: { sellerVatNumber: "43256663343256663322" } }
: undefined,
} as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
);
return { service, render, renderThermal };
};

View File

@@ -17,6 +17,7 @@ import { Booking } from "../bookings/entities/booking.entity";
// payers straight off the table.
import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity";
import { ShippingLineCredit } from "../shipping-lines/entities/shipping-line-credit.entity";
import { ManualPaymentSettingsService } from "../payment-settings/manual-payment-settings.service";
import { EimsConfig } from "../../config/eims.config";
import { CompaniesService } from "../companies/companies.service";
import { EimsInvoiceStatus } from "../eims/eims-registration.types";
@@ -201,6 +202,7 @@ export class BillingService {
private readonly invoiceDocuments: InvoiceDocumentService,
private readonly files: FilesService,
private readonly config: ConfigService,
private readonly manualPaymentSettings: ManualPaymentSettingsService,
) { }
// ── Reads ──────────────────────────────────────────────────────────────────
@@ -370,20 +372,24 @@ export class BillingService {
const pageSize =
filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20;
// Only currencies whose manual-payment channel is switched on are listed:
// a row Finance cannot act on is noise, and the confirm endpoint would
// refuse it anyway. All off → nothing to work.
const enabled = await this.manualPaymentSettings.enabledCurrencies();
if (!enabled.length) return { items: [], total: 0 };
const currencies = filter.currency
? enabled.filter((c) => c === filter.currency)
: enabled;
if (!currencies.length) return { items: [], total: 0 };
const qb = this.dataSource
.getRepository(Invoice)
.createQueryBuilder("invoice")
.leftJoinAndSelect("invoice.company", "company")
.where("UPPER(invoice.currency) IN ('USD', 'ETB')")
.where("UPPER(invoice.currency) IN (:...currencies)", { currencies })
.orderBy("invoice.issuedAt", "DESC")
.skip((page - 1) * pageSize)
.take(pageSize);
if (filter.currency) {
qb.andWhere("UPPER(invoice.currency) = :currency", {
currency: filter.currency,
});
}
if (filter.status) {
qb.andWhere("invoice.status = :status", { status: filter.status });
} else {
@@ -465,7 +471,8 @@ export class BillingService {
/**
* Finance confirms an invoice (USD or ETB) as paid manually — bank transfer
* or counter payment: stores the slip against the invoice and settles the
* or counter payment. Refused when that currency's manual-payment channel is
* switched off in settings. Stores the slip against the invoice and settles the
* FULL outstanding balance through
* {@link recordPayment}, which flips the invoice to PAID and (for bookings)
* emits `booking.invoice.paid` — the same event an online payment fires, so
@@ -485,6 +492,13 @@ export class BillingService {
): Promise<Invoice> {
const invoice = await this.invoices.findById(invoiceId);
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
// The channel is a setting, not a role: even a permitted user cannot
// settle by hand in a currency whose channel is switched off.
if (!(await this.manualPaymentSettings.isEnabled(invoice.currency))) {
throw new BadRequestException(
`Manual payment is disabled for ${invoice.currency ?? "this"} invoices. Enable it in Configuration → Manual payments first.`,
);
}
if (!file) {
throw new BadRequestException("The bank payment slip file is required.");
}

View File

@@ -151,7 +151,9 @@ describe("toEimsInvoice", () => {
// EimsLineTax.discount comment in eims-invoice.mapper.ts.
Discount: 25,
TotalLineAmount: 1050,
Unit: "CTR",
// Not "CTR" from the line's metadata.unit — that's our internal fee-basis tag, not a MoR
// unit of measure, and is never read for this field (see the mapper's own comment).
Unit: "PCS",
});
expect(doc.ValueDetails).toEqual({
Discount: null,

View File

@@ -463,7 +463,13 @@ export function toEimsInvoice(
const PreTaxValue = round2(num(line.amount));
const TaxAmount = round2((PreTaxValue * tax.ratePercent) / 100);
const ExciseTaxValue = round2(tax.exciseTaxValue);
const unit = typeof line.metadata?.unit === "string" ? line.metadata.unit : context.unitDefault;
// `line.metadata.unit` is our own fee-basis tag (PER_CONTAINER/PER_TON/PER_ITEM — how a charge
// is computed, see the fee-rule docs), never a MoR unit of measure — sending it as-is here
// (confirmed live 2026-08-17: "PER_CONTAINER" fails Unit's enum, its 8-char max, and its regex
// all at once) is what a prior version of this mapper did by mistake. MoR's own enum
// (LTR/MTR/101/PCS/ROL/MTS/PKG/SET/KLG) has no freight-shipment concept at all, so every line
// uses the single configured default rather than guessing a per-line value that doesn't exist.
const unit = context.unitDefault;
return {
Discount: round2(tax.discount),

View File

@@ -203,4 +203,12 @@ export class Invoice extends BaseEntity {
@ManyToOne(() => Invoice)
@JoinColumn({ name: "related_invoice_id" })
relatedInvoice?: Invoice | null;
/**
* Which `POST /v1/bulkRegister` batch this invoice was submitted in, if any — MoR's own
* conversation id, not one we generate. Lets a stuck batch (webhook never arrived) be found and
* reconciled. Null for every invoice filed through single `/v1/register`.
*/
@Column({ name: "eims_bulk_conversation_id", type: "text", nullable: true })
eimsBulkConversationId?: string | null;
}

View File

@@ -0,0 +1,391 @@
import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { DataSource } from 'typeorm';
import { Freight } from '@edr/types';
import { BillingService, InvoiceEventPayload } from '../billing/billing.service';
import { Invoice } from '../billing/entities/invoice.entity';
import { FilesService } from '../files/files.service';
import { BookingsService } from './bookings.service';
import { BookingsRepository } from './bookings.repository';
import { Booking } from './entities/booking.entity';
import {
BookingClearanceCharge,
ClearanceChargeType,
} from './entities/booking-clearance-charge.entity';
import { ClearanceEventService } from './clearance-event.service';
/** File-record codes the charge documents are stored under on the booking. */
const CHARGE_FILE_CODE: Record<ClearanceChargeType, string> = {
PORT_CHARGES: 'clearance_charge_port',
MISCELLANEOUS: 'clearance_charge_misc',
};
const CHARGE_LABEL: Record<ClearanceChargeType, string> = {
PORT_CHARGES: 'Port charges',
MISCELLANEOUS: 'Miscellaneous charges',
};
/**
* Post-finalization clearance charges billed to the customer. Two levels per
* booking: GL Djibouti uploads the port-charges document; GL Ethiopia bills it
* (amount + currency) and sends the invoice; once that invoice is paid GL
* Ethiopia may create and send the miscellaneous charge. ETB invoices are paid
* through the portal gateway, other currencies through Finance's manual
* settlement worklist — both settle via `clearance_charge.invoice.paid`.
*/
@Injectable()
export class BookingClearanceChargeService {
private readonly logger = new Logger(BookingClearanceChargeService.name);
constructor(
private readonly dataSource: DataSource,
private readonly filesService: FilesService,
private readonly billing: BillingService,
private readonly bookingsService: BookingsService,
private readonly bookingsRepository: BookingsRepository,
private readonly clearanceEvents: ClearanceEventService,
) {}
private repo() {
return this.dataSource.getRepository(BookingClearanceCharge);
}
/**
* Charges are a post-finalization step: block while the customer's clearance
* documents are still being collected/reviewed.
*/
private assertClearanceFinalized(booking: Booking): void {
const inReview =
booking.status === 'AWAITING_DOCUMENTS' ||
booking.status === 'DOCUMENTS_UNDER_REVIEW';
if (inReview && !booking.preClearanceFinalizedAt) {
throw new BadRequestException(
'Clearance charges open after document clearance is finalized.',
);
}
}
async list(bookingId: string): Promise<Freight.ClearanceCharge[]> {
const charges = await this.repo().find({
where: { bookingId },
order: { createdAt: 'ASC' },
});
if (charges.length === 0) return [];
const files = await this.filesService.findByResource(bookingId, 'bookings');
const fileById = new Map(files.map((f) => [f.id, f]));
const names = await this.bookingsRepository.resolveStaffNames(
charges.flatMap((c) => [c.uploadedByStaffId, c.billedByStaffId]),
);
const invoiceIds = charges
.map((c) => c.invoiceId)
.filter((id): id is string => Boolean(id));
const invoices = invoiceIds.length
? await this.dataSource
.getRepository(Invoice)
.find({ where: invoiceIds.map((id) => ({ id })) })
: [];
const invoiceById = new Map(invoices.map((i) => [i.id, i]));
return charges.map((c) => {
const file = c.fileRecordId ? (fileById.get(c.fileRecordId) ?? null) : null;
return {
id: c.id,
bookingId: c.bookingId,
type: c.type,
status: c.status,
file: file ? { id: file.id, name: file.name, url: file.url } : null,
amount: c.amount != null ? Number(c.amount) : null,
currency: c.currency ?? null,
invoiceId: c.invoiceId ?? null,
invoiceNumber: c.invoiceId
? (invoiceById.get(c.invoiceId)?.invoiceNumber ?? null)
: null,
uploadedByName: c.uploadedByStaffId
? (names.get(c.uploadedByStaffId) ?? null)
: null,
uploadedAt: c.uploadedAt ? c.uploadedAt.toISOString() : null,
billedByName: c.billedByStaffId
? (names.get(c.billedByStaffId) ?? null)
: null,
billedAt: c.billedAt ? c.billedAt.toISOString() : null,
paidAt: c.paidAt ? c.paidAt.toISOString() : null,
};
});
}
/** GL Djibouti uploads (or replaces, until billed) the port-charges document. */
async uploadPortDocument(
bookingId: string,
file: Express.Multer.File,
staffId: string,
): Promise<Freight.ClearanceCharge[]> {
const booking = await this.bookingsService.findById(bookingId);
this.assertClearanceFinalized(booking);
const existing = await this.repo().findOne({
where: { bookingId, type: 'PORT_CHARGES' },
});
if (existing && existing.status !== 'DOC_UPLOADED') {
throw new ConflictException(
'The port charge has already been billed — ask GL Ethiopia to revise it instead.',
);
}
const record = await this.filesService.upsertByCode(
{
resourceId: bookingId,
resource: 'bookings',
code: CHARGE_FILE_CODE.PORT_CHARGES,
file,
},
{ userId: staffId },
);
if (existing) {
await this.repo().update(existing.id, {
fileRecordId: record.id,
uploadedByStaffId: staffId,
uploadedAt: new Date(),
});
} else {
await this.repo().save(
this.repo().create({
bookingId,
type: 'PORT_CHARGES',
status: 'DOC_UPLOADED',
fileRecordId: record.id,
uploadedByStaffId: staffId,
uploadedAt: new Date(),
}),
);
}
await this.clearanceEvents.record({
bookingId,
action: 'CHARGE_PORT_DOC_UPLOADED',
label: existing
? 'Replaced the port-charges document'
: 'Uploaded the port-charges document',
actorId: staffId,
metadata: { fileName: file.originalname },
});
return this.list(bookingId);
}
/**
* GL Ethiopia sets (or, on the customer's request, revises) amount +
* currency. Revising a SENT charge cancels its unpaid invoice; a PAID charge
* is immutable.
*/
async billCharge(
bookingId: string,
chargeId: string,
input: { amount: number; currency: string },
staffId: string,
): Promise<Freight.ClearanceCharge[]> {
const charge = await this.repo().findOne({
where: { id: chargeId, bookingId },
});
if (!charge) throw new NotFoundException('Clearance charge not found');
if (charge.status === 'PAID') {
throw new ConflictException('A paid charge can no longer be changed.');
}
if (!(input.amount > 0)) {
throw new BadRequestException('Amount must be greater than zero.');
}
if (!input.currency?.trim()) {
throw new BadRequestException('Currency is required.');
}
if (charge.status === 'SENT' && charge.invoiceId) {
await this.billing.cancelInvoice(charge.invoiceId);
}
await this.repo().update(charge.id, {
amount: input.amount.toFixed(2),
currency: input.currency.trim().toUpperCase(),
status: 'BILLED',
invoiceId: null,
billedByStaffId: staffId,
billedAt: new Date(),
});
await this.clearanceEvents.record({
bookingId,
action: 'CHARGE_BILLED',
label: `${charge.status === 'SENT' ? 'Revised' : 'Billed'} ${CHARGE_LABEL[
charge.type
].toLowerCase()}: ${input.amount} ${input.currency.trim().toUpperCase()}`,
actorId: staffId,
metadata: {
chargeType: charge.type,
amount: input.amount,
currency: input.currency.trim().toUpperCase(),
revised: charge.status === 'SENT',
},
});
return this.list(bookingId);
}
/** GL Ethiopia issues the payable invoice to the customer. */
async sendCharge(
bookingId: string,
chargeId: string,
staffId?: string,
): Promise<Freight.ClearanceCharge[]> {
const charge = await this.repo().findOne({
where: { id: chargeId, bookingId },
});
if (!charge) throw new NotFoundException('Clearance charge not found');
if (charge.status !== 'BILLED') {
throw new ConflictException(
'Set the amount and currency before sending the charge to the customer.',
);
}
const booking = await this.bookingsService.findById(bookingId);
const invoice = await this.billing.generateInvoice({
source: Freight.InvoiceSource.ClearanceCharge,
// The charge's own id, NOT the booking id — booking-scoped invoice
// lookups (findPayable/expirePayable/CBE billQuery) must never match it.
sourceId: charge.id,
type: charge.type,
companyId: booking.companyId,
companyProfileId: booking.companyProfileId,
currency: charge.currency ?? 'ETB',
lines: [
{
chargeType: charge.type,
description: `${CHARGE_LABEL[charge.type]}${booking.reference ?? bookingId}`,
amount: Number(charge.amount),
},
],
});
await this.repo().update(charge.id, {
status: 'SENT',
invoiceId: invoice.id,
});
await this.clearanceEvents.record({
bookingId,
action: 'CHARGE_INVOICE_SENT',
label: `Sent ${CHARGE_LABEL[charge.type].toLowerCase()} invoice ${invoice.invoiceNumber} to the customer`,
actorId: staffId ?? null,
metadata: {
chargeType: charge.type,
invoiceNumber: invoice.invoiceNumber,
amount: Number(charge.amount),
currency: charge.currency,
},
});
this.logger.log(
`Clearance charge ${charge.type} on booking ${bookingId} sent as invoice ${invoice.invoiceNumber}`,
);
return this.list(bookingId);
}
/**
* GL Ethiopia creates the miscellaneous charge whole (document + amount +
* currency). Second payment level: allowed only once the port charge is paid.
*/
async createMiscellaneous(
bookingId: string,
file: Express.Multer.File,
input: { amount: number; currency: string },
staffId: string,
): Promise<Freight.ClearanceCharge[]> {
const booking = await this.bookingsService.findById(bookingId);
this.assertClearanceFinalized(booking);
const port = await this.repo().findOne({
where: { bookingId, type: 'PORT_CHARGES' },
});
if (port?.status !== 'PAID') {
throw new ConflictException(
'Miscellaneous charges open after the port charge is paid.',
);
}
const existing = await this.repo().findOne({
where: { bookingId, type: 'MISCELLANEOUS' },
});
if (existing) {
throw new ConflictException(
'This booking already has a miscellaneous charge — revise it instead.',
);
}
if (!(input.amount > 0)) {
throw new BadRequestException('Amount must be greater than zero.');
}
if (!input.currency?.trim()) {
throw new BadRequestException('Currency is required.');
}
const record = await this.filesService.upsertByCode(
{
resourceId: bookingId,
resource: 'bookings',
code: CHARGE_FILE_CODE.MISCELLANEOUS,
file,
},
{ userId: staffId },
);
await this.repo().save(
this.repo().create({
bookingId,
type: 'MISCELLANEOUS',
status: 'BILLED',
fileRecordId: record.id,
amount: input.amount.toFixed(2),
currency: input.currency.trim().toUpperCase(),
uploadedByStaffId: staffId,
uploadedAt: new Date(),
billedByStaffId: staffId,
billedAt: new Date(),
}),
);
await this.clearanceEvents.record({
bookingId,
action: 'CHARGE_MISC_CREATED',
label: `Created miscellaneous charge: ${input.amount} ${input.currency.trim().toUpperCase()}`,
actorId: staffId,
metadata: {
amount: input.amount,
currency: input.currency.trim().toUpperCase(),
fileName: file.originalname,
},
});
return this.list(bookingId);
}
/** Gateway and manual settlements both land here (`${source}.invoice.paid`). */
@OnEvent('clearance_charge.invoice.paid')
async onChargeInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
const charge = await this.repo().findOne({
where: { id: payload.sourceId },
});
if (!charge || charge.status === 'PAID') return;
await this.repo().update(charge.id, {
status: 'PAID',
paidAt: new Date(),
});
await this.clearanceEvents.record({
bookingId: charge.bookingId,
action: 'CHARGE_PAID',
label: `${CHARGE_LABEL[charge.type]} paid (invoice ${payload.invoiceNumber})`,
actorType: 'SYSTEM',
metadata: {
chargeType: charge.type,
invoiceNumber: payload.invoiceNumber,
},
});
this.logger.log(
`Clearance charge ${charge.type} on booking ${charge.bookingId} paid (invoice ${payload.invoiceNumber})`,
);
}
}

View File

@@ -470,6 +470,36 @@ export class BookingLifecycleNotifierService {
);
}
/**
* A shared-wagon pairing is waiting for a human decision. Two customers' cargo
* on one wagon is a commercial call, so this never auto-advances.
*/
consolidationApprovalRequestedToStaff(b: Booking, partnerReference: string): void {
this.inAppStaff(
b,
'Shared wagon needs approval',
`Booking ${this.ref(b)} shares a wagon with ${partnerReference} — approve the consolidation before it reaches Operations.`,
);
}
/** The pairing was approved; both halves move on to Operations together. */
consolidationApprovedToStaff(b: Booking, partnerReference: string): void {
this.inAppStaff(
b,
'Shared wagon approved',
`The shared wagon for ${this.ref(b)} and ${partnerReference} was approved — both bookings are now with Operations.`,
);
}
/** The pairing was rejected; both halves go back to GL for changes. */
consolidationRejectedToStaff(b: Booking, partnerReference: string, reason: string): void {
this.inAppStaff(
b,
'Shared wagon rejected',
`The shared wagon for ${this.ref(b)} and ${partnerReference} was rejected: ${reason}`,
);
}
/** Customer uploaded clearance documents — review is next. */
clearanceDocsUploadedToStaff(b: Booking): void {
this.inAppStaff(

View File

@@ -59,6 +59,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
{ record: jest.fn() } as never, // clearanceEvents
{ emit: jest.fn() } as never, // events
);
return { service, bookingsRepository, ruleEngineService, contractService };

View File

@@ -68,6 +68,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
{ record: jest.fn() } as never, // clearanceEvents
{ emit: jest.fn() } as never, // events
);
return { service, bookingsRepository };
@@ -172,6 +173,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
{ record: jest.fn() } as never, // clearanceEvents
{ emit: jest.fn() } as never, // events
);
return { service, bookingsRepository };
@@ -262,6 +264,7 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
{ record: jest.fn() } as never, // clearanceEvents
{ emit: jest.fn() } as never, // events
);
return { service, bookingsRepository, filesService };

View File

@@ -71,6 +71,7 @@ describe('BookingTransitionService — operation review', () => {
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
{ record: jest.fn() } as never, // clearanceEvents
{ emit: jest.fn() } as never, // events
);
return { service, bookingsRepository, bookingBatchService, invoiceService };
@@ -172,6 +173,7 @@ describe('BookingTransitionService — requestOperation export space gate', () =
{} as never, // invoiceService
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
notifier as never,
{ record: jest.fn() } as never, // clearanceEvents
{ emit: jest.fn() } as never, // events
);
return { service, bookingsRepository, bookingBatchService };

View File

@@ -0,0 +1,133 @@
import { BookingTransitionService } from './booking-transition.service';
import { Booking } from './entities/booking.entity';
/**
* Staff decisions on a consolidated pair. Two bookings sharing a wagon must move
* together: accepting one alone would put half a wagon into the approval chain,
* and cancelling one alone would strand the other on a wagon it can no longer
* fill. All-or-nothing — if either half throws, neither booking moved.
*/
describe('BookingTransitionService — paired staff decisions', () => {
function makeService(booking: Partial<Booking>) {
const bookingsService = {
findById: jest.fn().mockResolvedValue(booking as Booking),
};
// Runs the callback so a throw propagates, which is what the all-or-nothing
// guarantee reduces to from this service's point of view.
const dataSource = {
transaction: jest.fn(async (cb: () => Promise<unknown>) => cb()),
};
const service = new BookingTransitionService(
{} as never, // bookingsRepository
{} as never, // ruleEngineService
{} as never, // pricingService
{} as never, // contractService
{} as never, // filesService
{} as never, // fileUploadSettingsService
{} as never, // bookingBatchService
bookingsService as never,
{} as never, // bookingClearanceService
{} as never, // workflowService
{} as never, // invoiceService
{} as never, // containerValidationService
{} as never, // notifier
{ record: jest.fn() } as never, // clearanceEvents
{} as never, // events
undefined, // milestoneService
dataSource as never,
);
return { service, dataSource };
}
const paired = {
id: 'b-1',
reference: 'BK-1',
consolidationPartnerId: 'b-2',
} as Booking;
it('accepts both halves with the same validity window', async () => {
const { service } = makeService(paired);
const accept = jest
.spyOn(service, 'acceptIntake')
.mockImplementation(async (id) => ({ id }) as Booking);
const result = await service.applyPairedDecision('b-1', 'accept', 'staff-1', {
validityDays: 30,
});
expect(accept).toHaveBeenCalledTimes(2);
expect(accept).toHaveBeenNthCalledWith(1, 'b-1', 'staff-1', 30);
expect(accept).toHaveBeenNthCalledWith(2, 'b-2', 'staff-1', 30);
expect(result.booking.id).toBe('b-1');
expect(result.partner.id).toBe('b-2');
});
it('cancels both halves with the same reason', async () => {
const { service } = makeService(paired);
const cancel = jest
.spyOn(service, 'cancel')
.mockImplementation(async (id) => ({ id }) as Booking);
await service.applyPairedDecision('b-1', 'cancel', 'staff-1', {
reason: 'customer withdrew',
});
expect(cancel).toHaveBeenNthCalledWith(1, 'b-1', 'customer withdrew');
expect(cancel).toHaveBeenNthCalledWith(2, 'b-2', 'customer withdrew');
});
it('propagates a failure on the second half so neither is committed', async () => {
const { service, dataSource } = makeService(paired);
jest
.spyOn(service, 'cancel')
.mockImplementationOnce(async (id) => ({ id }) as Booking)
.mockImplementationOnce(async () => {
throw new Error('partner is already in transit');
});
await expect(
service.applyPairedDecision('b-1', 'cancel', 'staff-1', { reason: 'x' }),
).rejects.toThrow('partner is already in transit');
// Both halves ran inside one transaction, so the throw rolls the first back.
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
});
it('refuses a booking that has no partner', async () => {
const { service } = makeService({
id: 'b-1',
consolidationPartnerId: null,
} as Booking);
await expect(
service.applyPairedDecision('b-1', 'cancel', 'staff-1', { reason: 'x' }),
).rejects.toThrow(/no consolidation partner/i);
});
it('requires a validity window to accept', async () => {
const { service } = makeService(paired);
const accept = jest.spyOn(service, 'acceptIntake');
await expect(
service.applyPairedDecision('b-1', 'accept', 'staff-1', {}),
).rejects.toThrow(/validity/i);
expect(accept).not.toHaveBeenCalled();
});
it('routes operationAccept through the operation review on both halves', async () => {
const { service } = makeService(paired);
const review = jest
.spyOn(service, 'reviewOperationRequest')
.mockImplementation(async (id) => ({ id }) as Booking);
await service.applyPairedDecision('b-1', 'operationAccept', 'staff-1', {});
expect(review).toHaveBeenNthCalledWith(1, 'b-1', 'ACCEPT', 'staff-1', {
note: undefined,
});
expect(review).toHaveBeenNthCalledWith(2, 'b-2', 'ACCEPT', 'staff-1', {
note: undefined,
});
});
});

View File

@@ -27,7 +27,15 @@ import { BookingPricingService } from './booking-pricing.service';
import { ContainerValidationService } from './container-validation.service';
import { BookingsRepository } from './bookings.repository';
import { assertBookingStatus } from './booking-status.util';
import { clearanceCodesForBooking } from './clearance.util';
import {
clearanceCodesForBooking,
clearanceDocumentsOpen,
} from './clearance.util';
import {
buildClearanceDocHistory,
type ClearanceDocEvent,
} from './clearance-doc-history.util';
import { ClearanceEventService } from './clearance-event.service';
import { computeNextStep, type BookingNextStep } from './booking-next-step.util';
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
import { PriceLineItemDto } from './dto/generate-price-response.dto';
@@ -68,6 +76,7 @@ export class BookingTransitionService {
private readonly invoiceService: BookingInvoiceService,
private readonly containerValidationService: ContainerValidationService,
private readonly notifier: BookingLifecycleNotifierService,
private readonly clearanceEvents: ClearanceEventService,
private readonly events: EventEmitter2,
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
// Optional + last so the hand-constructed service in *.spec.ts files keeps
@@ -81,6 +90,28 @@ export class BookingTransitionService {
/** Reject submit when the booking's 20ft containers can't be balanced onto wagons. */
private async assert20ftPairable(booking: Booking): Promise<void> {
// Parity gate. 20ft ride two per wagon, so an odd total leaves one container
// that cannot be placed. Consolidation (pairing it with another customer's
// odd booking) is built end to end but switched off for now, so an odd total
// is rejected here rather than parked for a partner.
// containerSize is not always populated (some rows carry only the container
// type), so fall back to the type's sizeFt rather than silently skipping
// those lines and letting an odd booking through.
const ft20Quantity = (booking.bookingContainers ?? [])
.filter((bc) =>
bc.containerSize
? bc.containerSize.includes("20")
: Number(bc.containerType?.sizeFt) === 20,
)
.reduce((sum, bc) => sum + Number(bc.quantity || 0), 0);
if (ft20Quantity % 2 === 1) {
throw new BadRequestException(
`20ft containers travel two per wagon, so they must be booked in even ` +
`numbers. This booking has ${ft20Quantity} — add one more or remove ` +
`one (book ${ft20Quantity + 1} or ${ft20Quantity - 1}).`,
);
}
const violations =
await this.containerValidationService.validate20ftPairing(booking);
if (violations.length) {
@@ -455,6 +486,75 @@ export class BookingTransitionService {
return this.cancel(bookingId, reason ?? "Customer cancelled before payment");
}
/**
* Run a staff decision across BOTH halves of a consolidated pair.
*
* Two bookings that share a wagon must move together: accepting one while the
* other stays behind would put half a wagon into the approval chain, and
* cancelling one alone would strand the other on a wagon it can no longer
* fill. All-or-nothing — if either half throws, the transaction rolls back and
* neither booking moved.
*
* Each half still runs the ordinary single-booking transition, so pricing,
* invoicing and notifications stay per booking: the customers are billed and
* notified separately, exactly as they are today.
*/
async applyPairedDecision(
bookingId: string,
decision: "accept" | "cancel" | "operationAccept" | "requestChanges",
actorId: string,
options: { reason?: string; note?: string; validityDays?: number } = {},
): Promise<{ booking: Booking; partner: Booking }> {
const booking = await this.bookingsService.findById(bookingId);
const partnerId = booking.consolidationPartnerId;
if (!partnerId) {
throw new BadRequestException(
"This booking has no consolidation partner — use the single-booking action.",
);
}
const runOne = async (id: string): Promise<Booking> => {
switch (decision) {
case "accept":
// Same requirement as the single-booking accept: the approval chain
// needs a contract validity window.
if (!(Number(options.validityDays) > 0)) {
throw new BadRequestException(
"Contract validity (days) is required to accept.",
);
}
return this.acceptIntake(id, actorId, Number(options.validityDays));
case "cancel":
return this.cancel(
id,
options.reason ?? "Cancelled with its consolidation partner",
);
case "operationAccept":
return this.reviewOperationRequest(id, "ACCEPT", actorId, {
note: options.note,
});
case "requestChanges":
return this.requestChanges(id, options.note ?? "", actorId);
}
};
// Without a DataSource (unit tests hand-construct this service) fall back to
// running the two halves directly — the ordering guarantee still holds, only
// the rollback does not.
if (!this.dataSource) {
const own = await runOne(bookingId);
const other = await runOne(partnerId);
return { booking: own, partner: other };
}
return this.dataSource.transaction(async () => {
// Sequential: one connection per transaction context.
const own = await runOne(bookingId);
const other = await runOne(partnerId);
return { booking: own, partner: other };
});
}
async cancel(bookingId: string, reason: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [
@@ -536,8 +636,13 @@ export class BookingTransitionService {
file: { id: string; name: string; url: string } | null;
reviewStatus: "PENDING" | "APPROVED" | "QUERIED" | null;
note: string | null;
uploadedAt: string | null;
reviewedAt: string | null;
reviewedByName: string | null;
history: ClearanceDocEvent[];
}>;
allApproved: boolean;
documentsOpen: boolean;
phase?: string | null;
milestones?: unknown[];
nextAction?: unknown;
@@ -561,6 +666,18 @@ export class BookingTransitionService {
const reviewByKey = new Map(
reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r]),
);
const allVersions = await this.filesService.findAllVersionsByResource(
bookingId,
"bookings",
);
const queryNotes = await this.bookingsRepository.findReviewNotes(
bookingId,
"CHANGES_REQUESTED",
);
const reviewerNames = await this.bookingsRepository.resolveStaffNames([
...reviews.map((r) => r.reviewedByStaffId),
...queryNotes.map((n) => n.authorId),
]);
const documents: Awaited<
ReturnType<BookingTransitionService["getClearanceView"]>
@@ -589,6 +706,18 @@ export class BookingTransitionService {
file: file ? { id: file.id, name: file.name, url: file.url } : null,
reviewStatus: review?.status ?? null,
note: review?.note ?? null,
uploadedAt: file?.createdAt ? file.createdAt.toISOString() : null,
reviewedAt: review?.reviewedAt ? review.reviewedAt.toISOString() : null,
reviewedByName: review?.reviewedByStaffId
? (reviewerNames.get(review.reviewedByStaffId) ?? null)
: null,
history: buildClearanceDocHistory({
fileKey: field.fileKey,
allVersions,
queryNotes,
review,
names: reviewerNames,
}),
});
}
};
@@ -609,6 +738,18 @@ export class BookingTransitionService {
file: { id: f.id, name: f.name, url: f.url },
reviewStatus: review?.status ?? null,
note: review?.note ?? null,
uploadedAt: f.createdAt ? f.createdAt.toISOString() : null,
reviewedAt: review?.reviewedAt ? review.reviewedAt.toISOString() : null,
reviewedByName: review?.reviewedByStaffId
? (reviewerNames.get(review.reviewedByStaffId) ?? null)
: null,
history: buildClearanceDocHistory({
fileKey: f.code,
allVersions,
queryNotes,
review,
names: reviewerNames,
}),
});
}
@@ -621,6 +762,7 @@ export class BookingTransitionService {
outputCode,
documents,
allApproved,
documentsOpen: clearanceDocumentsOpen(booking),
};
}
@@ -660,12 +802,17 @@ export class BookingTransitionService {
async submitClearanceDocuments(
bookingId: string,
files: Express.Multer.File[],
userId?: string,
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [
"AWAITING_DOCUMENTS",
"DOCUMENTS_UNDER_REVIEW",
]);
// Documents stay open until the shipment is paid — a customs shipment keeps
// collecting paperwork (amended invoices, port documents) well past
// clearance finalization. See {@link clearanceDocumentsOpen}.
if (!clearanceDocumentsOpen(booking)) {
throw new ConflictException(
`Clearance documents are closed for this booking (status "${booking.status}").`,
);
}
const { inputCode } = clearanceCodesForBooking(booking);
if (!inputCode) {
throw new BadRequestException(
@@ -703,21 +850,41 @@ export class BookingTransitionService {
});
}
await this.bookingsRepository.update(bookingId, {
status: "DOCUMENTS_UNDER_REVIEW",
} as never);
// Only the pre-finalization submission drives the booking into review.
// A later addition (an amended invoice while the shipment is already
// scheduled) must never rewind the status or reopen the phased workflow —
// it lands as a new PENDING document for GL to approve where it stands.
const inDocumentPhase =
booking.status === "AWAITING_DOCUMENTS" ||
booking.status === "DOCUMENTS_UNDER_REVIEW";
if (this.isPhasedCustoms(booking)) {
await this.workflowService.onCustomerDocsUploadedForBooking(
bookingId,
booking.tradeDirection ?? 'IMPORT',
);
await this.workflowService.onDocumentReviewReopenedForBooking(bookingId);
if (inDocumentPhase) {
await this.bookingsRepository.update(bookingId, {
clearanceCurrentPhase: ContractDocPhase.GlEtReview,
status: "DOCUMENTS_UNDER_REVIEW",
} as never);
if (this.isPhasedCustoms(booking)) {
await this.workflowService.onCustomerDocsUploadedForBooking(
bookingId,
booking.tradeDirection ?? 'IMPORT',
);
await this.workflowService.onDocumentReviewReopenedForBooking(bookingId);
await this.bookingsRepository.update(bookingId, {
clearanceCurrentPhase: ContractDocPhase.GlEtReview,
} as never);
}
}
const fileKeys = files.map((f) => f.fieldname);
await this.clearanceEvents.record({
bookingId,
action: 'DOCS_SUBMITTED',
label: `Customer submitted ${files.length} clearance document(s): ${fileKeys.join(', ')}`,
actorType: 'CUSTOMER',
actorId: userId ?? null,
metadata: { fileKeys },
});
const fresh = await this.bookingsService.findById(bookingId);
this.notifier.clearanceDocsUploadedToStaff(fresh);
return fresh;
@@ -770,7 +937,14 @@ export class BookingTransitionService {
note?: string,
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]);
// GL keeps reviewing for as long as the customer can still submit — the
// two sides share one predicate so they can never drift apart. Documents
// added after clearance was finalized still need approving/querying.
if (!clearanceDocumentsOpen(booking)) {
throw new ConflictException(
`Clearance documents are closed for this booking (status "${booking.status}").`,
);
}
const { inputCode, outputCode } = clearanceCodesForBooking(booking);
const existing =
@@ -787,15 +961,6 @@ export class BookingTransitionService {
"A note is required when querying a document",
);
}
if (
status === 'QUERIED' &&
this.isPhasedCustoms(booking) &&
booking.preClearanceFinalizedAt
) {
throw new BadRequestException(
'Customer documents cannot be queried after pre-clearance is finalized.',
);
}
await this.bookingsRepository.setDocumentReviewStatus(
bookingId,
@@ -805,6 +970,16 @@ export class BookingTransitionService {
staffId,
note,
);
await this.clearanceEvents.record({
bookingId,
action: status === 'APPROVED' ? 'DOC_APPROVED' : 'DOC_QUERIED',
label:
status === 'APPROVED'
? `Approved document "${fileKey.replace(/_/g, ' ')}"`
: `Opened query on document "${fileKey.replace(/_/g, ' ')}"`,
actorId: staffId,
metadata: { fileKey, note: note ?? null },
});
if (status === "QUERIED") {
await this.bookingsRepository.createReviewNote(
bookingId,
@@ -812,7 +987,10 @@ export class BookingTransitionService {
"CHANGES_REQUESTED",
staffId,
);
if (this.isPhasedCustoms(booking)) {
// Reopening the review phase only makes sense while clearance is still
// being decided. Querying a document that arrived afterwards must not
// drag a finalized shipment back into the GL review phase.
if (this.isPhasedCustoms(booking) && !booking.preClearanceFinalizedAt) {
await this.workflowService.onDocumentReviewReopenedForBooking(bookingId);
await this.bookingsRepository.update(bookingId, {
clearanceCurrentPhase: ContractDocPhase.GlEtReview,
@@ -824,7 +1002,10 @@ export class BookingTransitionService {
if (status === "QUERIED") {
this.notifier.documentQueried(updated, fileKey, note ?? '');
}
if (this.isPhasedCustoms(updated)) {
// Same reasoning as the query branch: advance the workflow only while
// clearance is still open. Approving a late-added document leaves an
// already-finalized shipment's phase exactly where it is.
if (this.isPhasedCustoms(updated) && !updated.preClearanceFinalizedAt) {
const allApproved = await this.isClearanceFullyApproved(updated);
if (allApproved) {
await this.workflowService.onAllDocsApprovedForBooking(bookingId);
@@ -845,6 +1026,7 @@ export class BookingTransitionService {
async uploadClearanceOutputDocuments(
bookingId: string,
files: Express.Multer.File[],
userId?: string,
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]);
@@ -865,6 +1047,15 @@ export class BookingTransitionService {
file,
});
}
await this.clearanceEvents.record({
bookingId,
action: 'OUTPUT_DOCS_UPLOADED',
label: `Uploaded customs output document(s): ${files
.map((f) => f.fieldname.replace(/_/g, ' '))
.join(', ')}`,
actorId: userId ?? null,
metadata: { fileKeys: files.map((f) => f.fieldname) },
});
return this.bookingsService.findById(bookingId);
}
@@ -872,7 +1063,7 @@ export class BookingTransitionService {
* GL confirms clearance: requires every customer document APPROVED (100% gate)
* and, for customs, the required output documents present → CLEARANCE_READY.
*/
async finalizeClearance(bookingId: string): Promise<Booking> {
async finalizeClearance(bookingId: string, userId?: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
if (this.isPhasedCustoms(booking)) {
throw new BadRequestException(
@@ -928,6 +1119,12 @@ export class BookingTransitionService {
await this.bookingsRepository.update(bookingId, {
status: "CLEARANCE_READY",
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'CLEARANCE_FINALIZED',
label: 'Finalized document approval — clearance ready',
actorId: userId ?? null,
});
const fresh = await this.bookingsService.findById(bookingId);
this.notifier.clearanceReady(fresh);
return fresh;
@@ -954,6 +1151,8 @@ export class BookingTransitionService {
* the customer pools, so the gate here would wrongly reject them).
*/
bypassDayPool?: boolean;
/** Acting user, recorded in the clearance history. */
userId?: string;
},
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
@@ -1068,6 +1267,14 @@ export class BookingTransitionService {
scheduledDate: date,
requestedTrainScheduleId: requestedId,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'OPERATION_REQUESTED',
label: `Requested operation for shipment day ${scheduledDate}`,
actorType: 'CUSTOMER',
actorId: opts?.userId ?? null,
metadata: { scheduledDate },
});
const fresh = await this.bookingsService.findById(bookingId);
this.notifier.operationRequestedToStaff(fresh);
return fresh;

View File

@@ -1,4 +1,5 @@
import {
BadRequestException,
Body,
Controller,
Delete,
@@ -38,6 +39,9 @@ import {
} from "@nestjs/swagger";
import type { Response } from "express";
import { BookingClearanceChargeService } from './booking-clearance-charge.service';
import { ClearanceEventService } from './clearance-event.service';
import { BillClearanceChargeDto } from './dto/clearance-charge.dto';
import { BookingContractService } from './booking-contract.service';
import { BookingPricingService } from './booking-pricing.service';
import { BookingTransitionService } from './booking-transition.service';
@@ -50,6 +54,7 @@ import { BookingReferenceDataService } from './booking-reference-data.service';
import { scopedDirections } from '../user-trade-access/trade-scope.util';
import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service';
import { BookingsService } from './bookings.service';
import { ConsolidationApprovalService } from './consolidation-approval.service';
import { BookingReferenceDataDto } from './dto/booking-reference-data.dto';
import { CreateBookingDto } from './dto/create-booking.dto';
import { BookingListSummaryDto } from './dto/booking-list-summary.dto';
@@ -58,7 +63,10 @@ import { GeneratePriceResponseDto } from './dto/generate-price-response.dto';
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
import {
AcceptIntakeDto,
ApproveConsolidationDto,
CancelBookingDto,
PairedDecisionDto,
RejectConsolidationDto,
RejectBookingDto,
RequestChangesDto,
ReviewDocumentDto,
@@ -165,6 +173,9 @@ export class BookingsController {
private readonly lastMileService: LastMileService,
private readonly userTradeAccessService: UserTradeAccessService,
private readonly wagonCancellationService: BookingWagonCancellationService,
private readonly consolidationApprovalService: ConsolidationApprovalService,
private readonly clearanceChargeService: BookingClearanceChargeService,
private readonly clearanceEventService: ClearanceEventService,
) {}
@Post()
@@ -935,8 +946,8 @@ export class BookingsController {
@Get('clearance/et-queue')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({ summary: 'GL ET queue — general customs bookings awaiting ET action' })
getBookingEtClearanceQueue() {
return this.bookingClearanceService.etQueue();
getBookingEtClearanceQueue(@CurrentUser() user: unknown) {
return this.bookingClearanceService.etQueue(user);
}
@Get('clearance/dj-queue')
@@ -966,10 +977,12 @@ export class BookingsController {
async submitClearanceDocuments(
@Param("id", ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.submitClearanceDocuments(
id,
files ?? [],
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@@ -986,11 +999,13 @@ export class BookingsController {
async proceedToOperation(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: RequestOperationDto,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.requestOperation(
id,
dto.scheduledDate,
dto.trainScheduleId ?? null,
{ userId: resolveAuthUserId(user) },
);
return this.transitionService.enrichBookingResponse(booking);
}
@@ -1068,6 +1083,114 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Get(":id/clearance/history")
@BookingStaff([
FREIGHT_PERMS.contracts.clearanceEtActions,
FREIGHT_PERMS.contracts.clearanceDjActions,
])
@ApiOperation({
summary:
"Clearance action history for the booking — reviews, workflow steps, charges (newest first)",
})
getClearanceHistory(@Param("id", ParseUUIDPipe) id: string) {
return this.clearanceEventService.list(id);
}
// ── Clearance charges (post-finalization customer billing) ────────────────
@Get(":id/clearance/charges")
@BookingStaff([
FREIGHT_PERMS.contracts.clearanceEtActions,
FREIGHT_PERMS.contracts.clearanceDjActions,
])
@ApiOperation({
summary: "Clearance charges billed to the customer (port + miscellaneous)",
})
getClearanceCharges(@Param("id", ParseUUIDPipe) id: string) {
return this.clearanceChargeService.list(id);
}
@Post(":id/clearance/charges/port-document")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@UseInterceptors(FileInterceptor("file"))
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary: "GL Djibouti uploads (or replaces, until billed) the port-charges document",
})
uploadPortChargeDocument(
@Param("id", ParseUUIDPipe) id: string,
@UploadedFile() file: Express.Multer.File,
@CurrentUser() user: AuthUserPayload,
) {
if (!file) throw new BadRequestException("A document file is required");
return this.clearanceChargeService.uploadPortDocument(
id,
file,
resolveAuthUserId(user),
);
}
@Patch(":id/clearance/charges/:chargeId/bill")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({
summary:
"GL Ethiopia sets or revises the charge's amount + currency (revising a sent charge cancels its unpaid invoice)",
})
billClearanceCharge(
@Param("id", ParseUUIDPipe) id: string,
@Param("chargeId", ParseUUIDPipe) chargeId: string,
@Body() dto: BillClearanceChargeDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.clearanceChargeService.billCharge(
id,
chargeId,
dto,
resolveAuthUserId(user),
);
}
@Post(":id/clearance/charges/:chargeId/send")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({
summary:
"GL Ethiopia issues the charge's payable invoice to the customer (ETB pays via gateway, other currencies via manual settlement)",
})
sendClearanceCharge(
@Param("id", ParseUUIDPipe) id: string,
@Param("chargeId", ParseUUIDPipe) chargeId: string,
@CurrentUser() user: AuthUserPayload,
) {
return this.clearanceChargeService.sendCharge(
id,
chargeId,
resolveAuthUserId(user),
);
}
@Post(":id/clearance/charges/miscellaneous")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@UseInterceptors(FileInterceptor("file"))
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary:
"GL Ethiopia creates the miscellaneous charge (document + amount + currency); unlocked once the port charge is paid",
})
createMiscellaneousCharge(
@Param("id", ParseUUIDPipe) id: string,
@UploadedFile() file: Express.Multer.File,
@Body() dto: BillClearanceChargeDto,
@CurrentUser() user: AuthUserPayload,
) {
if (!file) throw new BadRequestException("A document file is required");
return this.clearanceChargeService.createMiscellaneous(
id,
file,
dto,
resolveAuthUserId(user),
);
}
@Post(":id/clearance/output-documents")
@BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput)
@UseInterceptors(AnyFilesInterceptor())
@@ -1076,10 +1199,12 @@ export class BookingsController {
async uploadClearanceOutput(
@Param("id", ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.uploadClearanceOutputDocuments(
id,
files ?? [],
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@@ -1090,8 +1215,14 @@ export class BookingsController {
summary:
"GL finalizes clearance (requires 100% approved) → CLEARANCE_READY",
})
async finalizeClearance(@Param("id", ParseUUIDPipe) id: string) {
const booking = await this.transitionService.finalizeClearance(id);
async finalizeClearance(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.finalizeClearance(
id,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@@ -1104,8 +1235,13 @@ export class BookingsController {
async requestBookingTransitAssignee(
@Param('id', ParseUUIDPipe) id: string,
@Body('note') note: string | undefined,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.bookingClearanceService.requestTransitAssignee(id, note);
const booking = await this.bookingClearanceService.requestTransitAssignee(
id,
note,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@@ -1118,8 +1254,13 @@ export class BookingsController {
async assignBookingTransitAssignee(
@Param('id', ParseUUIDPipe) id: string,
@Body('transitAgentId', ParseUUIDPipe) transitAgentId: string,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.bookingClearanceService.assignTransitAssignee(id, transitAgentId);
const booking = await this.bookingClearanceService.assignTransitAssignee(
id,
transitAgentId,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@@ -1203,8 +1344,14 @@ export class BookingsController {
summary:
'Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia',
})
async acceptBookingDraftDeclaration(@Param('id', ParseUUIDPipe) id: string) {
const booking = await this.bookingClearanceService.acceptDraftDeclaration(id);
async acceptBookingDraftDeclaration(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.bookingClearanceService.acceptDraftDeclaration(
id,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@@ -1230,8 +1377,14 @@ export class BookingsController {
@Post(':id/clearance/finalize-pre-clearance')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({ summary: 'GL ET finalizes import pre-clearance on booking' })
async finalizeBookingPreClearance(@Param('id', ParseUUIDPipe) id: string) {
const booking = await this.bookingClearanceService.finalizePreClearance(id);
async finalizeBookingPreClearance(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.bookingClearanceService.finalizePreClearance(
id,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@@ -1243,8 +1396,13 @@ export class BookingsController {
async uploadBookingDutySlip(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFile() file: Express.Multer.File,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.bookingClearanceService.uploadDutySlip(id, file);
const booking = await this.bookingClearanceService.uploadDutySlip(
id,
file,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@@ -1541,6 +1699,92 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
// ── Shared-wagon (consolidation) approval gate ────────────────────────────
// A consolidated pair is held here, not in the operations queue: two
// customers' cargo on one wagon is a commercial call, so a person signs off
// on the pairing before Operations sees either half.
@Get("consolidation-approvals/queue")
@BookingStaff(FREIGHT_PERMS.bookings.approveConsolidation)
@ApiOperation({
summary:
"Shared-wagon pairings awaiting approval, oldest first. Each row covers BOTH bookings on the wagon.",
})
consolidationApprovalQueue() {
return this.consolidationApprovalService.queue();
}
@Get(":id/consolidation-approvals")
@BookingStaff(FREIGHT_PERMS.bookings.view)
@ApiOperation({
summary:
"Approval history for this booking's shared wagon — who decided what, when, and why.",
})
consolidationApprovalHistory(@Param("id", ParseUUIDPipe) id: string) {
return this.consolidationApprovalService.historyForBooking(id);
}
@Post("consolidation-approvals/:approvalId/approve")
@BookingStaff(FREIGHT_PERMS.bookings.approveConsolidation)
@ApiOperation({
summary:
"Approve a shared wagon: both bookings leave the gate and continue to Operations together.",
})
approveConsolidation(
@Param("approvalId", ParseUUIDPipe) approvalId: string,
@Body() dto: ApproveConsolidationDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.consolidationApprovalService.approve(
approvalId,
resolveAuthUserId(user) ?? "",
dto.note,
);
}
@Post("consolidation-approvals/:approvalId/reject")
@BookingStaff(FREIGHT_PERMS.bookings.approveConsolidation)
@ApiOperation({
summary:
"Reject a shared wagon: both bookings go back to GL for changes with the reason.",
})
rejectConsolidation(
@Param("approvalId", ParseUUIDPipe) approvalId: string,
@Body() dto: RejectConsolidationDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.consolidationApprovalService.reject(
approvalId,
resolveAuthUserId(user) ?? "",
dto.reason,
);
}
@Post(":id/paired-decision")
@BookingStaff(FREIGHT_PERMS.bookings.cancel)
@ApiOperation({
summary:
"Apply a staff decision (accept / cancel / operationAccept / requestChanges) to BOTH halves of a consolidated pair, all-or-nothing.",
})
async pairedDecision(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: PairedDecisionDto,
@CurrentUser() user: AuthUserPayload,
) {
const { booking, partner } = await this.transitionService.applyPairedDecision(
id,
dto.decision,
resolveAuthUserId(user),
{ reason: dto.reason, note: dto.note, validityDays: dto.validityDays },
);
// Sequential enrichment: both go back so the UI can refresh either tab.
const enrichedBooking =
await this.transitionService.enrichBookingResponse(booking);
const enrichedPartner =
await this.transitionService.enrichBookingResponse(partner);
return { booking: enrichedBooking, partner: enrichedPartner };
}
@Post(":id/cancel")
@BookingStaff(FREIGHT_PERMS.bookings.cancel)
@ApiOperation({ summary: "Cancel booking" })

View File

@@ -30,10 +30,17 @@ import { BookingsController } from './bookings.controller';
// import { PayController } from './pay.controller';
import { BookingsRepository } from './bookings.repository';
import { ConsolidationService } from './consolidation.service';
import { ConsolidationApprovalService } from './consolidation-approval.service';
import { ConsolidationApprovalsRepository } from './consolidation-approvals.repository';
import { ConsolidationApproval } from './entities/consolidation-approval.entity';
import { ContainerValidationService } from './container-validation.service';
import { BookingsService } from './bookings.service';
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
import { BookingDocumentReview } from './entities/booking-document-review.entity';
import { BookingClearanceCharge } from './entities/booking-clearance-charge.entity';
import { BookingClearanceChargeService } from './booking-clearance-charge.service';
import { BookingClearanceEvent } from './entities/booking-clearance-event.entity';
import { ClearanceEventService } from './clearance-event.service';
import { BookingContainer } from './entities/booking-container.entity';
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
@@ -72,6 +79,9 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
BookingWagonCancellation,
CustomerTruckAssignment,
CustomerTruckContainer,
ConsolidationApproval,
BookingClearanceCharge,
BookingClearanceEvent,
]),
BillingModule,
DocumentsModule,
@@ -98,6 +108,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
BookingsService,
BookingsRepository,
ConsolidationService,
ConsolidationApprovalService,
ConsolidationApprovalsRepository,
ContainerValidationService,
BookingReferenceDataService,
BookingPricingService,
@@ -105,6 +117,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
BookingTransitionService,
BookingContractService,
BookingInvoiceService,
BookingClearanceChargeService,
ClearanceEventService,
ContractTemplateResolver,
ContractViewModelBuilder,
ContractPricingScheduleBuilder,
@@ -120,12 +134,15 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
exports: [
BookingsService,
BookingsRepository,
ClearanceEventService,
BookingPricingService,
ContainerValidationService,
BookingInvoiceService,
BookingLifecycleNotifierService,
BookingTransitionService,
ConsolidationService,
ConsolidationApprovalService,
ConsolidationApprovalsRepository,
CustomerTruckService,
ContainerReceiptService,
BookingWagonCancellationService,

View File

@@ -13,6 +13,7 @@ import {
} from 'typeorm';
import { computeFacets, FacetBucket } from '../../common/utils/facets.util';
import { resolveIamUserNames } from '../../common/utils/iam-user-name.util';
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { Contract } from '../contracts/entities/contract.entity';
@@ -310,6 +311,72 @@ export class BookingsRepository extends BaseRepository<Booking> {
.find({ where: { contractId } });
}
/**
* Bookings a GL operator may manually link to `booking` as its odd-20ft
* consolidation partner (Path B customs flow). Unlike
* {@link findComplementaryConsolidationPartner} — which auto-pairs on an exact
* quantity complement — this lists CANDIDATES for a human to choose from, so
* the filter is deliberately looser: any other customs booking on the same
* route/direction that is itself carrying an odd 20ft count. Two odd counts
* always sum to even, so any pick fills the shared wagon.
*
* Bare instances awaiting completion have no persisted containers yet, so the
* odd-count test runs on the requested container lines when they exist and the
* booking is offered as a candidate when they do not (GL enters its cargo on
* the split form).
*/
async findManualConsolidationCandidates(
booking: Booking,
limit = 50,
): Promise<Booking[]> {
const rows = await this.repository
.createQueryBuilder('b')
.leftJoinAndSelect('b.bookingContainers', 'bc')
.leftJoinAndSelect('bc.containerType', 'ct')
.leftJoinAndSelect('b.company', 'company')
.where('b.id != :bookingId', { bookingId: booking.id })
// Never offer a booking that already shares a wagon with someone else.
.andWhere('b.consolidationPartnerId IS NULL')
// Customs-only: this manual flow exists because a customs (Path B)
// instance is completed by GL, not by the customer.
.andWhere('b.customsClearingEnabled = true')
// Same physical wagon ⇒ same route and same direction.
.andWhere('b.originYardId = :originYardId', {
originYardId: booking.originYardId,
})
.andWhere('b.destinationYardId = :destinationYardId', {
destinationYardId: booking.destinationYardId,
})
.andWhere('b.tradeDirection = :tradeDirection', {
tradeDirection: booking.tradeDirection,
})
// Bookable = clearance finished and the booking is waiting to be completed,
// the same set completeUnderContract accepts, plus one already parked for a
// partner.
.andWhere('b.status IN (:...statuses)', {
statuses: [
'CLEARANCE_READY',
'OPERATION_CHANGES_REQUESTED',
'PENDING_CONSOLIDATION',
],
})
.orderBy('b.createdAt', 'ASC')
.take(limit)
.getMany();
// Odd-20ft test in memory: a bare instance has no containers yet (GL fills
// them on the split form) and stays a candidate; one that already carries
// cargo qualifies only when its 20ft total is odd.
return rows.filter((row) => {
const lines = row.bookingContainers ?? [];
if (lines.length === 0) return true;
const ft20 = lines
.filter((line) => Number(line.containerType?.sizeFt) === 20)
.reduce((sum, line) => sum + Number(line.quantity || 0), 0);
return ft20 % 2 === 1;
});
}
/**
* Find another booking whose container quantity complements this one to fill whole wagon(s)
* (same route, same container type, partial wagon on both sides). Only 20ft lines ever
@@ -510,6 +577,25 @@ export class BookingsRepository extends BaseRepository<Booking> {
} as never);
}
/**
* Link two bookings as consolidation partners WITHOUT touching their statuses.
* Used by the manual GL pairing, where both bookings have just been completed
* into their live status — unlike {@link pairConsolidation}, which exists to
* resume bookings parked in PENDING_CONSOLIDATION and rewrites status as part
* of that resume.
*/
async linkConsolidationPartners(
bookingId: string,
partnerId: string,
): Promise<void> {
await this.repository.update(bookingId, {
consolidationPartnerId: partnerId,
} as never);
await this.repository.update(partnerId, {
consolidationPartnerId: bookingId,
} as never);
}
/** Un-pair a consolidation. */
async unpairConsolidation(bookingId: string, partnerId: string): Promise<void> {
await this.repository.update(bookingId, {
@@ -536,6 +622,29 @@ export class BookingsRepository extends BaseRepository<Booking> {
});
}
/**
* Bookings (of those given) that have at least one customer document still
* waiting on GL — PENDING or QUERIED. Includes ad-hoc `custom_*` documents,
* which no milestone tracks, so a file added after clearance was finalized
* still surfaces as needing review. One query for a whole queue page.
*/
async findBookingsWithUnreviewedDocuments(
bookingIds: string[],
): Promise<Set<string>> {
if (bookingIds.length === 0) return new Set();
const rows = (await this.dataSource
.getRepository(BookingDocumentReview)
.createQueryBuilder('r')
.select('DISTINCT r.booking_id', 'bookingId')
.where('r.booking_id IN (:...bookingIds)', { bookingIds })
.andWhere('r.status IN (:...statuses)', {
statuses: ['PENDING', 'QUERIED'],
})
.andWhere('r.deleted_at IS NULL')
.getRawMany()) as Array<{ bookingId: string }>;
return new Set(rows.map((r) => r.bookingId));
}
findDocumentReview(
bookingId: string,
settingCode: string,
@@ -577,6 +686,13 @@ export class BookingsRepository extends BaseRepository<Booking> {
await repo.save(repo.create({ ...input, status: 'PENDING' }));
}
/** Display names for reviewer staff ids — one query for the whole set. */
async resolveStaffNames(
staffIds: (string | null | undefined)[],
): Promise<Map<string, string>> {
return resolveIamUserNames(this.dataSource, staffIds);
}
/** GL marks a document APPROVED or QUERIED (with an optional note). */
async setDocumentReviewStatus(
bookingId: string,

View File

@@ -0,0 +1,74 @@
import type { BookingDocumentReview } from './entities/booking-document-review.entity';
import type { BookingReviewNote } from './entities/booking-review-note.entity';
import type { FileRecord } from '../files/entities/file.entity';
/** One entry of a clearance document's per-card audit trail, oldest first. */
export interface ClearanceDocEvent {
type: 'UPLOADED' | 'RESUBMITTED' | 'QUERIED' | 'APPROVED';
at: string;
byName: string | null;
note: string | null;
}
/**
* Query review notes are written as `Document "<fileKey>" queried: <note>`
* (see BookingTransitionService.reviewDocument) — the only place a past query
* decision survives after the customer re-uploads and the review row resets.
*/
const QUERY_NOTE_RE = /^Document "(.+?)" queried: ([\s\S]*)$/;
/**
* Per-document audit trail assembled from data the flow already persists:
* every stored file version (first = customer upload, later ones = the
* customer's amendment responses), every query note (who opened it, when,
* why), and the review row's current approval. Approvals that were later
* reset by a re-upload are the one thing not kept anywhere — the trail shows
* the decision that currently stands.
*/
export function buildClearanceDocHistory(input: {
fileKey: string;
/** All versions of all files on the booking, createdAt ASC, deleted included. */
allVersions: FileRecord[];
/** CHANGES_REQUESTED review notes for the booking. */
queryNotes: BookingReviewNote[];
review: BookingDocumentReview | null;
/** staff id → display name. */
names: Map<string, string>;
}): ClearanceDocEvent[] {
const { fileKey, allVersions, queryNotes, review, names } = input;
const events: ClearanceDocEvent[] = [];
const versions = allVersions.filter((v) => v.code === fileKey);
versions.forEach((v, i) => {
events.push({
type: i === 0 ? 'UPLOADED' : 'RESUBMITTED',
at: v.createdAt.toISOString(),
byName: v.uploadedByName ?? null,
note: null,
});
});
for (const n of queryNotes) {
const m = QUERY_NOTE_RE.exec(n.note);
if (!m || m[1] !== fileKey) continue;
events.push({
type: 'QUERIED',
at: n.createdAt.toISOString(),
byName: n.authorId ? (names.get(n.authorId) ?? null) : null,
note: m[2] || null,
});
}
if (review?.status === 'APPROVED' && review.reviewedAt) {
events.push({
type: 'APPROVED',
at: review.reviewedAt.toISOString(),
byName: review.reviewedByStaffId
? (names.get(review.reviewedByStaffId) ?? null)
: null,
note: null,
});
}
return events.sort((a, b) => a.at.localeCompare(b.at));
}

View File

@@ -0,0 +1,47 @@
import { Booking } from './entities/booking.entity';
import { clearanceDocumentsOpen } from './clearance.util';
/**
* The customer may attach clearance documents — and GL may review them — right
* up to payment, not merely until clearance is finalized. Both the upload and
* the review endpoint gate on this one predicate, so a drift here silently
* desynchronizes the two sides.
*/
const booking = (patch: Partial<Booking>): Booking =>
({ status: 'CLEARANCE_READY', paymentStatus: 'PENDING', ...patch }) as Booking;
describe('clearanceDocumentsOpen', () => {
it('stays open across the whole pre-payment flow', () => {
for (const status of [
'AWAITING_DOCUMENTS',
'DOCUMENTS_UNDER_REVIEW',
'CLEARANCE_READY',
'OPERATION_REQUEST_PENDING',
'SELECTED_FOR_BATCH',
'PNR_GENERATED',
'PAYMENT_VERIFICATION_IN_PROGRESS',
]) {
expect(clearanceDocumentsOpen(booking({ status }))).toBe(true);
}
});
it('closes once the shipment is paid or finished', () => {
for (const status of ['PAID', 'IN_TRANSIT', 'ARRIVED', 'COMPLETED']) {
expect(clearanceDocumentsOpen(booking({ status }))).toBe(false);
}
});
it('closes on a dead booking', () => {
for (const status of ['REJECTED', 'CANCELLED', 'EXPIRED']) {
expect(clearanceDocumentsOpen(booking({ status }))).toBe(false);
}
});
it('closes when payment settled before the status caught up', () => {
expect(
clearanceDocumentsOpen(
booking({ status: 'PNR_GENERATED', paymentStatus: 'PAID' }),
),
).toBe(false);
});
});

View File

@@ -0,0 +1,89 @@
import { Injectable, Logger } from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import { Freight } from '@edr/types';
import { resolveIamUserNames } from '../../common/utils/iam-user-name.util';
import {
BookingClearanceEvent,
ClearanceEventActorType,
} from './entities/booking-clearance-event.entity';
export interface RecordClearanceEventInput {
bookingId: string;
action: string;
/** Human sentence for the History tab, frozen at write time. */
label: string;
actorType?: ClearanceEventActorType;
/** IAM user id (staff or portal customer); name is resolved here. */
actorId?: string | null;
metadata?: Record<string, unknown> | null;
/** Join the caller's transaction so the event commits (or rolls back) with the action. */
manager?: EntityManager;
}
/**
* The clearance History tab's write/read path. Every clearance mutation calls
* {@link record} — document reviews, phased workflow steps, customer charges.
* Recording is deliberately NOT fire-and-forget: the insert shares the caller's
* transaction when a manager is passed, and otherwise a failed insert fails the
* action, because a silent gap in an audit trail is worse than a retry.
*/
@Injectable()
export class ClearanceEventService {
private readonly logger = new Logger(ClearanceEventService.name);
constructor(private readonly dataSource: DataSource) {}
async record(input: RecordClearanceEventInput): Promise<void> {
const mg = input.manager ?? this.dataSource.manager;
const actorName = input.actorId
? ((await resolveIamUserNames(this.dataSource, [input.actorId])).get(
input.actorId,
) ?? null)
: null;
await mg.save(
mg.create(BookingClearanceEvent, {
bookingId: input.bookingId,
action: input.action,
label: input.label,
actorType: input.actorType ?? 'STAFF',
actorId: input.actorId ?? null,
actorName,
metadata: input.metadata ?? null,
}),
);
this.logger.log(
`clearance-history ${input.action} on booking ${input.bookingId}${
actorName ? ` by ${actorName}` : ''
}`,
);
}
/** History for one booking, newest first. */
async list(bookingId: string): Promise<Freight.ClearanceHistoryEvent[]> {
const rows = await this.dataSource
.getRepository(BookingClearanceEvent)
.find({ where: { bookingId }, order: { createdAt: 'DESC' } });
// Rows whose actor name failed to resolve at write time get one more try.
const missing = rows
.filter((r) => !r.actorName && r.actorId)
.map((r) => r.actorId as string);
const names = missing.length
? await resolveIamUserNames(this.dataSource, missing).catch(
() => new Map<string, string>(),
)
: new Map<string, string>();
return rows.map((r) => ({
id: r.id,
action: r.action,
label: r.label,
actorType: r.actorType,
actorName:
r.actorName ?? (r.actorId ? (names.get(r.actorId) ?? null) : null),
metadata: r.metadata ?? null,
at: r.createdAt.toISOString(),
}));
}
}

View File

@@ -113,3 +113,36 @@ export function clearanceCodesForBooking(booking: Booking): {
includesCustoms,
};
}
/**
* Statuses after which clearance documents are closed: the shipment is paid
* and moving. Everything before that — review, clearance ready, operation
* request, batch selection, PNR, payment verification — still accepts new
* customer documents and still lets GL review them.
*/
const CLEARANCE_DOCS_CLOSED_STATUSES = new Set<string>([
'PAID',
'IN_TRANSIT',
'ARRIVED',
'COMPLETED',
'REJECTED',
'CANCELLED',
'EXPIRED',
]);
/**
* True while the customer may still attach clearance documents and GL may
* still approve or query them.
*
* Clearance finalization is NOT the cut-off: a customs shipment keeps
* collecting paperwork (amended invoices, revised packing lists, port
* documents) right up to the final invoice being settled. Both the customer's
* upload endpoint and GL's review endpoint gate on this one predicate, so the
* two sides can never drift apart.
*/
export function clearanceDocumentsOpen(booking: Booking): boolean {
if (CLEARANCE_DOCS_CLOSED_STATUSES.has(booking.status)) return false;
// Payment settled ahead of the status transition (webhook ordering).
if (booking.paymentStatus === 'PAID') return false;
return true;
}

View File

@@ -0,0 +1,207 @@
import {
ConsolidationApprovalService,
CONSOLIDATION_APPROVAL_PENDING,
} from './consolidation-approval.service';
import { ConsolidationApprovalStatus } from './entities/consolidation-approval.entity';
import { Booking } from './entities/booking.entity';
/**
* The shared-wagon approval gate. Two customers' cargo on one wagon is a
* commercial call, so the pair is held for a human decision instead of going
* straight to Operations.
*
* The invariants that matter: both halves are held and released TOGETHER (a
* decision on one side of a shared wagon is meaningless without the other), and
* a decided pairing cannot be decided twice.
*/
describe('ConsolidationApprovalService', () => {
const PENDING = {
id: 'ap-1',
bookingId: 'b-1',
partnerBookingId: 'b-2',
status: ConsolidationApprovalStatus.Pending,
requestedBy: 'gl-user',
};
function makeService(overrides: {
approvals?: Partial<Record<string, jest.Mock>>;
bookingsRepository?: Partial<Record<string, jest.Mock>>;
} = {}) {
const approvals = {
findPendingForBooking: jest.fn().mockResolvedValue(null),
findById: jest.fn().mockResolvedValue(PENDING),
create: jest.fn().mockResolvedValue({ id: 'ap-1' }),
decide: jest.fn().mockResolvedValue(true),
findQueue: jest.fn().mockResolvedValue([]),
findAllForBooking: jest.fn().mockResolvedValue([]),
...overrides.approvals,
};
const bookingsRepository = {
update: jest.fn().mockResolvedValue(undefined),
createReviewNote: jest.fn().mockResolvedValue(undefined),
...overrides.bookingsRepository,
};
const bookingsService = {
findById: jest.fn(async (id: string) =>
({ id, reference: `BK-${id}` }) as Booking,
),
};
const notifier = {
consolidationApprovalRequestedToStaff: jest.fn(),
consolidationApprovedToStaff: jest.fn(),
consolidationRejectedToStaff: jest.fn(),
operationRequestedToStaff: jest.fn(),
};
const dataSource = {
transaction: jest.fn(async (cb: () => Promise<unknown>) => cb()),
};
const service = new ConsolidationApprovalService(
approvals as never,
bookingsRepository as never,
bookingsService as never,
notifier as never,
dataSource as never,
);
return { service, approvals, bookingsRepository, notifier };
}
it('holds BOTH halves at the gate when a pairing is created', async () => {
const { service, approvals, bookingsRepository, notifier } = makeService();
await service.requestApproval('b-1', 'b-2', 'gl-user');
expect(approvals.create).toHaveBeenCalledWith(
expect.objectContaining({
bookingId: 'b-1',
partnerBookingId: 'b-2',
requestedBy: 'gl-user',
}),
);
// Neither half may sit in the operations queue while the wagon is unreviewed.
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', {
status: CONSOLIDATION_APPROVAL_PENDING,
});
expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', {
status: CONSOLIDATION_APPROVAL_PENDING,
});
expect(
notifier.consolidationApprovalRequestedToStaff,
).toHaveBeenCalledTimes(1);
});
it('does not open a second review for a pairing already pending', async () => {
const { service, approvals } = makeService({
approvals: {
findPendingForBooking: jest.fn().mockResolvedValue(PENDING),
},
});
const result = await service.requestApproval('b-1', 'b-2', 'gl-user');
expect(result).toBe(PENDING);
expect(approvals.create).not.toHaveBeenCalled();
});
it('releases BOTH halves to Operations on approval, logging who decided', async () => {
const { service, approvals, bookingsRepository, notifier } = makeService();
await service.approve('ap-1', 'approver-1', 'looks fine');
expect(approvals.decide).toHaveBeenCalledWith(
'ap-1',
ConsolidationApprovalStatus.Approved,
'approver-1',
'looks fine',
);
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', {
status: 'OPERATION_REQUEST_PENDING',
});
expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', {
status: 'OPERATION_REQUEST_PENDING',
});
// Operations only learns about the pair now — the gate is what kept it out.
expect(notifier.operationRequestedToStaff).toHaveBeenCalledTimes(2);
});
it('sends BOTH halves back to GL on rejection, with the reason on each', async () => {
const { service, approvals, bookingsRepository } = makeService();
await service.reject('ap-1', 'approver-1', 'partner cargo is wrong');
expect(approvals.decide).toHaveBeenCalledWith(
'ap-1',
ConsolidationApprovalStatus.Rejected,
'approver-1',
'partner cargo is wrong',
);
expect(bookingsRepository.createReviewNote).toHaveBeenCalledWith(
'b-1',
'partner cargo is wrong',
'CHANGES_REQUESTED',
);
expect(bookingsRepository.createReviewNote).toHaveBeenCalledWith(
'b-2',
'partner cargo is wrong',
'CHANGES_REQUESTED',
);
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', {
status: 'OPERATION_CHANGES_REQUESTED',
});
expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', {
status: 'OPERATION_CHANGES_REQUESTED',
});
});
it('lets the requester approve their own pairing', async () => {
// No maker-checker separation: the permission alone decides who may approve,
// and the audit trail still records requester and approver separately.
const { service, approvals } = makeService();
await service.approve('ap-1', 'gl-user');
expect(approvals.decide).toHaveBeenCalledWith(
'ap-1',
ConsolidationApprovalStatus.Approved,
'gl-user',
undefined,
);
});
it('requires a reason to reject', async () => {
const { service, approvals } = makeService();
await expect(service.reject('ap-1', 'approver-1', ' ')).rejects.toThrow(
/reason is required/i,
);
expect(approvals.decide).not.toHaveBeenCalled();
});
it('refuses a pairing that was already decided', async () => {
const { service, bookingsRepository } = makeService({
approvals: {
findById: jest.fn().mockResolvedValue({
...PENDING,
status: ConsolidationApprovalStatus.Approved,
}),
},
});
await expect(service.approve('ap-1', 'approver-1')).rejects.toThrow(
/already approved/i,
);
expect(bookingsRepository.update).not.toHaveBeenCalled();
});
it('loses cleanly when another approver decides the same pairing first', async () => {
// decide() writes only against a still-PENDING row, so the loser of the race
// affects nothing and must not move the bookings.
const { service } = makeService({
approvals: { decide: jest.fn().mockResolvedValue(false) },
});
await expect(service.approve('ap-1', 'approver-1')).rejects.toThrow(
/already decided by someone else/i,
);
});
});

View File

@@ -0,0 +1,242 @@
import {
BadRequestException,
ConflictException,
Inject,
Injectable,
Logger,
NotFoundException,
forwardRef,
} from "@nestjs/common";
import { DataSource } from "typeorm";
import { Booking } from "./entities/booking.entity";
import {
ConsolidationApproval,
ConsolidationApprovalStatus,
} from "./entities/consolidation-approval.entity";
import { ConsolidationApprovalsRepository } from "./consolidation-approvals.repository";
import { BookingsRepository } from "./bookings.repository";
import { BookingsService } from "./bookings.service";
import { BookingLifecycleNotifierService } from "./booking-lifecycle-notifier.service";
/** Where a rejected pair goes back to, so GL can fix and resubmit. */
const REJECTED_STATUS = "OPERATION_CHANGES_REQUESTED";
/** The gate's own holding status — neither half reaches Operations from here. */
export const CONSOLIDATION_APPROVAL_PENDING = "CONSOLIDATION_APPROVAL_PENDING";
/**
* The shared-wagon approval gate.
*
* A booking that fills its own wagons goes straight from GL completion to the
* operations queue. A consolidated one does not: two customers' cargo rides one
* physical wagon under two separate invoices, so a person reviews the pairing
* before Operations sees either half.
*
* Both halves are held and released TOGETHER — the wagon is shared, so a
* decision on one is meaningless without the other. Every request is kept,
* decided or not: the table is the audit trail of who approved which pairing,
* when, and why.
*
* No maker-checker separation: whoever holds the approve permission may decide a
* pairing, including the GL user who created it. The record of who requested and
* who decided is still kept either way.
*/
@Injectable()
export class ConsolidationApprovalService {
private readonly logger = new Logger(ConsolidationApprovalService.name);
constructor(
private readonly approvals: ConsolidationApprovalsRepository,
private readonly bookingsRepository: BookingsRepository,
@Inject(forwardRef(() => BookingsService))
private readonly bookingsService: BookingsService,
private readonly notifier: BookingLifecycleNotifierService,
private readonly dataSource: DataSource,
) {}
/**
* Park a newly consolidated pair for review instead of letting it continue to
* Operations. Called from the completion path once the two halves are linked.
*
* Idempotent: a pair that already has an undecided request is left alone, so a
* retried completion cannot open a second review of the same wagon.
*/
async requestApproval(
bookingId: string,
partnerBookingId: string,
requestedBy: string | null,
): Promise<ConsolidationApproval> {
const existing = await this.approvals.findPendingForBooking(bookingId);
if (existing) return existing;
// Sequential reads: one connection per transaction context.
const booking = await this.bookingsService.findById(bookingId);
const partner = await this.bookingsService.findById(partnerBookingId);
if (!booking || !partner) {
throw new NotFoundException("Both bookings of the pair must exist.");
}
const approval = await this.approvals.create({
bookingId,
partnerBookingId,
requestedBy,
scheduledDate: booking.scheduledDate ?? null,
bookingReference: booking.reference ?? null,
partnerBookingReference: partner.reference ?? null,
});
// Hold BOTH halves: the wagon is shared, so neither may advance alone.
await this.bookingsRepository.update(bookingId, {
status: CONSOLIDATION_APPROVAL_PENDING,
} as never);
await this.bookingsRepository.update(partnerBookingId, {
status: CONSOLIDATION_APPROVAL_PENDING,
} as never);
this.notifier.consolidationApprovalRequestedToStaff(
booking,
partner.reference ?? partnerBookingId,
);
this.logger.log(
`Consolidation ${booking.reference} + ${partner.reference} awaiting approval (${approval.id}).`,
);
return approval;
}
/**
* Approve the pairing: both halves leave the gate and continue to Operations,
* which is exactly where a non-consolidated booking would already be.
*
* All-or-nothing — the two status writes and the decision record share one
* transaction, so the audit trail can never claim an approval that did not
* take effect.
*/
async approve(
approvalId: string,
decidedBy: string,
note?: string,
): Promise<{ booking: Booking; partner: Booking }> {
const approval = await this.loadPending(approvalId);
await this.dataSource.transaction(async () => {
const claimed = await this.approvals.decide(
approval.id,
ConsolidationApprovalStatus.Approved,
decidedBy,
note,
);
// Lost the race to another approver deciding the same pairing.
if (!claimed) {
throw new ConflictException(
"This consolidation was already decided by someone else.",
);
}
await this.bookingsRepository.update(approval.bookingId, {
status: "OPERATION_REQUEST_PENDING",
} as never);
await this.bookingsRepository.update(approval.partnerBookingId, {
status: "OPERATION_REQUEST_PENDING",
} as never);
});
const booking = await this.bookingsService.findById(approval.bookingId);
const partner = await this.bookingsService.findById(
approval.partnerBookingId,
);
this.notifier.consolidationApprovedToStaff(
booking,
partner.reference ?? approval.partnerBookingId,
);
// Operations only now learns about the pair — the gate is what kept it out.
this.notifier.operationRequestedToStaff(booking);
this.notifier.operationRequestedToStaff(partner);
return { booking, partner };
}
/**
* Reject the pairing: both halves go back to GL as OPERATION_CHANGES_REQUESTED
* with the reason, so the cargo or the partner can be changed and resubmitted.
*/
async reject(
approvalId: string,
decidedBy: string,
reason: string,
): Promise<{ booking: Booking; partner: Booking }> {
if (!reason?.trim()) {
throw new BadRequestException(
"A reason is required to reject a consolidation.",
);
}
const approval = await this.loadPending(approvalId);
await this.dataSource.transaction(async () => {
const claimed = await this.approvals.decide(
approval.id,
ConsolidationApprovalStatus.Rejected,
decidedBy,
reason.trim(),
);
if (!claimed) {
throw new ConflictException(
"This consolidation was already decided by someone else.",
);
}
await this.bookingsRepository.createReviewNote(
approval.bookingId,
reason.trim(),
"CHANGES_REQUESTED",
);
await this.bookingsRepository.createReviewNote(
approval.partnerBookingId,
reason.trim(),
"CHANGES_REQUESTED",
);
await this.bookingsRepository.update(approval.bookingId, {
status: REJECTED_STATUS,
} as never);
await this.bookingsRepository.update(approval.partnerBookingId, {
status: REJECTED_STATUS,
} as never);
});
const booking = await this.bookingsService.findById(approval.bookingId);
const partner = await this.bookingsService.findById(
approval.partnerBookingId,
);
this.notifier.consolidationRejectedToStaff(
booking,
partner.reference ?? approval.partnerBookingId,
reason.trim(),
);
return { booking, partner };
}
/** Pending pairings awaiting a decision, oldest first. */
queue(): Promise<ConsolidationApproval[]> {
return this.approvals.findQueue();
}
/** Full decision history for one booking — who decided what, and when. */
historyForBooking(bookingId: string): Promise<ConsolidationApproval[]> {
return this.approvals.findAllForBooking(bookingId);
}
/** The undecided request covering this booking, if any. */
pendingForBooking(bookingId: string): Promise<ConsolidationApproval | null> {
return this.approvals.findPendingForBooking(bookingId);
}
private async loadPending(approvalId: string): Promise<ConsolidationApproval> {
const approval = await this.approvals.findById(approvalId);
if (!approval) {
throw new NotFoundException(`Approval ${approvalId} not found`);
}
if (approval.status !== ConsolidationApprovalStatus.Pending) {
throw new ConflictException(
`This consolidation was already ${approval.status.toLowerCase()}.`,
);
}
return approval;
}
}

View File

@@ -0,0 +1,120 @@
import { Injectable } from "@nestjs/common";
import { DataSource, In, Repository } from "typeorm";
import {
ConsolidationApproval,
ConsolidationApprovalStatus,
} from "./entities/consolidation-approval.entity";
/**
* Persistence for the shared-wagon approval gate. Rows are never deleted —
* decided rows are the audit trail of who approved which pairing and when.
*/
@Injectable()
export class ConsolidationApprovalsRepository {
private readonly repository: Repository<ConsolidationApproval>;
constructor(private readonly dataSource: DataSource) {
this.repository = this.dataSource.getRepository(ConsolidationApproval);
}
/**
* The undecided request covering `bookingId`, from EITHER side of the pair —
* one row governs both halves, and the caller may hold either one.
*/
findPendingForBooking(
bookingId: string,
): Promise<ConsolidationApproval | null> {
return this.repository.findOne({
where: [
{ bookingId, status: ConsolidationApprovalStatus.Pending },
{
partnerBookingId: bookingId,
status: ConsolidationApprovalStatus.Pending,
},
],
});
}
/** Every request touching this booking, newest first (the audit trail). */
findAllForBooking(bookingId: string): Promise<ConsolidationApproval[]> {
return this.repository.find({
where: [{ bookingId }, { partnerBookingId: bookingId }],
order: { createdAt: "DESC" },
});
}
findById(id: string): Promise<ConsolidationApproval | null> {
return this.repository.findOne({ where: { id } });
}
/** Pending requests for the review queue, oldest first (FIFO). */
findQueue(): Promise<ConsolidationApproval[]> {
return this.repository.find({
where: { status: ConsolidationApprovalStatus.Pending },
relations: {
booking: { company: true },
partnerBooking: { company: true },
},
order: { requestedAt: "ASC" },
});
}
create(input: {
bookingId: string;
partnerBookingId: string;
requestedBy?: string | null;
scheduledDate?: Date | null;
bookingReference?: string | null;
partnerBookingReference?: string | null;
}): Promise<ConsolidationApproval> {
return this.repository.save(
this.repository.create({
...input,
status: ConsolidationApprovalStatus.Pending,
requestedAt: new Date(),
}),
);
}
/**
* Record the decision. Written only against a row still PENDING, so two
* approvers racing on the same pairing cannot both succeed — the second
* update matches nothing and the caller sees `false`.
*/
async decide(
id: string,
status:
| ConsolidationApprovalStatus.Approved
| ConsolidationApprovalStatus.Rejected,
decidedBy: string | null,
decisionNote?: string | null,
): Promise<boolean> {
const result = await this.repository.update(
{ id, status: ConsolidationApprovalStatus.Pending },
{
status,
decidedBy,
decidedAt: new Date(),
decisionNote: decisionNote ?? null,
},
);
return (result.affected ?? 0) > 0;
}
/** Undecided requests covering any of these bookings (list badging). */
findPendingForBookings(
bookingIds: string[],
): Promise<ConsolidationApproval[]> {
if (bookingIds.length === 0) return Promise.resolve([]);
return this.repository.find({
where: [
{ bookingId: In(bookingIds), status: ConsolidationApprovalStatus.Pending },
{
partnerBookingId: In(bookingIds),
status: ConsolidationApprovalStatus.Pending,
},
],
});
}
}

View File

@@ -0,0 +1,16 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsNumber, IsPositive, IsString, Length } from 'class-validator';
export class BillClearanceChargeDto {
@ApiProperty({ example: 12500.5 })
@Type(() => Number)
@IsNumber()
@IsPositive()
amount!: number;
@ApiProperty({ example: 'ETB' })
@IsString()
@Length(3, 8)
currency!: string;
}

View File

@@ -125,3 +125,59 @@ export class OperationReviewDto {
@IsString()
note?: string;
}
/**
* A staff decision applied to BOTH halves of a consolidated pair. The two
* bookings share a wagon, so they advance or cancel together — never one alone.
*/
export class PairedDecisionDto {
@ApiProperty({
enum: ["accept", "cancel", "operationAccept", "requestChanges"],
description: 'Which staff decision to apply to both bookings.',
})
@IsIn(["accept", "cancel", "operationAccept", "requestChanges"])
decision!: "accept" | "cancel" | "operationAccept" | "requestChanges";
@ApiPropertyOptional({ description: "Cancellation reason (decision=cancel)." })
@IsOptional()
@IsString()
reason?: string;
@ApiPropertyOptional({
description: "Message to the customer (decision=requestChanges).",
})
@IsOptional()
@IsString()
note?: string;
@ApiPropertyOptional({
description: "Contract validity window in days (decision=accept).",
})
@IsOptional()
@IsInt()
@Min(1)
validityDays?: number;
}
/** Approve a shared-wagon pairing. The note is optional context for the audit. */
export class ApproveConsolidationDto {
@ApiPropertyOptional({
description: "Optional note recorded with the approval.",
maxLength: 500,
})
@IsOptional()
@IsString()
note?: string;
}
/** Reject a shared-wagon pairing. A reason is mandatory — GL has to act on it. */
export class RejectConsolidationDto {
@ApiProperty({
description:
"Why the pairing is rejected. Sent back to GL on both bookings.",
maxLength: 500,
})
@IsString()
@MinLength(1)
reason!: string;
}

View File

@@ -0,0 +1,68 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Booking } from './booking.entity';
export const CLEARANCE_CHARGE_TYPES = ['PORT_CHARGES', 'MISCELLANEOUS'] as const;
export type ClearanceChargeType = (typeof CLEARANCE_CHARGE_TYPES)[number];
export const CLEARANCE_CHARGE_STATUSES = [
'DOC_UPLOADED',
'BILLED',
'SENT',
'PAID',
] as const;
export type ClearanceChargeStatus = (typeof CLEARANCE_CHARGE_STATUSES)[number];
/**
* Post-finalization clearance charge billed to the customer — at most one
* PORT_CHARGES and one MISCELLANEOUS row per booking. GL Djibouti uploads the
* port-charges document (DOC_UPLOADED); GL Ethiopia sets amount + currency
* (BILLED) and issues the invoice (SENT); the billing `clearance_charge.invoice.paid`
* event marks it PAID. MISCELLANEOUS is created whole by GL Ethiopia and only
* after the port charge is paid.
*/
@Entity({ schema: 'freight', name: 'booking_clearance_charge' })
@Index(['bookingId', 'type'], { unique: true })
export class BookingClearanceCharge extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@ManyToOne(() => Booking, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'booking_id' })
booking?: Booking;
@Column({ name: 'type', type: 'varchar', length: 20 })
type!: ClearanceChargeType;
@Column({ name: 'status', type: 'varchar', length: 20, default: 'DOC_UPLOADED' })
status!: ClearanceChargeStatus;
/** The supporting charge document (FileRecord). */
@Column({ name: 'file_record_id', type: 'uuid', nullable: true })
fileRecordId?: string | null;
@Column({ name: 'amount', type: 'numeric', precision: 14, scale: 2, nullable: true })
amount?: string | null;
@Column({ name: 'currency', type: 'varchar', length: 8, nullable: true })
currency?: string | null;
/** The payable invoice issued for this charge (null until SENT). */
@Column({ name: 'invoice_id', type: 'uuid', nullable: true })
invoiceId?: string | null;
@Column({ name: 'uploaded_by_staff_id', type: 'uuid', nullable: true })
uploadedByStaffId?: string | null;
@Column({ name: 'uploaded_at', type: 'timestamptz', nullable: true })
uploadedAt?: Date | null;
@Column({ name: 'billed_by_staff_id', type: 'uuid', nullable: true })
billedByStaffId?: string | null;
@Column({ name: 'billed_at', type: 'timestamptz', nullable: true })
billedAt?: Date | null;
@Column({ name: 'paid_at', type: 'timestamptz', nullable: true })
paidAt?: Date | null;
}

View File

@@ -0,0 +1,48 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Booking } from './booking.entity';
export const CLEARANCE_EVENT_ACTOR_TYPES = ['STAFF', 'CUSTOMER', 'SYSTEM'] as const;
export type ClearanceEventActorType = (typeof CLEARANCE_EVENT_ACTOR_TYPES)[number];
/**
* One row per action in a booking's clearance flow — the History tab's source
* of truth. Written explicitly (and, where the caller runs one, inside the
* caller's transaction) by every clearance mutation: document review, phased
* workflow steps (transit, declaration, duty, DO/RO, permits), and customer
* charges. `action` is a stable machine code; `label` is the human sentence
* rendered as written, so old rows survive later wording changes.
*/
@Entity({ schema: 'freight', name: 'booking_clearance_event' })
@Index(['bookingId', 'createdAt'])
export class BookingClearanceEvent extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@ManyToOne(() => Booking, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'booking_id' })
booking?: Booking;
/** Stable machine code, e.g. DOC_APPROVED, DECLARATION_UPLOADED. */
@Column({ name: 'action', type: 'varchar', length: 64 })
action!: string;
/** Human sentence shown in the History tab, frozen at write time. */
@Column({ name: 'label', type: 'varchar', length: 500 })
label!: string;
@Column({ name: 'actor_type', type: 'varchar', length: 16, default: 'STAFF' })
actorType!: ClearanceEventActorType;
/** IAM user id of the actor (null for SYSTEM events). */
@Column({ name: 'actor_id', type: 'uuid', nullable: true })
actorId?: string | null;
/** Display name resolved at write time (iam.users); null when unresolvable. */
@Column({ name: 'actor_name', type: 'varchar', length: 150, nullable: true })
actorName?: string | null;
/** Action details: fileKey, note, amount, currency, file names, … */
@Column({ name: 'metadata', type: 'jsonb', nullable: true })
metadata?: Record<string, unknown> | null;
}

View File

@@ -58,6 +58,10 @@ export const BOOKING_STATUSES = [
// the booking enters the batch holding pool.
'OPERATION_REQUEST_PENDING',
'OPERATION_CHANGES_REQUESTED',
// Shared-wagon review gate: a consolidated pair waits for a human decision
// before either half reaches Operations. Two customers' cargo on one wagon is
// a commercial call, so it is never auto-advanced.
'CONSOLIDATION_APPROVAL_PENDING',
'OPERATION_PRICE_PENDING_CONFIRM',
] as const;

View File

@@ -0,0 +1,98 @@
import { BaseEntity } from "@edr/api-common";
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
import { Booking } from "./booking.entity";
export enum ConsolidationApprovalStatus {
Pending = "PENDING",
Approved = "APPROVED",
Rejected = "REJECTED",
}
/**
* Approval gate for a consolidated (shared-wagon) booking pair.
*
* A booking that fills its own wagons goes straight from GL completion to the
* operations queue. A consolidated one does not: two customers' cargo rides one
* physical wagon, under two separate invoices and two separate liabilities. That
* pairing is a commercial decision, so a person reviews it before Operations
* sees either half.
*
* The pair is approved as a UNIT — one row covers both halves — so nobody can
* approve one side of a shared wagon and leave the other pending. Rows are never
* deleted: decided rows are the audit trail of who approved which pairing, when,
* and why.
*/
@Entity({ schema: "freight", name: "consolidation_approvals" })
@Index(["bookingId", "status"])
@Index(["status"])
export class ConsolidationApproval extends BaseEntity {
@Column({ name: "booking_id", type: "uuid" })
bookingId!: string;
@ManyToOne(() => Booking)
@JoinColumn({ name: "booking_id" })
booking?: Booking;
/** The other half of the shared wagon. */
@Column({ name: "partner_booking_id", type: "uuid" })
partnerBookingId!: string;
@ManyToOne(() => Booking)
@JoinColumn({ name: "partner_booking_id" })
partnerBooking?: Booking;
@Column({
name: "status",
type: "enum",
enum: ConsolidationApprovalStatus,
default: ConsolidationApprovalStatus.Pending,
})
status!: ConsolidationApprovalStatus;
/** IAM user id of the GL staff whose completion created the pairing. */
@Column({ name: "requested_by", type: "uuid", nullable: true })
requestedBy?: string | null;
@Column({ name: "requested_at", type: "timestamptz", default: () => "now()" })
requestedAt!: Date;
/** IAM user id of the approver; 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;
/** Why it was approved or rejected. Required on reject, optional on approve. */
@Column({
name: "decision_note",
type: "varchar",
length: 500,
nullable: true,
})
decisionNote?: string | null;
// ── Snapshot ──────────────────────────────────────────────────────────────
// Copied at request time so the audit trail still reads correctly after the
// bookings themselves move on (rebooked to another day, cancelled, renamed).
@Column({ name: "scheduled_date", type: "timestamptz", nullable: true })
scheduledDate?: Date | null;
@Column({
name: "booking_reference",
type: "varchar",
length: 50,
nullable: true,
})
bookingReference?: string | null;
@Column({
name: "partner_booking_reference",
type: "varchar",
length: 50,
nullable: true,
})
partnerBookingReference?: string | null;
}

View File

@@ -0,0 +1,73 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import type { ConfigType } from '@nestjs/config';
import { NotificationType, type NotifyInput } from '@edr/types';
import chatConfig from '../../config/chat.config';
import { MatrixClient } from './matrix.client';
const FALLBACK_ROOM = { alias: 'freight-alerts', name: 'Freight Alerts' };
/**
* Best-effort per-type routing to an existing dept room. Anything not listed
* (including GENERIC) falls through to #freight-alerts — safer than a wrong
* guess at which department a type belongs to. Extend as real usage shows
* which types actually want a dept room instead of the shared feed.
*
* `name` matters only if this bridge is the very first thing to touch that
* alias (normally the nightly/on-demand reconcile creates dept rooms first,
* with the position's real name) — ensureRoom never renames an existing
* room, so this must match what ChatProvisioningService would have used.
*/
const ROOM_FOR_TYPE: Partial<Record<NotificationType, { alias: string; name: string }>> = {
[NotificationType.REQUEST_SUBMITTED]: { alias: 'dept-operation', name: 'Operation' },
[NotificationType.CLEARANCE_REVIEW]: { alias: 'dept-operation', name: 'Operation' },
};
/**
* Mirrors BACKOFFICE-audience notifications into chat so staff see them
* without having the inbox open. Hooked once into
* NotificationInboxService.notify() — every one of that service's ~20
* callers gets this for free.
*
* Gated on BACKOFFICE only: notify() also serves PORTAL (customer)
* notifications, which must never land in an internal staff room.
*/
@Injectable()
export class ChatBridgeService {
private readonly logger = new Logger(ChatBridgeService.name);
constructor(
@Inject(chatConfig.KEY)
private readonly config: ConfigType<typeof chatConfig>,
private readonly matrix: MatrixClient,
) {}
async bridge(input: NotifyInput): Promise<void> {
if (!this.config.enabled) return;
try {
const room = ROOM_FOR_TYPE[input.type] ?? FALLBACK_ROOM;
const roomId = await this.matrix.ensureRoom(room.alias, room.name);
const body = input.link ? `${input.title}\n${input.body}\n${input.link}` : `${input.title}\n${input.body}`;
const html = `<strong>${escapeHtml(input.title)}</strong><br/>${escapeHtml(input.body)}${
input.link ? `<br/><a href="${escapeHtml(input.link)}">${escapeHtml(input.link)}</a>` : ''
}`;
await this.matrix.sendMessage(roomId, body, html);
} catch (err) {
// Same contract as NotificationInboxService.notify(): a chat-bridge
// failure must never break or roll back the notification that
// triggered it.
this.logger.error(
`Chat bridge failed for ${input.type}: ${(err as Error).message}`,
);
}
}
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

View File

@@ -0,0 +1,237 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { MatrixClient } from './matrix.client';
/** edr-org.seeder.ts's EDR_ORG_KEY / EDR_UNIT_KEY — the org is currently flat
* (one org, one unit), so this is the entire scope of what gets provisioned. */
const ORG_KEY = 'edr_freight';
const UNIT_KEY = 'edr_freight_app';
const SPACE_ALIAS = 'edr-freight';
const GENERAL_ALIAS = 'general';
interface PositionHolder {
positionKey: string;
positionName: string;
userId: string;
userName: string;
}
export interface ReconcileResult {
rooms: number;
joined: number;
kicked: number;
deactivated: number;
}
/**
* Keeps Matrix rooms and their membership in sync with IAM's unit/position
* tree. There is no local hook on "employee position changed" — IAM writes
* happen inside the vendored @tria-plc/iamapi-common package — so this is a
* reconcile loop, not an event handler: nightly, plus on-demand via
* POST /chat/sync.
*
* Room identity is a deterministic alias (#dept-<positionKey>), not a stored
* mapping table — resolved via the directory API, created on first miss.
* Room membership is diffed against Matrix's own joined_members, not a local
* snapshot — so a user removed from IAM disappears from chat on the very
* next reconcile, with no extra state for this service to own.
*/
@Injectable()
export class ChatProvisioningService {
private readonly logger = new Logger(ChatProvisioningService.name);
constructor(
@InjectDataSource() private readonly dataSource: DataSource,
private readonly matrix: MatrixClient,
) {}
@Cron(CronExpression.EVERY_DAY_AT_3AM, { name: 'chat-provisioning-reconcile' })
async scheduledReconcile(): Promise<void> {
try {
const result = await this.reconcile();
this.logger.log(
`Chat reconcile: ${result.rooms} room(s), ${result.joined} joined, ` +
`${result.kicked} kicked, ${result.deactivated} deactivated`,
);
} catch (err) {
// Never throws into the scheduler — chat provisioning must not be able
// to take down anything else on the cron registry.
this.logger.error(
`Chat reconcile failed: ${(err as Error).message}`,
(err as Error).stack,
);
}
}
/** Every current holder in the unit, or just one person's rows when `userId` is given. */
private async currentHolders(userId?: string): Promise<PositionHolder[]> {
return this.dataSource.query(
`SELECT p.key AS "positionKey",
COALESCE(p.name->>'en', p.key) AS "positionName",
e.user_id AS "userId",
COALESCE(iu.name->>'en', iu.username, iu.email) AS "userName"
FROM iam.employee_positions ep
JOIN iam.employees e ON e.id = ep.employee_id
JOIN iam.positions p ON p.id = ep.position_id
JOIN iam.units u ON u.id = p.unit_id
JOIN iam.organizations o ON o.id = u.organization_id
JOIN iam.users iu ON iu.id = e.user_id
WHERE ep.is_current = true
AND e.is_current = true
AND o.key = $1
AND u.key = $2
${userId ? 'AND e.user_id = $3' : ''}`,
userId ? [ORG_KEY, UNIT_KEY, userId] : [ORG_KEY, UNIT_KEY],
);
}
/**
* Put one person in their rooms right now.
*
* {@link reconcile} is nightly, so without this a new employee's first
* sign-in shows an empty client until 3AM — the SSO handoff creates their
* account but joins them to nothing. Called on every /chat/sso, so it is
* scoped to the one user (a full reconcile per click would be a room-count
* multiple of Matrix calls) and every step is get-or-create.
*
* Someone holding no current position in the unit joins nothing, by the same
* rule the reconcile uses — chat membership follows the org tree.
*/
async joinUserRooms(userId: string, displayName: string): Promise<number> {
const positions = await this.currentHolders(userId);
if (positions.length === 0) return 0;
const mxid = this.matrix.mxidFor(userId, displayName);
// The JWT login auto-registers too, but that happens after this runs and
// the admin join API 404s on an account that does not exist yet.
await this.matrix.ensureUser(mxid, displayName);
const spaceId = await this.matrix.ensureRoom(SPACE_ALIAS, 'EDR Freight', {
isSpace: true,
});
const generalRoomId = await this.matrix.ensureRoom(GENERAL_ALIAS, 'General', {
parentSpaceId: spaceId,
});
await this.matrix.ensureJoined(generalRoomId, mxid);
for (const position of positions) {
const roomId = await this.matrix.ensureRoom(
`dept-${position.positionKey}`,
position.positionName,
{ parentSpaceId: spaceId },
);
await this.matrix.ensureJoined(roomId, mxid);
}
return positions.length + 1;
}
/** Force-joins additions, kicks+deactivates users no longer entitled anywhere. */
private async syncMembership(
roomId: string,
desiredUserIds: Set<string>,
botMxid: string,
): Promise<{ joined: number; kicked: string[] }> {
const current = await this.matrix.joinedMembers(roomId);
const currentSet = new Set(current.filter((id) => id !== botMxid));
let joined = 0;
for (const userId of desiredUserIds) {
if (!currentSet.has(userId)) {
await this.matrix.ensureJoined(roomId, userId);
joined += 1;
}
}
const kicked: string[] = [];
for (const userId of currentSet) {
if (!desiredUserIds.has(userId)) {
await this.matrix.kick(roomId, userId, 'No longer assigned to this room');
kicked.push(userId);
}
}
return { joined, kicked };
}
async reconcile(): Promise<ReconcileResult> {
const holders = await this.currentHolders();
const botMxid = await this.matrix.whoami();
const spaceId = await this.matrix.ensureRoom(SPACE_ALIAS, 'EDR Freight', {
isSpace: true,
});
const generalRoomId = await this.matrix.ensureRoom(GENERAL_ALIAS, 'General', {
parentSpaceId: spaceId,
});
const allUserIds = new Set(
holders.map((h) => this.matrix.mxidFor(h.userId, h.userName)),
);
// Accounts are otherwise only created lazily on first JWT login (see
// ChatSsoService) — force-joining someone who has never clicked "Chat"
// yet 404s ("User not found") without this.
const seenUserIds = new Set<string>();
for (const h of holders) {
const mxid = this.matrix.mxidFor(h.userId, h.userName);
if (seenUserIds.has(mxid)) continue;
seenUserIds.add(mxid);
await this.matrix.ensureUser(mxid, h.userName);
}
let rooms = 2; // space + general
let joined = 0;
let kicked = 0;
// A user kicked from anything while holding zero current positions
// anywhere in the unit (allUserIds spans every position) is a full
// leaver, not just moved between positions — deactivate their account.
const kickedUserIds = new Set<string>();
const generalDiff = await this.syncMembership(generalRoomId, allUserIds, botMxid);
joined += generalDiff.joined;
kicked += generalDiff.kicked.length;
generalDiff.kicked.forEach((uid) => kickedUserIds.add(uid));
const byPosition = new Map<string, { name: string; userIds: Set<string> }>();
for (const h of holders) {
const entry = byPosition.get(h.positionKey) ?? {
name: h.positionName,
userIds: new Set<string>(),
};
entry.userIds.add(this.matrix.mxidFor(h.userId, h.userName));
byPosition.set(h.positionKey, entry);
}
for (const [positionKey, { name, userIds }] of byPosition) {
const roomId = await this.matrix.ensureRoom(`dept-${positionKey}`, name, {
parentSpaceId: spaceId,
});
rooms += 1;
const diff = await this.syncMembership(roomId, userIds, botMxid);
joined += diff.joined;
kicked += diff.kicked.length;
diff.kicked.forEach((uid) => kickedUserIds.add(uid));
}
let deactivated = 0;
for (const userId of kickedUserIds) {
if (allUserIds.has(userId)) continue; // moved position, still current elsewhere
try {
await this.matrix.deactivateUser(userId);
deactivated += 1;
} catch (err) {
this.logger.warn(
`Failed to deactivate departed user ${userId}: ${(err as Error).message}`,
);
}
}
return { rooms, joined, kicked, deactivated };
}
}

View File

@@ -0,0 +1,91 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import type { ConfigType } from '@nestjs/config';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { SignJWT } from 'jose';
import chatConfig from '../../config/chat.config';
import { ChatProvisioningService } from './chat-provisioning.service';
import { MatrixClient, chatLocalpart } from './matrix.client';
/** Long enough for one login call, short enough to be worthless if it leaks. */
const JWT_TTL_SECONDS = 60;
function displayName(user: TCurrentUser): string {
return (
user.name?.en ||
Object.values(user.name ?? {}).find((v) => typeof v === 'string' && v) ||
user.username ||
user.email
);
}
/**
* The SSO handoff: turn an already-authenticated freight session into a
* one-click Element sign-in link, with no second password anywhere.
*
* 1. Sign a short-lived JWT asserting this user's id (Synapse's
* org.matrix.login.jwt auto-registers the account on first use).
* 2. Trade that JWT for a real Matrix session.
* 3. Hand the caller a link to Element's sso.html shim, which writes that
* session into localStorage and drops the user straight into Element.
*
* Step 3 used to mint a one-shot login_token and let Element redeem it. That
* path is capped at one request per user per minute by a limiter hardcoded in
* Synapse, so a second click inside a minute returned M_LIMIT_EXCEEDED — and a
* spent token surfaces in Element as "Incorrect username and/or password".
* Element accepts a plaintext token out of localStorage (Lifecycle.ts
* getStoredToken/tryDecryptToken), so handing over the session we already hold
* removes both failure modes and one round-trip.
*/
@Injectable()
export class ChatSsoService {
private readonly logger = new Logger(ChatSsoService.name);
constructor(
@Inject(chatConfig.KEY)
private readonly config: ConfigType<typeof chatConfig>,
private readonly matrix: MatrixClient,
private readonly provisioning: ChatProvisioningService,
) {}
async getSsoUrl(user: TCurrentUser): Promise<{ url: string }> {
const secret = new TextEncoder().encode(this.config.jwtSecret);
const name = displayName(user);
// Before the link, not after: the reconcile that fills rooms is nightly, so
// a first sign-in would otherwise open an empty client. Best-effort —
// failing to join a room is no reason to refuse someone a sign-in link.
try {
await this.provisioning.joinUserRooms(user.id, name);
} catch (err) {
this.logger.error(
`Room join on sign-in failed for ${user.id}: ${(err as Error).message}`,
);
}
// Synapse takes the localpart straight from `sub` on auto-registration, so
// this must be byte-identical to what ChatProvisioningService derives for
// the same person — otherwise SSO signs them into one account while the
// reconcile force-joins a different one into the rooms.
const jwt = await new SignJWT({ name })
.setProtectedHeader({ alg: 'HS256' })
.setSubject(chatLocalpart(user.id, name))
.setIssuer('edr-freight-api')
.setAudience('matrix')
.setIssuedAt()
.setExpirationTime(`${JWT_TTL_SECONDS}s`)
.sign(secret);
const session = await this.matrix.loginWithJwt(jwt);
// Session goes in the URL fragment, never the query: a fragment is not sent
// to any server, so the token stays out of Element's access log, and
// sso.html replaces the entry so it does not linger in history either.
const params = new URLSearchParams({
hs: this.config.publicBaseUrl,
t: session.access_token,
u: session.user_id,
d: session.device_id,
});
return { url: `${this.config.webUrl}/sso.html#${params.toString()}` };
}
}

View File

@@ -0,0 +1,35 @@
import { Controller, Get, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '@tria-plc/api-common/modules/auth/decorators/current-user.decorator';
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { ChatSync } from '../../common/booking-guards';
import { ChatProvisioningService } from './chat-provisioning.service';
import { ChatSsoService } from './chat-sso.service';
@ApiTags('chat')
@Controller('chat')
@ApiBearerAuth()
export class ChatController {
constructor(
private readonly sso: ChatSsoService,
private readonly provisioning: ChatProvisioningService,
) {}
@Get('sso')
@UseGuards(JwtGuard)
@ApiOperation({ summary: 'One-click sign-in link into EDR internal chat' })
getSso(@CurrentUser() user: TCurrentUser) {
return this.sso.getSsoUrl(user);
}
@Post('sync')
@ChatSync()
@ApiOperation({
summary: 'Re-run the chat room/membership reconcile immediately (normally nightly)',
})
sync() {
return this.provisioning.reconcile();
}
}

View File

@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { ChatBridgeService } from './chat-bridge.service';
import { ChatController } from './chat.controller';
import { ChatProvisioningService } from './chat-provisioning.service';
import { ChatSsoService } from './chat-sso.service';
import { MatrixClient } from './matrix.client';
@Module({
controllers: [ChatController],
providers: [MatrixClient, ChatSsoService, ChatProvisioningService, ChatBridgeService],
// ChatBridgeService: consumed by NotificationInboxModule to mirror
// BACKOFFICE notifications into chat — see notification-inbox.module.ts.
exports: [ChatBridgeService],
})
export class ChatModule {}

View File

@@ -0,0 +1,35 @@
import { chatLocalpart } from './matrix.client';
describe('chatLocalpart', () => {
it('reads from the name, not the id', () => {
expect(
chatLocalpart('03f5eb9e-23a0-4413-8d98-8de4b98b1be2', 'Nati Wondi'),
).toBe('nati-wondi.03f5eb');
});
it('separates two people who share a name', () => {
// Both of these are real dev rows — same name, different employees.
const a = chatLocalpart('11111111-1111-4111-8111-111111111111', 'MARKOS REGASA');
const b = chatLocalpart('22222222-2222-4222-8222-222222222222', 'Markos REGASA');
expect(a).not.toBe(b);
});
it('is stable for the same person', () => {
const id = '7d798218-09de-47a1-98eb-f61ec44e9280';
expect(chatLocalpart(id, 'Naod')).toBe(chatLocalpart(id, 'Naod'));
});
it('still yields a usable localpart for a name that slugs to nothing', () => {
expect(chatLocalpart('7d798218-09de-47a1-98eb-f61ec44e9280', 'ናኦድ')).toBe(
'user.7d7982',
);
});
it('only emits characters Matrix accepts in a localpart', () => {
for (const name of ['Mubarek Jemal Hassen', "N'gozi O_Brien", 'ናኦድ', 'José']) {
expect(chatLocalpart('7d798218-09de-47a1-98eb-f61ec44e9280', name)).toMatch(
/^[a-z0-9._=\-/]+$/,
);
}
});
});

View File

@@ -0,0 +1,320 @@
import { Inject, Injectable } from '@nestjs/common';
import type { ConfigType } from '@nestjs/config';
import chatConfig from '../../config/chat.config';
/**
* Thin wrapper over the handful of Matrix Client-Server + Synapse Admin API
* calls this app needs. Not a general Matrix SDK — matrix-js-sdk is a
* browser/Element concern; the server side only ever provisions rooms/users
* and posts bot messages, so a fetch wrapper is the whole job.
*
* All admin-scoped calls act as the account behind MATRIX_ADMIN_TOKEN. That
* same account also posts the notification-bridge messages (see
* ChatBridgeService) — one bot/admin account covers both jobs, no separate
* bot user needed.
*/
/**
* Localpart of a staff member's MXID: their name, plus the first 6 hex of
* their freight user id.
*
* The tail is not decoration. Names collide — 19 of the 114 users in the dev
* IAM share a slug with someone else ("MARKOS REGASA" and "Markos REGASA" are
* two different people) — and an MXID is permanent, so a bare slug would hand
* two employees the same Matrix account and each other's rooms. The id is
* already random, so 6 hex of it separates them without a lookup or a mapping
* table, and keeps the derivation pure: ChatSsoService (which mints the JWT
* `sub`) and ChatProvisioningService (which force-joins rooms) must agree on
* this string exactly or they provision two accounts per person.
*/
export function chatLocalpart(userId: string, displayName: string): string {
const slug = displayName
// NFKD splits an accent off its letter; the non-alnum sweep below then
// folds the leftover mark into the same `-` run as the neighbouring space.
.normalize('NFKD')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40);
// Amharic-only names slug to nothing — the tail still makes it unique.
return `${slug || 'user'}.${userId.replace(/-/g, '').slice(0, 6)}`;
}
@Injectable()
export class MatrixClient {
constructor(
@Inject(chatConfig.KEY)
private readonly config: ConfigType<typeof chatConfig>,
) {}
/**
* Alias localparts go in a URL path segment, so a `/` in one is fatal:
* Synapse decodes the path before routing, and `%2F` splits the request into
* a route that doesn't exist ("M_UNRECOGNIZED"). resolveAlias reads that 404
* as "no such room" and ensureRoom then tries to create the same broken alias
* on every run. Position keys are `edr_freight_app/opn` shaped, so this hits
* every dept room but the handful whose key happens to be a bare word.
*/
private static aliasSafe(alias: string): string {
return alias.replace(/[^A-Za-z0-9._=-]/g, '-');
}
/** `@<localpart>:<server_name>` — the one place this format is assembled. */
mxid(localpart: string): string {
return `@${localpart}:${this.config.serverName}`;
}
/** The MXID of a freight user — see {@link chatLocalpart}. */
mxidFor(userId: string, displayName: string): string {
return this.mxid(chatLocalpart(userId, displayName));
}
get serverName(): string {
return this.config.serverName;
}
private async request<T>(
method: string,
path: string,
body?: unknown,
token: string = this.config.adminToken,
): Promise<T> {
const res = await fetch(`${this.config.baseUrl}${path}`, {
method,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(
`Matrix ${method} ${path} -> ${res.status}: ${text.slice(0, 500)}`,
);
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
/** No auth — only /login accepts a bare JWT with nothing else on the request. */
private async publicRequest<T>(
method: string,
path: string,
body: unknown,
): Promise<T> {
const res = await fetch(`${this.config.baseUrl}${path}`, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(
`Matrix ${method} ${path} -> ${res.status}: ${text.slice(0, 500)}`,
);
}
return (await res.json()) as T;
}
/** 404 → null. Every other non-2xx still throws via {@link request}. */
private async requestOrNull<T>(
method: string,
path: string,
token?: string,
): Promise<T | null> {
const res = await fetch(`${this.config.baseUrl}${path}`, {
method,
headers: { Authorization: `Bearer ${token ?? this.config.adminToken}` },
});
if (res.status === 404) return null;
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(
`Matrix ${method} ${path} -> ${res.status}: ${text.slice(0, 500)}`,
);
}
return (await res.json()) as T;
}
/** Sign an already-authenticated freight session into a Matrix session. */
loginWithJwt(
jwt: string,
): Promise<{ access_token: string; user_id: string; device_id: string }> {
return this.publicRequest('POST', '/_matrix/client/v3/login', {
type: 'org.matrix.login.jwt',
token: jwt,
initial_device_display_name: 'EDR Backoffice',
});
}
/** The account behind MATRIX_ADMIN_TOKEN — used to exclude the bot itself from membership reconciliation. */
async whoami(): Promise<string> {
const res = await this.request<{ user_id: string }>(
'GET',
'/_matrix/client/v3/account/whoami',
);
return res.user_id;
}
/** Currently-joined user ids for a room (not full member-event state). */
async joinedMembers(roomId: string): Promise<string[]> {
const res = await this.request<{ joined: Record<string, unknown> }>(
'GET',
`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/joined_members`,
);
return Object.keys(res.joined);
}
// No getLoginToken here on purpose. POST /_matrix/client/v1/login/get_token
// is rate limited to 1 request per user per MINUTE, hardcoded in Synapse
// (rest/client/login_token_request.py: "Ratelimit aggressively … could be
// abused by a malicious client to create many sessions") and not settable
// from homeserver.yaml. A second click inside a minute got M_LIMIT_EXCEEDED.
// ChatSsoService hands Element the session from loginWithJwt directly
// instead, which needs no second call.
/** null when the alias doesn't resolve to a room yet. */
resolveAlias(alias: string): Promise<{ room_id: string } | null> {
return this.requestOrNull(
'GET',
`/_matrix/client/v3/directory/room/${encodeURIComponent(alias)}`,
);
}
createRoom(input: {
alias: string;
name: string;
topic?: string;
isSpace?: boolean;
parentSpaceId?: string;
}): Promise<{ room_id: string }> {
return this.request('POST', '/_matrix/client/v3/createRoom', {
room_alias_name: input.alias,
name: input.name,
topic: input.topic,
preset: 'private_chat',
creation_content: input.isSpace ? { type: 'm.space' } : undefined,
initial_state: input.parentSpaceId
? [
{
type: 'm.space.parent',
state_key: input.parentSpaceId,
content: { via: [this.config.serverName], canonical: true },
},
]
: undefined,
});
}
addToSpace(spaceId: string, childRoomId: string): Promise<void> {
return this.request(
'PUT',
`/_matrix/client/v3/rooms/${encodeURIComponent(spaceId)}/state/m.space.child/${encodeURIComponent(childRoomId)}`,
{ via: [this.config.serverName] },
);
}
/**
* Get-or-create by alias — the room identity scheme this whole module
* relies on instead of a local id-mapping table. Idempotent: safe to call
* on every reconcile run and every bridged notification alike.
*/
async ensureRoom(
rawAlias: string,
name: string,
opts: { isSpace?: boolean; parentSpaceId?: string } = {},
): Promise<string> {
const alias = MatrixClient.aliasSafe(rawAlias);
const existing = await this.resolveAlias(`#${alias}:${this.config.serverName}`);
if (existing) return existing.room_id;
const { room_id } = await this.createRoom({
alias,
name,
isSpace: opts.isSpace,
parentSpaceId: opts.parentSpaceId,
});
if (opts.parentSpaceId) {
await this.addToSpace(opts.parentSpaceId, room_id);
}
return room_id;
}
/**
* Create the account if absent (no password — this deployment is JWT-SSO
* only), or no-op if it already exists. Needed before force-joining a
* position holder who has never clicked "Chat": accounts are otherwise
* only created lazily on first JWT login, and the admin join API 404s
* ("User not found") on an account that doesn't exist yet.
*/
async ensureUser(userId: string, displayName?: string): Promise<void> {
const existing = await this.requestOrNull<{ name: string }>(
'GET',
`/_synapse/admin/v2/users/${encodeURIComponent(userId)}`,
);
if (existing) return;
await this.request(
'PUT',
`/_synapse/admin/v2/users/${encodeURIComponent(userId)}`,
displayName ? { displayname: displayName } : {},
);
}
/** Server-admin force-join — no invite to accept, works even mid-outage for the invitee. */
forceJoin(roomIdOrAlias: string, userId: string): Promise<void> {
return this.request(
'POST',
`/_synapse/admin/v1/join/${encodeURIComponent(roomIdOrAlias)}`,
{ user_id: userId },
);
}
/**
* Force-join, treating "already a member" as success. Synapse answers a
* repeat join with 403 `M_FORBIDDEN: "<user> is already in the room."`, which
* is a failure only if you assumed you knew the membership first. Callers
* that just want someone in a room (sign-in, reconcile racing itself) want
* this; the raw 403 tells them nothing they can act on.
*/
async ensureJoined(roomIdOrAlias: string, userId: string): Promise<void> {
try {
await this.forceJoin(roomIdOrAlias, userId);
} catch (err) {
if (!/already in the room/i.test((err as Error).message)) throw err;
}
}
kick(roomId: string, userId: string, reason: string): Promise<void> {
return this.request(
'POST',
`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/kick`,
{ user_id: userId, reason },
);
}
/** Deactivating (rather than just kicking) a leaver's account revokes all their sessions. */
deactivateUser(userId: string): Promise<void> {
return this.request(
'POST',
`/_synapse/admin/v1/deactivate/${encodeURIComponent(userId)}`,
{ erase: false },
);
}
sendMessage(roomId: string, body: string, formattedBody?: string): Promise<void> {
const txnId = `edr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
return this.request(
'PUT',
`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`,
formattedBody
? {
msgtype: 'm.text',
body,
format: 'org.matrix.custom.html',
formatted_body: formattedBody,
}
: { msgtype: 'm.text', body },
);
}
}

View File

@@ -288,10 +288,25 @@ export class CompaniesController {
dto.roles,
dto.nationality,
dto.cooperative,
dto.investorLicence,
);
return new CompanyInfoResponseDto(profile, company);
}
@Post("onboarding/revert-to-etrade")
@PortalCustomer()
@ApiOperation({
summary:
"Drop the manual-registration route (co-operative or foreign investment licence): clear the typed registration and reopen onboarding so the TIN is verified against eTrade",
})
async revertToRegularCompany(
@CurrentUser() user: CurrentIamUser,
): Promise<CompanyInfoResponseDto> {
const { profile, company } =
await this.companiesService.revertToRegularCompany(user.id);
return new CompanyInfoResponseDto(profile, company);
}
@Post("company-profile")
@PortalCustomer()
@ApiOperation({

View File

@@ -0,0 +1,292 @@
import { BadRequestException } from "@nestjs/common";
import { CompaniesService } from "./companies.service";
import {
CompanyNationality,
CompanyStatus,
CompanyType,
} from "./entities/company.entity";
import {
ProfileStatus,
ProfileType,
} from "./entities/company-profile.entity";
/**
* A foreign company on an Investment Commission licence has no eTrade record,
* so it types its registration — and the flag saying so is what makes the
* backoffice treat those fields as unverified. Two things must hold: only a
* foreign company can carry it, and dropping it must not leave the typed
* registration behind looking like eTrade's.
*
* The dropping half is shared with the co-operative route, which has the same
* "eTrade holds nothing" shape, so it is exercised here for both.
*/
function makeService(company: Record<string, unknown> | null) {
const companiesRepo = {
findById: jest.fn(async () => company),
update: jest.fn(async () => null),
create: jest.fn(async (row: Record<string, unknown>) => ({
id: "company-1",
...row,
})),
existsByTin: jest.fn(async () => false),
};
const companyProfilesRepo = {
findByCompanyId: jest.fn(async (): Promise<Record<string, unknown>[]> => []),
updateStatus: jest.fn(async () => null),
create: jest.fn(async (row: Record<string, unknown>) => ({
id: "cp-1",
...row,
})),
softDelete: jest.fn(async () => undefined),
};
const profilesRepo = {
findByUserId: jest.fn(async () =>
company
? { id: "external-1", companyId: "company-1", company: { id: "company-1" } }
: null,
),
create: jest.fn(async (row: Record<string, unknown>) => ({
id: "external-1",
...row,
})),
update: jest.fn(async () => null),
};
const service = new CompaniesService(
companiesRepo as never,
companyProfilesRepo as never,
{} as never,
{} as never,
profilesRepo as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
);
jest
.spyOn(service, "getCompanyInfoByUserId")
.mockImplementation(
async () =>
({ profile: { id: "external-1" }, company: { id: "company-1" } }) as never,
);
return { service, companiesRepo, companyProfilesRepo, profilesRepo };
}
const identity = { userId: "user-1", firstName: "Abebe", lastName: "K" };
const start = (
service: CompaniesService,
nationality: CompanyNationality | undefined,
cooperative: boolean,
investorLicence: boolean,
) =>
service.startOnboarding(
identity as never,
CompanyType.Customer,
[ProfileType.importer],
nationality,
cooperative,
investorLicence,
);
describe("the foreign investment-licence route", () => {
it("refuses the flag for an Ethiopian company", async () => {
const { service } = makeService(null);
await expect(
start(service, CompanyNationality.Ethiopian, false, true),
).rejects.toBeInstanceOf(BadRequestException);
});
it("refuses the flag alongside the co-operative one", async () => {
const { service } = makeService(null);
await expect(
start(service, CompanyNationality.Foreign, true, true),
).rejects.toBeInstanceOf(BadRequestException);
});
it("stores the flag on a new foreign draft", async () => {
const { service, companiesRepo } = makeService(null);
await start(service, CompanyNationality.Foreign, false, true);
expect(companiesRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
nationality: CompanyNationality.Foreign,
attributes: { investorLicence: true },
}),
);
});
it("clears the typed registration and reopens onboarding when switching back to eTrade", async () => {
const { service, companiesRepo, profilesRepo } = makeService({
id: "company-1",
attributes: { investorLicence: true, etradeManagerName: "Typed Name" },
});
await service.revertToRegularCompany("user-1");
const [, updates] = companiesRepo.update.mock.calls[0] as unknown as [
string,
Record<string, unknown>,
];
expect(updates.attributes).toEqual({});
expect(updates.status).toBe(CompanyStatus.Pending);
// The wizard treats a populated registration as a passed lookup, so leaving
// any of it behind would walk the customer straight past the eTrade step.
expect(updates.licenceNumber).toBeNull();
expect(updates.region).toBeNull();
expect(profilesRepo.update).toHaveBeenCalledWith("external-1", {
onboardingCompleted: false,
onboardingStep: "company",
});
});
it("clears the typed registration when the box is un-ticked on the way back", async () => {
const { service, companiesRepo, profilesRepo } = makeService({
id: "company-1",
nationality: CompanyNationality.Foreign,
attributes: { investorLicence: true, etradeManagerName: "Typed Name" },
region: "Addis Ababa",
licenceNumber: "TYPED-1",
});
await start(service, CompanyNationality.Foreign, false, false);
const [, updates] = companiesRepo.update.mock.calls[0] as unknown as [
string,
Record<string, unknown>,
];
// The wizard sends both flags; the typed manager does not survive.
expect(updates.attributes).toEqual({
cooperative: false,
investorLicence: false,
});
expect(updates.licenceNumber).toBeNull();
expect(updates.region).toBeNull();
// Resume must land back on the company step, or the customer never reaches
// the eTrade lookup they just opted back into.
expect(profilesRepo.update).toHaveBeenCalledWith("external-1", {
onboardingStep: "company",
});
});
it("does the same for a co-operative that stops being one", async () => {
const { service, companiesRepo } = makeService({
id: "company-1",
nationality: CompanyNationality.Ethiopian,
attributes: { cooperative: true },
region: "Oromia",
});
await start(service, CompanyNationality.Ethiopian, false, false);
const [, updates] = companiesRepo.update.mock.calls[0] as unknown as [
string,
Record<string, unknown>,
];
expect(updates.region).toBeNull();
});
it("leaves the registration alone while the flag stays on", async () => {
const { service, companiesRepo, profilesRepo } = makeService({
id: "company-1",
nationality: CompanyNationality.Foreign,
attributes: { investorLicence: true },
region: "Addis Ababa",
});
await start(service, CompanyNationality.Foreign, false, true);
const [, updates] = companiesRepo.update.mock.calls[0] as unknown as [
string,
Record<string, unknown>,
];
expect(updates).not.toHaveProperty("region");
expect(profilesRepo.update).not.toHaveBeenCalled();
});
it("refuses to switch a company that never took a manual-registration route", async () => {
const { service } = makeService({ id: "company-1", attributes: {} });
await expect(service.revertToRegularCompany("user-1")).rejects.toBeInstanceOf(
BadRequestException,
);
});
/**
* The switch belongs to both manual-registration routes, not just this one. A
* co-operative that has since taken out a trade licence had no way back at
* all: the wizard is where the flag is chosen, and an onboarded company can no
* longer reach it.
*/
it("switches a co-operative back to eTrade on the same terms", async () => {
const { service, companiesRepo, profilesRepo } = makeService({
id: "company-1",
nationality: CompanyNationality.Ethiopian,
attributes: { cooperative: true, etradeManagerName: "Typed Name" },
region: "Oromia",
licenceNumber: "TYPED-1",
});
await service.revertToRegularCompany("user-1");
const [, updates] = companiesRepo.update.mock.calls[0] as unknown as [
string,
Record<string, unknown>,
];
expect(updates.attributes).toEqual({});
expect(updates.status).toBe(CompanyStatus.Pending);
expect(updates.licenceNumber).toBeNull();
expect(updates.region).toBeNull();
// A co-op owes no per-role business licence; once it stops being one it
// does, so the application has to be re-opened and re-reviewed.
expect(profilesRepo.update).toHaveBeenCalledWith("external-1", {
onboardingCompleted: false,
onboardingStep: "company",
});
});
/**
* Approval of a co-op's role was granted without a business licence, because
* a co-op owes none. Leaving makes one due, so the approval no longer stands
* for what it said.
*/
it("sends a co-operative's approved roles back for approval", async () => {
const { service, companyProfilesRepo } = makeService({
id: "company-1",
attributes: { cooperative: true },
});
companyProfilesRepo.findByCompanyId.mockResolvedValue([
{ id: "role-active", status: ProfileStatus.Active },
{ id: "role-blocked", status: ProfileStatus.Blacklisted },
{ id: "role-pending", status: ProfileStatus.Pending },
]);
await service.revertToRegularCompany("user-1");
expect(companyProfilesRepo.updateStatus).toHaveBeenCalledWith(
"role-active",
ProfileStatus.Pending,
);
// A staff decision is not the customer's to undo by switching registration:
// promoting a blocked role to "awaiting approval" would launder the block.
expect(companyProfilesRepo.updateStatus).toHaveBeenCalledTimes(1);
});
it("leaves an investor's roles alone — their licences were always due", async () => {
const { service, companyProfilesRepo } = makeService({
id: "company-1",
attributes: { investorLicence: true },
});
companyProfilesRepo.findByCompanyId.mockResolvedValue([
{ id: "role-active", status: ProfileStatus.Active },
]);
await service.revertToRegularCompany("user-1");
expect(companyProfilesRepo.updateStatus).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,187 @@
import { CompaniesService } from "./companies.service";
import { CompanyStatus } from "./entities/company.entity";
import {
ProfileStatus,
ProfileType,
} from "./entities/company-profile.entity";
/**
* Uploading a business licence only ever adds a row — nothing overwrites. So a
* customer answering a rejection or a document correction used to end up with
* the refused licence still listed beside the new one, in the portal and in the
* backoffice, with nothing saying which is current. An upload that answers a
* reviewer now retires what it answers; an upload with nothing outstanding is a
* genuine addition and still just adds.
*/
interface StoredFile {
id: string;
name: string;
code: string;
createdAt: Date;
reviewStatus?: string | null;
removed?: boolean;
}
const T0 = new Date("2026-01-01T00:00:00Z");
const REJECTED_AT = new Date("2026-02-01T00:00:00Z");
const T2 = new Date("2026-03-01T00:00:00Z");
function makeService(
status: ProfileStatus,
files: StoredFile[],
reviewedAt: Date | null = null,
) {
const stored = [...files];
const profile = {
id: "profile-1",
companyId: "company-1",
type: ProfileType.importer,
status,
reviewedAt,
};
const company = {
id: "company-1",
status:
status === ProfileStatus.Active
? CompanyStatus.Active
: CompanyStatus.Pending,
companyProfiles: [profile],
};
const live = () => stored.filter((f) => !f.removed);
const filesService = {
upload: jest.fn(async (input: { code: string; file: { originalname: string } }) => {
const record = {
id: `file-${stored.length + 1}`,
name: input.file.originalname,
code: input.code,
createdAt: T2,
size: 1,
mimeType: "application/pdf",
};
stored.push(record);
return record;
}),
findByResource: jest.fn(async () => live()),
findWithOpenChangeRequest: jest.fn(async () =>
live().filter((f) => f.reviewStatus === "change_requested"),
),
findById: jest.fn(async (id: string) => ({
...stored.find((f) => f.id === id),
resource: "company_profiles",
resourceId: "profile-1",
})),
remove: jest.fn(async (id: string) => {
const found = stored.find((f) => f.id === id);
if (found) found.removed = true;
}),
clearReview: jest.fn(async (id: string) => {
const found = stored.find((f) => f.id === id);
if (found) found.reviewStatus = null;
}),
};
const changeRequestRepo = {
findPendingByCompanyId: jest.fn(async () => null),
create: jest.fn(async (row: Record<string, unknown>) => ({ id: "cr-1", ...row })),
update: jest.fn(async () => ({ id: "cr-1" })),
findByCompanyId: jest.fn(async () => []),
};
const service = new CompaniesService(
{ findById: jest.fn(async () => company) } as never,
{ findByCompanyId: jest.fn(async () => [profile]) } as never,
changeRequestRepo as never,
{} as never,
{ findByCompanyId: jest.fn(async () => []) } as never,
{} as never,
filesService as never,
{} as never,
{} as never,
{ changeRequestSubmitted: jest.fn() } as never,
{} as never,
{} as never,
);
jest
.spyOn(service, "getCompanyInfoByUserId")
.mockImplementation(
async () => ({ profile: { id: "external-1" }, company }) as never,
);
return { service, stored, live, filesService, changeRequestRepo };
}
const upload = (service: CompaniesService) =>
service.addProfileLicenseFiles("user-1", "profile-1", [
{ originalname: "new-licence.pdf" } as never,
]);
describe("a business licence uploaded to answer a reviewer", () => {
it("retires the file the reviewer flagged for correction", async () => {
const { service, live } = makeService(ProfileStatus.Pending, [
{ id: "file-old", name: "old.pdf", code: "business_license", createdAt: T0, reviewStatus: "change_requested" },
]);
await upload(service);
expect(live().map((f) => f.name)).toEqual(["new-licence.pdf"]);
});
it("retires what was on file when the role was rejected, but not the customer's own fix so far", async () => {
// Two uploads answering one rejection (a second page, or a re-pick) must not
// cannibalise each other — only what the reviewer actually refused goes.
const { service, live } = makeService(
ProfileStatus.Rejected,
[
{ id: "file-refused", name: "refused.pdf", code: "business_license", createdAt: T0 },
{ id: "file-fix-1", name: "fix-page-1.pdf", code: "business_license", createdAt: T2 },
],
REJECTED_AT,
);
await upload(service);
expect(live().map((f) => f.name)).toEqual([
"fix-page-1.pdf",
"new-licence.pdf",
]);
});
it("leaves an ordinary addition alone when nothing was asked for", async () => {
const { service, live } = makeService(ProfileStatus.Pending, [
{ id: "file-old", name: "existing.pdf", code: "business_license", createdAt: T0 },
]);
await upload(service);
expect(live().map((f) => f.name)).toEqual([
"existing.pdf",
"new-licence.pdf",
]);
});
it("stages the swap for review on an approved role instead of deleting", async () => {
// A live role's licence is not the customer's to remove unilaterally: the
// old file stays until a reviewer approves the swap.
const { service, live, changeRequestRepo } = makeService(
ProfileStatus.Active,
[
{ id: "file-old", name: "old.pdf", code: "business_license", createdAt: T0, reviewStatus: "change_requested" },
],
);
await upload(service);
expect(live().map((f) => f.name)).toEqual(["old.pdf", "new-licence.pdf"]);
const intents = changeRequestRepo.create.mock.calls.flatMap(
([row]) => (row as any).documents.licenseChanges,
);
expect(intents).toEqual(
expect.arrayContaining([
expect.objectContaining({ op: "add", fileId: "file-2" }),
expect.objectContaining({ op: "remove", fileId: "file-old" }),
]),
);
});
});

View File

@@ -0,0 +1,115 @@
import { BadRequestException } from "@nestjs/common";
import { CompaniesService } from "./companies.service";
import { Company, CompanyStatus } from "./entities/company.entity";
import {
CompanyProfile,
ProfileStatus,
ProfileType,
} from "./entities/company-profile.entity";
/**
* A rejection hands the role back to the customer: they fix what was flagged
* and resubmit (`reapplyCompanyProfile` → Pending). The reviewer used to be
* able to skip that entirely and approve straight out of Rejected — granting
* the role over the documents that were just refused, while the customer's
* "please fix this" note was still on their screen.
*/
function makeService(status: ProfileStatus) {
const profile: Partial<CompanyProfile> = {
id: "profile-1",
companyId: "company-1",
type: ProfileType.importer,
status,
reference: null,
reviewNote: status === ProfileStatus.Rejected ? "Licence expired" : null,
};
const company = {
id: "company-1",
status: CompanyStatus.Pending,
attributes: {},
};
const written: Partial<CompanyProfile>[] = [];
const profileRepo = {
update: jest.fn(async (_id: string, patch: Partial<CompanyProfile>) => {
written.push(patch);
Object.assign(profile, patch);
return null;
}),
findOne: jest.fn(async () => profile),
};
const companyRepo = { findOne: jest.fn(async () => company), update: jest.fn() };
const companyProfilesRepo = {
findById: jest.fn(async () => profile),
generateReference: jest.fn(async () => "IM-A00001"),
};
const profilesRepo = {
// Onboarding submitted — the other gate in this method is not what these
// tests are about.
findByCompanyId: jest.fn(async () => [{ onboardingCompleted: true }]),
};
const filesService = { findWithOpenChangeRequest: jest.fn(async () => []) };
const dataSource = {
transaction: jest.fn(async (cb: (m: unknown) => Promise<unknown>) =>
cb({
findOne: jest.fn(async () => company),
getRepository: (entity: unknown) =>
entity === Company ? companyRepo : profileRepo,
}),
),
};
const companyNotifier = { profileStatusChanged: jest.fn(), companyApproved: jest.fn() };
const service = new CompaniesService(
{} as never,
companyProfilesRepo as never,
{} as never,
{} as never,
profilesRepo as never,
{} as never,
filesService as never,
{} as never,
{} as never,
companyNotifier as never,
dataSource as never,
{} as never,
);
return { service, profile, written, companyProfilesRepo };
}
describe("approving an operational role", () => {
it("refuses to approve a role the customer has not resubmitted", async () => {
const { service, companyProfilesRepo } = makeService(ProfileStatus.Rejected);
await expect(
service.setCompanyProfileStatus("profile-1", ProfileStatus.Active),
).rejects.toBeInstanceOf(BadRequestException);
// Refused before any reference could be minted against the rejected role.
expect(companyProfilesRepo.generateReference).not.toHaveBeenCalled();
});
it("lets a reviewer undo their own rejection, and drops the note with it", async () => {
const { service, written } = makeService(ProfileStatus.Rejected);
await service.setCompanyProfileStatus("profile-1", ProfileStatus.Pending);
expect(written[0]).toMatchObject({
status: ProfileStatus.Pending,
reviewNote: null,
});
});
it("still approves a role that is awaiting its first decision", async () => {
const { service, written } = makeService(ProfileStatus.Pending);
await service.setCompanyProfileStatus("profile-1", ProfileStatus.Active);
expect(written[0]).toMatchObject({
status: ProfileStatus.Active,
reference: "IM-A00001",
});
});
});

View File

@@ -63,7 +63,10 @@ import {
CompanyStatus,
CompanyType,
COOPERATIVE_KEY,
INVESTOR_LICENCE_KEY,
hasInvestorLicence,
isCooperative,
usesManualRegistration,
} from "./entities/company.entity";
import { ExternalProfile } from "./entities/external-profile.entity";
import {
@@ -378,6 +381,7 @@ export class CompaniesService {
roles: ProfileType[],
nationality?: CompanyNationality,
cooperative?: boolean,
investorLicence?: boolean,
): Promise<{ profile: ExternalProfile; company: Company }> {
// Already started — reuse the existing draft, just ensure roles exist and
// keep the nationality up to date if it was (re)selected.
@@ -388,13 +392,20 @@ export class CompaniesService {
// flag into `attributes`, or to read a stored one the caller didn't send.
const needsCompany =
cooperative !== undefined ||
investorLicence !== undefined ||
roles.includes(ProfileType.freightForwarder);
const current = needsCompany
? await this.companiesRepo.findById(companyId)
: null;
const isCoop = cooperative ?? isCooperative(current);
const isInvestor = investorLicence ?? hasInvestorLicence(current);
this.assertRolesAllowedForCooperative(isCoop, roles);
this.assertNationalityAllowedForCooperative(isCoop, nationality);
this.assertInvestorLicenceAllowed(
isInvestor,
isCoop,
nationality ?? current?.nationality ?? undefined,
);
await this.syncCompanyProfiles(companyId, companyType, roles);
const updates: Partial<Company> = {};
if (nationality) updates.nationality = nationality;
@@ -402,20 +413,49 @@ export class CompaniesService {
// stored nationality too, or the company keeps resolving to the foreign
// document set.
if (isCoop) updates.nationality = CompanyNationality.Ethiopian;
if (cooperative !== undefined) {
if (cooperative !== undefined || investorLicence !== undefined) {
updates.attributes = {
...(current?.attributes ?? {}),
[COOPERATIVE_KEY]: cooperative,
...(cooperative !== undefined
? { [COOPERATIVE_KEY]: cooperative }
: {}),
...(investorLicence !== undefined
? { [INVESTOR_LICENCE_KEY]: investorLicence }
: {}),
};
}
// Going back and un-ticking the box is the same act as the settings
// switch, so it has to cost the same: the registration the customer typed
// goes, and onboarding drops back to the company step. Without this the
// draft keeps the typed values, `hasRegistrationDetails` reads as a passed
// lookup, resume lands past the company step entirely — and the company
// finishes onboarding on unverified data with no flag left to say so.
const backToEtrade =
usesManualRegistration(current) && !isCoop && !isInvestor;
if (backToEtrade) {
Object.assign(updates, CompaniesService.CLEARED_REGISTRATION);
updates.attributes = this.withoutTypedEtradeManager(
updates.attributes ?? current?.attributes,
);
}
if (Object.keys(updates).length > 0) {
await this.companiesRepo.update(companyId, updates);
}
if (backToEtrade) {
await this.profilesRepo.update(existing.id, {
onboardingStep: "company",
});
}
return this.getCompanyInfoByUserId(identity.userId);
}
this.assertRolesAllowedForCooperative(cooperative === true, roles);
this.assertNationalityAllowedForCooperative(cooperative === true, nationality);
this.assertInvestorLicenceAllowed(
investorLicence === true,
cooperative === true,
nationality,
);
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
const chosenTypes = roles.filter((t) => allowedTypes.includes(t));
@@ -428,7 +468,14 @@ export class CompaniesService {
country: "Ethiopia",
nationality: nationality ?? CompanyNationality.Ethiopian,
status: CompanyStatus.Pending,
...(cooperative ? { attributes: { [COOPERATIVE_KEY]: true } } : {}),
...(cooperative || investorLicence
? {
attributes: {
...(cooperative ? { [COOPERATIVE_KEY]: true } : {}),
...(investorLicence ? { [INVESTOR_LICENCE_KEY]: true } : {}),
},
}
: {}),
});
await this.profilesRepo.create({
@@ -485,6 +532,73 @@ export class CompaniesService {
}
}
/**
* The registration block as it must look when nobody has verified it.
*
* Used wherever a company stops being one eTrade cannot answer for: whatever
* sits in these columns was the customer's own statement, and the wizard
* treats a populated registration as a lookup that already passed
* (`hasRegistrationDetails`). Leaving it behind would hand the company an
* eTrade-verified record eTrade never supplied — and, once the flag is gone,
* a backoffice screen that says so.
*/
private static readonly CLEARED_REGISTRATION: Partial<Company> = {
licenceNumber: null,
statusDescription: null,
dateRegistered: null,
renewedFrom: null,
renewalDate: null,
renewedTo: null,
region: null,
zone: null,
woreda: null,
kebele: null,
houseNo: null,
etradePhone: null,
};
/**
* The company's own `attributes`, minus the manager captured alongside a
* typed registration. It never came from a licence, so it must not outlive
* the registration it belonged to.
*/
private withoutTypedEtradeManager(
attributes: Record<string, unknown> | null | undefined,
): Record<string, unknown> {
const next = { ...(attributes ?? {}) };
delete next.etradeManagerName;
delete next.etradeManagerPhone;
return next;
}
/**
* An investment licence belongs to a foreign company and to nothing else.
*
* It is the Ethiopian Investment Commission's licence, issued to a foreign
* investor — an Ethiopian company registers with the trade registry, which is
* exactly the eTrade record this flag says does not exist. A co-operative
* cannot hold one either: it is Ethiopian by construction, and the two flags
* resolve to different document sets, so a company carrying both would owe an
* incoherent list of papers.
*/
private assertInvestorLicenceAllowed(
investorLicence: boolean,
cooperative: boolean,
nationality: CompanyNationality | undefined,
): void {
if (!investorLicence) return;
if (cooperative) {
throw new BadRequestException(
"A co-operative union or farm is registered in Ethiopia — it cannot also onboard on a foreign investment licence.",
);
}
if (nationality !== CompanyNationality.Foreign) {
throw new BadRequestException(
"Only a foreign company can onboard on an investment licence.",
);
}
}
/**
* Reconcile the company's operational profiles with the roles the user has
* selected: create the missing ones, drop the ones they deselected.
@@ -1716,6 +1830,24 @@ export class CompaniesService {
);
}
// A rejected role is waiting on the customer, not on the reviewer: nothing
// has been resubmitted, and the note telling them what to fix is still on
// their screen. Approving straight out of Rejected grants the very role that
// was refused, over the documents that were refused with it. The way back is
// the customer's own resubmission (`reapplyCompanyProfile` → Pending); a
// rejection made in error is undone by moving the role back to pending
// review first — the same shape as "withdraw the change request first" on
// the document gate below.
if (
status === ProfileStatus.Active &&
existing.status === ProfileStatus.Rejected
) {
throw new BadRequestException(
"This role was rejected — the customer has to fix what was flagged and resubmit it before it can be approved. " +
"If the rejection was a mistake, move the role back to pending review first.",
);
}
// A self-registered company is only reviewable once its owner submits the
// onboarding wizard (markOnboardingComplete) — until then its profiles are
// half-filled drafts and approving one would mint a reference against an
@@ -1844,7 +1976,14 @@ export class CompaniesService {
status === ProfileStatus.Suspended
) {
patch.reviewNote = note ?? null;
} else if (status === ProfileStatus.Active) {
} else if (
status === ProfileStatus.Active ||
status === ProfileStatus.Pending
) {
// Pending only reaches here when a reviewer withdraws their own rejection
// (the customer's resubmission clears the note in `reapplyCompanyProfile`),
// so the reason they gave goes with it — leaving it would keep telling the
// customer to fix something nobody is waiting on any more.
patch.reviewNote = null;
}
if (status !== ProfileStatus.Pending) {
@@ -2143,6 +2282,7 @@ export class CompaniesService {
// or farm holds no business licence, so it owes its own list rather than the
// nationality list plus extras.
const cooperative = isCooperative(company);
const investorLicence = hasInvestorLicence(company);
const documentSettingCode = this.documentSettingCodeFor(company);
const [setting, uploadedFiles] = await Promise.all([
this.fileUploadSettingsService
@@ -2292,6 +2432,7 @@ export class CompaniesService {
documentSettingCode,
nationality: company.nationality ?? CompanyNationality.Ethiopian,
cooperative,
investorLicence,
companyInfo: {
complete: missingInfo.length === 0,
missingFields: missingInfo,
@@ -2369,6 +2510,97 @@ export class CompaniesService {
return this.getCompanyInfoByUserId(userId);
}
/**
* Drop whichever manual-registration route the company is on and send it back
* through the normal eTrade one.
*
* Both routes exist for the same reason — eTrade holds no record to fetch —
* so leaving one is the same act whichever it is, and it is the only way back
* to eTrade for either. A co-operative union or farm that has since taken out
* a trade licence had no exit at all before this; its only route was the
* wizard, which an onboarded company can no longer reach.
*
* Everything the flag let the customer type is cleared, not kept: the
* registration block on file was their own statement, and leaving it there
* would let the wizard treat the company as already looked-up
* (`hasRegistrationDetails` is what stands in for a verified TIN on a
* resume) and walk straight past the eTrade step this switch exists to
* reach. Onboarding reopens at the company step and the company goes back to
* pending — an approval granted against typed data cannot silently carry over
* to a record that now claims to be eTrade's.
*
* Switching the other way — INTO a co-operative or an investment licence — is
* deliberately not here. It is the wizard's nationality/role step, which this
* reopens, and which is the one place the mutually-exclusive rules live
* (`assertInvestorLicenceAllowed`, `assertRolesAllowedForCooperative`,
* `assertNationalityAllowedForCooperative`). A second entry point would have
* to restate all three.
*/
async revertToRegularCompany(
userId: string,
): Promise<{ profile: ExternalProfile; company: Company }> {
const profile = await this.profilesRepo.findByUserId(userId);
if (!profile)
throw new NotFoundException(`Profile for user ${userId} not found`);
const companyId = profile.company?.id ?? profile.companyId;
const company = await this.companiesRepo.findById(companyId);
if (!company)
throw new NotFoundException(`Company ${companyId} not found`);
if (!usesManualRegistration(company)) {
throw new BadRequestException(
"This company is already registered through eTrade — there is nothing to switch.",
);
}
const wasCooperative = isCooperative(company);
// Both flags go, not just the one that was set: they are mutually exclusive
// and a company can only ever hold one, but the destination is "neither",
// so stripping only the one we happened to check for would leave the other
// behind if the pair ever did coexist.
const attributes = this.withoutTypedEtradeManager(company.attributes);
delete attributes[INVESTOR_LICENCE_KEY];
delete attributes[COOPERATIVE_KEY];
await this.companiesRepo.update(companyId, {
...CompaniesService.CLEARED_REGISTRATION,
attributes,
status: CompanyStatus.Pending,
});
await this.profilesRepo.update(profile.id, {
onboardingCompleted: false,
onboardingStep: "company",
});
// A co-operative owes no per-role business licence — that is the whole
// reason its own document set stands in for one. The moment it stops being
// one, every role owes a licence that was never uploaded, so an approval
// granted without one no longer means what it said: back to Pending, and
// the reviewer sees the licence with the rest of the re-application.
//
// Only Active roles move. Rejected, Suspended and Blacklisted are the
// backoffice's own decisions, and quietly promoting a blocked role to
// "awaiting approval" would launder the block away. The reference survives
// either way — it is minted once (`setCompanyProfileStatus`) and re-approval
// reuses it, so bookings that cite it keep citing the same number.
//
// An investor is untouched: it always held a licence per role, so nothing
// becomes due that was not already reviewed.
if (wasCooperative) {
const roles = await this.companyProfilesRepo.findByCompanyId(companyId);
for (const role of roles) {
if (role.status !== ProfileStatus.Active) continue;
await this.companyProfilesRepo.updateStatus(
role.id,
ProfileStatus.Pending,
);
}
}
return this.getCompanyInfoByUserId(userId);
}
/**
* Block a self-service action when the company account isn't active, naming
* the actual status — a suspended customer told "awaiting approval" has no
@@ -2468,6 +2700,9 @@ export class CompaniesService {
* with the role itself. Only for an already-approved role are they staged under
* the pending code and recorded as `add` intents on a pending change request —
* a licence swap on a live role is a change; a licence on a new role is not.
*
* An upload that answers a reviewer also retires the licence it answers (see
* below), so a correction never leaves both copies on file.
*/
async addProfileLicenseFiles(
userId: string,
@@ -2479,6 +2714,28 @@ export class CompaniesService {
const gated = profile.status === ProfileStatus.Active;
const code = gated ? LICENSE_PENDING_CODE : LICENSE_CODE;
// An upload that answers the reviewer replaces what they refused; it does
// not sit next to it. Uploading only ever adds a row, so without this the
// refused licence stays listed in the portal and the backoffice beside the
// new one and nothing says which is current. Two things count as refused:
// the file the reviewer flagged for correction, and — when the whole role
// came back rejected — every licence that was already on file when they
// rejected it. Anything the customer uploaded *since* that decision is part
// of the same fix (a second page, a re-pick), so it survives, and an upload
// with nothing outstanding is a genuine addition and is left alone.
const rejectedAt =
profile.status === ProfileStatus.Rejected
? (profile.reviewedAt ?? null)
: null;
const superseded = rejectedAt
? (
await this.filesService.findByResource(profileId, LICENSE_RESOURCE)
).filter((f) => f.createdAt < rejectedAt)
: await this.filesService.findWithOpenChangeRequest(
[profileId],
LICENSE_RESOURCE,
);
const uploaded = await Promise.all(
files.map((file) =>
this.filesService.upload({
@@ -2503,6 +2760,13 @@ export class CompaniesService {
);
}
// Retire what the upload supersedes, through the normal removal path so an
// approved role stages a `remove` intent (reviewed as a swap) while an
// unapproved one just drops the file.
for (const stale of superseded) {
await this.removeProfileLicenseFile(userId, profileId, stale.id);
}
// A fresh licence upload answers any correction the reviewer asked for on the
// previous one, so the old row must stop blocking approval.
await this.resolveDocumentChangeRequests(
@@ -3535,7 +3799,7 @@ export class CompaniesService {
// the registered address themselves, and what they send IS the data. The
// check is skipped rather than failed: running the lookup would 400 every
// save with "no registration found for this TIN".
if (isCooperative(company)) return;
if (usesManualRegistration(company)) return;
const touched = ETRADE_SOURCED_FIELDS.some(
(key) => key !== "tin" && dto[key] !== undefined,

View File

@@ -78,6 +78,14 @@ export class OnboardingRequirementsResponseDto {
*/
cooperative: boolean;
/**
* The company is a foreign investor on an investment licence: no eTrade
* record, so the registration was typed. The nationality document set still
* applies (it already asks for the investment licence itself), and so does
* the per-role business licence.
*/
investorLicence: boolean;
/** Required company-information fields and whether each is filled. */
companyInfo: {
complete: boolean;
@@ -116,6 +124,7 @@ export class OnboardingRequirementsResponseDto {
this.documentSettingCode = init.documentSettingCode;
this.nationality = init.nationality;
this.cooperative = init.cooperative;
this.investorLicence = init.investorLicence;
this.companyInfo = init.companyInfo;
this.documents = init.documents;
this.licenseProfiles = init.licenseProfiles;

View File

@@ -2,7 +2,11 @@ import {
buildCompanyIdentityState,
CompanyIdentityStateDto,
} from "./complete-identity-verification.dto";
import { Company, isCooperative } from "../entities/company.entity";
import {
Company,
hasInvestorLicence,
isCooperative,
} from "../entities/company.entity";
import { ExternalProfile } from "../entities/external-profile.entity";
import {
ChangeRequestStatus,
@@ -21,6 +25,12 @@ export class ProfileResponseDto {
* it from eTrade.
*/
cooperative: boolean;
/**
* The company is a foreign investor on an investment licence: eTrade holds
* no record, so the company step collects the registration by hand. Drives
* the settings card that switches back to the eTrade route.
*/
investorLicence: boolean;
companyLocation: string;
companyAddress: string | null;
tinNumber: string;
@@ -93,6 +103,7 @@ export class ProfileResponseDto {
this.companyType = company.type;
this.nationality = company.nationality ?? null;
this.cooperative = isCooperative(company);
this.investorLicence = hasInvestorLicence(company);
this.companyProfiles =
company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)) ??
[];

View File

@@ -3,6 +3,7 @@ import {
CompanyType,
CompanyStatus,
CompanyNationality,
hasInvestorLicence,
isCooperative,
} from '../entities/company.entity';
import {
@@ -62,6 +63,13 @@ export class ResponseCompanyDto {
* eTrade manager to check the owner against.
*/
cooperative: boolean;
/**
* The company onboarded as a foreign investor on an investment licence:
* eTrade holds no record for its TIN, so its registration below was typed by
* the customer rather than fetched — nothing here has been checked against a
* licence, and the reviewer is the check.
*/
investorLicence: boolean;
tin: string;
vatNumber?: string | null;
fanNumber?: string | null;
@@ -118,6 +126,7 @@ export class ResponseCompanyDto {
this.status = company.status;
this.nationality = company.nationality ?? null;
this.cooperative = isCooperative(company);
this.investorLicence = hasInvestorLicence(company);
this.tin = company.tin;
this.vatNumber = company.vatNumber;
this.fanNumber = company.fanNumber;

View File

@@ -31,4 +31,15 @@ export class StartOnboardingDto {
@IsOptional()
@IsBoolean()
cooperative?: boolean;
/**
* The company is a foreign investor: it operates on an investment licence
* issued by the Ethiopian Investment Commission, so eTrade holds no record
* for its TIN and the registration is typed here instead. Chosen on the same
* step for the same reason as the co-operative flag — it decides what the
* company step asks for. Only a foreign company can hold one.
*/
@IsOptional()
@IsBoolean()
investorLicence?: boolean;
}

View File

@@ -51,6 +51,37 @@ export function isCooperative(
return company?.attributes?.[COOPERATIVE_KEY] === true;
}
/**
* `attributes` key marking a foreign company onboarding on an investment
* licence.
*
* The Ethiopian Investment Commission registers it, not the trade registry, so
* eTrade holds no record for its TIN: the registration is typed and the eTrade
* authenticity check is skipped rather than failed — exactly as for a
* co-operative. What does NOT change is the licence: the company still holds
* one per operational role, so that requirement stands.
*/
export const INVESTOR_LICENCE_KEY = "investorLicence";
/** Is this a foreign company registered on an investment licence? */
export function hasInvestorLicence(
company: Pick<Company, "attributes"> | null | undefined,
): boolean {
return company?.attributes?.[INVESTOR_LICENCE_KEY] === true;
}
/**
* eTrade holds nothing for this company, so its registration was typed by hand
* rather than fetched — and the backoffice is told so. Two different companies
* reach it (a co-operative has no licence at all; a foreign investor's is not
* the trade registry's), and every consequence they share hangs off this.
*/
export function usesManualRegistration(
company: Pick<Company, "attributes"> | null | undefined,
): boolean {
return isCooperative(company) || hasInvestorLicence(company);
}
@Entity({ schema: "freight", name: "companies" })
@Index(["tin"])
@Index(["type"])

View File

@@ -32,11 +32,17 @@ function makeService(overrides?: {
workflowThrows?: boolean;
/** Resolve the input doc set with no required fields → every doc counts approved. */
docsApproved?: boolean;
/** Yard ids the caller is scoped to; `null` (default) = unrestricted. */
yardScope?: string[] | null;
}) {
const booking = overrides?.booking ?? generalImportBooking;
const bookingsRepository = {
findDocumentReviews: jest.fn().mockResolvedValue([]),
update: jest.fn().mockResolvedValue(booking),
findByStatuses: jest.fn().mockResolvedValue([]),
findBookingsWithUnreviewedDocuments: jest
.fn()
.mockResolvedValue(new Set<string>()),
};
const bookingsService = {
findById: jest.fn().mockResolvedValue(booking),
@@ -111,6 +117,8 @@ function makeService(overrides?: {
.mockResolvedValue({ id: 'ta-1', name: 'Ahmed Bourhan' }),
} as never, // transit agents
{ findAll: jest.fn().mockResolvedValue([]) } as never, // contracts repository
{ getScopedYardIds: jest.fn().mockResolvedValue(overrides?.yardScope ?? null) } as never, // yard scope
{ record: jest.fn() } as never, // clearanceEvents
);
return {
@@ -124,6 +132,30 @@ function makeService(overrides?: {
}
describe('BookingClearanceService', () => {
describe('etQueue yard scope', () => {
const queueBookings = [
{ ...generalImportBooking, id: 'b-mojo-out', originYardId: 'mojo', destinationYardId: 'dire' },
{ ...generalImportBooking, id: 'b-mojo-in', originYardId: 'addis', destinationYardId: 'mojo' },
{ ...generalImportBooking, id: 'b-elsewhere', originYardId: 'addis', destinationYardId: 'dire' },
] as unknown as Booking[];
it('keeps only bookings whose origin or destination is in scope', async () => {
const { service, bookingsRepository, workflowService } = makeService({ yardScope: ['mojo'] });
bookingsRepository.findByStatuses.mockResolvedValue(queueBookings);
workflowService.listMilestonesForBooking.mockResolvedValue([{ status: 'PENDING' }]);
const rows = await service.etQueue({});
expect(rows.map((b) => b.id)).toEqual(['b-mojo-out', 'b-mojo-in']);
});
it('shows everything when the position has no yard mapping', async () => {
const { service, bookingsRepository, workflowService } = makeService({ yardScope: null });
bookingsRepository.findByStatuses.mockResolvedValue(queueBookings);
workflowService.listMilestonesForBooking.mockResolvedValue([{ status: 'PENDING' }]);
const rows = await service.etQueue({});
expect(rows).toHaveLength(3);
});
});
describe('adviseDuty', () => {
it('skips duty milestones when duty is not required', async () => {
const { service, workflowService, bookingsRepository } = makeService();

View File

@@ -30,10 +30,18 @@ import { ClearanceMilestoneService } from './clearance-milestone.service';
import { GlOperationsService } from './gl-operations.service';
import { GlExchangeService } from './gl-exchange.service';
import { TransitAgentsService } from '../transit-agents/transit-agents.service';
import { YardScopeService } from '../rule-engine/services/yard-scope.service';
import { ContractsRepository } from './contracts.repository';
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDeliveryOrderUploads, persistDraftDeclarationUploads, persistReleaseOrderUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util';
import {
buildClearanceDocHistory,
type ClearanceDocEvent,
} from '../bookings/clearance-doc-history.util';
import { ClearanceEventService } from '../bookings/clearance-event.service';
import { clearanceDocumentsOpen } from '../bookings/clearance.util';
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
export interface BookingClearanceView {
@@ -51,8 +59,13 @@ export interface BookingClearanceView {
file: { id: string; name: string; url: string } | null;
reviewStatus: 'PENDING' | 'APPROVED' | 'QUERIED' | null;
note: string | null;
uploadedAt: string | null;
reviewedAt: string | null;
reviewedByName: string | null;
history: ClearanceDocEvent[];
}>;
allApproved: boolean;
documentsOpen: boolean;
phase?: string | null;
milestones?: Array<{
id: string;
@@ -158,6 +171,8 @@ export class BookingClearanceService {
private readonly glExchangeService: GlExchangeService,
private readonly transitAgentsService: TransitAgentsService,
private readonly contractsRepository: ContractsRepository,
private readonly yardScope: YardScopeService,
private readonly clearanceEvents: ClearanceEventService,
) {}
private async assertPhasedCustoms(booking: Booking): Promise<void> {
@@ -183,6 +198,18 @@ export class BookingClearanceService {
const fileByCode = new Map(files.map((f) => [f.code, f]));
const reviews = await this.bookingsRepository.findDocumentReviews(bookingId);
const reviewByKey = new Map(reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r]));
const allVersions = await this.filesService.findAllVersionsByResource(
bookingId,
'bookings',
);
const queryNotes = await this.bookingsRepository.findReviewNotes(
bookingId,
'CHANGES_REQUESTED',
);
const reviewerNames = await this.bookingsRepository.resolveStaffNames([
...reviews.map((r) => r.reviewedByStaffId),
...queryNotes.map((n) => n.authorId),
]);
const documents: BookingClearanceView['documents'] = [];
@@ -206,6 +233,18 @@ export class BookingClearanceService {
file: file ? { id: file.id, name: file.name, url: file.url } : null,
reviewStatus: review?.status ?? null,
note: review?.note ?? null,
uploadedAt: file?.createdAt ? file.createdAt.toISOString() : null,
reviewedAt: review?.reviewedAt ? review.reviewedAt.toISOString() : null,
reviewedByName: review?.reviewedByStaffId
? (reviewerNames.get(review.reviewedByStaffId) ?? null)
: null,
history: buildClearanceDocHistory({
fileKey: field.fileKey,
allVersions,
queryNotes,
review,
names: reviewerNames,
}),
});
}
};
@@ -225,6 +264,18 @@ export class BookingClearanceService {
file: { id: f.id, name: f.name, url: f.url },
reviewStatus: review?.status ?? null,
note: review?.note ?? null,
uploadedAt: f.createdAt ? f.createdAt.toISOString() : null,
reviewedAt: review?.reviewedAt ? review.reviewedAt.toISOString() : null,
reviewedByName: review?.reviewedByStaffId
? (reviewerNames.get(review.reviewedByStaffId) ?? null)
: null,
history: buildClearanceDocHistory({
fileKey: f.code,
allVersions,
queryNotes,
review,
names: reviewerNames,
}),
});
}
@@ -305,6 +356,7 @@ export class BookingClearanceService {
outputCode,
documents,
allApproved,
documentsOpen: clearanceDocumentsOpen(booking),
phase,
milestones: milestones.map((m) => ({
id: m.id,
@@ -477,6 +529,7 @@ export class BookingClearanceService {
async requestTransitAssignee(
bookingId: string,
note: string | undefined,
userId?: string,
): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
@@ -484,6 +537,13 @@ export class BookingClearanceService {
transitAssigneeRequestedAt: new Date(),
transitAssigneeRequestNote: note?.trim() || null,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'TRANSIT_ASSIGNEE_REQUESTED',
label: 'Requested a transit assignee from GL Djibouti',
actorId: userId ?? null,
metadata: { note: note?.trim() || null },
});
this.notifier.transitAssigneeRequested(booking, note?.trim() ?? null);
return this.bookingsService.findById(bookingId);
@@ -495,7 +555,11 @@ export class BookingClearanceService {
* Answering unblocks the declaration for Ethiopia. A later call overwrites
* the name (reassignment) and re-notifies.
*/
async assignTransitAssignee(bookingId: string, transitAgentId: string): Promise<Booking> {
async assignTransitAssignee(
bookingId: string,
transitAgentId: string,
userId?: string,
): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (!booking.transitAssigneeRequestedAt) {
throw new BadRequestException(
@@ -509,6 +573,13 @@ export class BookingClearanceService {
transitAssigneeName: agent.name,
transitAssigneeAssignedAt: new Date(),
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'TRANSIT_ASSIGNEE_ASSIGNED',
label: `Assigned transit officer "${agent.name}"`,
actorId: userId ?? null,
metadata: { transitAgentId, agentName: agent.name, previous },
});
this.notifier.transitAssigneeAssigned(booking, agent.name, previous);
return this.bookingsService.findById(bookingId);
@@ -561,6 +632,13 @@ export class BookingClearanceService {
? ContractDocPhase.GlEtPostClearance
: ContractDocPhase.CustomerDuty,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'DECLARATION_UPLOADED',
label: `Uploaded customs declaration (${files.length} file(s))`,
actorId: userId ?? null,
metadata: { fileNames: files.map((f) => f.originalname) },
});
return this.bookingsService.findById(bookingId);
}
@@ -614,6 +692,19 @@ export class BookingClearanceService {
);
this.notifier.dutyAdvised(booking, dto.amount, dto.currency ?? 'ETB');
}
await this.clearanceEvents.record({
bookingId,
action: 'DUTY_ADVISED',
label: dto.dutyRequired
? `Advised duty/tax of ${dto.amount} ${dto.currency ?? 'ETB'}`
: 'Advised that no duty/tax applies',
actorId: userId ?? null,
metadata: {
dutyRequired: dto.dutyRequired,
amount: dto.amount ?? null,
currency: dto.currency ?? null,
},
});
return this.bookingsService.findById(bookingId);
}
@@ -661,6 +752,14 @@ export class BookingClearanceService {
clearanceCurrentPhase: ContractDocPhase.GlEtOutput,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'DRAFT_DECLARATION_SENT',
label: `Sent draft customs declaration (estimated ${price} ${currency})`,
actorId: userId ?? null,
metadata: { price, currency, fileNames: files.map((f) => f.originalname) },
});
const updated = await this.bookingsService.findById(bookingId);
this.notifier.draftDeclarationReady(updated, price, currency);
return updated;
@@ -670,7 +769,7 @@ export class BookingClearanceService {
* The customer accepts the draft declaration — GL Ethiopia may now file the
* real customs declaration.
*/
async acceptDraftDeclaration(bookingId: string): Promise<Booking> {
async acceptDraftDeclaration(bookingId: string, userId?: string): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (booking.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Draft declaration applies only to import bookings.');
@@ -682,6 +781,13 @@ export class BookingClearanceService {
}
await this.workflowService.completeMilestoneForBooking(bookingId, 'DRAFT_DECLARATION_ACCEPTED');
await this.clearanceEvents.record({
bookingId,
action: 'DRAFT_DECLARATION_ACCEPTED',
label: 'Customer accepted the draft customs declaration',
actorType: 'CUSTOMER',
actorId: userId ?? null,
});
return this.bookingsService.findById(bookingId);
}
@@ -731,12 +837,25 @@ export class BookingClearanceService {
clearanceCurrentPhase: ContractDocPhase.GlEtOutput,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'DRAFT_DECLARATION_CHANGE_REQUESTED',
label: 'Customer requested a change to the draft declaration',
actorType: 'CUSTOMER',
actorId: userId ?? null,
metadata: { note: note.trim() },
});
const updated = await this.bookingsService.findById(bookingId);
this.notifier.draftDeclarationChangeRequested(updated, note.trim());
return updated;
}
async uploadDutySlip(bookingId: string, file: Express.Multer.File): Promise<Booking> {
async uploadDutySlip(
bookingId: string,
file: Express.Multer.File,
userId?: string,
): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (booking.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Duty slip upload applies only to import bookings.');
@@ -758,6 +877,15 @@ export class BookingClearanceService {
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'DUTY_SLIP_UPLOADED',
label: 'Customer uploaded the duty/tax payment slip',
actorType: 'CUSTOMER',
actorId: userId ?? null,
metadata: { fileName: file.originalname },
});
this.notifier.dutySlipUploadedToStaff(booking, 'first');
return this.bookingsService.findById(bookingId);
}
@@ -790,11 +918,18 @@ export class BookingClearanceService {
await this.bookingsRepository.update(bookingId, {
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'TRANSIT_PERMIT_UPLOADED',
label: `Uploaded transit permit (${files.length} file(s))`,
actorId: userId ?? null,
metadata: { fileNames: files.map((f) => f.originalname) },
});
return this.bookingsService.findById(bookingId);
}
async finalizePreClearance(bookingId: string): Promise<Booking> {
async finalizePreClearance(bookingId: string, userId?: string): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (booking.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Pre-clearance finalize applies only to import bookings.');
@@ -814,6 +949,12 @@ export class BookingClearanceService {
preClearanceFinalizedAt: new Date(),
clearanceCurrentPhase: ContractDocPhase.GlDjCollection,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'PRE_CLEARANCE_FINALIZED',
label: 'Finalized pre-clearance — handed over to GL Djibouti collection',
actorId: userId ?? null,
});
// GL Djibouti may have uploaded the DO early (un-gated) — count it now.
const files = await this.filesService.findByResource(bookingId, 'bookings');
@@ -847,6 +988,17 @@ export class BookingClearanceService {
vesselArrivalDate,
doCollectedDate,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'DELIVERY_ORDER_UPLOADED',
label: 'Uploaded Delivery Order',
actorId: userId ?? null,
metadata: {
vesselArrivalDate: vesselArrivalDate ?? null,
doCollectedDate: doCollectedDate ?? null,
fileNames: (files ?? []).map((f) => f.originalname),
},
});
if (booking.preClearanceFinalizedAt) {
await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId);
@@ -904,6 +1056,16 @@ export class BookingClearanceService {
vesselDepartureDate,
roAmendmentRequestedAt: null,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'RELEASE_ORDER_UPLOADED',
label: `Uploaded Release Order (vessel departs ${vesselDepartureDate})`,
actorId: userId ?? null,
metadata: {
vesselDepartureDate,
fileNames: (files ?? []).map((f) => f.originalname),
},
});
if (leadDays < minDays) {
const reason = `Vessel departs in ${leadDays} day(s) — minimum lead time is ${minDays} day(s). Request a port amendment or upload a new RO with a later date.`;
@@ -963,6 +1125,13 @@ export class BookingClearanceService {
userId,
);
}
await this.clearanceEvents.record({
bookingId,
action: 'RO_AMENDMENT_REQUESTED',
label: 'Requested a port amendment on the Release Order',
actorId: userId ?? null,
metadata: { note: reason },
});
return this.bookingsService.findById(bookingId);
}
@@ -978,10 +1147,16 @@ export class BookingClearanceService {
'EXPORT_RELEASED',
);
await this.workflowService.onExportReleasedForBooking(bookingId, userId);
await this.clearanceEvents.record({
bookingId,
action: 'EXPORT_RELEASE_CONFIRMED',
label: 'Confirmed export release',
actorId: userId ?? null,
});
return this.bookingsService.findById(bookingId);
}
async etQueue(): Promise<Booking[]> {
async etQueue(user?: unknown): Promise<Booking[]> {
const candidates = await this.bookingsRepository.findByStatuses([
...PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES,
]);
@@ -989,9 +1164,50 @@ export class BookingClearanceService {
for (const b of candidates) {
if (!this.isPhasedCustomsBooking(b)) continue;
const milestones = await this.workflowService.listMilestonesForBooking(b.id);
if (belongsOnEtClearanceQueue(milestones)) filtered.push(b);
if (!belongsOnEtClearanceQueue(milestones)) continue;
// Surfaced on the queue row: every required document is approved even
// though the booking status stays DOCUMENTS_UNDER_REVIEW until finalize.
(b as Booking & { allDocsApproved?: boolean }).allDocsApproved =
milestones.some(
(m) =>
m.milestoneCode === 'DOCUMENTS_APPROVED' &&
(m.status === 'COMPLETED' || m.status === 'SKIPPED'),
);
filtered.push(b);
}
return this.attachContractSummary(filtered);
// A document added after clearance was finalized lands as PENDING without
// moving the booking's status — the row would otherwise still read
// "Clearance ready" while GL has something waiting. Ad-hoc documents are
// tracked by no milestone, so this reads the review rows directly.
const pending = await this.bookingsRepository.findBookingsWithUnreviewedDocuments(
filtered.map((b) => b.id),
);
for (const b of filtered) {
(b as Booking & { hasDocumentsAwaitingReview?: boolean })
.hasDocumentsAwaitingReview = pending.has(b.id);
}
const rows = await this.attachContractSummary(filtered);
return this.narrowToYardScope(rows, user);
}
/**
* Keep only bookings whose ORIGIN or DESTINATION yard is one of the caller's
* assigned yards (`freight.yard_positions` via the active position). Yards in
* the middle of a route do not count. An unmapped position, super admin or
* `yards:view_all` holder sees everything (scope resolves to `null`).
* Runs after {@link attachContractSummary} so route-fallback yards count too.
*/
private async narrowToYardScope(bookings: Booking[], user: unknown): Promise<Booking[]> {
const scope = await this.yardScope.getScopedYardIds(user as never);
if (scope === null) return bookings;
const inScope = (id: string | null | undefined) => !!id && scope.includes(id);
return bookings.filter(
(b) =>
inScope(b.originYardId ?? b.originYard?.id) ||
inScope(b.destinationYardId ?? b.destinationYard?.id),
);
}
/**

View File

@@ -30,7 +30,8 @@ export class BookingRequestRepository extends BaseRepository<BookingRequest> {
async findQueue(): Promise<BookingRequest[]> {
return this.repository.find({
order: { createdAt: 'DESC' },
relations: { contract: { company: true } },
// `routes` rides along so the queue can be narrowed to the caller's yards.
relations: { contract: { company: true, routes: true } },
});
}

View File

@@ -7,6 +7,7 @@ import {
} from '@nestjs/common';
import type { Freight } from '@edr/types';
import { YardScopeService } from '../rule-engine/services/yard-scope.service';
import { BookingRequestRepository } from './booking-request.repository';
import { ContractsService } from './contracts.service';
import { ContractBookingService } from './contract-booking.service';
@@ -28,6 +29,7 @@ export class BookingRequestService {
private readonly contractsService: ContractsService,
private readonly contractBookingService: ContractBookingService,
private readonly notifier: ContractNotifierService,
private readonly yardScope: YardScopeService,
) {}
/**
@@ -168,8 +170,25 @@ export class BookingRequestService {
return request;
}
queue(): Promise<BookingRequest[]> {
return this.repo.findQueue();
/**
* GL queue narrowed to the caller's yards: a request stays when its route's
* ORIGIN or DESTINATION yard is one the caller's active position is mapped to
* (unmapped position / super admin → everything). A request with no
* resolvable route (no `contractRouteId` on a multi-route contract) has no
* yards to judge by and is kept visible.
*/
async queue(user?: unknown): Promise<BookingRequest[]> {
const rows = await this.repo.findQueue();
const scope = await this.yardScope.getScopedYardIds(user as never);
if (scope === null) return rows;
return rows.filter((r) => {
const routes = r.contract?.routes ?? [];
const route =
routes.find((x) => x.id === r.contractRouteId) ??
(routes.length === 1 ? routes[0] : undefined);
if (!route) return true;
return scope.includes(route.originYardId) || scope.includes(route.destinationYardId);
});
}
private async findPending(requestId: string): Promise<BookingRequest> {

View File

@@ -30,6 +30,7 @@ describe('ContractBookingService — quantity-cap completion', () => {
{} as never, // trainSchedulingService
{} as never, // bookingBatchService
{} as never, // bookingTransitionService
{} as never, // consolidationApprovalService
);
return { service, contractsRepository };
}
@@ -156,6 +157,7 @@ describe('ContractBookingService — quantity-cap completion', () => {
{} as never,
{} as never,
{} as never,
{} as never, // consolidationApprovalService
);
return { service, contractsRepository };
}

View File

@@ -64,6 +64,7 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
{} as never, // trainSchedulingService
{} as never, // bookingBatchService
{} as never, // bookingTransitionService
{} as never, // consolidationApprovalService
);
return {
service,

View File

@@ -26,6 +26,7 @@ describe('ContractBookingService — customs booking gate', () => {
{} as never, // trainSchedulingService
{} as never, // bookingBatchService
{} as never, // bookingTransitionService
{} as never, // consolidationApprovalService
);
}

View File

@@ -0,0 +1,216 @@
import { ContractBookingService } from './contract-booking.service';
import { Booking } from '../bookings/entities/booking.entity';
/**
* Manual (GL-driven) odd-20ft consolidation. On a customs contract GL completes
* the booking, so GL also picks who shares its wagon: two bookings each carrying
* an odd 20ft count are completed together onto one wagon.
*
* The two invariants that matter are that the pair is all-or-nothing (a failure
* on either half must leave NEITHER booking completed and no link written) and
* that the two bookings stay financially separate — one completion each, so one
* price and one invoice each.
*/
describe('ContractBookingService — manual odd-20ft consolidation', () => {
function makeService(overrides: {
bookingsRepository?: Partial<Record<string, jest.Mock>>;
dataSource?: unknown;
}) {
const bookingsRepository = {
findByIdWithFiles: jest.fn(),
findManualConsolidationCandidates: jest.fn().mockResolvedValue([]),
linkConsolidationPartners: jest.fn().mockResolvedValue(undefined),
...overrides.bookingsRepository,
};
// A transaction that simply runs the callback — enough to assert the
// all-or-nothing contract: whatever throws inside propagates out, and the
// caller observes no link written.
const dataSource = overrides.dataSource ?? {
transaction: jest.fn(async (cb: (m: unknown) => Promise<unknown>) => cb({})),
};
const service = new ContractBookingService(
{ findByIdWithRelations: jest.fn() } as never,
bookingsRepository as never,
{} as never, // bookingPricingService
{} as never, // consolidationService
{} as never, // containerTypesService
{} as never, // ruleEngineService
{} as never, // milestoneService
{} as never, // invoiceService
{} as never, // bookingNotifier
dataSource as never,
{} as never, // trainSchedulingService
{} as never, // bookingBatchService
{} as never, // bookingTransitionService
// The pairing is parked for approval rather than going straight to
// Operations; the gate itself is covered by its own spec.
{ requestApproval: jest.fn().mockResolvedValue({ id: 'ap-1' }) } as never,
);
return { service, bookingsRepository, dataSource };
}
const partnerBooking = {
id: 'b-2',
reference: 'BK-2',
contractId: 'c-2',
consolidationPartnerId: null,
} as unknown as Booking;
const pairDto = {
partnerBookingId: 'b-2',
booking: { scheduledDate: '2026-09-01' },
partner: { scheduledDate: '2026-09-01' },
};
it('completes both halves and links them', async () => {
const { service, bookingsRepository } = makeService({
bookingsRepository: {
findByIdWithFiles: jest
.fn()
// partner lookup before the transaction
.mockResolvedValueOnce(partnerBooking)
// the two reloads after it
.mockResolvedValueOnce({ id: 'b-1', reference: 'BK-1' } as Booking)
.mockResolvedValueOnce({ id: 'b-2', reference: 'BK-2' } as Booking),
},
});
// Each half runs the ordinary completion machine — one call per booking, so
// each is priced and invoiced on its own.
const complete = jest
.spyOn(service, 'completeUnderContract')
.mockImplementation(
async (_contractId, bookingId) =>
({
booking: { id: bookingId } as Booking,
warnings: [],
}) as never,
);
const result = await service.completeConsolidatedPair(
'c-1',
'b-1',
pairDto as never,
);
expect(complete).toHaveBeenCalledTimes(2);
// The partner is completed against ITS OWN contract, not this one.
expect(complete.mock.calls[0][0]).toBe('c-1');
expect(complete.mock.calls[1][0]).toBe('c-2');
// Neither half may re-enter the automatic matcher — GL links them here.
expect(complete.mock.calls[0][2]).toMatchObject({
skipAutoConsolidation: true,
});
expect(complete.mock.calls[1][2]).toMatchObject({
skipAutoConsolidation: true,
});
expect(bookingsRepository.linkConsolidationPartners).toHaveBeenCalledWith(
'b-1',
'b-2',
);
expect(result.booking.id).toBe('b-1');
expect(result.partner.id).toBe('b-2');
});
it('links nothing when the partner half fails (all-or-nothing)', async () => {
const { service, bookingsRepository } = makeService({
bookingsRepository: {
findByIdWithFiles: jest.fn().mockResolvedValue(partnerBooking),
},
});
jest
.spyOn(service, 'completeUnderContract')
.mockImplementationOnce(
async () => ({ booking: { id: 'b-1' } as Booking, warnings: [] }) as never,
)
.mockImplementationOnce(async () => {
throw new Error('no train space for the partner');
});
await expect(
service.completeConsolidatedPair('c-1', 'b-1', pairDto as never),
).rejects.toThrow('no train space for the partner');
// The link is the last write in the transaction — it must never happen when
// a half failed, so the rollback leaves no dangling pairing.
expect(bookingsRepository.linkConsolidationPartners).not.toHaveBeenCalled();
});
it('refuses a partner that already shares a wagon', async () => {
const { service } = makeService({
bookingsRepository: {
findByIdWithFiles: jest.fn().mockResolvedValue({
...partnerBooking,
consolidationPartnerId: 'b-9',
}),
},
});
await expect(
service.completeConsolidatedPair('c-1', 'b-1', pairDto as never),
).rejects.toThrow(/already shares a wagon/i);
});
it('refuses to consolidate a booking with itself', async () => {
const { service } = makeService({});
await expect(
service.completeConsolidatedPair('c-1', 'b-1', {
...pairDto,
partnerBookingId: 'b-1',
} as never),
).rejects.toThrow(/cannot be consolidated with itself/i);
});
it('offers only bookings whose own 20ft count is odd', async () => {
// Two odd counts always sum to even, so an odd partner is exactly what fills
// the wagon; an even one would leave the pair partial again.
const rows = [
{
id: 'odd',
reference: 'BK-ODD',
bookingContainers: [
{ quantity: 3, containerType: { sizeFt: 20 } },
],
},
{
id: 'even',
reference: 'BK-EVEN',
bookingContainers: [
{ quantity: 4, containerType: { sizeFt: 20 } },
],
},
// A bare instance has no cargo yet — GL enters it on the split form, so it
// stays a candidate.
{ id: 'bare', reference: 'BK-BARE', bookingContainers: [] },
];
const { service } = makeService({
bookingsRepository: {
findByIdWithFiles: jest
.fn()
.mockResolvedValue({ id: 'b-1', contractId: 'c-1' } as Booking),
findManualConsolidationCandidates: jest.fn(async (booking: Booking) =>
// Mirror the repository's in-memory odd filter.
rows.filter((row) => {
void booking;
const lines = row.bookingContainers ?? [];
if (lines.length === 0) return true;
const ft20 = lines
.filter((l) => Number(l.containerType?.sizeFt) === 20)
.reduce((sum, l) => sum + Number(l.quantity || 0), 0);
return ft20 % 2 === 1;
}),
),
},
});
const candidates = await service.listConsolidationCandidates('c-1', 'b-1');
expect(candidates.map((c) => c.reference)).toEqual(['BK-ODD', 'BK-BARE']);
expect(candidates[0].ft20Quantity).toBe(3);
expect(candidates[1].hasCargo).toBe(false);
});
});

View File

@@ -59,6 +59,7 @@ describe('ContractBookingService — changes-requested resubmit restating cargo'
trainSchedulingService as never,
{} as never, // bookingBatchService
{} as never, // bookingTransitionService
{} as never, // consolidationApprovalService
);
return { service, bookingsRepository, invoiceService };
}

View File

@@ -20,6 +20,7 @@ import { BookingPricingService } from '../bookings/booking-pricing.service';
import { BookingTransitionService } from '../bookings/booking-transition.service';
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
import { ConsolidationService } from '../bookings/consolidation.service';
import { ConsolidationApprovalService } from '../bookings/consolidation-approval.service';
import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
@@ -44,6 +45,7 @@ import {
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { isEffectivelyExpired } from './utils/contract-expiry.util';
import {
CompleteConsolidatedPairDto,
CreateBookingContainerLineDto,
CreateBookingUnderContractDto,
} from './dto/create-booking-under-contract.dto';
@@ -62,6 +64,25 @@ export interface CreateBookingUnderContractResult {
warnings: string[];
}
/**
* A booking GL may pick as the shared-wagon partner of an odd-20ft customs
* booking. `hasCargo` is false for a bare instance whose containers GL still has
* to enter on the split completion form.
*/
export interface ConsolidationCandidate {
id: string;
reference: string;
contractId: string | null;
companyName: string | null;
status: string;
tradeDirection: string | null;
originYardId: string | null;
destinationYardId: string | null;
scheduledDate: string | null;
ft20Quantity: number;
hasCargo: boolean;
}
/**
* Outstanding split remainder of a contract: what was booked in the first split
* booking's pre-split snapshot MINUS everything currently booked. Container
@@ -110,6 +131,8 @@ export class ContractBookingService {
private readonly bookingBatchService: BookingBatchService,
@Inject(forwardRef(() => BookingTransitionService))
private readonly bookingTransitionService: BookingTransitionService,
@Inject(forwardRef(() => ConsolidationApprovalService))
private readonly consolidationApprovalService: ConsolidationApprovalService,
) {}
async createUnderContract(
@@ -598,6 +621,146 @@ export class ContractBookingService {
return created;
}
/**
* Candidate partners a GL operator may link to an odd-20ft customs booking.
* Manual counterpart to the automatic pairing in {@link consolidateDrawdown} —
* a customs instance is completed by GL, so GL also chooses who shares its
* wagon rather than waiting for the auto-matcher to find an exact complement.
*/
async listConsolidationCandidates(
contractId: string,
bookingId: string,
): Promise<ConsolidationCandidate[]> {
const booking = await this.bookingsRepository.findByIdWithFiles(bookingId);
if (!booking || booking.contractId !== contractId) {
throw new NotFoundException(`Booking ${bookingId} not found on this contract`);
}
const rows = await this.bookingsRepository.findManualConsolidationCandidates(
booking,
);
return rows.map((row) => {
const lines = row.bookingContainers ?? [];
return {
id: row.id,
reference: row.reference,
contractId: row.contractId ?? null,
companyName: row.company?.name ?? null,
status: row.status,
tradeDirection: row.tradeDirection ?? null,
originYardId: row.originYardId ?? null,
destinationYardId: row.destinationYardId ?? null,
scheduledDate: row.scheduledDate ? row.scheduledDate.toISOString() : null,
ft20Quantity: lines
.filter((line) => Number(line.containerType?.sizeFt) === 20)
.reduce((sum, line) => sum + Number(line.quantity || 0), 0),
hasCargo: lines.length > 0,
};
});
}
/**
* Complete an odd-20ft customs booking together with the partner booking GL
* picked for its shared wagon. Both halves run the ordinary
* {@link completeUnderContract} machine — same gates, same pricing, same
* per-booking invoice, so each customer still pays only its own shipment — and
* are linked as consolidation partners at the end.
*
* All-or-nothing: the two completions plus the pairing run inside one
* transaction, so a failure on either half leaves neither booking completed
* and no half-linked wagon behind. `runInTransaction` is used rather than a
* manual QueryRunner so the nested services join the same transactional
* context through the shared DataSource.
*/
async completeConsolidatedPair(
contractId: string,
bookingId: string,
dto: CompleteConsolidatedPairDto,
actorPermissions?: unknown,
/** IAM id of the GL user creating the pairing — recorded on the approval. */
actorUserId?: string | null,
): Promise<{
booking: Booking;
partner: Booking;
warnings: string[];
}> {
if (dto.partnerBookingId === bookingId) {
throw new BadRequestException(
'A booking cannot be consolidated with itself.',
);
}
const partner = await this.bookingsRepository.findByIdWithFiles(
dto.partnerBookingId,
);
if (!partner) {
throw new NotFoundException(
`Partner booking ${dto.partnerBookingId} not found`,
);
}
if (partner.consolidationPartnerId) {
throw new ConflictException(
`Booking ${partner.reference} already shares a wagon with another booking.`,
);
}
if (!partner.contractId) {
throw new BadRequestException(
`Booking ${partner.reference} is not a contract booking and cannot be completed here.`,
);
}
const warnings: string[] = [];
const { ownId, partnerId } = await this.dataSource.transaction(async () => {
const own = await this.completeUnderContract(
contractId,
bookingId,
{ ...dto.booking, skipAutoConsolidation: true },
// Both halves are completed by the same GL actor that reached this
// endpoint — the customs gate in completeUnderContract re-checks it.
actorPermissions,
);
warnings.push(...own.warnings);
const other = await this.completeUnderContract(
partner.contractId as string,
partner.id,
{ ...dto.partner, skipAutoConsolidation: true },
actorPermissions,
);
warnings.push(...other.warnings);
// Link the two halves. Written directly (not via pairConsolidation) because
// both bookings have just been completed into their live status here —
// pairConsolidation exists to RESUME bookings parked in
// PENDING_CONSOLIDATION and would overwrite that status.
await this.bookingsRepository.linkConsolidationPartners(
own.booking.id,
other.booking.id,
);
return { ownId: own.booking.id, partnerId: other.booking.id };
});
// Both halves have just been completed into the operations queue by the
// ordinary completion machine. A shared wagon does not go there unreviewed:
// pull the pair back into the approval gate, which releases them to
// Operations only once a person signs off on the pairing.
await this.consolidationApprovalService.requestApproval(
ownId,
partnerId,
actorUserId ?? null,
);
// Sequential reads: one connection per transaction context.
const finalBooking = await this.bookingsRepository.findByIdWithFiles(ownId);
const finalPartner = await this.bookingsRepository.findByIdWithFiles(partnerId);
return {
booking: finalBooking!,
partner: finalPartner ?? partner,
warnings,
};
}
/**
* Complete a bare initiated booking after its per-booking clearance is
* finalized (CLEARANCE_READY) or operations returned it for changes
@@ -826,10 +989,18 @@ export class ContractBookingService {
// exactly like a drawdown created with cargo does. The shipment day is
// stored first so the pairing event can resume straight into the
// operations queue.
// Customs (Path B) instances are exempt from the AUTO-matcher: GL links
// their shared wagon by hand through completeConsolidatedPair, so nothing
// may claim a partner for them behind GL's back. A customs half completed
// as part of a manual pair carries `skipAutoConsolidation`; one completed
// alone still falls through to the automatic gate below, so an odd 20ft
// booking can never proceed on a partial wagon. Non-customs drawdowns are
// unaffected.
const withContainers = await this.bookingsRepository.findByIdWithFiles(booking.id);
if (
withContainers &&
freightType === 'CONTAINER' &&
!dto.skipAutoConsolidation &&
(await this.consolidationService.needsConsolidationFromBooking(withContainers))
) {
await this.bookingsRepository.update(booking.id, {
@@ -2287,6 +2458,22 @@ export class ContractBookingService {
private async assert20ftPairableAtCreate(
dto: CreateBookingUnderContractDto,
): Promise<void> {
// Parity gate. 20ft containers ride two per wagon, so an odd total leaves
// one container that cannot be placed. Consolidation (pairing it with
// another customer's odd booking) is built end to end but switched off for
// now, so an odd total is rejected outright — server-side, because the
// frontend block alone is not a guarantee.
const ft20Quantity = (dto.containers ?? [])
.filter((line) => (line.containerSize ?? '').includes('20'))
.reduce((sum, line) => sum + Number(line.quantity || 0), 0);
if (ft20Quantity % 2 === 1) {
throw new BadRequestException(
`20ft containers travel two per wagon, so they must be booked in even ` +
`numbers. This booking has ${ft20Quantity} — add one more or remove ` +
`one (book ${ft20Quantity + 1} or ${ft20Quantity - 1}).`,
);
}
const twentyFtUnits = (dto.containers ?? [])
.filter((line) => (line.containerSize ?? '').includes('20'))
.flatMap((line, lineIdx) =>

View File

@@ -358,32 +358,39 @@ export class ContractClearanceService {
// Once GL creates the shipment booking, surface its reference + status so the
// customer sees the concrete booking instead of a stale "will be created
// shortly" message. Reuse the export booking load; fetch for import too.
let linkedBookingId: string | null = null;
let linkedBookingReference: string | null = null;
let linkedBookingStatus: string | null = null;
let linkedBookingReviewNote: string | null = null;
let linkedBookingScheduledDate: string | null = null;
if (cycle?.bookingId) {
const booking = await this.bookingsService.findById(cycle.bookingId);
if (booking) {
linkedBookingReference = booking.reference ?? null;
linkedBookingStatus = booking.status ?? null;
linkedBookingScheduledDate = booking.scheduledDate
? new Date(booking.scheduledDate).toISOString()
: null;
// Newest changes-requested note (reviewNotes ride along on findById).
linkedBookingReviewNote =
[...(booking.reviewNotes ?? [])]
.filter((n) => n.type === 'CHANGES_REQUESTED')
.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
)[0]?.note ?? null;
if (contract.tradeDirection === 'EXPORT') {
nextAction = this.workflowService.computeNextActionForBooking(
booking,
bookingMilestones,
);
}
// The cycle is the historical link, but it is not written on every path (an
// FCFS export booking and a GL drawdown both reach the operations queue
// without a cycle row), so fall back to the contract's own live booking —
// otherwise the clearance page sees no linked booking at all and cannot show
// its status or the actions that depend on it.
const booking = cycle?.bookingId
? await this.bookingsService.findById(cycle.bookingId)
: await this.contractsRepository.findLatestBookingForContract(contractId);
if (booking) {
linkedBookingId = booking.id ?? null;
linkedBookingReference = booking.reference ?? null;
linkedBookingStatus = booking.status ?? null;
linkedBookingScheduledDate = booking.scheduledDate
? new Date(booking.scheduledDate).toISOString()
: null;
// Newest changes-requested note (reviewNotes ride along on findById).
linkedBookingReviewNote =
[...(booking.reviewNotes ?? [])]
.filter((n) => n.type === 'CHANGES_REQUESTED')
.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
)[0]?.note ?? null;
if (contract.tradeDirection === 'EXPORT') {
nextAction = this.workflowService.computeNextActionForBooking(
booking,
bookingMilestones,
);
}
}
@@ -420,7 +427,7 @@ export class ContractClearanceService {
bookingReady: boundary,
preClearanceFinalized: Boolean(cycle?.preClearanceFinalizedAt),
exportClearanceFinalized: Boolean(cycle?.completedAt),
linkedBookingId: cycle?.bookingId ?? null,
linkedBookingId,
linkedBookingReference,
linkedBookingStatus,
linkedBookingReviewNote,

View File

@@ -3,17 +3,20 @@ import { BadRequestException } from '@nestjs/common';
import { ContractTransitionService } from './contract-transition.service';
/**
* Where the booking-contract view reads the global stamp live, the contracts
* path SNAPSHOTS it onto the signature row at signing time, so replacing the
* company stamp can never restamp an already-executed contract. These specs
* pin the sourcing split: EDR always seals with the global stamp and staff
* never supply one, while the customer must upload their own.
* The staff signature seals with the ONE global stamp by REFERENCE: the
* signature row stores the current global stampFileId instead of re-uploading
* a copy per contract. That id stays valid after the stamp is replaced
* (StampSettingsService never deletes retired stamp files), so each contract
* keeps the exact seal it was signed with. These specs pin the sourcing
* split: EDR always seals with the global stamp and staff never supply one,
* while the customer must upload their own.
*/
describe('applySignature stamp sourcing', () => {
const contract = { id: 'c-1', reference: 'CTR-1', status: 'SIGNED_CUSTOMER' };
const GLOBAL_STAMP = 'data:image/png;base64,RURS';
const GLOBAL_STAMP_FILE_ID = 'file-global-stamp';
const build = (globalStamp: string | null = GLOBAL_STAMP) => {
const build = (globalStampFileId: string | null = GLOBAL_STAMP_FILE_ID) => {
const uploads: Array<{ code: string; image: string }> = [];
const saved: unknown[] = [];
const service = Object.create(
@@ -22,7 +25,10 @@ describe('applySignature stamp sourcing', () => {
Object.assign(service, {
logger: { warn: jest.fn(), log: jest.fn() },
stampSettings: {
getStampImageUrl: jest.fn().mockResolvedValue(globalStamp),
get: jest.fn().mockResolvedValue({
id: 's-1',
stampFileId: globalStampFileId,
}),
},
contractsRepository: {
saveSignature: jest.fn((row: unknown) => {
@@ -62,35 +68,36 @@ describe('applySignature stamp sourcing', () => {
signatureImageBase64: 'data:image/png;base64,U0lH',
};
it('seals the EDR side with the global stamp', async () => {
it('seals the EDR side by referencing the global stamp file, without re-uploading it', async () => {
const { service, uploads, saved } = build();
await apply(service, staffDto);
expect(uploads).toContainEqual({ code: 'stamp_staff', image: GLOBAL_STAMP });
expect(uploads.map((u) => u.code)).toEqual(['signature_staff']);
expect(saved[0]).toEqual(
expect.objectContaining({ stampFileId: 'file-stamp_staff' }),
expect.objectContaining({ stampFileId: GLOBAL_STAMP_FILE_ID }),
);
});
it('ignores a stamp a staff client tries to supply', async () => {
const { service, uploads } = build();
const { service, uploads, saved } = build();
await apply(service, {
...staffDto,
stampImageBase64: 'data:image/png;base64,SEFDSw==',
});
expect(uploads).toContainEqual({ code: 'stamp_staff', image: GLOBAL_STAMP });
expect(uploads.map((u) => u.image)).not.toContain(
'data:image/png;base64,SEFDSw==',
);
expect(saved[0]).toEqual(
expect.objectContaining({ stampFileId: GLOBAL_STAMP_FILE_ID }),
);
});
/**
* Failing loudly matters here: getStampImageUrl degrades to null when the
* stamp cannot be inlined, and silently executing an unsealed contract would
* be worse than refusing to counter-sign.
* Failing loudly matters here: silently executing an unsealed contract
* would be worse than refusing to counter-sign.
*/
it('refuses to counter-sign when no global stamp is configured', async () => {
const { service, saved } = build(null);

View File

@@ -1128,17 +1128,27 @@ export class ContractTransitionService {
);
}
// Snapshot whichever stamp applies onto the signature row rather than
// referencing the global one, so replacing the company stamp later can
// never restamp an already-executed contract.
let stampImageBase64 = dto.stampImageBase64 ?? null;
// STAFF seals by REFERENCE to the one global stamp file — no per-contract
// copy of the image. Safe because StampSettingsService.setStamp/clearStamp
// never delete a replaced stamp file: the referenced id keeps rendering
// the exact seal that was current at signing, even after the global stamp
// is later replaced. The customer's stamp is their own upload and is still
// stored per contract.
let stampFileId: string | null = null;
if (role === 'STAFF') {
stampImageBase64 = await this.stampSettings.getStampImageUrl();
if (!stampImageBase64) {
stampFileId = (await this.stampSettings.get()).stampFileId ?? null;
if (!stampFileId) {
throw new BadRequestException(
'No company stamp is configured. Upload the company stamp under Settings before counter-signing contracts.',
);
}
} else if (dto.stampImageBase64) {
const stampRecord = await this.uploadSignatureAsset(
contract,
`stamp_${role.toLowerCase()}`,
dto.stampImageBase64,
);
stampFileId = stampRecord.id;
}
const fileRecord = await this.uploadSignatureAsset(
@@ -1146,13 +1156,6 @@ export class ContractTransitionService {
`signature_${role.toLowerCase()}`,
imageBase64,
);
const stampRecord = stampImageBase64
? await this.uploadSignatureAsset(
contract,
`stamp_${role.toLowerCase()}`,
stampImageBase64,
)
: null;
await this.contractsRepository.saveSignature({
contractId: contract.id,
@@ -1160,7 +1163,7 @@ export class ContractTransitionService {
signerDisplayName,
signedAt: new Date(),
signatureFileId: fileRecord.id,
stampFileId: stampRecord?.id ?? null,
stampFileId,
consentText: dto.consentText ?? null,
});

View File

@@ -76,7 +76,10 @@ import {
import { SignContractDto } from './dto/sign-contract.dto';
import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto';
import { RenewContractDto } from './dto/renew-contract.dto';
import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto';
import {
CompleteConsolidatedPairDto,
CreateBookingUnderContractDto,
} from './dto/create-booking-under-contract.dto';
import {
CreateBookingRequestDto,
ReviewBookingRequestDto,
@@ -120,8 +123,8 @@ export class ContractsController {
@Get('booking-requests/queue')
@BookingStaff(FREIGHT_PERMS.contracts.createBooking)
@ApiOperation({ summary: 'GL queue: shipment requests across contracts (all statuses, newest first)' })
bookingRequestQueue() {
return this.bookingRequestService.queue();
bookingRequestQueue(@CurrentUser() user: AuthUserPayload) {
return this.bookingRequestService.queue(user);
}
@Get('booking-requests/:reqId')
@@ -1152,10 +1155,48 @@ export class ContractsController {
// Customs (Path B) instances may only be completed by GL Ethiopia — the
// service checks the actor's contracts:create_booking permission.
return this.contractBookingService.completeUnderContract(
id,
bookingId,
// skipAutoConsolidation is internal to the manual pair-completion path; a
// client must never suppress the wagon gate on a lone booking.
{ ...dto, skipAutoConsolidation: false },
user,
);
}
@Get(':id/bookings/:bookingId/consolidation-candidates')
@MixedAudience(FREIGHT_PERMS.contracts.createBooking)
@ApiOperation({
summary:
'Bookings GL may link to this odd-20ft customs booking as its shared-wagon partner (same route and direction, customs, odd 20ft, unpaired).',
})
listConsolidationCandidates(
@Param('id', ParseUUIDPipe) id: string,
@Param('bookingId', ParseUUIDPipe) bookingId: string,
) {
return this.contractBookingService.listConsolidationCandidates(id, bookingId);
}
@Post(':id/bookings/:bookingId/complete-consolidated')
@MixedAudience(FREIGHT_PERMS.contracts.createBooking)
@ApiOperation({
summary:
'Complete this booking and its chosen shared-wagon partner together (all-or-nothing). Each booking is priced and invoiced separately — only the wagon is shared.',
})
completeConsolidatedPair(
@Param('id', ParseUUIDPipe) id: string,
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: CompleteConsolidatedPairDto,
@CurrentUser() user: TCurrentUser & { sub?: string },
) {
return this.contractBookingService.completeConsolidatedPair(
id,
bookingId,
dto,
user,
// Recorded as the requester on the approval: the person who created the
// pairing may not be the one who approves it.
user?.id ?? user?.sub ?? null,
);
}

View File

@@ -663,6 +663,31 @@ export class ContractsRepository extends BaseRepository<Contract> {
.getCount();
}
/**
* The live shipment booking on a contract, newest first.
*
* The clearance view historically reached the booking through
* `currentCycle().bookingId`, but a cycle row is not created on every path —
* an FCFS export booking and a GL drawdown both reach
* OPERATION_REQUEST_PENDING without one — so that lookup returns null and the
* clearance page loses the booking's status entirely. This resolves it from
* the bookings themselves, which is the authoritative link (bookings carry
* contract_id), and is used as the fallback when the cycle has no booking.
*/
async findLatestBookingForContract(
contractId: string,
): Promise<Booking | null> {
return this.dataSource
.getRepository(Booking)
.createQueryBuilder('b')
.where('b.contract_id = :contractId', { contractId })
.andWhere('b.status NOT IN (:...terminal)', {
terminal: TERMINAL_BOOKING_STATUSES,
})
.orderBy('b.created_at', 'DESC')
.getOne();
}
async createReviewNote(
contractId: string,
body: string,

View File

@@ -1,4 +1,4 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ApiHideProperty, ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import {
IsArray,
@@ -219,4 +219,46 @@ export class CreateBookingUnderContractDto {
@IsOptional()
@IsString()
notes?: string;
/**
* Internal: set by the manual GL pair-completion path, never by a client.
* Suppresses the automatic wagon-consolidation gate for this completion
* because the caller links the shared wagon itself. Excluded from the public
* schema so a client cannot set it to bypass the gate on a lone booking.
*/
@ApiHideProperty()
@IsOptional()
@IsBoolean()
skipAutoConsolidation?: boolean;
}
/**
* Complete an odd-20ft customs booking together with the partner booking GL
* picked to share its wagon. Each half carries its own full completion payload —
* the two bookings stay separately priced and separately invoiced, they only
* share the wagon.
*/
export class CompleteConsolidatedPairDto {
@ApiProperty({
format: 'uuid',
description: 'The booking chosen to share this bookings wagon.',
})
@IsUUID()
partnerBookingId!: string;
@ApiProperty({
type: CreateBookingUnderContractDto,
description: 'Completion payload for the booking in the URL.',
})
@ValidateNested()
@Type(() => CreateBookingUnderContractDto)
booking!: CreateBookingUnderContractDto;
@ApiProperty({
type: CreateBookingUnderContractDto,
description: 'Completion payload for the partner booking.',
})
@ValidateNested()
@Type(() => CreateBookingUnderContractDto)
partner!: CreateBookingUnderContractDto;
}

View File

@@ -0,0 +1,33 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { ArrayMinSize, IsArray, IsOptional, IsString, IsUUID, Length, ValidateNested } from "class-validator";
export class BulkCancelEimsItemDto {
@ApiProperty({ description: "Invoice ID to cancel." })
@IsUUID()
invoiceId!: string;
@ApiProperty({
description: 'Numeric reason code, e.g. "1" (Duplicate), "6" (Calculation Error).',
example: "1",
})
@IsString()
@Length(1, 8)
reasonCode!: string;
@ApiPropertyOptional({ description: "Free-text cancellation note.", example: "Duplicate submission" })
@IsOptional()
@IsString()
@Length(0, 500)
remark?: string;
}
/** `POST invoices/eims/bulk-cancel` body — see `EimsCancellationService.cancelBulkWithEims`. */
export class BulkCancelEimsRegistrationDto {
@ApiProperty({ type: [BulkCancelEimsItemDto] })
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => BulkCancelEimsItemDto)
items!: BulkCancelEimsItemDto[];
}

View File

@@ -0,0 +1,11 @@
import { ApiProperty } from "@nestjs/swagger";
import { ArrayMinSize, IsArray, IsUUID } from "class-validator";
/** `POST invoices/eims/bulk-register` body — see `EimsBulkRegistrationService.registerBulk`. */
export class BulkRegisterEimsInvoiceDto {
@ApiProperty({ type: [String], description: "Invoice IDs to register with MoR EIMS in one batch." })
@IsArray()
@ArrayMinSize(1)
@IsUUID("4", { each: true })
invoiceIds!: string[];
}

View File

@@ -0,0 +1,359 @@
import { BadRequestException, ConflictException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { DataSource } from "typeorm";
import { Invoice } from "../billing/entities/invoice.entity";
import { NotificationsService } from "../notifications/notifications.service";
import { eimsConfig, eimsInvoiceConfig } from "./eims-test-fixtures";
import { EimsAuthService } from "./eims-auth.service";
import { EimsBulkRegistrationService } from "./eims-bulk-registration.service";
import { EimsClientService } from "./eims-client.service";
import { EimsSellerCacheService } from "./eims-seller-cache.service";
import { EimsSystemState } from "./entities/eims-system-state.entity";
import { EimsApiException } from "./eims.errors";
import { EimsInvoiceStatus } from "./eims-registration.types";
import { buildEimsSeller } from "./eims-invoice-context";
const SYSTEM_NUMBER = "B0360154BA";
const INVOICE_A = "11111111-1111-4111-8111-111111111111";
const INVOICE_B = "22222222-2222-4222-8222-222222222222";
const CONVERSATION_ID = "2345678901-1735900502800-c04f8dd6-e6e2-4198-b871-c6e504fc14f5";
const invoiceRow = (over: Partial<Invoice> = {}): Invoice =>
({
id: INVOICE_A,
invoiceNumber: "INV-20260807-00001",
currency: "ETB",
companyId: "company-1",
issuedAt: new Date(2026, 7, 7, 9, 5, 3),
totalAmount: "10000.00",
eimsStatus: EimsInvoiceStatus.NotSubmitted,
eimsIrn: null,
eimsDocumentType: "INV",
eimsBulkConversationId: null,
company: {
name: "ABC Trading PLC",
tin: "0999930000",
vatNumber: "123475885858",
phone: "0912345678",
region: "13",
zone: "SHA",
woreda: "574",
kebele: "03",
houseNo: "NEW",
country: "Ethiopia",
},
...over,
}) as unknown as Invoice;
const LINES = (id: string) => [
{
invoiceId: id,
chargeType: "RAIL_FREIGHT",
description: "Addis to Djibouti",
quantity: "1.00",
unitRate: "10000.00",
amount: "10000.00",
},
];
/** In-memory stand-in covering the query/manager surface this service actually calls. */
class FakeDb {
invoices = new Map<string, Invoice>();
state: EimsSystemState;
companyContact: { phone: string | null; email: string | null } | null = null;
constructor(invoices: Invoice[], state: Partial<EimsSystemState> = {}) {
for (const inv of invoices) this.invoices.set(inv.id, inv);
this.state = {
id: "state-1",
systemNumber: SYSTEM_NUMBER,
nextInvoiceCounter: 1,
nextDocumentNumber: 1,
previousIrn: null,
inFlightInvoiceId: null,
inFlightCounter: null,
inFlightDocumentNumber: null,
inFlightConversationId: null,
blockedReason: null,
...state,
} as EimsSystemState;
}
private matches(entity: Invoice | EimsSystemState, where: Record<string, unknown>): boolean {
return Object.entries(where).every(([key, value]) => (entity as never)[key] === value);
}
private queryBuilder(entityCtor: unknown) {
let where: Record<string, unknown> = {};
const builder = {
setLock: () => builder,
where: (_clause: string, params: Record<string, unknown>) => {
where = { ...where, ...this.normalizeParams(params) };
return builder;
},
andWhere: (_clause: string, params: Record<string, unknown>) => {
where = { ...where, ...this.normalizeParams(params) };
return builder;
},
getOne: async () => this.find(entityCtor, where)[0] ?? null,
getMany: async () => this.find(entityCtor, where),
};
return builder;
}
private normalizeParams(params: Record<string, unknown>): Record<string, unknown> {
// Test-only mapping from the SQL param names used in the service's own queries to entity fields.
const map: Record<string, string> = {
invoiceId: "id",
systemNumber: "systemNumber",
id: "eimsBulkConversationId",
};
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(params)) out[map[k] ?? k] = v;
return out;
}
private find(entityCtor: unknown, where: Record<string, unknown>): Array<Invoice | EimsSystemState> {
const isState = entityCtor === EimsSystemState;
const pool: Array<Invoice | EimsSystemState> = isState ? [this.state] : [...this.invoices.values()];
return pool.filter((e) => this.matches(e, where));
}
private manager = {
createQueryBuilder: (entityCtor: unknown) => this.queryBuilder(entityCtor),
query: async () => [],
findOne: async (entityCtor: unknown, options: { where: Record<string, unknown> }) =>
this.find(entityCtor, options.where)[0] ?? null,
update: async (entityCtor: unknown, idOrWhere: string | Record<string, unknown>, patch: Record<string, unknown>) => {
const targets =
typeof idOrWhere === "string"
? this.find(entityCtor, { id: idOrWhere })
: this.find(entityCtor, idOrWhere);
for (const t of targets) Object.assign(t, patch);
return { affected: targets.length };
},
getRepository: (entityCtor: unknown) => ({
findOne: async (options: { where: { id: string } }) => this.find(entityCtor, { id: options.where.id })[0] ?? null,
}),
};
asDataSource(): DataSource {
return {
manager: this.manager,
// Routed by SQL text: the lines lookup and sendCompanyChannels' contact lookup share this
// one entry point in the real DataSource.
query: async (sql: string) => {
if (sql.includes("invoice_lines")) {
return [...this.invoices.keys()].flatMap((id) => LINES(id));
}
return this.companyContact ? [this.companyContact] : [];
},
transaction: async (body: (m: unknown) => Promise<unknown>) => body(this.manager),
getRepository: () => ({
find: async (options: { where: { id: { value: string[] } } }) => {
const ids = options.where.id.value ?? [];
return ids.map((id: string) => this.invoices.get(id)).filter(Boolean);
},
createQueryBuilder: (alias: string) => {
void alias;
return this.queryBuilder(Invoice);
},
count: async (options: { where: Record<string, unknown> }) => this.find(Invoice, options.where).length,
}),
} as unknown as DataSource;
}
}
const build = (db: FakeDb, postSigned: jest.Mock, directSend: jest.Mock = jest.fn().mockResolvedValue(undefined)) =>
new EimsBulkRegistrationService(
db.asDataSource(),
{ get: () => eimsConfig({ invoice: eimsInvoiceConfig() }) } as unknown as ConfigService,
{ postSigned } as unknown as EimsClientService,
{ getSessionContext: async () => ({ systemNumber: SYSTEM_NUMBER, systemType: "SYS" }) } as unknown as EimsAuthService,
{ directSend } as unknown as NotificationsService,
{ getSellerDetails: (c: unknown) => buildEimsSeller(c as never) } as unknown as EimsSellerCacheService,
);
const accepted = (conversationId = CONVERSATION_ID) => ({ conversationId, status: 202 });
describe("EimsBulkRegistrationService.registerBulk", () => {
it("reserves sequential counters, sends one signed array, and claims MoR's real conversation id", async () => {
const db = new FakeDb(
[invoiceRow(), invoiceRow({ id: INVOICE_B, invoiceNumber: "INV-20260807-00002" })],
{ nextInvoiceCounter: 5, nextDocumentNumber: 5, previousIrn: "prev-irn" },
);
const postSigned = jest.fn().mockResolvedValue(accepted());
const result = await build(db, postSigned).registerBulk([INVOICE_A, INVOICE_B]);
expect(result).toEqual({ conversationId: CONVERSATION_ID, accepted: [INVOICE_A, INVOICE_B], alreadyRegistered: [] });
const [, request] = postSigned.mock.calls[0];
expect(request).toHaveLength(2);
expect(request[0].SourceSystem.InvoiceCounter).toBe(5);
expect(request[0].DocumentDetails.DocumentNumber).toBe("5");
expect(request[0].ReferenceDetails.PreviousIrn).toBe("prev-irn");
expect(request[1].SourceSystem.InvoiceCounter).toBe(6);
// Only the first item in a bulk batch chains — the rest have no IRN to reference yet.
expect(request[1].ReferenceDetails.PreviousIrn).toBe("");
expect(db.invoices.get(INVOICE_A)).toMatchObject({ eimsStatus: EimsInvoiceStatus.Submitting, eimsBulkConversationId: CONVERSATION_ID });
expect(db.invoices.get(INVOICE_B)).toMatchObject({ eimsStatus: EimsInvoiceStatus.Submitting, eimsBulkConversationId: CONVERSATION_ID });
expect(db.state.nextInvoiceCounter).toBe(7);
expect(db.state.inFlightConversationId).toBe(CONVERSATION_ID);
});
it("skips an already-registered invoice, without consuming a counter for it", async () => {
const db = new FakeDb([
invoiceRow({ eimsIrn: "already-irn", eimsStatus: EimsInvoiceStatus.Registered }),
invoiceRow({ id: INVOICE_B, invoiceNumber: "INV-20260807-00002" }),
]);
const postSigned = jest.fn().mockResolvedValue(accepted());
const result = await build(db, postSigned).registerBulk([INVOICE_A, INVOICE_B]);
expect(result.alreadyRegistered).toEqual([INVOICE_A]);
expect(result.accepted).toEqual([INVOICE_B]);
const [, request] = postSigned.mock.calls[0];
expect(request).toHaveLength(1);
});
it("refuses the whole batch — no reservation, no HTTP call — when a DEB note has no registered original", async () => {
const db = new FakeDb([
invoiceRow({ eimsDocumentType: "DEB", relatedInvoice: { eimsIrn: null, invoiceNumber: "INV-orig" } as never }),
]);
const postSigned = jest.fn();
await expect(build(db, postSigned).registerBulk([INVOICE_A])).rejects.toBeInstanceOf(BadRequestException);
expect(postSigned).not.toHaveBeenCalled();
expect(db.state.inFlightConversationId).toBeNull();
});
it("refuses when a single-invoice submission is already in flight", async () => {
const db = new FakeDb([invoiceRow()], { inFlightInvoiceId: "some-other-invoice" });
await expect(build(db, jest.fn()).registerBulk([INVOICE_A])).rejects.toBeInstanceOf(ConflictException);
});
it("refuses when another bulk batch is already in flight", async () => {
const db = new FakeDb([invoiceRow()], { inFlightConversationId: "other-conversation" });
await expect(build(db, jest.fn()).registerBulk([INVOICE_A])).rejects.toBeInstanceOf(ConflictException);
});
it("a deterministic rejection rolls back the whole block and clears the in-flight marker", async () => {
const db = new FakeDb(
[invoiceRow(), invoiceRow({ id: INVOICE_B, invoiceNumber: "INV-20260807-00002" })],
{ nextInvoiceCounter: 5, nextDocumentNumber: 5 },
);
const postSigned = jest.fn().mockRejectedValue(new EimsApiException("SCHEMA_VALIDATION", "bad", 400));
await expect(build(db, postSigned).registerBulk([INVOICE_A, INVOICE_B])).rejects.toBeInstanceOf(EimsApiException);
expect(db.state.nextInvoiceCounter).toBe(5);
expect(db.state.nextDocumentNumber).toBe(5);
expect(db.state.inFlightConversationId).toBeNull();
expect(db.invoices.get(INVOICE_A)?.eimsStatus).toBe(EimsInvoiceStatus.Failed);
expect(db.invoices.get(INVOICE_A)?.eimsBulkConversationId).toBeNull();
});
it("an ambiguous failure blocks the system number and leaves counters consumed", async () => {
const db = new FakeDb([invoiceRow()], { nextInvoiceCounter: 5, nextDocumentNumber: 5 });
const postSigned = jest.fn().mockRejectedValue(new EimsApiException("TIMEOUT", "timed out"));
await expect(build(db, postSigned).registerBulk([INVOICE_A])).rejects.toBeInstanceOf(EimsApiException);
expect(db.state.nextInvoiceCounter).toBe(6);
expect(db.state.blockedReason).toMatch(/never acknowledged/);
expect(db.invoices.get(INVOICE_A)?.eimsStatus).toBe(EimsInvoiceStatus.Unknown);
});
it("refuses an empty invoice list", async () => {
const db = new FakeDb([invoiceRow()]);
await expect(build(db, jest.fn()).registerBulk([])).rejects.toBeInstanceOf(BadRequestException);
});
});
describe("EimsBulkRegistrationService.handleBulkCallback", () => {
const submittingRow = (over: Partial<Invoice>) =>
invoiceRow({
eimsStatus: EimsInvoiceStatus.Submitting,
eimsBulkConversationId: CONVERSATION_ID,
...over,
});
it("settles a mixed success/error callback, advancing previousIrn to the last accepted item", async () => {
const db = new FakeDb(
[
submittingRow({ eimsInvoiceCounter: 5, eimsDocumentNumber: "5" }),
submittingRow({ id: INVOICE_B, invoiceNumber: "INV-20260807-00002", eimsInvoiceCounter: 6, eimsDocumentNumber: "6" }),
],
{ inFlightConversationId: CONVERSATION_ID },
);
const results = await build(db, jest.fn()).handleBulkCallback([
{ irn: "irn-a", status: "A", documentNumber: "5" },
{ ruleError: [{ portion: "DocumentDetails", errorMessage: ["bad date"] }], status: "ERROR", docNo: "6" },
{ conversionId: CONVERSATION_ID },
]);
expect(db.invoices.get(INVOICE_A)).toMatchObject({ eimsStatus: EimsInvoiceStatus.Registered, eimsIrn: "irn-a" });
expect(db.invoices.get(INVOICE_B)).toMatchObject({ eimsStatus: EimsInvoiceStatus.Failed });
expect(db.state.previousIrn).toBe("irn-a");
expect(db.state.inFlightConversationId).toBeNull();
expect(results).toEqual(
expect.arrayContaining([
expect.objectContaining({ invoiceId: INVOICE_A, success: true, irn: "irn-a" }),
expect.objectContaining({ invoiceId: INVOICE_B, success: false }),
]),
);
});
it("ignores a callback for an unknown or already-settled conversation", async () => {
const db = new FakeDb([invoiceRow()]);
const results = await build(db, jest.fn()).handleBulkCallback([
{ irn: "irn-x", status: "A", documentNumber: "1" },
{ conversionId: "no-such-conversation" },
]);
expect(results).toEqual([]);
});
it("does not clear the in-flight marker while another invoice in the batch is still submitting", async () => {
const db = new FakeDb(
[
submittingRow({ eimsInvoiceCounter: 5, eimsDocumentNumber: "5" }),
submittingRow({ id: INVOICE_B, invoiceNumber: "INV-20260807-00002", eimsInvoiceCounter: 6, eimsDocumentNumber: "6" }),
],
{ inFlightConversationId: CONVERSATION_ID },
);
// Callback only reports on one of the two invoices in this batch.
await build(db, jest.fn()).handleBulkCallback([
{ irn: "irn-a", status: "A", documentNumber: "5" },
{ conversionId: CONVERSATION_ID },
]);
expect(db.state.inFlightConversationId).toBe(CONVERSATION_ID);
});
it("reports the current state without re-settling an invoice that already resolved", async () => {
const db = new FakeDb(
[
invoiceRow({
eimsStatus: EimsInvoiceStatus.Registered,
eimsIrn: "irn-a",
eimsDocumentNumber: "1",
eimsBulkConversationId: CONVERSATION_ID,
}),
],
{ inFlightConversationId: CONVERSATION_ID },
);
const results = await build(db, jest.fn()).handleBulkCallback([
{ irn: "irn-a", status: "A", documentNumber: "1" },
{ conversionId: CONVERSATION_ID },
]);
expect(results).toEqual([
expect.objectContaining({ invoiceId: INVOICE_A, success: true, message: expect.stringContaining("Already settled") }),
]);
});
});

View File

@@ -0,0 +1,518 @@
import { randomUUID } from "node:crypto";
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { InjectDataSource } from "@nestjs/typeorm";
import { DataSource, EntityManager, In } from "typeorm";
import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity.js";
import { EimsConfig } from "../../config/eims.config";
import { Invoice } from "../billing/entities/invoice.entity";
import { EimsDocumentType, EimsMapperLine, toEimsInvoice } from "../billing/eims-invoice.mapper";
import { sendCompanyChannels } from "../notifications/notify-company.util";
import { NotificationsService } from "../notifications/notifications.service";
import { EimsAuthService } from "./eims-auth.service";
import { EimsClientService } from "./eims-client.service";
import { EimsApiException, EimsConfigException } from "./eims.errors";
import { EimsSellerCacheService } from "./eims-seller-cache.service";
import { EimsSystemState } from "./entities/eims-system-state.entity";
import { assertEimsInvoiceConfig, buildEimsContext } from "./eims-invoice-context";
import {
EimsBulkCallbackItem,
EimsBulkRegisterAcceptedResponse,
EimsBulkRegisterItemResult,
EimsBulkRegisterRequest,
EimsInvoiceError,
EimsInvoiceStatus,
} from "./eims-registration.types";
const DETERMINISTIC_KINDS = new Set(["SCHEMA_VALIDATION", "RULE_VALIDATION", "AUTH", "FORBIDDEN"]);
interface BulkReservation {
stateId: string;
invoice: Invoice & { lines: EimsMapperLine[] };
documentType: EimsDocumentType;
relatedDocument: string | null;
invoiceCounter: number;
documentNumber: string;
previousIrn: string;
}
/**
* Registers many invoices with MoR EIMS in one call — `POST /v1/bulkRegister`.
*
* Fundamentally different shape from `EimsInvoiceRegistrationService.registerInvoiceWithEims`:
* that endpoint answers synchronously (an IRN or a rejection, in the HTTP response itself). Bulk
* does not — it returns only `{conversationId, status:202}` immediately, and the real per-invoice
* results (a mix of accepted/rejected in one array, per the collection's own examples) arrive later
* as a POST to a webhook MoR was configured with out of band. That means this service has two
* halves that don't share a call stack: `registerBulk` reserves and submits; `handleBulkCallback`
* — invoked by `EimsWebhookController`, whenever MoR gets around to it — settles.
*
* Reservation follows the same durable-reservation doctrine as the single-invoice service (counters
* consumed and the holder recorded, committed, before the HTTP call leaves the process), extended
* to a contiguous block of N counters instead of one. The "something is in flight" marker is
* `EimsSystemState.inFlightConversationId`, not `inFlightInvoiceId` — a whole batch is outstanding,
* not one invoice — and the two markers block each other: a single registration cannot start while
* a bulk batch is pending, and vice versa, because they share the same counter sequence.
*
* The conversation id is not known until MoR's 202 response arrives, so reservation stamps a
* locally-generated placeholder token first (same "commit the reservation before the network call"
* reasoning as the single flow), then swaps it for MoR's real conversation id right after — the only
* value the webhook callback can actually use to find this batch again.
*
* Not live-testable from this sandbox (no route to MoR's real gateway) — signing the whole array as
* one envelope, the way single `/v1/register` was confirmed live to need despite the collection's
* raw example showing no envelope, is the reasonable extension of that confirmed behavior, not a
* blind guess, but it has not itself been exercised against the real gateway.
*/
@Injectable()
export class EimsBulkRegistrationService {
private readonly logger = new Logger(EimsBulkRegistrationService.name);
constructor(
@InjectDataSource() private readonly dataSource: DataSource,
private readonly config: ConfigService,
private readonly client: EimsClientService,
private readonly auth: EimsAuthService,
private readonly notifications: NotificationsService,
private readonly sellerCache: EimsSellerCacheService,
) {}
private get cfg(): EimsConfig {
return this.config.get<EimsConfig>("eims")!;
}
/**
* Reserve counters for every eligible invoice and submit them as one `/v1/bulkRegister` call.
* An invoice that already has an IRN is silently skipped (idempotent, matching single register);
* everything else must pass the same DEB/CRE precondition single register checks, or the whole
* call is refused before anything is reserved.
*/
async registerBulk(
invoiceIds: string[],
): Promise<{ conversationId: string | null; accepted: string[]; alreadyRegistered: string[] }> {
const cfg = this.cfg;
assertEimsInvoiceConfig(cfg);
const ids = [...new Set(invoiceIds)];
if (ids.length === 0) {
throw new BadRequestException({ code: "EIMS_BULK_EMPTY", message: "No invoice ids given" });
}
const invoices = await this.loadInvoicesForMapping(ids);
const alreadyRegistered = invoices.filter((inv) => inv.eimsIrn).map((inv) => inv.id);
const pending = invoices.filter((inv) => !inv.eimsIrn);
// Same DEB/CRE precondition as single register, checked for every pending invoice before any
// counter is touched: a bad member must fail the whole batch, not surface mid-submission.
const prepared = pending.map((invoice) => {
const documentType = (invoice.eimsDocumentType as EimsDocumentType | undefined) ?? "INV";
let relatedDocument: string | null = null;
if (documentType !== "INV") {
if (!invoice.relatedInvoice) {
throw new BadRequestException({
code: "EIMS_RELATED_INVOICE_REQUIRED",
message: `Invoice ${invoice.invoiceNumber} is a ${documentType} but has no related invoice set.`,
});
}
if (!invoice.relatedInvoice.eimsIrn) {
throw new BadRequestException({
code: "EIMS_RELATED_INVOICE_NOT_REGISTERED",
message: `Invoice ${invoice.invoiceNumber} is a ${documentType} against invoice ${invoice.relatedInvoice.invoiceNumber}, which was never registered with EIMS — nothing to reference.`,
});
}
relatedDocument = invoice.relatedInvoice.eimsIrn;
}
return { invoice, documentType, relatedDocument };
});
if (prepared.length === 0) {
return { conversationId: null, accepted: [], alreadyRegistered };
}
const session = await this.auth.getSessionContext();
const placeholder = `local:${randomUUID()}`;
const reservations = await this.reserveBulk(prepared, session.systemNumber, placeholder);
let conversationId: string;
try {
const requests: EimsBulkRegisterRequest = reservations.map((r) =>
toEimsInvoice(
r.invoice,
this.sellerCache.getSellerDetails(cfg),
buildEimsContext(cfg, {
documentNumber: r.documentNumber,
invoiceCounter: r.invoiceCounter,
previousIrn: r.previousIrn,
session,
documentType: r.documentType,
reason: r.invoice.eimsReason,
relatedDocument: r.relatedDocument,
}),
),
);
const response = await this.client.postSigned<EimsBulkRegisterRequest, EimsBulkRegisterAcceptedResponse>(
"/v1/bulkRegister",
requests,
);
if (!response?.conversationId) {
throw new EimsApiException(
"SCHEMA_VALIDATION",
"EIMS bulkRegister returned no conversationId",
response?.status,
);
}
conversationId = response.conversationId;
} catch (err) {
await this.settleBulkFailure(reservations, err);
throw err;
}
await this.claimConversationId(placeholder, conversationId);
this.logger.log(
`Bulk-registered ${reservations.length} invoice(s) with EIMS (conversation ${conversationId}), awaiting callback`,
);
return { conversationId, accepted: reservations.map((r) => r.invoice.id), alreadyRegistered };
}
/**
* Settle a batch's callback, whenever MoR gets around to sending it. Called by
* `EimsWebhookController` with the raw parsed array body — no auth on that route (MoR calls it,
* not a logged-in user), so the only thing standing between this and a forged callback is the
* conversation id itself: an item is only ever applied to an invoice actually holding that exact
* id, and an unknown id is logged and ignored rather than touching anything.
*/
async handleBulkCallback(items: EimsBulkCallbackItem[]): Promise<EimsBulkRegisterItemResult[]> {
const settlements = items.filter(
(item): item is Exclude<EimsBulkCallbackItem, { conversationId?: string; conversionId?: string }> =>
"irn" in item || "ruleError" in item,
);
const conversationId = this.markerFrom(items);
const invoices = await this.dataSource
.getRepository(Invoice)
.createQueryBuilder("invoice")
.where("invoice.eims_bulk_conversation_id = :id", { id: conversationId })
.getMany();
if (invoices.length === 0) {
this.logger.warn(
`EIMS bulk callback for an unknown or already-settled conversation — ignored (${settlements.length} item(s))`,
);
return [];
}
const byDocumentNumber = new Map(invoices.map((inv) => [inv.eimsDocumentNumber, inv]));
// Process in invoiceCounter order so `previousIrn` ends up as the last-accepted item's IRN —
// the same "advance the chain" semantics as single register's settleSuccess.
const ordered = [...settlements].sort((a, b) => {
const invA = byDocumentNumber.get("documentNumber" in a ? a.documentNumber : a.docNo);
const invB = byDocumentNumber.get("documentNumber" in b ? b.documentNumber : b.docNo);
return (invA?.eimsInvoiceCounter ?? 0) - (invB?.eimsInvoiceCounter ?? 0);
});
const results: EimsBulkRegisterItemResult[] = [];
for (const item of ordered) {
const docNumber = "documentNumber" in item ? item.documentNumber : item.docNo;
const invoice = byDocumentNumber.get(docNumber);
if (!invoice) {
this.logger.warn(`EIMS bulk callback item for unknown document number ${docNumber} — ignored`);
continue;
}
if (invoice.eimsStatus !== EimsInvoiceStatus.Submitting) {
// Already settled — a duplicate callback delivery. Report the current state, touch nothing.
results.push({
invoiceId: invoice.id,
invoiceNumber: invoice.invoiceNumber,
success: invoice.eimsStatus === EimsInvoiceStatus.Registered,
message: `Already settled (${invoice.eimsStatus})`,
irn: invoice.eimsIrn ?? undefined,
});
continue;
}
if ("irn" in item) {
await this.settleBulkItemSuccess(invoice, item.irn, conversationId, item.signedQR);
results.push({
invoiceId: invoice.id,
invoiceNumber: invoice.invoiceNumber,
success: true,
message: `Registered with EIMS (IRN ${item.irn})`,
irn: item.irn,
});
} else {
const message = item.ruleError.flatMap((e) => e.errorMessage).join("; ") || "EIMS bulk rule validation error";
await this.settleBulkItemFailure(invoice, message);
results.push({ invoiceId: invoice.id, invoiceNumber: invoice.invoiceNumber, success: false, message });
}
}
// Clear the batch's in-flight marker only once nothing submitted under this conversation is
// still waiting — a partial/incremental callback (not expected per the collection's docs, but
// not ruled out either) must not prematurely unblock the system number.
const stillPending = await this.dataSource
.getRepository(Invoice)
.count({ where: { eimsBulkConversationId: conversationId, eimsStatus: EimsInvoiceStatus.Submitting } });
if (stillPending === 0) {
await this.dataSource.manager.update(
EimsSystemState,
{ inFlightConversationId: conversationId },
{ inFlightConversationId: null },
);
this.logger.log(`EIMS bulk conversation ${conversationId} fully settled (${results.length} item(s))`);
}
return results;
}
// ── transactions ─────────────────────────────────────────────────────────────────────────────
/** TX1. Reserve a contiguous block of N counters, one per invoice, in the given order. */
private async reserveBulk(
prepared: Array<{ invoice: Invoice & { lines: EimsMapperLine[] }; documentType: EimsDocumentType; relatedDocument: string | null }>,
systemNumber: string,
placeholder: string,
): Promise<BulkReservation[]> {
return this.dataSource.transaction(async (manager) => {
const state = await this.lockSystemState(manager, systemNumber);
if (state.blockedReason) {
throw new ConflictException({
code: "EIMS_SYSTEM_BLOCKED",
message: `EIMS registration is blocked for system ${systemNumber}: ${state.blockedReason}. Resolve the affected invoice before registering anything else.`,
});
}
if (state.inFlightInvoiceId) {
throw new ConflictException({
code: "EIMS_SUBMISSION_IN_FLIGHT",
message: `A submission for invoice ${state.inFlightInvoiceId} is already in flight on system ${systemNumber}. Wait for it to settle, or resolve it if the process was interrupted.`,
});
}
if (state.inFlightConversationId) {
throw new ConflictException({
code: "EIMS_BULK_IN_FLIGHT",
message: `A bulk submission (conversation ${state.inFlightConversationId}) is already in flight on system ${systemNumber}. Wait for its callback, or resolve it if the process was interrupted.`,
});
}
let counter = Number(state.nextInvoiceCounter);
let docNumber = Number(state.nextDocumentNumber);
let previousIrn = state.previousIrn ?? "";
const reservations: BulkReservation[] = [];
// Locked in the caller's given order — stable, avoids two concurrent bulk calls deadlocking
// on the opposite lock order.
for (const { invoice, documentType, relatedDocument } of prepared) {
const locked = await this.lockInvoice(manager, invoice.id);
const thisCounter = counter++;
const thisDocNumber = String(docNumber++);
const thisPreviousIrn = reservations.length === 0 ? previousIrn : "";
await manager.update(Invoice, invoice.id, {
eimsStatus: EimsInvoiceStatus.Submitting,
eimsInvoiceCounter: thisCounter,
eimsDocumentNumber: thisDocNumber,
eimsSubmittedAt: new Date(),
eimsLastError: null,
eimsBulkConversationId: placeholder,
} as QueryDeepPartialEntity<Invoice>);
reservations.push({
stateId: state.id,
invoice: Object.assign(locked, { lines: invoice.lines }),
documentType,
relatedDocument,
invoiceCounter: thisCounter,
documentNumber: thisDocNumber,
previousIrn: thisPreviousIrn,
});
}
await manager.update(EimsSystemState, state.id, {
nextInvoiceCounter: counter,
nextDocumentNumber: docNumber,
inFlightConversationId: placeholder,
});
return reservations;
});
}
/** Swap the local placeholder for MoR's real conversation id, on both the state row and every invoice. */
private async claimConversationId(placeholder: string, conversationId: string): Promise<void> {
await this.dataSource.transaction(async (manager) => {
await manager.update(EimsSystemState, { inFlightConversationId: placeholder }, { inFlightConversationId: conversationId });
await manager.update(Invoice, { eimsBulkConversationId: placeholder }, { eimsBulkConversationId: conversationId });
});
}
/**
* TX2b for the whole batch — the same determinism doctrine as single register's settleFailure,
* applied once since `/v1/bulkRegister` either accepts the whole array (202) or fails as one HTTP
* call; there is no per-item answer yet at this point, only after the callback.
*/
private async settleBulkFailure(reservations: BulkReservation[], err: unknown): Promise<void> {
const api = err instanceof EimsApiException ? err : null;
const deterministic = api ? DETERMINISTIC_KINDS.has(api.kind) : true;
const status = deterministic ? EimsInvoiceStatus.Failed : EimsInvoiceStatus.Unknown;
const localKind = err instanceof EimsConfigException ? "CONFIG" : "LOCAL";
const lastError: EimsInvoiceError = {
kind: api?.kind ?? localKind,
message: (err as Error)?.message ?? "unknown error",
httpStatus: api?.httpStatus,
details: api?.details,
at: new Date().toISOString(),
};
const first = reservations[0];
await this.dataSource.transaction(async (manager) => {
for (const r of reservations) {
await manager.update(Invoice, r.invoice.id, {
eimsStatus: status,
eimsLastError: lastError,
...(deterministic ? { eimsBulkConversationId: null } : {}),
} as QueryDeepPartialEntity<Invoice>);
}
await manager.update(
EimsSystemState,
first.stateId,
deterministic
? {
// The whole block returns: MoR never counted a refused batch against either sequence.
nextInvoiceCounter: first.invoiceCounter,
nextDocumentNumber: Number(first.documentNumber),
inFlightConversationId: null,
}
: {
blockedReason:
`A bulk submission of ${reservations.length} invoice(s) (starting counter ${first.invoiceCounter}) ` +
`was sent but never acknowledged (${lastError.kind}). No further document can be filed until it is resolved.`,
},
);
});
this.logger.error(`EIMS bulk submission ${status}: ${lastError.message}`);
}
/** One callback item accepted. */
private async settleBulkItemSuccess(
invoice: Invoice,
irn: string,
conversationId: string,
signedQR?: string,
): Promise<void> {
await this.dataSource.transaction(async (manager) => {
await this.lockInvoice(manager, invoice.id);
await manager.update(Invoice, invoice.id, {
eimsStatus: EimsInvoiceStatus.Registered,
eimsIrn: irn,
eimsSignedQr: signedQR ?? null,
eimsLastError: null,
});
// Looked up by conversation id, not system number — this batch's state row is whichever one
// is holding this conversation, which is exactly what `inFlightConversationId` already tracks.
await manager.update(EimsSystemState, { inFlightConversationId: conversationId }, { previousIrn: irn });
});
this.logger.log(`Invoice ${invoice.invoiceNumber} registered with EIMS via bulk (IRN ${irn})`);
if (invoice.companyId) {
try {
await sendCompanyChannels(
this.dataSource,
this.notifications,
invoice.companyId,
`Invoice ${invoice.invoiceNumber} has been registered with MoR EIMS. Reference (IRN): ${irn}`,
);
} catch (err) {
this.logger.warn(`EIMS buyer notification failed for invoice ${invoice.id}: ${(err as Error).message}`);
}
}
}
/**
* One callback item rejected. Unlike single register's settleFailure, the counter/document
* number are not returned — MoR's own bulk processing already advanced the whole array's
* allocation regardless of this item's individual outcome, so there is nothing local to roll back.
*/
private async settleBulkItemFailure(invoice: Invoice, message: string): Promise<void> {
const lastError: EimsInvoiceError = { kind: "RULE_VALIDATION", message, at: new Date().toISOString() };
await this.dataSource.manager.update(Invoice, invoice.id, {
eimsStatus: EimsInvoiceStatus.Failed,
eimsLastError: lastError,
} as QueryDeepPartialEntity<Invoice>);
this.logger.error(`Invoice ${invoice.invoiceNumber} EIMS bulk registration FAILED: ${message}`);
}
// ── internals ────────────────────────────────────────────────────────────────────────────────
private markerFrom(items: EimsBulkCallbackItem[]): string {
const marker = items.find((i) => "conversationId" in i || "conversionId" in i) as
| { conversationId?: string; conversionId?: string }
| undefined;
return marker?.conversationId ?? marker?.conversionId ?? "";
}
private async lockInvoice(manager: EntityManager, invoiceId: string): Promise<Invoice> {
const invoice = await manager
.createQueryBuilder(Invoice, "invoice")
.setLock("pessimistic_write")
.where("invoice.id = :invoiceId", { invoiceId })
.getOne();
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
return invoice;
}
private async lockSystemState(manager: EntityManager, systemNumber: string): Promise<EimsSystemState> {
const select = () =>
manager
.createQueryBuilder(EimsSystemState, "state")
.setLock("pessimistic_write")
.where("state.system_number = :systemNumber", { systemNumber })
.getOne();
const existing = await select();
if (existing) return existing;
await manager.query(
`INSERT INTO freight.eims_system_state (system_number) VALUES ($1) ON CONFLICT (system_number) DO NOTHING`,
[systemNumber],
);
const created = await select();
if (!created) throw new Error(`Could not initialise EIMS system state for ${systemNumber}`);
return created;
}
private async loadInvoicesForMapping(invoiceIds: string[]): Promise<Array<Invoice & { lines: EimsMapperLine[] }>> {
const invoices = await this.dataSource.getRepository(Invoice).find({
where: { id: In(invoiceIds) },
relations: { company: true, companyProfile: true, relatedInvoice: true },
});
const found = new Set(invoices.map((inv) => inv.id));
const missing = invoiceIds.filter((id) => !found.has(id));
if (missing.length > 0) {
throw new NotFoundException(`Invoice(s) not found: ${missing.join(", ")}`);
}
const lines: Array<EimsMapperLine & { invoiceId: string }> = await this.dataSource.query(
`SELECT invoice_id AS "invoiceId", charge_type AS "chargeType", description, quantity,
unit_rate AS "unitRate", amount, currency, metadata
FROM freight.invoice_lines
WHERE invoice_id = ANY($1) AND deleted_at IS NULL
ORDER BY created_at ASC`,
[invoiceIds],
);
const linesByInvoice = new Map<string, EimsMapperLine[]>();
for (const line of lines) {
const { invoiceId, ...rest } = line;
if (!linesByInvoice.has(invoiceId)) linesByInvoice.set(invoiceId, []);
linesByInvoice.get(invoiceId)!.push(rest);
}
// Preserve the caller's given order — reservation and result ordering both depend on it.
return invoiceIds.map((id) => {
const invoice = invoices.find((inv) => inv.id === id)!;
return Object.assign(invoice, { lines: linesByInvoice.get(id) ?? [] });
});
}
}

View File

@@ -9,7 +9,9 @@ import { EimsApiException } from "./eims.errors";
import { EimsInvoiceStatus } from "./eims-registration.types";
const INVOICE_ID = "11111111-1111-4111-8111-111111111111";
const OTHER_INVOICE_ID = "22222222-2222-4222-8222-222222222222";
const IRN = "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0";
const OTHER_IRN = "0af579eaef6f1e2d39fa77bd21cf8ecc64e26869275ae1c04eaa9ffea78b6c06";
const invoiceRow = (over: Partial<Invoice> = {}): Invoice =>
({
@@ -153,3 +155,105 @@ describe("EimsCancellationService.cancelInvoiceWithEims", () => {
expect(view.eimsStatus).toBe(EimsInvoiceStatus.Cancelled);
});
});
describe("EimsCancellationService.cancelBulkWithEims", () => {
it("cancels every eligible invoice in one call, matching results back by IRN", async () => {
const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID, eimsIrn: OTHER_IRN })]);
const postBearer = jest.fn().mockResolvedValue({
statusCode: 200,
body: [
{ id: 1, tin: "t", status: "C", mode: "bulk", Irn: OTHER_IRN, ReasonCode: "6", Remark: "x" },
{ id: 2, tin: "t", status: "C", mode: "bulk", Irn: IRN, ReasonCode: "1", Remark: "" },
],
});
const results = await build(db, postBearer).cancelBulkWithEims([
{ invoiceId: INVOICE_ID, reasonCode: "1" },
{ invoiceId: OTHER_INVOICE_ID, reasonCode: "6", remark: "x" },
]);
expect(postBearer).toHaveBeenCalledWith("/v1/bulkCancel", [
{ Irn: IRN, ReasonCode: "1", Remark: "" },
{ Irn: OTHER_IRN, ReasonCode: "6", Remark: "x" },
]);
expect(results).toEqual([
{ invoiceId: INVOICE_ID, success: true, message: expect.stringContaining("cancelled") },
{ invoiceId: OTHER_INVOICE_ID, success: true, message: expect.stringContaining("cancelled") },
]);
expect(db.invoices.get(INVOICE_ID)?.eimsStatus).toBe(EimsInvoiceStatus.Cancelled);
expect(db.invoices.get(OTHER_INVOICE_ID)?.eimsStatus).toBe(EimsInvoiceStatus.Cancelled);
// Bulk success carries no cancellationDate at all, unlike single cancel.
expect(db.invoices.get(INVOICE_ID)?.eimsCancellationDate).toBeNull();
});
it("refuses an already-cancelled or never-registered invoice locally — never sent to MoR", async () => {
const db = new FakeDb([
invoiceRow({ eimsStatus: EimsInvoiceStatus.Cancelled }),
invoiceRow({ id: OTHER_INVOICE_ID, eimsStatus: EimsInvoiceStatus.NotSubmitted, eimsIrn: null }),
]);
const postBearer = jest.fn();
const results = await build(db, postBearer).cancelBulkWithEims([
{ invoiceId: INVOICE_ID, reasonCode: "1" },
{ invoiceId: OTHER_INVOICE_ID, reasonCode: "1" },
]);
expect(postBearer).not.toHaveBeenCalled();
expect(results).toEqual([
{ invoiceId: INVOICE_ID, success: false, message: expect.stringContaining("already cancelled") },
{ invoiceId: OTHER_INVOICE_ID, success: false, message: expect.stringContaining("never registered") },
]);
});
it("a mix of MoR success and rejection only updates the succeeding invoice", async () => {
const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID, eimsIrn: OTHER_IRN })]);
const postBearer = jest.fn().mockResolvedValue({
statusCode: 200,
body: [
{ id: 1, tin: "t", status: "C", mode: "bulk", Irn: IRN, ReasonCode: "1", Remark: "" },
{ Status: "Processing_Error", msg: "IRN already Canceled.", Irn: OTHER_IRN },
],
});
const results = await build(db, postBearer).cancelBulkWithEims([
{ invoiceId: INVOICE_ID, reasonCode: "1" },
{ invoiceId: OTHER_INVOICE_ID, reasonCode: "1" },
]);
expect(db.invoices.get(INVOICE_ID)?.eimsStatus).toBe(EimsInvoiceStatus.Cancelled);
expect(db.invoices.get(OTHER_INVOICE_ID)?.eimsStatus).toBe(EimsInvoiceStatus.Registered);
expect(results).toEqual([
{ invoiceId: INVOICE_ID, success: true, message: expect.stringContaining("cancelled") },
{ invoiceId: OTHER_INVOICE_ID, success: false, message: "IRN already Canceled." },
]);
});
it("makes no HTTP call at all when every item fails the local eligibility check", async () => {
const db = new FakeDb([invoiceRow({ eimsStatus: EimsInvoiceStatus.Cancelled })]);
const postBearer = jest.fn();
await build(db, postBearer).cancelBulkWithEims([{ invoiceId: INVOICE_ID, reasonCode: "1" }]);
expect(postBearer).not.toHaveBeenCalled();
});
it("notifies the buyer only for invoices that actually got cancelled", async () => {
const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID, eimsIrn: OTHER_IRN })]);
db.companyContact = { phone: "+251911000000", email: null };
const directSend = jest.fn().mockResolvedValue(undefined);
const postBearer = jest.fn().mockResolvedValue({
statusCode: 200,
body: [
{ status: "C", Irn: IRN },
{ Status: "Processing_Error", msg: "boom", Irn: OTHER_IRN },
],
});
await build(db, postBearer, directSend).cancelBulkWithEims([
{ invoiceId: INVOICE_ID, reasonCode: "1" },
{ invoiceId: OTHER_INVOICE_ID, reasonCode: "1" },
]);
expect(directSend).toHaveBeenCalledTimes(1);
});
});

View File

@@ -6,7 +6,15 @@ import { Invoice } from "../billing/entities/invoice.entity";
import { NotificationsService } from "../notifications/notifications.service";
import { sendCompanyChannels } from "../notifications/notify-company.util";
import { EimsClientService } from "./eims-client.service";
import { EimsCancelRequest, EimsCancelResponse, EimsInvoiceStatus, EimsInvoiceStatusView } from "./eims-registration.types";
import {
EimsBulkCancelItemResult,
EimsBulkCancelRequest,
EimsBulkCancelResponse,
EimsCancelRequest,
EimsCancelResponse,
EimsInvoiceStatus,
EimsInvoiceStatusView,
} from "./eims-registration.types";
import { toEimsInvoiceStatusView } from "./eims-invoice-view.util";
/**
@@ -92,6 +100,102 @@ export class EimsCancellationService {
return this.getEimsCancellationStatus(invoiceId);
}
/**
* `POST /v1/bulkCancel` — one MoR call for every eligible invoice in `items`, matching the
* collection's own shape (an array in, an array of mixed success/error results back).
*
* Same local-eligibility doctrine as `cancelInvoiceWithEims`, applied per item before anything
* goes to MoR: an already-cancelled or never-registered invoice is refused right here (no HTTP
* call, no seat in the batch) rather than sent and rejected remotely. Only genuinely eligible
* invoices are batched into the one `/v1/bulkCancel` request; everything else is reported back
* immediately.
*
* ponytail: the eligibility pass is per-invoice transactions, not one covering the whole batch —
* same reasoning as the single-cancel path (cancel is idempotent at MoR, so a lock held across
* every item for the whole call isn't needed for correctness, only for avoiding a wasted call on
* an item that's already ineligible).
*/
async cancelBulkWithEims(
items: Array<{ invoiceId: string; reasonCode: string; remark?: string }>,
): Promise<EimsBulkCancelItemResult[]> {
const results = new Map<string, EimsBulkCancelItemResult>();
const eligible: Array<{ invoice: Invoice; reasonCode: string; remark?: string }> = [];
for (const item of items) {
try {
const invoice = await this.dataSource.transaction(async (manager) => {
const inv = await this.lockInvoice(manager, item.invoiceId);
if (inv.eimsStatus === EimsInvoiceStatus.Cancelled) {
throw new ConflictException(
`Invoice ${inv.invoiceNumber} was already cancelled with EIMS${inv.eimsCancellationDate ? ` (${inv.eimsCancellationDate})` : ""}.`,
);
}
if (!inv.eimsIrn) {
throw new BadRequestException(
`Invoice ${inv.invoiceNumber} was never registered with EIMS — nothing to cancel.`,
);
}
return inv;
});
eligible.push({ invoice, reasonCode: item.reasonCode, remark: item.remark });
} catch (err) {
results.set(item.invoiceId, {
invoiceId: item.invoiceId,
success: false,
message: (err as Error).message,
});
}
}
if (eligible.length > 0) {
const request: EimsBulkCancelRequest = eligible.map((e) => ({
Irn: e.invoice.eimsIrn!,
ReasonCode: e.reasonCode,
Remark: e.remark ?? "",
}));
// Outside any transaction — no DB lock held across the wire, same as single cancel.
const response = await this.client.postBearer<EimsBulkCancelRequest, EimsBulkCancelResponse>(
"/v1/bulkCancel",
request,
);
const byIrn = new Map((response?.body ?? []).map((entry) => [entry.Irn, entry]));
for (const { invoice, reasonCode, remark } of eligible) {
const entry = byIrn.get(invoice.eimsIrn!);
const failed = !entry || "Status" in entry;
if (failed) {
const message = entry && "msg" in entry ? entry.msg : "EIMS bulk cancel returned no result for this invoice.";
this.logger.warn(`Invoice ${invoice.invoiceNumber} bulk cancel failed: ${message}`);
results.set(invoice.id, { invoiceId: invoice.id, success: false, message });
continue;
}
await this.dataSource.transaction(async (manager) => {
const fresh = await this.lockInvoice(manager, invoice.id);
// Re-checked under lock: a concurrent call may have already recorded this cancellation.
if (fresh.eimsStatus === EimsInvoiceStatus.Cancelled) return;
await manager.update(Invoice, invoice.id, {
eimsStatus: EimsInvoiceStatus.Cancelled,
eimsCancelledAt: new Date(),
// The bulk success shape carries no cancellationDate at all, unlike single cancel.
eimsCancellationDate: null,
eimsCancellationReasonCode: reasonCode,
eimsCancellationRemark: remark ?? null,
});
});
this.logger.log(`Invoice ${invoice.invoiceNumber} cancelled with EIMS via bulk (IRN ${invoice.eimsIrn})`);
await this.notifyBuyer(invoice);
results.set(invoice.id, {
invoiceId: invoice.id,
success: true,
message: `Invoice ${invoice.invoiceNumber} cancelled with EIMS.`,
});
}
}
return items.map((item) => results.get(item.invoiceId)!);
}
async getEimsCancellationStatus(invoiceId: string): Promise<EimsInvoiceStatusView> {
const invoice = await this.dataSource.manager.findOne(Invoice, { where: { id: invoiceId } });
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);

View File

@@ -5,10 +5,13 @@ import type { Response } from "express";
import { BookingStaff } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { sendPdf } from "../billing/billing.controller";
import { BulkCancelEimsRegistrationDto } from "./dto/bulk-cancel-eims-registration.dto";
import { BulkRegisterEimsInvoiceDto } from "./dto/bulk-register-eims-invoice.dto";
import { CancelEimsRegistrationDto } from "./dto/cancel-eims-registration.dto";
import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto";
import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto";
import { ResolveEimsRegistrationDto } from "./dto/resolve-eims-registration.dto";
import { EimsBulkRegistrationService } from "./eims-bulk-registration.service";
import { EimsCancellationService } from "./eims-cancellation.service";
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
import { EimsReceiptService } from "./eims-receipt.service";
@@ -38,6 +41,7 @@ import { EimsReceiptService } from "./eims-receipt.service";
export class EimsInvoiceController {
constructor(
private readonly registration: EimsInvoiceRegistrationService,
private readonly bulkRegistration: EimsBulkRegistrationService,
private readonly cancellation: EimsCancellationService,
private readonly receipts: EimsReceiptService,
) {}
@@ -52,6 +56,18 @@ export class EimsInvoiceController {
return this.registration.registerInvoiceWithEims(id);
}
@Post("eims/bulk-register")
@BookingStaff(FREIGHT_PERMS.invoices.eimsRegister)
@ApiOperation({
summary:
"Submit multiple invoices to MoR EIMS in one call. Asynchronous — this only confirms MoR " +
"accepted the batch (a conversation id), not the per-invoice outcome. Real results (IRN or " +
"rejection per invoice) arrive later via MoR's own callback; poll GET :id/eims/status.",
})
bulkRegister(@Body() dto: BulkRegisterEimsInvoiceDto) {
return this.bulkRegistration.registerBulk(dto.invoiceIds);
}
@Post(":id/eims/verify")
@BookingStaff(FREIGHT_PERMS.invoices.eimsRegister)
@ApiOperation({ summary: "Verify the invoice's stored IRN against EIMS" })
@@ -89,6 +105,17 @@ export class EimsInvoiceController {
return this.cancellation.cancelInvoiceWithEims(id, dto.reasonCode, dto.remark);
}
@Post("eims/bulk-cancel")
@BookingStaff(FREIGHT_PERMS.invoices.eimsCancel)
@ApiOperation({
summary:
"Cancel multiple invoices' registered EIMS documents in one call. Each invoice's outcome is " +
"reported independently — one failure never blocks the rest.",
})
bulkCancel(@Body() dto: BulkCancelEimsRegistrationDto) {
return this.cancellation.cancelBulkWithEims(dto.items);
}
@Post(":id/eims/receipt/sales")
@BookingStaff(FREIGHT_PERMS.invoices.eimsReceiptRegister)
@ApiOperation({ summary: "Register a sales receipt with MoR EIMS against a registered invoice" })

View File

@@ -1,3 +1,4 @@
import { EimsInvoiceRequest } from "../billing/eims-invoice.mapper";
import { EimsErrorResponse } from "./eims.types";
/**
@@ -89,6 +90,102 @@ export interface EimsCancelResponse {
body?: EimsCancelResponseBody;
}
/** `POST /v1/bulkCancel` — an array of the same `Irn`/`ReasonCode`/`Remark` shape as single cancel. */
export type EimsBulkCancelRequest = EimsCancelRequest[];
/**
* One element of a `/v1/bulkCancel` response array — MoR mixes success and error shapes in the same
* array, one entry per submitted IRN, disambiguated by `Status` (capital, error) vs `status`
* (lowercase, success — always `"C"`). Unlike single cancel, a bulk success carries no
* `cancellationDate` at all.
*/
export interface EimsBulkCancelSuccessItem {
id?: number;
tin?: string;
status: string;
mode?: string;
Irn: string;
ReasonCode?: string;
Remark?: string;
}
export interface EimsBulkCancelErrorItem {
Status: string;
msg: string;
Irn: string;
}
export type EimsBulkCancelResponseItem = EimsBulkCancelSuccessItem | EimsBulkCancelErrorItem;
export interface EimsBulkCancelResponse {
statusCode?: number;
message?: string;
body?: EimsBulkCancelResponseItem[];
}
/** One invoice's outcome from `cancelBulkWithEims` — local eligibility failure or MoR's own result. */
export interface EimsBulkCancelItemResult {
invoiceId: string;
success: boolean;
message: string;
}
/**
* `POST /v1/bulkRegister` — same array-of-full-documents shape as single register (`EimsInvoiceRequest`
* from `eims-invoice.mapper.ts`), one element per invoice, sent as one signed envelope.
*/
export type EimsBulkRegisterRequest = EimsInvoiceRequest[];
/**
* Immediate response to `bulkRegister` — unlike single register, this is not the result, just an
* acknowledgement. The real per-invoice outcomes arrive later via `EimsBulkCallbackItem`s pushed to
* a webhook MoR was configured with out of band (see `EimsBulkRegistrationService`).
*/
export interface EimsBulkRegisterAcceptedResponse {
conversationId: string;
status: number;
}
/** A settled item in the async callback — `irn` present means MoR accepted this document. */
export interface EimsBulkCallbackSuccessItem {
irn: string;
status: string;
documentNumber: string;
signedQR?: string;
signedInvoice?: string;
}
/** A rejected item in the async callback — `docNo` echoes what we submitted as `DocumentNumber`. */
export interface EimsBulkCallbackErrorItem {
ruleError: Array<{ portion: string; errorMessage: string[] }>;
status: string;
docNo: string;
}
/**
* The callback array's last element, per the collection's own examples — never a settlement result,
* just the batch id echoed back. Spelled two different ways across the collection's own docs
* ("conversationId" on the initial 202, "conversionId" in the callback examples); accept both.
*/
export interface EimsBulkCallbackMarker {
conversationId?: string;
conversionId?: string;
}
export type EimsBulkCallbackItem =
| EimsBulkCallbackSuccessItem
| EimsBulkCallbackErrorItem
| EimsBulkCallbackMarker;
/** One invoice's outcome once a bulk batch's callback has been processed. */
export interface EimsBulkRegisterItemResult {
invoiceId: string;
invoiceNumber: string;
success: boolean;
message: string;
irn?: string;
}
/** Persisted failure detail. Carries the gateway's own error fields only — never our envelope. */
export interface EimsInvoiceError {
kind: string;

View File

@@ -0,0 +1,25 @@
import { Body, Controller, HttpCode, Post } from "@nestjs/common";
import { ApiExcludeController } from "@nestjs/swagger";
import { Public } from "@edr/api-common";
import { EimsBulkCallbackItem } from "./eims-registration.types";
import { EimsBulkRegistrationService } from "./eims-bulk-registration.service";
/**
* MoR's own callback for `POST /v1/bulkRegister`, not a route a person calls. Public — MoR has no
* JWT to send — so the conversation id embedded in the payload is the only thing standing between
* this and a forged callback: `EimsBulkRegistrationService.handleBulkCallback` only ever touches
* invoices actually holding that exact id, and an unrecognised one is logged and ignored. See the
* "Callback Mechanism" section of the collection's own docs for the payload shape.
*/
@ApiExcludeController()
@Controller("eims/webhook")
export class EimsWebhookController {
constructor(private readonly bulk: EimsBulkRegistrationService) {}
@Public()
@Post("bulk-register")
@HttpCode(200)
bulkRegisterCallback(@Body() items: EimsBulkCallbackItem[]) {
return this.bulk.handleBulkCallback(items);
}
}

View File

@@ -9,6 +9,7 @@ import { NotificationInboxModule } from "../notification-inbox/notification-inbo
import { NotificationsModule } from "../notifications/notifications.module";
import { EimsAuthService } from "./eims-auth.service";
import { EimsAutoSubmitService } from "./eims-auto-submit.service";
import { EimsBulkRegistrationService } from "./eims-bulk-registration.service";
import { EimsCancellationService } from "./eims-cancellation.service";
import { EimsClientService } from "./eims-client.service";
import { EimsCredentialsProvider } from "./eims-credentials.provider";
@@ -17,6 +18,7 @@ import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.serv
import { EimsReceiptService } from "./eims-receipt.service";
import { EimsSellerCacheService } from "./eims-seller-cache.service";
import { EimsSignerService } from "./eims-signer.service";
import { EimsWebhookController } from "./eims-webhook.controller";
import { EimsReceipt } from "./entities/eims-receipt.entity";
import { EimsSystemState } from "./entities/eims-system-state.entity";
@@ -40,13 +42,14 @@ import { EimsSystemState } from "./entities/eims-system-state.entity";
// so this stays a plain one-directional import, not a new cycle.
CompaniesModule,
],
controllers: [EimsInvoiceController],
controllers: [EimsInvoiceController, EimsWebhookController],
providers: [
EimsCredentialsProvider,
EimsSignerService,
EimsAuthService,
EimsClientService,
EimsInvoiceRegistrationService,
EimsBulkRegistrationService,
EimsAutoSubmitService,
EimsCancellationService,
EimsReceiptService,
@@ -56,6 +59,7 @@ import { EimsSystemState } from "./entities/eims-system-state.entity";
EimsAuthService,
EimsClientService,
EimsInvoiceRegistrationService,
EimsBulkRegistrationService,
EimsCancellationService,
EimsReceiptService,
],

View File

@@ -54,4 +54,11 @@ export class EimsSystemState extends BaseEntity {
*/
@Column({ name: "blocked_reason", type: "text", nullable: true })
blockedReason?: string | null;
/**
* Bulk equivalent of `in_flight_invoice_id` — a whole batch, not one invoice, is outstanding
* while MoR processes `POST /v1/bulkRegister` asynchronously. See `EimsBulkRegistrationService`.
*/
@Column({ name: "in_flight_conversation_id", type: "text", nullable: true })
inFlightConversationId?: string | null;
}

View File

@@ -84,6 +84,21 @@ export class FilesRepository extends BaseRepository<FileRecord> {
});
}
/**
* Every version of every document on a resource, oldest first — superseded
* versions included. One query for a whole document grid's upload history.
*/
findAllVersionsByResource(
resourceId: string,
resource: string,
): Promise<FileRecord[]> {
return this.repository.find({
where: { resourceId, resource },
withDeleted: true,
order: { createdAt: "ASC" },
});
}
/**
* Documents belonging to any of the given resources that a reviewer has asked
* the customer to correct. Used by the approval gate, so it takes a list of

View File

@@ -327,6 +327,14 @@ export class FilesService {
return this.filesRepository.findByResource(resourceId, resource);
}
/** All versions of every document on a resource (superseded included), oldest first. */
findAllVersionsByResource(
resourceId: string,
resource: string,
): Promise<FileRecord[]> {
return this.filesRepository.findAllVersionsByResource(resourceId, resource);
}
/**
* Files for many resources of one kind, grouped by resource id. Resources with
* no files are absent from the map (callers should default to `[]`).

View File

@@ -4,6 +4,7 @@ import { Session } from "@tria-plc/iamapi-common/entities/iam/user/session.entit
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
import { BackofficeModule } from "../backoffice/backoffice.module";
import { ChatModule } from "../chat/chat.module";
import { CompaniesModule } from "../companies/companies.module";
import { NotificationsModule } from "../notifications/notifications.module";
import { Notification } from "./entities/notification.entity";
@@ -24,6 +25,8 @@ import { WsAuthService } from "./ws-auth.service";
BackofficeModule,
// EmailClientService + SmsClientService (HIGH-priority fan-out)
NotificationsModule,
// ChatBridgeService (mirrors BACKOFFICE notifications into chat)
ChatModule,
],
controllers: [NotificationInboxController],
providers: [

View File

@@ -1,4 +1,5 @@
import {
NotificationAudience,
NotificationChannels,
NotificationChannelsSent,
NotificationDto,
@@ -11,6 +12,7 @@ import { InjectRepository } from "@nestjs/typeorm";
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
import { Repository } from "typeorm";
import { ChatBridgeService } from "../chat/chat-bridge.service";
import { EmailClientService } from "../notifications/email-client.service";
import { SmsClientService } from "../notifications/sms-client.service";
import { ListNotificationsQueryDto } from "./dto/list-notifications-query.dto";
@@ -37,6 +39,7 @@ export class NotificationInboxService {
private readonly gateway: NotificationsGateway,
private readonly emailClient: EmailClientService,
private readonly smsClient: SmsClientService,
private readonly chatBridge: ChatBridgeService,
@InjectRepository(User)
private readonly users: Repository<User>,
) {}
@@ -44,11 +47,18 @@ export class NotificationInboxService {
/**
* Fan a logical notification out to every resolved recipient: persist one row
* each, push it live over WebSocket, and (for HIGH priority) also queue
* email/SMS via the existing clients.
* email/SMS via the existing clients. BACKOFFICE-audience notifications are
* also mirrored into internal chat (ChatBridgeService) — a shared-room
* broadcast, not per-recipient, so it runs once regardless of how many (if
* any) in-app rows get created below. Never PORTAL — that's customer-facing
* and must never reach a staff room.
*/
async notify(input: NotifyInput): Promise<void> {
try {
const userIds = await this.recipients.resolve(input.recipients);
if (input.audience === NotificationAudience.BACKOFFICE) {
await this.chatBridge.bridge(input);
}
if (userIds.length === 0) {
this.logger.debug(
`notify(${input.type}) resolved 0 recipients — skipped`,

View File

@@ -0,0 +1,18 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsBoolean, IsOptional } from "class-validator";
/**
* Partial update: the UI flips one currency at a time, so an omitted field
* leaves that currency's channel exactly as it was.
*/
export class UpdateManualPaymentSettingDto {
@ApiPropertyOptional({ description: "Allow manual settlement of ETB invoices" })
@IsOptional()
@IsBoolean()
etbEnabled?: boolean;
@ApiPropertyOptional({ description: "Allow manual settlement of USD invoices" })
@IsOptional()
@IsBoolean()
usdEnabled?: boolean;
}

View File

@@ -0,0 +1,26 @@
import { BaseEntity } from "@edr/api-common";
import { Column, Entity } from "typeorm";
/**
* Single-row table controlling whether Finance may settle invoices by hand
* (bank transfer / counter payment) instead of the customer paying online.
*
* Per currency on purpose: the two channels are operationally different — USD
* bookings have always been bank-transfer-only, while ETB normally goes
* through the gateway and manual settlement is the exception. Switching one
* off must not switch off the other.
*/
@Entity({ schema: "freight", name: "manual_payment_settings" })
export class ManualPaymentSetting extends BaseEntity {
/** Manual settlement allowed for ETB invoices. */
@Column({ name: "etb_enabled", type: "boolean", default: false })
etbEnabled!: boolean;
/** Manual settlement allowed for USD invoices. */
@Column({ name: "usd_enabled", type: "boolean", default: true })
usdEnabled!: boolean;
/** IAM user id of the last operator to change either toggle. */
@Column({ name: "updated_by_id", type: "uuid", nullable: true })
updatedById?: string | null;
}

View File

@@ -0,0 +1,47 @@
import { Body, Controller, Get, Patch } from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { CurrentUser } from "@edr/api-common";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { BookingStaff } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { UpdateManualPaymentSettingDto } from "./dto/update-manual-payment-setting.dto";
import { ManualPaymentSettingsService } from "./manual-payment-settings.service";
@ApiTags("payment-settings")
@ApiBearerAuth()
@Controller("payment-settings/manual")
export class ManualPaymentSettingsController {
constructor(private readonly service: ManualPaymentSettingsService) {}
/**
* Read is gated on `manual_payment:view`, which Finance also holds — the
* Manual Payments worklist reads this to know which currency tabs to offer.
*/
@Get()
@BookingStaff([
FREIGHT_PERMS.settings.manualPayment.view,
FREIGHT_PERMS.admin,
])
@ApiOperation({
summary: "Whether manual (offline) invoice settlement is enabled, per currency",
})
get() {
return this.service.get();
}
@Patch()
@BookingStaff([
FREIGHT_PERMS.settings.manualPayment.manage,
FREIGHT_PERMS.admin,
])
@ApiOperation({
summary: "Enable or disable manual invoice settlement for ETB and/or USD",
})
update(
@Body() dto: UpdateManualPaymentSettingDto,
@CurrentUser() user: TCurrentUser,
) {
return this.service.update(dto, user?.id ?? null);
}
}

View File

@@ -0,0 +1,71 @@
import { Injectable, Logger } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { ManualPaymentSetting } from "./entities/manual-payment-setting.entity";
/** The two currencies an invoice can be settled by hand in. */
export type ManualPaymentCurrency = "ETB" | "USD";
/**
* Owns the single `manual_payment_settings` row: whether Finance may settle
* invoices by hand, per currency.
*
* Defaults mirror how the platform behaved before the toggles existed — USD
* has always been bank-transfer-only so it starts ON; ETB manual settlement is
* the new capability and starts OFF, so enabling it is a deliberate act.
*/
@Injectable()
export class ManualPaymentSettingsService {
private readonly logger = new Logger(ManualPaymentSettingsService.name);
constructor(
@InjectRepository(ManualPaymentSetting)
private readonly repository: Repository<ManualPaymentSetting>,
) {}
/** The settings row, created at the defaults on first access. */
async get(): Promise<ManualPaymentSetting> {
const existing = await this.repository.findOne({ where: {} });
if (existing) return existing;
return this.repository.save(
this.repository.create({ etbEnabled: false, usdEnabled: true }),
);
}
/** Currencies manual settlement is currently allowed for. */
async enabledCurrencies(): Promise<ManualPaymentCurrency[]> {
const setting = await this.get();
const enabled: ManualPaymentCurrency[] = [];
if (setting.etbEnabled) enabled.push("ETB");
if (setting.usdEnabled) enabled.push("USD");
return enabled;
}
/** Whether one currency may be settled by hand right now. */
async isEnabled(currency: string | null | undefined): Promise<boolean> {
const upper = currency?.toUpperCase();
if (upper !== "ETB" && upper !== "USD") return false;
const setting = await this.get();
return upper === "ETB" ? setting.etbEnabled : setting.usdEnabled;
}
/** Flip either toggle; an omitted field leaves that currency unchanged. */
async update(
patch: { etbEnabled?: boolean; usdEnabled?: boolean },
updatedById?: string | null,
): Promise<ManualPaymentSetting> {
const current = await this.get();
await this.repository.update(current.id, {
...(patch.etbEnabled === undefined ? {} : { etbEnabled: patch.etbEnabled }),
...(patch.usdEnabled === undefined ? {} : { usdEnabled: patch.usdEnabled }),
updatedById: updatedById ?? null,
});
const updated = await this.get();
this.logger.warn(
`Manual payment channels set to ETB=${updated.etbEnabled} USD=${updated.usdEnabled} by ${updatedById ?? "unknown user"}`,
);
return updated;
}
}

View File

@@ -0,0 +1,20 @@
import { Global, Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { ManualPaymentSetting } from "./entities/manual-payment-setting.entity";
import { ManualPaymentSettingsController } from "./manual-payment-settings.controller";
import { ManualPaymentSettingsService } from "./manual-payment-settings.service";
/**
* Global so billing can inject {@link ManualPaymentSettingsService} to gate
* the manual-settlement worklist and confirmation endpoint without importing
* this module (and without a cycle, since this module needs nothing back).
*/
@Global()
@Module({
imports: [TypeOrmModule.forFeature([ManualPaymentSetting])],
controllers: [ManualPaymentSettingsController],
providers: [ManualPaymentSettingsService],
exports: [ManualPaymentSettingsService],
})
export class PaymentSettingsModule {}

View File

@@ -0,0 +1,85 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Put, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '@edr/api-common';
import { StaffReference } from '../../../common/booking-guards';
import { RuleEngineUpdate, RuleEngineView } from '../../../common/rule-engine-guards';
import {
ListYardPositionsQueryDto,
SetPositionYardsDto,
SetYardPositionsDto,
} from '../dto/yard-positions.dto';
import { YardPositionsService } from '../services/yard-positions.service';
import { YardScopeService } from '../services/yard-scope.service';
/**
* Desk↔yard mapping — which positions ("departments" in the user-management
* tree) staff which yard. It is yard configuration, so it is gated by the same
* rule-engine yard keys as the rest of the yards screen.
*
* Writes REPLACE the whole set for the side being edited. The admin UI submits
* the full multi-select value; a caller sending a delta will drop everything it
* omits. Both write paths flush the scope resolver's cache so a mapping change
* takes effect on the next request instead of up to a minute later.
*/
@ApiTags('yard-positions')
@Controller('yard-positions')
@ApiBearerAuth()
export class YardPositionsController {
constructor(
private readonly service: YardPositionsService,
private readonly scope: YardScopeService,
) {}
@Get()
@RuleEngineView('yards')
@ApiOperation({ summary: 'List desk↔yard mappings, optionally by yard or position' })
list(@Query() query: ListYardPositionsQueryDto) {
return this.service.list(query);
}
@Get('positions')
@RuleEngineView('yards')
@ApiOperation({ summary: 'Positions selectable as yard desks' })
listPositions() {
return this.service.listSelectablePositions();
}
@Get('my-yards')
// Any signed-in staff member, NOT gated on the yards keys: this returns the
// caller's own access and nothing else, and the frontend needs it to
// preselect yard filters. Gating it on `rule_engine:yards:view` 403'd every
// desk that does not administer yards — i.e. exactly the users it is for.
@StaffReference()
@ApiOperation({
summary: "The caller's own yard scope (null yardIds = unrestricted)",
})
async myYards(@CurrentUser() user: unknown) {
const yardIds = await this.scope.getScopedYardIds(user as never);
return { yardIds, unrestricted: yardIds === null, enforced: this.scope.enforced };
}
@Put('yard/:yardId')
@RuleEngineUpdate('yards')
@ApiOperation({ summary: "Replace a yard's whole position set" })
async setPositionsForYard(
@Param('yardId', ParseUUIDPipe) yardId: string,
@Body() dto: SetYardPositionsDto,
) {
const rows = await this.service.setPositionsForYard(yardId, dto.positionIds);
this.scope.invalidate();
return rows;
}
@Put('position/:positionId')
@RuleEngineUpdate('yards')
@ApiOperation({ summary: "Replace a position's whole yard set" })
async setYardsForPosition(
@Param('positionId', ParseUUIDPipe) positionId: string,
@Body() dto: SetPositionYardsDto,
) {
const rows = await this.service.setYardsForPosition(positionId, dto.yardIds);
this.scope.invalidate();
return rows;
}
}

View File

@@ -0,0 +1,30 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsArray, IsOptional, IsUUID } from 'class-validator';
export class ListYardPositionsQueryDto {
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
yardId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
positionId?: string;
}
/** Replaces the yard's whole position set — see the controller's PUT docs. */
export class SetYardPositionsDto {
@ApiProperty({ type: [String], format: 'uuid' })
@IsArray()
@IsUUID('4', { each: true })
positionIds!: string[];
}
/** Replaces the position's whole yard set. */
export class SetPositionYardsDto {
@ApiProperty({ type: [String], format: 'uuid' })
@IsArray()
@IsUUID('4', { each: true })
yardIds!: string[];
}

View File

@@ -0,0 +1,28 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Yard } from './yard.entity';
/**
* One desk staffed at one yard.
*
* The pairing that yard access scoping resolves against: a caller's active
* position decides which yards they may touch. Position rows live in `iam`
* (`iam.positions` — what the user-management tree labels "departments"), so
* `positionId` is an unconstrained uuid by design; see the migration for why.
*/
@Entity({ schema: 'freight', name: 'yard_positions' })
@Index(['yardId'])
@Index(['positionId'])
export class YardPosition extends BaseEntity {
@Column({ name: 'yard_id', type: 'uuid' })
yardId!: string;
@ManyToOne(() => Yard, { nullable: false, onDelete: 'CASCADE' })
@JoinColumn({ name: 'yard_id' })
yard?: Yard;
/** `iam.positions.id`. No FK — IAM is package-owned and soft-deletes. */
@Column({ name: 'position_id', type: 'uuid' })
positionId!: string;
}

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