diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index b1c797dc6..b6a7fdb69 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -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"
;;
diff --git a/CLAUDE.md b/CLAUDE.md
index b90d3b1dd..a20fbaee7 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,102 +1,362 @@
# EDR Platform — Developer Guide
+> This file is the contract. If something here contradicts the code, the code is the
+> truth and this file is a bug — fix it in the same PR.
+
+**Looking for where something lives? Read [`docs/MAP.md`](docs/MAP.md) first.** It routes
+you to the right module or page without a repo-wide grep.
+
## Overview
-Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Freight Management and Passenger Management applications, plus shared types, NestJS utilities, and React component libraries.
+Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Freight
+Management and Passenger Management applications, a payment microservice, plus shared
+types, NestJS utilities, and React component libraries.
+
+The freight domain is the largest and most active area. Its core flow is:
+**booking → receive to warehouse → store → load onto train → dispatch → arrive → unload
+→ customer truck (self-haul) or EDR last mile → handover → exit paper → delivered.**
+Fees (storage, demurrage, double handling, truck detention) and allocation rules
+(warehouse/yard/zone) hang off the warehouse stage.
## Apps
-| App | Package name | Purpose | Port |
-| ------------------------------ | --------------------------- | -------------------------------------------------- | ---- |
-| `edr-freight-api` | `@edr/freight-api` | NestJS API for freight management | 3001 |
-| `edr-freight-web/portal` | `@edr/freight-portal` | React frontend for freight customer/portal users | 5173 |
-| `edr-freight-web/backoffice` | `@edr/freight-backoffice` | React frontend for freight backoffice employees | 5183 |
-| `edr-passenger-api` | `@edr/passenger-api` | NestJS API for passenger management | 3002 |
-| `edr-payment-api` | `@edr/payment-api` | NestJS payment microservice (intents, webhooks) | 3003 |
-| `edr-passenger-web/portal` | `@edr/passenger-portal` | React frontend for passenger customer/portal users | 5174 |
-| `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | React frontend for passenger backoffice employees | 5184 |
+The two domains are **not built the same way**. Check which stack you are in before
+copying a pattern across:
-`edr-freight-web` and `edr-passenger-web` are grouping folders, not workspace packages. Each holds a `portal/` and `backoffice/` sub-app, both of which are independent pnpm workspace packages (declared in `pnpm-workspace.yaml`). The existing `pnpm dev:freight` / `pnpm dev:passenger` turbo filters (`@edr/freight-*` / `@edr/passenger-*`) cover all four web apps + their APIs.
+| App | Package name | Stack | Default port |
+| ------------------------------ | --------------------------- | ---------------------- | ------------ |
+| `edr-freight-api` | `@edr/freight-api` | NestJS + **TypeORM** | 3001 |
+| `edr-freight-web/portal` | `@edr/freight-portal` | React + **Vite** | 5273 |
+| `edr-freight-web/backoffice` | `@edr/freight-backoffice` | React + **Vite** | 5283 |
+| `edr-passenger-api` | `@edr/passenger-api` | NestJS + **Prisma** | 4000 |
+| `edr-passenger-web/portal` | `@edr/passenger-portal` | **Next.js** | 5174 |
+| `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | **Next.js** | 5184 |
+| `edr-payment-api` | `@edr/payment-api` | NestJS + **TypeORM** | 3003 |
+
+Those are the **fallbacks compiled into the code**, not what you will be running. Every
+port is overridden by `PORT` in the app's `.env` / `.env.development`; the freight vite
+apps read it in `vite.config.ts` (`Number(env.PORT) || 5273`). This machine is shared by
+the whole team and the low ports are contested — see the workspace root `CLAUDE.md` and
+`./wt ports` for who currently holds what.
+
+`edr-freight-web` and `edr-passenger-web` are grouping folders, not workspace packages.
+Each holds a `portal/` and `backoffice/` sub-app, both independent pnpm workspace
+packages (see `pnpm-workspace.yaml`).
+
+`apps/edr-landing/` exists on disk but has **no `package.json`** — it is not a workspace
+package and is not built, linted, or type-checked. Leave it alone unless asked.
+
+`apps/edr-gps-tracker/` is a separate service with its own `.env.example`.
## Packages
-| Package | Purpose |
-| ---------------------- | ---------------------------------------------------------------------------------- |
-| `@edr/types` | Shared TypeScript interfaces and enums |
-| `@edr/api-common` | Shared NestJS decorators, filters, interceptors, pipes, BaseEntity, BaseRepository |
-| `@edr/iam-seed` | IAM baseline seeder for the apps sharing the `iam` schema (freight + passenger) |
-| `@edr/ui-common` | Shared React components and theme |
-| `@edr/eslint-config` | Shared ESLint configurations (base/nestjs/react) |
-| `@edr/tsconfig` | Shared TypeScript configurations |
-| `@edr/prettier-config` | Shared Prettier configuration |
+| Package | Location | Purpose |
+| ----------------------- | ----------------------------- | ------------------------------------------------------------- |
+| `@edr/types` | `packages/types` | Shared TypeScript interfaces and enums |
+| `@edr/api-common` | `packages/api-common` | NestJS decorators, filters, interceptors, pipes, BaseEntity, BaseRepository |
+| `@edr/ui-common` | `packages/ui-common` | Shared React components and theme |
+| `@edr/iam-seed` | `packages/iam-seed` | IAM baseline seeder for apps sharing the `iam` schema |
+| `@edr/payment-providers`| `packages/payment-providers` | Payment gateway integrations |
+| `@edr/eslint-config` | `packages/config/eslint-config` | Shared ESLint configs (base/nestjs/react) |
+| `@edr/tsconfig` | `packages/config/tsconfig` | Shared TypeScript configs |
+| `@edr/prettier-config` | `packages/config/prettier-config` | Shared Prettier config |
+
+The three `config/*` packages are nested one level deeper than the rest — `packages/config`
+itself is not a package.
+
+**`@edr/types` is consumed as its built `dist/`** (`main: ./dist/index.js`). Editing a
+type in `packages/types/src` changes nothing for consumers until you rebuild:
+
+```bash
+pnpm turbo build --filter=@edr/types
+```
+
+If a type-check fails on a field you just added to `@edr/types`, this is why.
## Commands
-| Command | Description |
-| -------------------- | ---------------------------------- |
-| `pnpm install` | Install all workspace dependencies |
-| `pnpm dev` | Run every app in dev mode |
-| `pnpm dev:freight` | Run only freight API + web |
-| `pnpm dev:passenger` | Run only passenger API + web |
-| `pnpm build` | Build every package and app |
-| `pnpm test` | Run all tests |
-| `pnpm lint` | Lint everything |
-| `pnpm type-check` | Type-check every package |
-| `pnpm format` | Format all files with Prettier |
+| Command | Description |
+| ----------------------------- | ---------------------------------------- |
+| `pnpm install` | Install all workspace dependencies |
+| `pnpm dev` | Run every app in dev mode |
+| `pnpm dev:freight` | Freight API + portal + backoffice |
+| `pnpm dev:freight:api` | Freight API only |
+| `pnpm dev:freight:portal` | Freight portal only |
+| `pnpm dev:freight:backoffice` | Freight backoffice only |
+| `pnpm dev:passenger` | Passenger API + web |
+| `pnpm dev:payment` | Payment API |
+| `pnpm build` | Build every package and app |
+| `pnpm test` | Run all tests (turbo) |
+| `pnpm type-check` | Type-check every package |
+| `pnpm format` | Format all files with Prettier |
+| `pnpm lint` | **Does not work** — see below |
-## Standards
+**`pnpm lint` fails.** `eslint` is not installed anywhere in the workspace, so
+`turbo run lint` dies with `eslint: not found` even though every package declares a
+`lint` script and `@edr/eslint-config` exists. Until someone adds the dependency,
+tsc's `noUnusedLocals` is the only working unused-code check. Do not claim a change is
+"lint clean".
-- **TypeScript strict mode** is enabled in every package and app.
-- **pnpm** is the only supported package manager — never run `npm install` or `yarn`.
-- **Conventional commits** are enforced via commitlint on every commit.
-- **NestJS modules** follow the 4-layer pattern: `module → controller → service → repository` (entities and DTOs live alongside).
+`pnpm format` uses bare `prettier`, which ignores `@edr/prettier-config` — it is wired to
+nothing. On the single-quoted passenger apps it will re-quote the whole file. Pass
+`--config` explicitly there.
+
+Prefer targeted turbo filters over whole-repo runs — they are minutes faster:
+
+```bash
+pnpm turbo type-check --filter=@edr/freight-api --filter=@edr/freight-backoffice
+```
+
+`apps/edr-freight-api` also carries many `seed:*` scripts (demo bookings, wagons, trains,
+gate-pass scenarios). Read the script before running one; several write real rows.
+
+## Environment & database
+
+- Postgres is **external**. There is no postgres service in `docker-compose.yaml`, and
+ no port `5433`/`5434` is published anywhere in the repo.
+- Freight API connection comes from `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`,
+ `DB_NAME` (defaults: `localhost:5433`, `edr_freight`). Development points these at a
+ remote database.
+- The connection sits behind a **connection pooler**. Do **not** pass
+ `extra.options: '-c search_path=…'` — the pooler rejects it with
+ `08P01 unsupported startup parameter in options: search_path`. `search_path` is applied
+ per-connection in a pool `connect` handler instead. See
+ `apps/edr-freight-api/src/config/database.config.ts` before touching connection options.
+- Each app owns its own database. **No cross-database joins**; cross-domain data flows
+ through API calls or message queues.
+- IAM tables live in their own `iam` schema (`iam.users`, `iam.user_credentials`),
+ freight tables in `freight`.
+- `psql` is not installed on the dev machine. To query the database, use the `edr-db`
+ skill (below) or write a short Node script using `pg` and run it from
+ `apps/edr-freight-api`, where `pg` resolves.
+
+## Hard rules
+
+These are non-negotiable. Everything else is a strong default.
+
+- **pnpm only.** Never run `npm install` or `yarn`.
+- **TypeScript strict mode** is on in every package and app. Do not weaken it, and do not
+ reach for `any` to make an error go away.
+- **Never `synchronize: true`.** Not in production, not anywhere. It is currently `false`
+ in every config and it has already corrupted this database twice (see *Migrations*).
+ All schema changes go through migrations.
- **All entities** use UUID primary keys (`@PrimaryGeneratedColumn('uuid')`).
-- **All entities** have `createdAt`, `updatedAt`, `deletedAt` (soft delete) via `@edr/api-common`'s `BaseEntity`.
-- **All columns** use `snake_case` in the database (`@Column({ name: 'snake_case' })`); TypeScript properties use `camelCase`.
-- **Never use `synchronize: true`** in production database config. All schema changes go through TypeORM migrations.
-- **ESLint + Prettier** run on pre-commit via Husky + lint-staged.
-- **Services** never inject TypeORM `Repository` directly — they inject the custom repository class.
-- **Controllers** never contain business logic.
+- **All entities** extend `BaseEntity` from `@edr/api-common` — `createdAt`, `updatedAt`,
+ `deletedAt` (soft delete).
+- **All columns** are `snake_case` in the database (`@Column({ name: 'snake_case' })`);
+ TypeScript properties are `camelCase`.
+- **Controllers contain no business logic.** They validate, delegate, and shape the response.
+- **Conventional commits.** `fix(warehouses): …`, `feat(bookings): …`.
+- **Do not commit or push unless asked.** Propose the change; let the human decide when it lands.
+- **Do not break working behaviour to add new behaviour.** When a fix is risky, say so and
+ offer the safe version.
-## Auth
+## Architecture
-Authentication is handled by an external package (`@edr/iamui-common` or equivalent) that will be integrated later. **Do not** implement any auth, login, logout, JWT verification, password hashing, or user management code in this repo.
+### NestJS module shape
-When auth integration is needed, use placeholder TODO comments:
+`module → controller → service → repository`, with `entities/` and `dto/` alongside.
+`docs/MAP.md` lists the ~60 freight modules grouped by domain.
-- `// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth`
-- `// TODO: integrate @edr/auth — replace stub @CurrentUser with real one`
+### Data access — the real model
-The `@CurrentUser`, `@Roles`, and `@Public` decorators in `@edr/api-common` are bare metadata setters with no guard wiring — they exist so controllers can be annotated correctly without depending on auth infrastructure yet.
+There are two sanctioned ways to read and write, and you must pick the right one:
-## Port Assignments
+1. **Entity CRUD → the custom repository class.** Extends `BaseRepository` from
+ `@edr/api-common`. Services inject the repository class, never `Repository` directly.
+2. **Read projections, queue endpoints, cross-table reports → raw SQL** via
+ `this.dataSource.query(...)` or `manager.query(...)` inside a transaction.
-- `edr-freight-api`: 3001
-- `edr-freight-web/portal`: 5173
-- `edr-freight-web/backoffice`: 5183
-- `edr-passenger-api`: 3002
-- `edr-payment-api`: 3003
-- `edr-passenger-web/portal`: 5174
-- `edr-passenger-web/backoffice`: 5184
+Raw SQL is normal here, not a smell — the warehouse and scheduling modules are built on it.
+It carries one obligation:
-## Database Layout
+> **HARD RULE — validate every raw SQL statement against a real database before you ship it.**
+> A typo'd column name is a runtime 500 that no type-checker will catch. Run it through
+> `EXPLAIN` against the dev database. Column drift is real (see *Migrations*).
-- `postgres-freight` (port 5433): database `edr_freight` — freight API only.
-- `postgres-passenger` (port 5434): database `edr_passenger` — passenger API only.
-- `edr_payment` schema — lives in the same Postgres database as the domain system (whatever the passenger `DATABASE_URL` points at) but is owned exclusively by `apps/edr-payment-api`. Dedicated DB user, no cross-schema FKs, domain apps have no grants on it (see `docs/payment-service/`).
-- Each app owns its own DB. No cross-database joins; cross-domain data flows through API calls or message queues.
+Writes inside a transaction use `manager.getRepository(Entity)`, not the injected repository,
+so they join the caller's transaction.
+
+**Never do slow I/O inside a database transaction.** Queue the work and fan it out after
+commit. An SMS awaited inside a transaction once held capacity locks open for the whole
+gateway timeout. Any outbound HTTP call must set an explicit `timeout` — axios defaults to
+no timeout and will wait forever.
+
+### Migrations
+
+Migrations are the most dangerous surface in this repo. Two production-grade incidents have
+already come from it. **Freight and payment use TypeORM migrations; passenger uses Prisma**
+(`apps/edr-passenger-api/prisma/migrations`) — the rules below are about the TypeORM side.
+
+- `migrationsRun: false` — **migrations do NOT run on API boot.** They run as a separate
+ one-shot step, via the Dockerfile's `migration` build target (`docker build --target
+ migration`), with `migrationsTransactionMode: 'each'`.
+ - CI: `.github/workflows/deploy.yml` builds the `migration` image and runs it
+ (`docker run --rm --env-file ...`) *before* building/deploying the app image.
+ - e2e: `docker-compose.e2e.yaml`'s `freight-migration-e2e` service runs once and
+ `freight-api-e2e` depends on it (`condition: service_completed_successfully`).
+ - Local dev (`docker-compose.yaml`) has no equivalent migration service yet — run
+ migrations yourself before `docker compose up freight-api`, e.g.
+ `docker build --target migration -f apps/edr-freight-api/Dockerfile -t freight-migration .`
+ then `docker run --rm --env-file apps/edr-freight-api/.env freight-migration`. Don't
+ use `pnpm run migrate` for this — it runs via `ts-node`, which never writes compiled
+ output to `dist/`, and the freight migrations glob only matches `dist/migrations/*.js`.
+ It silently applies zero freight migrations while exiting 0.
+- Consequences you must design for:
+ - A watch-mode hot reload does **not** re-run migrations. If you add a column that new
+ code reads, apply it to the dev database yourself (idempotently) or fully restart.
+ - `apps/edr-freight-api/src/config/database.config.ts`'s `iamEntities` array is a
+ hand-maintained list of `@tria-plc/iamapi-common` entity classes. The live app never
+ notices when it's stale (`autoLoadEntities: true` papers over gaps via IAM's own
+ `forFeature()` registrations), but the standalone migration `DataSource`
+ (`data-source.ts`, no `autoLoadEntities`) does not have that fallback — a missing
+ entity throws `Entity metadata for X#y was not found` at `initialize()`, before a
+ single migration runs. **Every `@tria-plc/iamapi-common` version bump is a candidate
+ for this to break again** — diff the package's entity classes against `iamEntities`
+ when bumping it.
+- **Give every migration a unique timestamp.** `apps/edr-freight-api/src/migrations` holds
+ 39 files, and 8 timestamps are shared by two or more of them. TypeORM orders by timestamp
+ and breaks ties non-deterministically. Check before adding one:
+
+ ```bash
+ ls apps/edr-freight-api/src/migrations | grep -oE '^[0-9]+' | sort | uniq -d
+ ```
+
+ The prefix must be unused *and* higher than the newest recorded row. Note the
+ `freight.migrations` table has far more rows (~309) than this folder has files — most
+ come from `@tria-plc/iamapi-common`'s own migrations, which run from the same data source.
+- **Write idempotent DDL**: `ADD COLUMN IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`, and
+ backfills guarded by `WHERE col IS NULL`.
+- **Never assume a recorded migration actually applied.** `AddGrnNumberToWarehouseInventory`
+ was recorded in `migrations` while its column was absent — it had been dropped out of band.
+ TypeORM will never re-run a recorded migration, so the fix is a *new repair migration*.
+- **A repair migration's `down()` should be a no-op.** Reverting a repair must not
+ re-introduce the outage it fixed.
+
+### Auth
+
+Auth **is implemented in this repo.** Do not add TODO stubs, and do not write your own.
+
+- `@CurrentUser()` (`@edr/api-common`) is a real `createParamDecorator`, not a metadata stub.
+- Route protection uses `@UseGuards(JwtGuard)` and `@UseGuards(PermissionGuard([...]))`.
+- Freight-domain checks use `hasFreightPermission(user, FREIGHT_PERMS. .)`.
+- Permissions are declared in `apps/edr-freight-api/src/seed/freight-permissions.registry.ts`.
+ Add a permission there before referencing it.
+- Login is freight-api's own `POST /api/auth/login` (SharedAuthModule from
+ `@tria-plc/api-common`). Every login call needs an **`x-client-app` header** —
+ `backoffice` for employees, `portal` for customers. Without it the API 403s with
+ "Missing or unrecognized x-client-app header". Browsers send it; curl must add it.
+- IAM has its own migrations, run ahead of freight migrations from the same data source, and
+ its own CLI scripts (`iam:migration:run`, `iam:seed:run`).
+
+Ownership checks are separate from permission checks. A staff user passes
+`hasFreightPermission`; a customer must additionally pass an ownership assertion such as
+`assertCustomerCanAccessBooking`. Do not drop the ownership check because the permission check passed.
+
+## Frontend conventions
+
+- The **freight** web apps use **Mantine v9** (`^9.3.0`). Its APIs differ from v6/v7 —
+ check the installed version before copying a snippet.
+- `@edr/ui-common` holds shared components and theme; it is imported in ~94 files across the
+ freight web apps. Prefer it over re-implementing a component.
+- **Blob downloads need the async error decoder.** A request with `responseType: 'blob'`
+ delivers the JSON error body as a `Blob`, so the synchronous `extractErrorMessage` finds no
+ `.message` and degrades to `"Request failed with status code 400"`. Use
+ `await extractDownloadErrorMessage(error)` in every PDF/blob catch block. Mutation catches
+ keep the synchronous version — their bodies are already parsed JSON.
+- Server-side guards must be reflected in the UI. If the API will reject the action, the
+ button should be disabled, hidden, or explain the blocker — not fire and surface a 400.
+- Prefer disabling a control with a visible reason over silently hiding it.
+
+## Notifications
+
+In-app notifications resolve recipients from the company's **linked portal users**. If a
+company has none, `notify()` logs `0 recipients — skipped` and stores nothing, with no error.
+SMS and email still send, because they address the company's phone and email directly. Check
+this before debugging a "missing notification".
+
+## PDF generation
+
+Chromium is not installed in every environment. PDF paths must fall back to the hand-rolled
+generators (`styled-pdf.util.ts`, `buildFallbackPdf`, `buildTabularFallbackPdf`) rather than
+assume a headless browser exists.
## Adding a new module to a NestJS app
-1. Create `modules//` with `entities/`, `dto/`, and the four `.{module,controller,service,repository}.ts` files.
+1. Create `modules//` with `entities/`, `dto/`, and the four
+ `.{module,controller,service,repository}.ts` files.
2. The entity extends `BaseEntity` from `@edr/api-common`.
3. The repository extends `BaseRepository` from `@edr/api-common`.
4. The service injects the repository class (not `Repository` directly).
-5. The controller uses `@ApiTags()` + `@ApiOperation()` for Swagger.
+5. The controller uses `@ApiTags()` + `@ApiOperation()` for Swagger, and guards the route.
6. Register the module in the app's `app.module.ts`.
## Adding a new shared component to `@edr/ui-common`
1. Create `src/components//.tsx` and `src/components//index.ts`.
2. Export from `src/index.ts`.
-3. Component is a functional component with a `ComponentNameProps` interface (named-exported alongside the default).
+3. Component is a functional component with a `ComponentNameProps` interface
+ (named-exported alongside the default).
+
+## Definition of done
+
+A change is done when **all** of these hold. State explicitly which you ran.
+
+1. **It type-checks.** `pnpm turbo type-check --filter=` passes.
+ If you edited `packages/types`, you ran `pnpm turbo build --filter=@edr/types` first.
+2. **Raw SQL is verified.** Every new or edited SQL statement ran under `EXPLAIN` against the
+ dev database without error.
+3. **Migrations are safe.** Unique timestamp, idempotent DDL, and — if the migration adds
+ something the new code reads — applied to the dev database, since watch mode will not run it.
+4. **No new test failures.** `pnpm test` for `@edr/freight-api` has been red on `dev`, so a
+ fully green suite is not the bar — but confirm that for yourself rather than assuming it,
+ then run the specs covering what you touched and confirm you introduced no new failure.
+5. **Formatting is clean** for the files you touched. Git hooks do **not** run automatically
+ (see below), and `pnpm lint` does not work at all, so `noUnusedLocals` from the
+ type-check is your only unused-code signal.
+6. **The behaviour was actually observed**, not merely compiled — you drove the flow, hit the
+ endpoint, or ran the query. If you could not, say so plainly.
+7. **Report honestly.** If a check was skipped, tests failed, or a fix is unverified, say it in
+ the summary. Never describe unverified work as done.
+
+### Hooks do not run
+
+`commitlint.config.js` and a `lint-staged` config both exist, and husky's shims are installed
+at `.husky/_/`. But there are **no user hook scripts** (`.husky/pre-commit`,
+`.husky/commit-msg`), so husky's shim exits 0 and **neither lint-staged nor commitlint ever
+fire.** Nothing validates your commit message or formats your staged files. Run the checks by
+hand; do not assume the hook caught it.
+
+## Known traps
+
+| Trap | What happens | What to do |
+| --- | --- | --- |
+| Schema drift | A recorded migration's column is missing; queries and inserts 500 | Write a new repair migration; never edit the recorded one |
+| Duplicate migration timestamps | Non-deterministic ordering; a migration can be skipped | Pick a fresh, higher timestamp |
+| `@edr/types` not rebuilt | Consumers can't see your new field | `pnpm turbo build --filter=@edr/types` |
+| Slow I/O in a transaction | Locks held for the gateway timeout | Queue it; fan out after commit; always set an HTTP timeout |
+| Blob error bodies | Real 400 message replaced by "Request failed with status code 400" | `await extractDownloadErrorMessage(error)` |
+| Company with no portal user | In-app notification silently vanishes | Check portal users before debugging |
+| Watch-mode reload | New code, old schema → 500 | Apply the migration to the dev DB or restart fully |
+| Login 403 from curl | "Missing or unrecognized x-client-app header" | Send `x-client-app: backoffice` or `portal` |
+| Copying a passenger pattern into freight | Passenger is Prisma + Next.js, freight is TypeORM + Vite | Check which stack you are in first |
+
+## Project skills
+
+Reusable workflows live in `.claude/skills/`. Use them instead of re-deriving the steps:
+
+| Skill | Use for |
+| --- | --- |
+| `edr-db` | Query / `EXPLAIN`-validate / inspect the remote dev DB (`node .claude/skills/edr-db/query.cjs …`). psql is not installed — this is the sanctioned path. Also carries the 400/500 diagnosis loop. |
+| `verify` | The definition-of-done runner: targeted type-check, `@edr/types` rebuild, SQL validation, migration checklist, honest test bar. Run before calling anything finished. |
+| `standup` | "What did I do today / this week" reports for tickets, grounded in `git log` — including the check that commit subjects match their contents. |
+
+## Working style
+
+- **Verify before asserting.** Read the code or query the database. Do not infer behaviour
+ from a filename.
+- **Investigate, then propose.** For anything risky or wide-reaching, present the plan and the
+ trade-off before changing files.
+- **Small, reviewable commits**, one logical change each, conventional message.
+- **Branch from `dev`; PRs target `dev`.**
+- When a finding turns out to be wrong, say so and retract it. A rejected finding is a result.
diff --git a/CLAUDE_NEW.md b/CLAUDE_NEW.md
deleted file mode 100644
index a99f64d0a..000000000
--- a/CLAUDE_NEW.md
+++ /dev/null
@@ -1,313 +0,0 @@
-# EDR Platform — Developer Guide
-
-> This file is the contract. If something here contradicts the code, the code is the
-> truth and this file is a bug — fix it in the same PR.
-
-## Overview
-
-Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Freight
-Management and Passenger Management applications, a payment microservice, plus shared
-types, NestJS utilities, and React component libraries.
-
-The freight domain is the largest and most active area. Its core flow is:
-**booking → receive to warehouse → store → load onto train → dispatch → arrive → unload
-→ customer truck (self-haul) or EDR last mile → handover → exit paper → delivered.**
-Fees (storage, demurrage, double handling, truck detention) and allocation rules
-(warehouse/yard/zone) hang off the warehouse stage.
-
-## Apps
-
-| App | Package name | Purpose | Default port |
-| ------------------------------ | --------------------------- | -------------------------------------------------- | ------------ |
-| `edr-freight-api` | `@edr/freight-api` | NestJS API for freight management | 3001 |
-| `edr-freight-web/portal` | `@edr/freight-portal` | React frontend for freight customer/portal users | 5173 |
-| `edr-freight-web/backoffice` | `@edr/freight-backoffice` | React frontend for freight backoffice employees | 5183 |
-| `edr-passenger-api` | `@edr/passenger-api` | NestJS API for passenger management | 3002 |
-| `edr-payment-api` | `@edr/payment-api` | NestJS payment microservice (intents, webhooks) | 3003 |
-| `edr-passenger-web/portal` | `@edr/passenger-portal` | React frontend for passenger customer/portal users | 5174 |
-| `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | React frontend for passenger backoffice employees | 5184 |
-
-`edr-freight-web` and `edr-passenger-web` are grouping folders, not workspace packages.
-Each holds a `portal/` and `backoffice/` sub-app, both independent pnpm workspace
-packages (see `pnpm-workspace.yaml`).
-
-`apps/edr-landing/` exists on disk but has **no `package.json`** — it is not a workspace
-package and is not built, linted, or type-checked. Leave it alone unless asked.
-
-## Packages
-
-| Package | Purpose |
-| ---------------------- | ---------------------------------------------------------------------------------- |
-| `@edr/types` | Shared TypeScript interfaces and enums |
-| `@edr/api-common` | Shared NestJS decorators, filters, interceptors, pipes, BaseEntity, BaseRepository |
-| `@edr/ui-common` | Shared React components and theme |
-| `@edr/eslint-config` | Shared ESLint configurations (base/nestjs/react) |
-| `@edr/tsconfig` | Shared TypeScript configurations |
-| `@edr/prettier-config` | Shared Prettier configuration |
-
-**`@edr/types` is consumed as its built `dist/`** (`main: ./dist/index.js`). Editing a
-type in `packages/types/src` changes nothing for consumers until you rebuild:
-
-```bash
-pnpm turbo build --filter=@edr/types
-```
-
-If a type-check fails on a field you just added to `@edr/types`, this is why.
-
-## Commands
-
-| Command | Description |
-| --------------------------- | ---------------------------------------- |
-| `pnpm install` | Install all workspace dependencies |
-| `pnpm dev` | Run every app in dev mode |
-| `pnpm dev:freight` | Freight API + portal + backoffice |
-| `pnpm dev:freight:api` | Freight API only |
-| `pnpm dev:freight:portal` | Freight portal only |
-| `pnpm dev:freight:backoffice` | Freight backoffice only |
-| `pnpm dev:passenger` | Passenger API + web |
-| `pnpm dev:payment` | Payment API |
-| `pnpm build` | Build every package and app |
-| `pnpm test` | Run all tests (turbo) |
-| `pnpm lint` | Lint everything |
-| `pnpm type-check` | Type-check every package |
-| `pnpm format` | Format all files with Prettier |
-
-Prefer targeted turbo filters over whole-repo runs — they are minutes faster:
-
-```bash
-pnpm turbo type-check --filter=@edr/freight-api --filter=@edr/freight-backoffice
-```
-
-`apps/edr-freight-api` also carries many `seed:*` scripts (demo bookings, wagons, trains,
-gate-pass scenarios). Read the script before running one; several write real rows.
-
-## Environment & database
-
-- Postgres is **external**. There is no postgres service in `docker-compose.yaml`, and
- no port `5433`/`5434` is published anywhere in the repo.
-- Freight API connection comes from `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`,
- `DB_NAME` (defaults: `localhost:5433`, `edr_freight`). Development points these at a
- remote database.
-- The connection sits behind a **connection pooler**. Do **not** pass
- `extra.options: '-c search_path=…'` — the pooler rejects it with
- `08P01 unsupported startup parameter in options: search_path`. `search_path` is applied
- per-connection in a pool `connect` handler instead. See
- `apps/edr-freight-api/src/config/database.config.ts` before touching connection options.
-- Each app owns its own database. **No cross-database joins**; cross-domain data flows
- through API calls or message queues.
-- `psql` is not installed on the dev machine. To query the database, write a short Node
- script using the `pg` client and run it from `apps/edr-freight-api` (where `pg` resolves).
-
-## Hard rules
-
-These are non-negotiable. Everything else is a strong default.
-
-- **pnpm only.** Never run `npm install` or `yarn`.
-- **TypeScript strict mode** is on in every package and app. Do not weaken it, and do not
- reach for `any` to make an error go away.
-- **Never `synchronize: true`.** Not in production, not anywhere. It is currently `false`
- in every config and it has already corrupted this database twice (see *Migrations*).
- All schema changes go through TypeORM migrations.
-- **All entities** use UUID primary keys (`@PrimaryGeneratedColumn('uuid')`).
-- **All entities** extend `BaseEntity` from `@edr/api-common` — `createdAt`, `updatedAt`,
- `deletedAt` (soft delete).
-- **All columns** are `snake_case` in the database (`@Column({ name: 'snake_case' })`);
- TypeScript properties are `camelCase`.
-- **Controllers contain no business logic.** They validate, delegate, and shape the response.
-- **Conventional commits.** `fix(warehouses): …`, `feat(bookings): …`.
-- **Do not commit or push unless asked.** Propose the change; let the human decide when it lands.
-- **Do not break working behaviour to add new behaviour.** When a fix is risky, say so and
- offer the safe version.
-
-## Architecture
-
-### NestJS module shape
-
-`module → controller → service → repository`, with `entities/` and `dto/` alongside.
-
-### Data access — the real model
-
-There are two sanctioned ways to read and write, and you must pick the right one:
-
-1. **Entity CRUD → the custom repository class.** Extends `BaseRepository` from
- `@edr/api-common`. Services inject the repository class, never `Repository` directly.
-2. **Read projections, queue endpoints, cross-table reports → raw SQL** via
- `this.dataSource.query(...)` or `manager.query(...)` inside a transaction.
-
-Raw SQL is normal here, not a smell — the warehouse and scheduling modules are built on it.
-It carries one obligation:
-
-> **HARD RULE — validate every raw SQL statement against a real database before you ship it.**
-> A typo'd column name is a runtime 500 that no type-checker will catch. Run it through
-> `EXPLAIN` against the dev database. Column drift is real (see *Migrations*).
-
-Writes inside a transaction use `manager.getRepository(Entity)`, not the injected repository,
-so they join the caller's transaction.
-
-**Never do slow I/O inside a database transaction.** Queue the work and fan it out after
-commit. An SMS awaited inside a transaction once held capacity locks open for the whole
-gateway timeout. Any outbound HTTP call must set an explicit `timeout` — axios defaults to
-no timeout and will wait forever.
-
-### Migrations
-
-Migrations are the most dangerous surface in this repo. Two production-grade incidents have
-already come from it.
-
-- `migrationsRun: false` — **migrations do NOT run on API boot.** They run as a separate
- one-shot step, via the Dockerfile's `migration` build target (`docker build --target
- migration`), with `migrationsTransactionMode: 'each'`.
- - CI: `.github/workflows/deploy.yml` builds the `migration` image and runs it
- (`docker run --rm --env-file ...`) *before* building/deploying the app image.
- - e2e: `docker-compose.e2e.yaml`'s `freight-migration-e2e` service runs once and
- `freight-api-e2e` depends on it (`condition: service_completed_successfully`).
- - Local dev (`docker-compose.yaml`) has no equivalent migration service yet — run
- migrations yourself before `docker compose up freight-api`, e.g.
- `docker build --target migration -f apps/edr-freight-api/Dockerfile -t freight-migration .`
- then `docker run --rm --env-file apps/edr-freight-api/.env freight-migration`. Don't
- use `pnpm run migrate` for this — it runs via `ts-node`, which never writes compiled
- output to `dist/`, and the freight migrations glob only matches `dist/migrations/*.js`.
- It silently applies zero freight migrations while exiting 0.
-- Consequences you must design for:
- - A watch-mode hot reload does **not** re-run migrations. If you add a column that new
- code reads, apply it to the dev database yourself (idempotently) or fully restart.
- - `apps/edr-freight-api/src/config/database.config.ts`'s `iamEntities` array is a
- hand-maintained list of `@tria-plc/iamapi-common` entity classes. The live app never
- notices when it's stale (`autoLoadEntities: true` papers over gaps via IAM's own
- `forFeature()` registrations), but the standalone migration `DataSource`
- (`data-source.ts`, no `autoLoadEntities`) does not have that fallback — a missing
- entity throws `Entity metadata for X#y was not found` at `initialize()`, before a
- single migration runs. **Every `@tria-plc/iamapi-common` version bump is a candidate
- for this to break again** — diff the package's entity classes against `iamEntities`
- when bumping it.
-- **Give every migration a unique timestamp.** 34 timestamps are currently shared by two or
- more migrations. TypeORM orders by timestamp and breaks ties non-deterministically. Before
- adding one, check the filename prefix is unused *and* higher than the newest recorded row.
-- **Write idempotent DDL**: `ADD COLUMN IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`, and
- backfills guarded by `WHERE col IS NULL`.
-- **Never assume a recorded migration actually applied.** `AddGrnNumberToWarehouseInventory`
- was recorded in `migrations` while its column was absent — it had been dropped out of band.
- TypeORM will never re-run a recorded migration, so the fix is a *new repair migration*.
-- **A repair migration's `down()` should be a no-op.** Reverting a repair must not
- re-introduce the outage it fixed.
-
-### Auth
-
-Auth **is implemented in this repo.** Do not add TODO stubs, and do not write your own.
-
-- `@CurrentUser()` (`@edr/api-common`) is a real `createParamDecorator`, not a metadata stub.
-- Route protection uses `@UseGuards(JwtGuard)` and `@UseGuards(PermissionGuard([...]))`.
-- Freight-domain checks use `hasFreightPermission(user, FREIGHT_PERMS. .)`.
-- Permissions are declared in `apps/edr-freight-api/src/seed/freight-permissions.registry.ts`.
- Add a permission there before referencing it.
-- IAM has its own migrations, run ahead of freight migrations from the same data source, and
- its own CLI scripts (`iam:migration:run`, `iam:seed:run`).
-
-Ownership checks are separate from permission checks. A staff user passes
-`hasFreightPermission`; a customer must additionally pass an ownership assertion such as
-`assertCustomerCanAccessBooking`. Do not drop the ownership check because the permission check passed.
-
-## Frontend conventions
-
-- The web apps use **Mantine v9**. Its APIs differ from v6/v7 — check the installed version
- before copying a snippet.
-- `@edr/ui-common` holds shared components and theme; it is imported in ~94 files across the
- freight web apps. Prefer it over re-implementing a component.
-- **Blob downloads need the async error decoder.** A request with `responseType: 'blob'`
- delivers the JSON error body as a `Blob`, so the synchronous `extractErrorMessage` finds no
- `.message` and degrades to `"Request failed with status code 400"`. Use
- `await extractDownloadErrorMessage(error)` in every PDF/blob catch block. Mutation catches
- keep the synchronous version — their bodies are already parsed JSON.
-- Server-side guards must be reflected in the UI. If the API will reject the action, the
- button should be disabled, hidden, or explain the blocker — not fire and surface a 400.
-- Prefer disabling a control with a visible reason over silently hiding it.
-
-## Notifications
-
-In-app notifications resolve recipients from the company's **linked portal users**. If a
-company has none, `notify()` logs `0 recipients — skipped` and stores nothing, with no error.
-SMS and email still send, because they address the company's phone and email directly. Check
-this before debugging a "missing notification".
-
-## PDF generation
-
-Chromium is not installed in every environment. PDF paths must fall back to the hand-rolled
-generators (`styled-pdf.util.ts`, `buildFallbackPdf`, `buildTabularFallbackPdf`) rather than
-assume a headless browser exists.
-
-## Adding a new module to a NestJS app
-
-1. Create `modules//` with `entities/`, `dto/`, and the four
- `.{module,controller,service,repository}.ts` files.
-2. The entity extends `BaseEntity` from `@edr/api-common`.
-3. The repository extends `BaseRepository` from `@edr/api-common`.
-4. The service injects the repository class (not `Repository` directly).
-5. The controller uses `@ApiTags()` + `@ApiOperation()` for Swagger, and guards the route.
-6. Register the module in the app's `app.module.ts`.
-
-## Adding a new shared component to `@edr/ui-common`
-
-1. Create `src/components//.tsx` and `src/components//index.ts`.
-2. Export from `src/index.ts`.
-3. Component is a functional component with a `ComponentNameProps` interface
- (named-exported alongside the default).
-
-## Definition of done
-
-A change is done when **all** of these hold. State explicitly which you ran.
-
-1. **It type-checks.** `pnpm turbo type-check --filter=` passes.
- If you edited `packages/types`, you ran `pnpm turbo build --filter=@edr/types` first.
-2. **Raw SQL is verified.** Every new or edited SQL statement ran under `EXPLAIN` against the
- dev database without error.
-3. **Migrations are safe.** Unique timestamp, idempotent DDL, and — if the migration adds
- something the new code reads — applied to the dev database, since watch mode will not run it.
-4. **No new test failures.** `pnpm test` for `@edr/freight-api` is **currently red on `dev`**,
- so a fully green suite is not the bar. Run the specs covering what you touched and confirm
- you introduced no new failure.
-5. **Lint and format are clean** for the files you touched. Git hooks do **not** run these
- automatically (see below), so run them yourself.
-6. **The behaviour was actually observed**, not merely compiled — you drove the flow, hit the
- endpoint, or ran the query. If you could not, say so plainly.
-7. **Report honestly.** If a check was skipped, tests failed, or a fix is unverified, say it in
- the summary. Never describe unverified work as done.
-
-### Hooks do not run
-
-`commitlint.config.js` and a `lint-staged` config both exist, and husky's shims are installed
-at `.husky/_/`. But there are **no user hook scripts** (`.husky/pre-commit`,
-`.husky/commit-msg`), so husky's shim exits 0 and **neither lint-staged nor commitlint ever
-fire.** Nothing validates your commit message or formats your staged files. Run the checks by
-hand; do not assume the hook caught it.
-
-## Known traps
-
-| Trap | What happens | What to do |
-| --- | --- | --- |
-| Schema drift | A recorded migration's column is missing; queries and inserts 500 | Write a new repair migration; never edit the recorded one |
-| Duplicate migration timestamps | Non-deterministic ordering; a migration can be skipped | Pick a fresh, higher timestamp |
-| `@edr/types` not rebuilt | Consumers can't see your new field | `pnpm turbo build --filter=@edr/types` |
-| Slow I/O in a transaction | Locks held for the gateway timeout | Queue it; fan out after commit; always set an HTTP timeout |
-| Blob error bodies | Real 400 message replaced by "Request failed with status code 400" | `await extractDownloadErrorMessage(error)` |
-| Company with no portal user | In-app notification silently vanishes | Check portal users before debugging |
-| Watch-mode reload | New code, old schema → 500 | Apply the migration to the dev DB or restart fully |
-
-## Project skills
-
-Reusable workflows live in `.claude/skills/`. Use them instead of re-deriving the steps:
-
-| Skill | Use for |
-| --- | --- |
-| `edr-db` | Query / `EXPLAIN`-validate / inspect the remote dev DB (`node .claude/skills/edr-db/query.cjs …`). psql is not installed — this is the sanctioned path. Also carries the 400/500 diagnosis loop. |
-| `verify` | The definition-of-done runner: targeted type-check, `@edr/types` rebuild, SQL validation, migration checklist, honest test bar. Run before calling anything finished. |
-| `standup` | "What did I do today / this week" reports for tickets, grounded in `git log` — including the check that commit subjects match their contents. |
-
-## Working style
-
-- **Verify before asserting.** Read the code or query the database. Do not infer behaviour
- from a filename.
-- **Investigate, then propose.** For anything risky or wide-reaching, present the plan and the
- trade-off before changing files.
-- **Small, reviewable commits**, one logical change each, conventional message.
-- **Branch from `dev`; PRs target `dev`.**
-- When a finding turns out to be wrong, say so and retract it. A rejected finding is a result.
diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example
index d2359dd97..930c8f1ca 100644
--- a/apps/edr-freight-api/.env.example
+++ b/apps/edr-freight-api/.env.example
@@ -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=
diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts
index d38c6ea92..4ec11e082 100644
--- a/apps/edr-freight-api/src/app.module.ts
+++ b/apps/edr-freight-api/src/app.module.ts
@@ -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";
@@ -50,6 +51,7 @@ import { SupportChatModule } from "./modules/support-chat/support-chat.module";
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
import { ExchangeSettingsModule } from "./modules/exchange-settings/exchange-settings.module";
+import { 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";
@@ -116,7 +118,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";
@@ -135,6 +140,7 @@ if (!process.env.APPLICATION_NAME) {
rabbitmqConfig,
faydaConfig,
eimsConfig,
+ chatConfig,
],
}),
ScheduleModule.forRoot(),
@@ -213,6 +219,7 @@ if (!process.env.APPLICATION_NAME) {
FileUploadSettingsModule,
DropdownSettingsModule,
ExchangeSettingsModule,
+ PaymentSettingsModule,
StampSettingsModule,
LogoSettingsModule,
ContractTemplatesModule,
@@ -252,6 +259,7 @@ if (!process.env.APPLICATION_NAME) {
FleetHistoryModule,
AiModule,
AuditModule,
+ ChatModule,
],
providers: [
EdrOrgSeeder,
diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts
index 36ed7ae74..f9eab4d39 100644
--- a/apps/edr-freight-api/src/common/booking-guards.ts
+++ b/apps/edr-freight-api/src/common/booking-guards.ts
@@ -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
diff --git a/apps/edr-freight-api/src/common/utils/iam-user-name.util.ts b/apps/edr-freight-api/src/common/utils/iam-user-name.util.ts
new file mode 100644
index 000000000..e4a3465d0
--- /dev/null
+++ b/apps/edr-freight-api/src/common/utils/iam-user-name.util.ts
@@ -0,0 +1,49 @@
+import { DataSource } from "typeorm";
+
+/**
+ * `iam.users.name` is a localized object ({ en, am, … }), not a string — a
+ * plain `String(name)` there yields "[object Object]" in an audit trail.
+ */
+export interface IamUserRow {
+ name?: Record | string | null;
+ username?: string | null;
+ email?: string | null;
+}
+
+/** Best display name for a user row: English label → any locale → login → email. */
+export function pickUserName(user: IamUserRow): string | null {
+ const { name } = user;
+ if (typeof name === "string" && name.trim()) return name.trim();
+ if (name && typeof name === "object") {
+ const localized =
+ name.en ??
+ Object.values(name).find((v) => typeof v === "string" && v.trim());
+ if (localized?.trim()) return localized.trim();
+ }
+ return user.username?.trim() || user.email?.trim() || null;
+}
+
+/**
+ * Display names for a set of IAM user ids — one query for the whole set.
+ * `iam.users` is owned by the auth system and has no entity here, so it is read
+ * directly. A miss is not an error: the caller still holds the id and can fall
+ * back to it.
+ */
+export async function resolveIamUserNames(
+ dataSource: DataSource,
+ userIds: (string | null | undefined)[],
+): Promise> {
+ const resolved = new Map();
+ const ids = [...new Set(userIds.filter((id): id is string => Boolean(id)))];
+ if (ids.length === 0) return resolved;
+
+ const rows = (await dataSource.query(
+ `SELECT id, name, username, email FROM iam.users WHERE id = ANY($1::uuid[])`,
+ [ids],
+ )) as Array;
+ for (const row of rows) {
+ const name = pickUserName(row);
+ if (name) resolved.set(row.id, name);
+ }
+ return resolved;
+}
diff --git a/apps/edr-freight-api/src/config/chat.config.ts b/apps/edr-freight-api/src/config/chat.config.ts
new file mode 100644
index 000000000..ce20b610b
--- /dev/null
+++ b/apps/edr-freight-api/src/config/chat.config.ts
@@ -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!,
+ };
+});
diff --git a/apps/edr-freight-api/src/config/eims.config.spec.ts b/apps/edr-freight-api/src/config/eims.config.spec.ts
new file mode 100644
index 000000000..127b3ea62
--- /dev/null
+++ b/apps/edr-freight-api/src/config/eims.config.spec.ts
@@ -0,0 +1,131 @@
+import eimsConfigFactory from "./eims.config";
+
+const REQUIRED = {
+ EIMS_ENABLED: "true",
+ EIMS_CLIENT_ID: "cid",
+ EIMS_CLIENT_SECRET: "secret",
+ EIMS_API_KEY: "apikey",
+ EIMS_TIN: "0000000000",
+};
+
+const withEnv = (vars: Record, fn: () => void) => {
+ const prior: Record = {};
+ for (const [key, value] of Object.entries(vars)) {
+ prior[key] = process.env[key];
+ if (value === undefined) delete process.env[key];
+ else process.env[key] = value;
+ }
+ try {
+ fn();
+ } finally {
+ for (const [key, value] of Object.entries(prior)) {
+ if (value === undefined) delete process.env[key];
+ else process.env[key] = value;
+ }
+ }
+};
+
+describe("eims.config — private key / certificate resolution", () => {
+ it("unescapes a literal \\n when the PEM was pasted without real newlines", () => {
+ withEnv(
+ { ...REQUIRED, EIMS_PRIVATE_KEY: "line1\\nline2", EIMS_CERTIFICATE_PATH: "/dev/null" },
+ () => {
+ expect(eimsConfigFactory().privateKeyPem).toBe("line1\nline2");
+ },
+ );
+ });
+
+ it("leaves a PEM with real newlines untouched", () => {
+ withEnv(
+ { ...REQUIRED, EIMS_PRIVATE_KEY: "line1\nline2", EIMS_CERTIFICATE_PATH: "/dev/null" },
+ () => {
+ expect(eimsConfigFactory().privateKeyPem).toBe("line1\nline2");
+ },
+ );
+ });
+
+ it("throws naming all three key/cert options when none are set", () => {
+ withEnv(
+ {
+ ...REQUIRED,
+ EIMS_PRIVATE_KEY_PATH: undefined,
+ EIMS_PRIVATE_KEY_BASE64: undefined,
+ EIMS_PRIVATE_KEY: undefined,
+ EIMS_CERTIFICATE_PATH: "/dev/null",
+ },
+ () => {
+ expect(() => eimsConfigFactory()).toThrow(
+ /EIMS_PRIVATE_KEY_PATH or EIMS_PRIVATE_KEY_BASE64 or EIMS_PRIVATE_KEY/,
+ );
+ },
+ );
+ });
+
+ it("is satisfied by any single one of the three key options", () => {
+ withEnv(
+ { ...REQUIRED, EIMS_PRIVATE_KEY: "x", EIMS_CERTIFICATE_PATH: "/dev/null" },
+ () => {
+ expect(() => eimsConfigFactory()).not.toThrow();
+ },
+ );
+ });
+});
+
+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");
+ },
+ );
+ });
+});
diff --git a/apps/edr-freight-api/src/config/eims.config.ts b/apps/edr-freight-api/src/config/eims.config.ts
index c5565cbc1..e5530eaf2 100644
--- a/apps/edr-freight-api/src/config/eims.config.ts
+++ b/apps/edr-freight-api/src/config/eims.config.ts
@@ -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.
*
@@ -30,6 +32,23 @@ export interface EimsConfig {
privateKeyPath: string;
/** Filesystem path to the INSA-issued certificate bundle; sent as base64 of its exact bytes. */
certificatePath: string;
+ /**
+ * Inline alternative to `privateKeyPath` — the key file's own bytes, base64-encoded, so a
+ * container that can't be given a host bind mount can still receive it as a plain env var.
+ * Either one must be present when EIMS is enabled. Precedence: `privateKeyPem` > `privateKeyBase64`
+ * > `privateKeyPath`.
+ */
+ privateKeyBase64: string;
+ /** Inline alternative to `certificatePath`, same precedence rule as the key. */
+ certificateBase64: string;
+ /**
+ * The PEM key pasted directly into the env var, no encoding step at all — the most direct of the
+ * three inline forms, and the hardest for a broken transport step to mangle since there's no
+ * decode stage to get wrong. Wins over `privateKeyBase64`/`privateKeyPath` when set.
+ */
+ privateKeyPem: string;
+ /** Inline alternative to `certificateBase64`, same precedence rule. */
+ certificatePem: string;
httpTimeoutMs: number;
/** Re-authenticate this many ms before the access token actually expires. */
tokenSkewMs: number;
@@ -135,14 +154,14 @@ export interface EimsInvoiceConfig {
buyerIdNumber: string | null;
}
-const REQUIRED_VARS = [
- "EIMS_CLIENT_ID",
- "EIMS_CLIENT_SECRET",
- "EIMS_API_KEY",
- "EIMS_TIN",
- "EIMS_PRIVATE_KEY_PATH",
- "EIMS_CERTIFICATE_PATH",
-] as const;
+const REQUIRED_VARS = ["EIMS_CLIENT_ID", "EIMS_CLIENT_SECRET", "EIMS_API_KEY", "EIMS_TIN"] as const;
+
+// Key/cert each have three ways in (file path, inline base64, or raw PEM) — checked separately
+// from REQUIRED_VARS since it's "at least one of", not "this exact var".
+const REQUIRED_ANY_OF: string[][] = [
+ ["EIMS_PRIVATE_KEY_PATH", "EIMS_PRIVATE_KEY_BASE64", "EIMS_PRIVATE_KEY"],
+ ["EIMS_CERTIFICATE_PATH", "EIMS_CERTIFICATE_BASE64", "EIMS_CERTIFICATE"],
+];
const positiveInt = (raw: string | undefined, fallback: number, name: string): number => {
if (raw === undefined || raw === "") return fallback;
@@ -163,6 +182,14 @@ const parseCodeMap = (raw: string | undefined): Record => {
return map;
};
+// Some env stores (single-line .env files, certain secret managers) can't hold a literal newline
+// and expect the caller to write "\n" as two characters instead. If the raw value already has a
+// real newline, leave it alone; otherwise unescape "\n" so a PEM pasted that way still parses.
+const normalizePem = (raw: string | undefined): string => {
+ if (!raw) return "";
+ return raw.includes("\n") ? raw : raw.replace(/\\n/g, "\n");
+};
+
/** Unset stays null so the registration-time check can name it; a set-but-bogus value throws. */
const optionalNumber = (raw: string | undefined, name: string): number | null => {
if (raw === undefined || raw === "") return null;
@@ -189,6 +216,10 @@ export default registerAs("eims", (): EimsConfig => {
systemType: process.env.EIMS_SYSTEM_TYPE ?? "",
privateKeyPath: process.env.EIMS_PRIVATE_KEY_PATH ?? "",
certificatePath: process.env.EIMS_CERTIFICATE_PATH ?? "",
+ privateKeyBase64: process.env.EIMS_PRIVATE_KEY_BASE64 ?? "",
+ privateKeyPem: normalizePem(process.env.EIMS_PRIVATE_KEY),
+ certificatePem: normalizePem(process.env.EIMS_CERTIFICATE),
+ certificateBase64: process.env.EIMS_CERTIFICATE_BASE64 ?? "",
httpTimeoutMs,
tokenSkewMs,
autoSubmit: (process.env.EIMS_AUTO_SUBMIT ?? "false").toLowerCase() === "true",
@@ -229,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),
@@ -245,7 +278,10 @@ export default registerAs("eims", (): EimsConfig => {
if (!enabled) return base;
- const missing = REQUIRED_VARS.filter((name) => !process.env[name]);
+ const missing: string[] = REQUIRED_VARS.filter((name) => !process.env[name]);
+ for (const vars of REQUIRED_ANY_OF) {
+ if (vars.every((name) => !process.env[name])) missing.push(vars.join(" or "));
+ }
if (missing.length > 0) {
throw new Error(
`EIMS integration is enabled (EIMS_ENABLED=true) but the following env vars are missing: ${missing.join(", ")}`,
diff --git a/apps/edr-freight-api/src/config/ethiopia-geo-codes.ts b/apps/edr-freight-api/src/config/ethiopia-geo-codes.ts
new file mode 100644
index 000000000..ca48a7c5f
--- /dev/null
+++ b/apps/edr-freight-api/src/config/ethiopia-geo-codes.ts
@@ -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 => {
+ const map: Record = {};
+ 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 = 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 = buildMap((r) => [r[1], r[4]]);
+export const ETHIOPIA_WOREDA_CODES: Record = 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];
+}
diff --git a/apps/edr-freight-api/src/migrations/3560000000000-ManualPaymentSettings.ts b/apps/edr-freight-api/src/migrations/3560000000000-ManualPaymentSettings.ts
new file mode 100644
index 000000000..0c4f5698f
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/3560000000000-ManualPaymentSettings.ts
@@ -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 {
+ 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 {
+ await queryRunner.query(
+ `DROP TABLE IF EXISTS freight.manual_payment_settings;`,
+ );
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/3560000000000-YardPositions.ts b/apps/edr-freight-api/src/migrations/3560000000000-YardPositions.ts
new file mode 100644
index 000000000..102a5b85d
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/3560000000000-YardPositions.ts
@@ -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 {
+ 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 {
+ await queryRunner.query(`DROP TABLE IF EXISTS freight.yard_positions`);
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/3570000000000-ConsolidationApprovals.ts b/apps/edr-freight-api/src/migrations/3570000000000-ConsolidationApprovals.ts
new file mode 100644
index 000000000..879c9b472
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/3570000000000-ConsolidationApprovals.ts
@@ -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 {
+ 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 {
+ await queryRunner.query(
+ `DROP TABLE IF EXISTS freight.consolidation_approvals`,
+ );
+ await queryRunner.query(
+ `DROP TYPE IF EXISTS freight.consolidation_approvals_status_enum`,
+ );
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/3570000000000-YardViewAllPermission.ts b/apps/edr-freight-api/src/migrations/3570000000000-YardViewAllPermission.ts
new file mode 100644
index 000000000..0a53bd530
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/3570000000000-YardViewAllPermission.ts
@@ -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 {
+ 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 {
+ 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,
+ ]);
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts b/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts
index d46f63a7d..17b2d1580 100644
--- a/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts
+++ b/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts
@@ -633,6 +633,11 @@ export const AUDIT_ENDPOINTS: Readonly> = {
"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"],
diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts
index 243b1ee4d..c5cc9a81e 100644
--- a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts
+++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts
@@ -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 };
};
diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts
index 44e194a8d..d64361bf8 100644
--- a/apps/edr-freight-api/src/modules/billing/billing.service.ts
+++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts
@@ -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 {
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.");
}
diff --git a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts
index 6d58a7bec..75d39a500 100644
--- a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts
+++ b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts
@@ -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,
diff --git a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts
index b8755c709..c72e61c7a 100644
--- a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts
+++ b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts
@@ -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),
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.spec.ts
index 9a5960295..c9bdc4845 100644
--- a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.spec.ts
+++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.spec.ts
@@ -36,9 +36,13 @@ describe('BookingLifecycleNotifierService — operation changes requested', () =
);
});
- it('sends a GL-created booking back to the GL who created it, not the customer', async () => {
+ it('sends a GL-created customs booking back to the GL who created it, not the customer', async () => {
service.operationChangesRequested(
- booking({ createdByRole: 'GL_ET', createdByUserId: 'gl-user-1' }),
+ booking({
+ customsClearingEnabled: true,
+ createdByRole: 'GL_ET',
+ createdByUserId: 'gl-user-1',
+ }),
'Cargo weight does not match the declaration',
);
await flush();
@@ -54,7 +58,7 @@ describe('BookingLifecycleNotifierService — operation changes requested', () =
expect(notifications.directSend).not.toHaveBeenCalled();
});
- it('still tells the customer when the booking is their own', async () => {
+ it('still tells the customer when the booking is a non-customs self-service booking', async () => {
service.operationChangesRequested(booking(), 'Please attach the packing list');
await flush();
@@ -65,14 +69,38 @@ describe('BookingLifecycleNotifierService — operation changes requested', () =
expect(notifications.directSend).toHaveBeenCalled();
});
- it('falls back to the customer when the GL creator is unknown (legacy rows)', async () => {
+ it('routes a customer-opened customs booking to the clearance desk, not the customer', async () => {
+ // Path B lets the customer open the ONE_TIME shipment instance themselves
+ // (contract-booking.service assertGate's customerMayInitiate) — createdByRole
+ // stays 'CUSTOMER', but GL still owns completing/resubmitting it.
service.operationChangesRequested(
- booking({ createdByRole: 'GL_ET', createdByUserId: null }),
+ booking({ customsClearingEnabled: true, createdByRole: 'CUSTOMER' }),
'Fix the declaration',
);
await flush();
- expect(inbox.notify.mock.calls[0][0].recipients).toEqual({ companyId: 'co-1' });
+ const sent = inbox.notify.mock.calls[0][0];
+ expect(sent.recipients).toEqual({
+ permissionKeys: [FREIGHT_PERMS.bookings.clearanceGetNotification],
+ });
+ expect(sent.audience).toBe('BACKOFFICE');
+ expect(notifications.directSend).not.toHaveBeenCalled();
+ });
+
+ it('falls back to the clearance desk when the GL creator is unknown (legacy rows)', async () => {
+ service.operationChangesRequested(
+ booking({
+ customsClearingEnabled: true,
+ createdByRole: 'GL_ET',
+ createdByUserId: null,
+ }),
+ 'Fix the declaration',
+ );
+ await flush();
+
+ expect(inbox.notify.mock.calls[0][0].recipients).toEqual({
+ permissionKeys: [FREIGHT_PERMS.bookings.clearanceGetNotification],
+ });
});
});
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts
index 01f0fd053..78c07bd22 100644
--- a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts
@@ -247,20 +247,27 @@ export class BookingLifecycleNotifierService {
/**
* Operations returned the operation request for changes.
*
- * A customs (Path B) booking was created BY GL Ethiopia on the customer's
- * behalf — the customer cannot edit or resubmit it, so telling them to "update
- * from the portal" is a dead end. Those go to the GL who created it, linking
- * the contract clearance page they work from. Everything else (customer-made
- * bookings) keeps the portal message.
+ * A customs (Path B) booking is completed by GL Ethiopia on the customer's
+ * behalf regardless of who opened the shipment instance — the customer-opened
+ * ONE_TIME case (see contract-booking.service assertGate) still stamps
+ * createdByRole 'CUSTOMER', so gate on customsClearingEnabled, not on who
+ * created it. The customer cannot edit or resubmit a customs booking, so
+ * telling them to "update from the portal" is a dead end. Those go to the GL
+ * who created it when known, else the clearance desk, linking the contract
+ * clearance page they work from. Everything else (customer-made bookings)
+ * keeps the portal message.
*/
operationChangesRequested(b: Booking, note: string): void {
- if (b.createdByRole === 'GL_ET' && b.createdByUserId) {
+ if (b.customsClearingEnabled) {
const msg =
`Operations returned booking ${b.reference} for changes: ${note}. ` +
`Address it on the contract clearance page and resubmit to Operations.`;
this.logger.log(`OPERATION CHANGES REQUESTED (to GL) — ${this.ref(b)}`);
void this.inbox.notify({
- recipients: { userIds: [b.createdByUserId] },
+ recipients:
+ b.createdByRole === 'GL_ET' && b.createdByUserId
+ ? { userIds: [b.createdByUserId] }
+ : CLEARANCE_DESK,
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.BOOKING_STATUS,
title: `Booking ${b.reference} needs changes`,
@@ -463,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(
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.paired-decision.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.paired-decision.spec.ts
new file mode 100644
index 000000000..91b1bfd91
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.paired-decision.spec.ts
@@ -0,0 +1,132 @@
+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) {
+ 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) => 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
+ {} 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,
+ });
+ });
+});
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts
index 2f50d0f38..556ce0e98 100644
--- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts
@@ -455,6 +455,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 => {
+ 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 {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [
diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts
index 9c4b69ad6..67a0f930d 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts
@@ -50,6 +50,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 +59,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 +169,7 @@ export class BookingsController {
private readonly lastMileService: LastMileService,
private readonly userTradeAccessService: UserTradeAccessService,
private readonly wagonCancellationService: BookingWagonCancellationService,
+ private readonly consolidationApprovalService: ConsolidationApprovalService,
) {}
@Post()
@@ -1541,6 +1546,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" })
diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts
index 447292117..b3ea4a546 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts
@@ -30,6 +30,9 @@ 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';
@@ -72,6 +75,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
BookingWagonCancellation,
CustomerTruckAssignment,
CustomerTruckContainer,
+ ConsolidationApproval,
]),
BillingModule,
DocumentsModule,
@@ -98,6 +102,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
BookingsService,
BookingsRepository,
ConsolidationService,
+ ConsolidationApprovalService,
+ ConsolidationApprovalsRepository,
ContainerValidationService,
BookingReferenceDataService,
BookingPricingService,
@@ -126,6 +132,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
BookingLifecycleNotifierService,
BookingTransitionService,
ConsolidationService,
+ ConsolidationApprovalService,
+ ConsolidationApprovalsRepository,
CustomerTruckService,
ContainerReceiptService,
BookingWagonCancellationService,
diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts
index 310364927..7b042e0c7 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts
@@ -308,6 +308,72 @@ export class BookingsRepository extends BaseRepository {
.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 {
+ 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
@@ -508,6 +574,25 @@ export class BookingsRepository extends BaseRepository {
} 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 {
+ 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 {
await this.repository.update(bookingId, {
diff --git a/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.spec.ts
new file mode 100644
index 000000000..5f121f630
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.spec.ts
@@ -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>;
+ bookingsRepository?: Partial>;
+ } = {}) {
+ 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) => 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,
+ );
+ });
+});
diff --git a/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.ts b/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.ts
new file mode 100644
index 000000000..ab4f4891d
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.ts
@@ -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 {
+ 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 {
+ return this.approvals.findQueue();
+ }
+
+ /** Full decision history for one booking — who decided what, and when. */
+ historyForBooking(bookingId: string): Promise {
+ return this.approvals.findAllForBooking(bookingId);
+ }
+
+ /** The undecided request covering this booking, if any. */
+ pendingForBooking(bookingId: string): Promise {
+ return this.approvals.findPendingForBooking(bookingId);
+ }
+
+ private async loadPending(approvalId: string): Promise {
+ 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;
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/consolidation-approvals.repository.ts b/apps/edr-freight-api/src/modules/bookings/consolidation-approvals.repository.ts
new file mode 100644
index 000000000..b398e7e8c
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/bookings/consolidation-approvals.repository.ts
@@ -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;
+
+ 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 {
+ 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 {
+ return this.repository.find({
+ where: [{ bookingId }, { partnerBookingId: bookingId }],
+ order: { createdAt: "DESC" },
+ });
+ }
+
+ findById(id: string): Promise {
+ return this.repository.findOne({ where: { id } });
+ }
+
+ /** Pending requests for the review queue, oldest first (FIFO). */
+ findQueue(): Promise {
+ 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 {
+ 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 {
+ 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 {
+ if (bookingIds.length === 0) return Promise.resolve([]);
+ return this.repository.find({
+ where: [
+ { bookingId: In(bookingIds), status: ConsolidationApprovalStatus.Pending },
+ {
+ partnerBookingId: In(bookingIds),
+ status: ConsolidationApprovalStatus.Pending,
+ },
+ ],
+ });
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts
index 63ad5b9f4..9631def73 100644
--- a/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts
+++ b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts
@@ -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;
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts
index cdcfbc3eb..c85e15cfb 100644
--- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts
+++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts
@@ -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;
diff --git a/apps/edr-freight-api/src/modules/bookings/entities/consolidation-approval.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/consolidation-approval.entity.ts
new file mode 100644
index 000000000..91c3dcdee
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/bookings/entities/consolidation-approval.entity.ts
@@ -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;
+}
diff --git a/apps/edr-freight-api/src/modules/chat/chat-bridge.service.ts b/apps/edr-freight-api/src/modules/chat/chat-bridge.service.ts
new file mode 100644
index 000000000..75bbe9a29
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/chat/chat-bridge.service.ts
@@ -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> = {
+ [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,
+ private readonly matrix: MatrixClient,
+ ) {}
+
+ async bridge(input: NotifyInput): Promise {
+ 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 = `${escapeHtml(input.title)} ${escapeHtml(input.body)}${
+ input.link ? `${escapeHtml(input.link)} ` : ''
+ }`;
+ 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, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+}
diff --git a/apps/edr-freight-api/src/modules/chat/chat-provisioning.service.ts b/apps/edr-freight-api/src/modules/chat/chat-provisioning.service.ts
new file mode 100644
index 000000000..b122d8073
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/chat/chat-provisioning.service.ts
@@ -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-), 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 {
+ 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 {
+ 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 {
+ 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,
+ 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 {
+ 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();
+ 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();
+
+ 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 }>();
+ for (const h of holders) {
+ const entry = byPosition.get(h.positionKey) ?? {
+ name: h.positionName,
+ userIds: new Set(),
+ };
+ 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 };
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/chat/chat-sso.service.ts b/apps/edr-freight-api/src/modules/chat/chat-sso.service.ts
new file mode 100644
index 000000000..22edb8e1c
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/chat/chat-sso.service.ts
@@ -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,
+ 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()}` };
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/chat/chat.controller.ts b/apps/edr-freight-api/src/modules/chat/chat.controller.ts
new file mode 100644
index 000000000..0ecca04c0
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/chat/chat.controller.ts
@@ -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();
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/chat/chat.module.ts b/apps/edr-freight-api/src/modules/chat/chat.module.ts
new file mode 100644
index 000000000..8df827339
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/chat/chat.module.ts
@@ -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 {}
diff --git a/apps/edr-freight-api/src/modules/chat/matrix.client.spec.ts b/apps/edr-freight-api/src/modules/chat/matrix.client.spec.ts
new file mode 100644
index 000000000..ea5faa978
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/chat/matrix.client.spec.ts
@@ -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._=\-/]+$/,
+ );
+ }
+ });
+});
diff --git a/apps/edr-freight-api/src/modules/chat/matrix.client.ts b/apps/edr-freight-api/src/modules/chat/matrix.client.ts
new file mode 100644
index 000000000..1cd09ac33
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/chat/matrix.client.ts
@@ -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,
+ ) {}
+
+ /**
+ * 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, '-');
+ }
+
+ /** `@:` — 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(
+ method: string,
+ path: string,
+ body?: unknown,
+ token: string = this.config.adminToken,
+ ): Promise {
+ 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(
+ method: string,
+ path: string,
+ body: unknown,
+ ): Promise {
+ 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(
+ method: string,
+ path: string,
+ token?: string,
+ ): Promise {
+ 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 {
+ 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 {
+ const res = await this.request<{ joined: Record }>(
+ '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 {
+ 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 {
+ 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 {
+ 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 {
+ 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: " 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 {
+ 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 {
+ 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 {
+ return this.request(
+ 'POST',
+ `/_synapse/admin/v1/deactivate/${encodeURIComponent(userId)}`,
+ { erase: false },
+ );
+ }
+
+ sendMessage(roomId: string, body: string, formattedBody?: string): Promise {
+ 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 },
+ );
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts
index 175533c6d..5d29b32f6 100644
--- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts
+++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts
@@ -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 {
+ const { profile, company } =
+ await this.companiesService.revertToRegularCompany(user.id);
+ return new CompanyInfoResponseDto(profile, company);
+ }
+
@Post("company-profile")
@PortalCustomer()
@ApiOperation({
diff --git a/apps/edr-freight-api/src/modules/companies/companies.investor-licence.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.investor-licence.spec.ts
new file mode 100644
index 000000000..9de99186f
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/companies/companies.investor-licence.spec.ts
@@ -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 | null) {
+ const companiesRepo = {
+ findById: jest.fn(async () => company),
+ update: jest.fn(async () => null),
+ create: jest.fn(async (row: Record) => ({
+ id: "company-1",
+ ...row,
+ })),
+ existsByTin: jest.fn(async () => false),
+ };
+ const companyProfilesRepo = {
+ findByCompanyId: jest.fn(async (): Promise[]> => []),
+ updateStatus: jest.fn(async () => null),
+ create: jest.fn(async (row: Record) => ({
+ 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) => ({
+ 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,
+ ];
+ 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,
+ ];
+ // 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,
+ ];
+ 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,
+ ];
+ 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,
+ ];
+ 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();
+ });
+});
diff --git a/apps/edr-freight-api/src/modules/companies/companies.license-supersede.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.license-supersede.spec.ts
new file mode 100644
index 000000000..5a73b6040
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/companies/companies.license-supersede.spec.ts
@@ -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) => ({ 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" }),
+ ]),
+ );
+ });
+});
diff --git a/apps/edr-freight-api/src/modules/companies/companies.profile-approval.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.profile-approval.spec.ts
new file mode 100644
index 000000000..79fbdd479
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/companies/companies.profile-approval.spec.ts
@@ -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 = {
+ 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[] = [];
+ const profileRepo = {
+ update: jest.fn(async (_id: string, patch: Partial) => {
+ 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) =>
+ 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",
+ });
+ });
+});
diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts
index 98b30f006..d7151f200 100644
--- a/apps/edr-freight-api/src/modules/companies/companies.service.ts
+++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts
@@ -7,6 +7,7 @@ import {
ForbiddenException,
} from "@nestjs/common";
import { DataSource, EntityManager } from "typeorm";
+import { resolveIamUserNames } from "../../common/utils/iam-user-name.util";
import { CompaniesRepository } from "./companies.repository";
import { CompanyProfileRepository } from "./company-profile.repository";
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
@@ -62,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 {
@@ -377,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.
@@ -387,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 = {};
if (nationality) updates.nationality = nationality;
@@ -401,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));
@@ -427,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({
@@ -484,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 = {
+ 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 | null | undefined,
+ ): Record {
+ 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.
@@ -1141,16 +1256,55 @@ export class CompaniesService {
return new ProfileResponseDto(profile, live, request);
}
- /** List a company's change requests, newest first (backoffice review). */
+ /**
+ * List a company's change requests, newest first (backoffice review). Actor
+ * ids are resolved to display names here — the history screen has to say who
+ * asked for a change and who sent it back, not print two uuids.
+ */
async listChangeRequests(companyId: string): Promise {
await this.findCompanyById(companyId);
- return this.changeRequestRepo.findByCompanyId(companyId);
+ const requests = await this.changeRequestRepo.findByCompanyId(companyId);
+ const names = await this.resolveActorNames(
+ requests.flatMap((r) => [r.submittedBy, r.reviewedBy]),
+ );
+ for (const request of requests) {
+ request.submittedByName = request.submittedBy
+ ? (names.get(request.submittedBy) ?? null)
+ : null;
+ request.reviewedByName = request.reviewedBy
+ ? (names.get(request.reviewedBy) ?? null)
+ : null;
+ }
+ return requests;
}
/** Onboarding-phase edit history (see {@link recordCompanyRevision}), newest first. */
async listCompanyRevisions(companyId: string): Promise {
await this.findCompanyById(companyId);
- return this.revisionRepo.findByCompanyId(companyId);
+ const revisions = await this.revisionRepo.findByCompanyId(companyId);
+ const names = await this.resolveActorNames(revisions.map((r) => r.actorId));
+ for (const revision of revisions) {
+ revision.actorName = revision.actorId
+ ? (names.get(revision.actorId) ?? null)
+ : null;
+ }
+ return revisions;
+ }
+
+ /**
+ * Display names for actor ids, one query for the whole list. A lookup failure
+ * degrades the history to ids rather than failing the request — the entry is
+ * still worth showing without the name.
+ */
+ private async resolveActorNames(
+ actorIds: (string | null | undefined)[],
+ ): Promise> {
+ try {
+ return await resolveIamUserNames(this.dataSource, actorIds);
+ } catch (err) {
+ this.logger.warn(`Could not resolve actor names: ${String(err)}`);
+ return new Map();
+ }
}
/**
@@ -1519,6 +1673,7 @@ export class CompaniesService {
}
await this.discardLicenseChanges(request);
await this.discardDocumentChanges(request);
+ await this.notifyChangeRequestReturned(request, "rejected", note, reviewerId);
return (
(await this.changeRequestRepo.update(id, {
status: ChangeRequestStatus.Rejected,
@@ -1556,6 +1711,12 @@ export class CompaniesService {
`Change request ${id} is already ${request.status}`,
);
}
+ await this.notifyChangeRequestReturned(
+ request,
+ "changes_requested",
+ note,
+ reviewerId,
+ );
return (
(await this.changeRequestRepo.update(id, {
status: ChangeRequestStatus.ChangesRequested,
@@ -1566,6 +1727,35 @@ export class CompaniesService {
);
}
+ /**
+ * Tell the customer desk a change request came back unapproved. Best-effort:
+ * a missing company or an unresolvable reviewer name must not fail the
+ * reviewer's decision, which is already the point of the try/catch.
+ */
+ private async notifyChangeRequestReturned(
+ request: CompanyChangeRequest,
+ outcome: "rejected" | "changes_requested",
+ note: string,
+ reviewerId?: string,
+ ): Promise {
+ try {
+ const company = await this.companiesRepo.findById(request.companyId);
+ if (!company) return;
+ const names = await this.resolveActorNames([reviewerId]);
+ this.companyNotifier.changeRequestReturned(
+ company,
+ request.id,
+ outcome,
+ note,
+ reviewerId ? (names.get(reviewerId) ?? null) : null,
+ );
+ } catch (err) {
+ this.logger.warn(
+ `Could not notify the customer desk about ${request.id}: ${String(err)}`,
+ );
+ }
+ }
+
async deleteCompany(id: string): Promise {
await this.findCompanyById(id);
await this.companiesRepo.softDelete(id);
@@ -1640,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
@@ -1768,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) {
@@ -2067,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
@@ -2216,6 +2432,7 @@ export class CompaniesService {
documentSettingCode,
nationality: company.nationality ?? CompanyNationality.Ethiopian,
cooperative,
+ investorLicence,
companyInfo: {
complete: missingInfo.length === 0,
missingFields: missingInfo,
@@ -2293,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
@@ -2392,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,
@@ -2403,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({
@@ -2427,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(
@@ -3459,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,
diff --git a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts
index 6bcd21f08..d3a556f50 100644
--- a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts
+++ b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts
@@ -244,6 +244,35 @@ export class CompanyNotifierService {
);
}
+ /**
+ * A reviewer did NOT approve a customer's profile changes — they rejected it
+ * or sent it back for correction. The customer desk (Marketing included, via
+ * the `customers:get_notification` key) owns the follow-up with the customer,
+ * so the decision has to reach their inbox; without this it was silent, and
+ * only visible to whoever happened to reopen the customer's History tab.
+ */
+ changeRequestReturned(
+ company: Company,
+ changeRequestId: string,
+ outcome: "rejected" | "changes_requested",
+ note: string,
+ reviewerName?: string | null,
+ ): void {
+ const rejected = outcome === "rejected";
+ const by = reviewerName?.trim() ? ` by ${reviewerName.trim()}` : "";
+ this.logger.log(`CHANGE_REQUEST_${outcome.toUpperCase()} — ${company.id}`);
+ this.notifyStaff(
+ company,
+ rejected
+ ? "Customer profile changes rejected"
+ : "Customer profile changes sent back for correction",
+ `${company.name}'s profile changes were ` +
+ `${rejected ? "rejected" : "sent back for correction"}${by}. ` +
+ `Reason: ${note}`,
+ { changeRequestId, outcome, note, reviewerName: reviewerName ?? null },
+ );
+ }
+
// ── Customer-facing: a specific document needs correcting ──────────────────
/**
diff --git a/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts
index 4a931dae3..2a4f65708 100644
--- a/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts
+++ b/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts
@@ -23,8 +23,12 @@ export class ChangeRequestResponseDto {
documentChanges: DocumentChangeIntent[];
note: string | null;
submittedBy: string | null;
+ /** Who filed the request, for the history screen (null when unresolvable). */
+ submittedByName: string | null;
submittedAt: Date | null;
reviewedBy: string | null;
+ /** Who approved / rejected / sent it back. */
+ reviewedByName: string | null;
reviewedAt: Date | null;
createdAt: Date;
updatedAt: Date;
@@ -39,8 +43,10 @@ export class ChangeRequestResponseDto {
this.documentChanges = req.documents?.documentChanges ?? [];
this.note = req.note ?? null;
this.submittedBy = req.submittedBy ?? null;
+ this.submittedByName = req.submittedByName ?? null;
this.submittedAt = req.submittedAt ?? null;
this.reviewedBy = req.reviewedBy ?? null;
+ this.reviewedByName = req.reviewedByName ?? null;
this.reviewedAt = req.reviewedAt ?? null;
this.createdAt = req.createdAt;
this.updatedAt = req.updatedAt;
diff --git a/apps/edr-freight-api/src/modules/companies/dto/company-revision-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/company-revision-response.dto.ts
index c93c7387c..198a44e06 100644
--- a/apps/edr-freight-api/src/modules/companies/dto/company-revision-response.dto.ts
+++ b/apps/edr-freight-api/src/modules/companies/dto/company-revision-response.dto.ts
@@ -8,6 +8,8 @@ export class CompanyRevisionResponseDto {
id: string;
companyId: string;
actorId: string | null;
+ /** Who made the edit, for the history screen (null when unresolvable). */
+ actorName: string | null;
summary: string;
changes: CompanyRevisionChange[];
createdAt: Date;
@@ -16,6 +18,7 @@ export class CompanyRevisionResponseDto {
this.id = revision.id;
this.companyId = revision.companyId;
this.actorId = revision.actorId ?? null;
+ this.actorName = revision.actorName ?? null;
this.summary = revision.summary;
this.changes = revision.changes ?? [];
this.createdAt = revision.createdAt;
diff --git a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts
index 20bd0ab06..49dad4b8d 100644
--- a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts
+++ b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts
@@ -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;
diff --git a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts
index 0d7293ab0..14753f8ef 100644
--- a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts
+++ b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts
@@ -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)) ??
[];
diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts
index e75182889..7c4348e4f 100644
--- a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts
+++ b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts
@@ -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;
diff --git a/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts
index 91fcb44a1..0eac35a5f 100644
--- a/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts
+++ b/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts
@@ -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;
}
diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts
index 2ba39ecad..02b49f849 100644
--- a/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts
+++ b/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts
@@ -110,4 +110,12 @@ export class CompanyChangeRequest extends BaseEntity {
@Column({ name: "reviewed_at", type: "timestamptz", nullable: true })
reviewedAt?: Date | null;
+
+ /**
+ * Display names for {@link submittedBy} / {@link reviewedBy}, resolved from
+ * `iam.users` on read. Not columns — the history screen has to name the
+ * person who asked for the change, and an opaque uuid does not.
+ */
+ submittedByName?: string | null;
+ reviewedByName?: string | null;
}
diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-revision.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-revision.entity.ts
index 222a8364f..533f08d7a 100644
--- a/apps/edr-freight-api/src/modules/companies/entities/company-revision.entity.ts
+++ b/apps/edr-freight-api/src/modules/companies/entities/company-revision.entity.ts
@@ -43,4 +43,10 @@ export class CompanyRevision extends BaseEntity {
@Column({ name: "changes", type: "jsonb", default: () => `'[]'::jsonb` })
changes!: CompanyRevisionChange[];
+
+ /**
+ * Display name for {@link actorId}, resolved from `iam.users` on read. Not a
+ * column — history has to name who made the edit, and a uuid does not.
+ */
+ actorName?: string | null;
}
diff --git a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts
index 54c36bf41..68bf73cdf 100644
--- a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts
+++ b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts
@@ -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 | 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 | null | undefined,
+): boolean {
+ return isCooperative(company) || hasInvestorLicence(company);
+}
+
@Entity({ schema: "freight", name: "companies" })
@Index(["tin"])
@Index(["type"])
diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts
index c222ffc16..bdb8d74cd 100644
--- a/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts
@@ -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 };
}
diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts
index 84465145d..fb68b3f5c 100644
--- a/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts
@@ -64,6 +64,7 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
{} as never, // trainSchedulingService
{} as never, // bookingBatchService
{} as never, // bookingTransitionService
+ {} as never, // consolidationApprovalService
);
return {
service,
diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.initiate-gate.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.initiate-gate.spec.ts
index 6b5f54c91..6155eb583 100644
--- a/apps/edr-freight-api/src/modules/contracts/contract-booking.initiate-gate.spec.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.initiate-gate.spec.ts
@@ -26,6 +26,7 @@ describe('ContractBookingService — customs booking gate', () => {
{} as never, // trainSchedulingService
{} as never, // bookingBatchService
{} as never, // bookingTransitionService
+ {} as never, // consolidationApprovalService
);
}
diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.manual-consolidation.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.manual-consolidation.spec.ts
new file mode 100644
index 000000000..49e77627f
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.manual-consolidation.spec.ts
@@ -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>;
+ 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) => 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);
+ });
+});
diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.resubmit-cargo.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.resubmit-cargo.spec.ts
index d5d15d73d..0a892b108 100644
--- a/apps/edr-freight-api/src/modules/contracts/contract-booking.resubmit-cargo.spec.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.resubmit-cargo.spec.ts
@@ -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 };
}
diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts
index 3d362d53f..9e31aff34 100644
--- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts
@@ -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 {
+ 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, {
diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts
index 76106a9ff..826c7fdff 100644
--- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts
@@ -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,
diff --git a/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts
index e38f737c6..1f1469182 100644
--- a/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm';
+import { resolveIamUserNames } from '../../common/utils/iam-user-name.util';
import {
ContractDocumentChange,
diffSnapshots,
@@ -20,28 +21,6 @@ export interface RecordRevisionInput {
stepId?: string | null;
}
-/**
- * `iam.users.name` is a localized object ({ en, am, … }), not a string — a
- * plain `String(name)` there yields "[object Object]" in the audit trail.
- */
-interface IamUserRow {
- name?: Record | string | null;
- username?: string | null;
- email?: string | null;
-}
-
-/** Best display name for a user row: English label → any locale → login → email. */
-function pickUserName(user: IamUserRow): string | null {
- const { name } = user;
- if (typeof name === 'string' && name.trim()) return name.trim();
- if (name && typeof name === 'object') {
- const localized =
- name.en ?? Object.values(name).find((v) => typeof v === 'string' && v.trim());
- if (localized?.trim()) return localized.trim();
- }
- return user.username?.trim() || user.email?.trim() || null;
-}
-
/** Pre-computed changes (contract fields), rather than a document diff. */
export interface RecordChangesInput {
contractId: string;
@@ -120,23 +99,12 @@ export class ContractDocumentHistoryService {
private async resolveActorNames(
actorIds: string[],
): Promise> {
- const resolved = new Map();
- const ids = [...new Set(actorIds.filter(Boolean))];
- if (ids.length === 0) return resolved;
-
try {
- const rows = (await this.dataSource.query(
- `SELECT id, name, username, email FROM iam.users WHERE id = ANY($1::uuid[])`,
- [ids],
- )) as Array;
- for (const row of rows) {
- const name = pickUserName(row);
- if (name) resolved.set(row.id, name);
- }
+ return await resolveIamUserNames(this.dataSource, actorIds);
} catch (err) {
this.logger.warn(`Could not resolve actor names: ${String(err)}`);
+ return new Map();
}
- return resolved;
}
/** Revision history for a contract, newest first. */
diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts
index c9db49b24..9f602ec21 100644
--- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts
@@ -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,
@@ -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,
);
}
diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts
index d89d43250..309079010 100644
--- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts
@@ -656,6 +656,31 @@ export class ContractsRepository extends BaseRepository {
.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 {
+ 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,
diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts
index 8c1bf763d..f50ca9d4d 100644
--- a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts
+++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts
@@ -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 booking’s 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;
}
diff --git a/apps/edr-freight-api/src/modules/eims/dto/bulk-cancel-eims-registration.dto.ts b/apps/edr-freight-api/src/modules/eims/dto/bulk-cancel-eims-registration.dto.ts
new file mode 100644
index 000000000..c4782782c
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/eims/dto/bulk-cancel-eims-registration.dto.ts
@@ -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[];
+}
diff --git a/apps/edr-freight-api/src/modules/eims/eims-cancellation.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-cancellation.service.spec.ts
index 6bd9b04b3..b9fdf62df 100644
--- a/apps/edr-freight-api/src/modules/eims/eims-cancellation.service.spec.ts
+++ b/apps/edr-freight-api/src/modules/eims/eims-cancellation.service.spec.ts
@@ -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 =>
({
@@ -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);
+ });
+});
diff --git a/apps/edr-freight-api/src/modules/eims/eims-cancellation.service.ts b/apps/edr-freight-api/src/modules/eims/eims-cancellation.service.ts
index 9aa525ae3..7f8605c5f 100644
--- a/apps/edr-freight-api/src/modules/eims/eims-cancellation.service.ts
+++ b/apps/edr-freight-api/src/modules/eims/eims-cancellation.service.ts
@@ -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 {
+ const results = new Map();
+ 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(
+ "/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 {
const invoice = await this.dataSource.manager.findOne(Invoice, { where: { id: invoiceId } });
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
diff --git a/apps/edr-freight-api/src/modules/eims/eims-credentials.provider.ts b/apps/edr-freight-api/src/modules/eims/eims-credentials.provider.ts
index b68a4a32f..a5f837c1e 100644
--- a/apps/edr-freight-api/src/modules/eims/eims-credentials.provider.ts
+++ b/apps/edr-freight-api/src/modules/eims/eims-credentials.provider.ts
@@ -5,6 +5,17 @@ import { ConfigService } from "@nestjs/config";
import { EimsConfig } from "../../config/eims.config";
import { EimsConfigException } from "./eims.errors";
+const PEM_HEADER = /-----BEGIN [A-Z ]*(PRIVATE KEY|CERTIFICATE)-----/;
+
+/**
+ * A safe-to-log fingerprint of decoded key/cert bytes: length + a printable-only preview of the
+ * first line. Never the actual key material — PEM headers aren't secret, the base64 body is.
+ */
+const describeBytes = (bytes: Buffer): string => {
+ const preview = bytes.toString("utf8", 0, 40).replace(/[^\x20-\x7e]/g, "?");
+ return `${bytes.length} bytes, starts with "${preview}"`;
+};
+
/**
* Loads the INSA-issued EIMS credentials from disk, once, and keeps them in memory.
*
@@ -24,25 +35,61 @@ export class EimsCredentialsProvider {
return this.config.get("eims")!;
}
- /** RSA private key, parsed once. Throws a config error if the path is missing or unusable. */
+ /**
+ * RSA private key, parsed once. Three ways in, checked in this order: `privateKeyPem` (the PEM
+ * text itself, no encoding step to get wrong), `privateKeyBase64` (for stores that can't hold a
+ * literal newline), `privateKeyPath` (the original file-on-disk form). Throws a config error if
+ * none is usable.
+ */
getPrivateKey(): KeyObject {
if (this.privateKey) return this.privateKey;
- const path = this.cfg.privateKeyPath;
- if (!path) throw new EimsConfigException("EIMS_PRIVATE_KEY_PATH is not set");
+ const { privateKeyPem, privateKeyBase64, privateKeyPath: path } = this.cfg;
+ const source = privateKeyPem
+ ? "EIMS_PRIVATE_KEY"
+ : privateKeyBase64
+ ? "EIMS_PRIVATE_KEY_BASE64"
+ : `EIMS_PRIVATE_KEY_PATH (${path})`;
+ if (!privateKeyPem && !privateKeyBase64 && !path) {
+ throw new EimsConfigException("EIMS_PRIVATE_KEY_PATH is not set");
+ }
+
+ let bytes: Buffer;
+ try {
+ bytes = privateKeyPem
+ ? Buffer.from(privateKeyPem, "utf8")
+ : privateKeyBase64
+ ? Buffer.from(privateKeyBase64, "base64")
+ : readFileSync(path);
+ } catch (err) {
+ throw new EimsConfigException(
+ `EIMS private key from ${source} could not be read or parsed: ${(err as Error).message}`,
+ );
+ }
+
+ // Fail with a diagnosable message before handing possibly-garbled bytes to OpenSSL, whose own
+ // error ("unsupported") gives no hint whether the problem is truncation, double-encoding, or a
+ // genuinely wrong file — all indistinguishable from outside without seeing the decoded bytes.
+ if (!PEM_HEADER.test(bytes.toString("utf8", 0, 100))) {
+ throw new EimsConfigException(
+ `EIMS private key from ${source} does not look like a PEM key after decoding ` +
+ `(${describeBytes(bytes)}) — check it's base64 of the raw key file with no line-wrapping ` +
+ `or truncation, and not base64 applied twice.`,
+ );
+ }
let key: KeyObject;
try {
- key = createPrivateKey(readFileSync(path));
+ key = createPrivateKey(bytes);
} catch (err) {
- // The path is operational information, not a secret; the key material never appears.
+ // The source is operational information, not a secret; the key material never appears.
throw new EimsConfigException(
- `EIMS private key at ${path} could not be read or parsed: ${(err as Error).message}`,
+ `EIMS private key from ${source} could not be read or parsed: ${(err as Error).message}`,
);
}
if (key.asymmetricKeyType !== "rsa") {
throw new EimsConfigException(
- `EIMS private key at ${path} is ${key.asymmetricKeyType ?? "of unknown type"}; EIMS requires RSA`,
+ `EIMS private key from ${source} is ${key.asymmetricKeyType ?? "of unknown type"}; EIMS requires RSA`,
);
}
@@ -51,11 +98,26 @@ export class EimsCredentialsProvider {
return key;
}
- /** Base64 of the certificate file's exact bytes. No parsing, no re-encoding. */
+ /**
+ * Base64 of the certificate file's exact bytes. No parsing, no re-encoding of what MoR issued.
+ * `certificatePem`/`certificateBase64` config win when set (used as-is, or re-encoded from the
+ * pasted text respectively); otherwise read from `certificatePath`.
+ */
getCertificateBase64(): string {
if (this.certificateBase64) return this.certificateBase64;
- const path = this.cfg.certificatePath;
+ const { certificatePem: pem, certificateBase64: inline, certificatePath: path } = this.cfg;
+ if (pem) {
+ this.certificateBase64 = Buffer.from(pem, "utf8").toString("base64");
+ this.logger.log(`EIMS certificate bundle loaded from EIMS_CERTIFICATE`);
+ return this.certificateBase64;
+ }
+ if (inline) {
+ this.certificateBase64 = inline;
+ this.logger.log(`EIMS certificate bundle loaded from EIMS_CERTIFICATE_BASE64`);
+ return this.certificateBase64;
+ }
+
if (!path) throw new EimsConfigException("EIMS_CERTIFICATE_PATH is not set");
let bytes: Buffer;
diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts
index 79d3204dd..f560ff917 100644
--- a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts
+++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts
@@ -10,7 +10,7 @@ import { NotificationInboxService } from "../notification-inbox/notification-inb
import { NotificationsService } from "../notifications/notifications.service";
import { EimsAuthService } from "./eims-auth.service";
import { EimsClientService } from "./eims-client.service";
-import { EimsApiException } from "./eims.errors";
+import { EimsApiException, EimsConfigException } from "./eims.errors";
import { buildEimsSeller } from "./eims-invoice-context";
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
import { EimsSellerCacheService } from "./eims-seller-cache.service";
@@ -479,6 +479,59 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
});
});
+ it("a config error (bad key, never reached MoR) rolls back both counters, no system block", async () => {
+ const db = new FakeDb([invoiceRow()]);
+ const postSigned = jest
+ .fn()
+ .mockRejectedValue(new EimsConfigException("EIMS private key ... could not be read or parsed"));
+
+ await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf(
+ EimsConfigException,
+ );
+
+ expect(db.invoices.get(INVOICE_ID)).toMatchObject({
+ eimsStatus: EimsInvoiceStatus.Failed,
+ eimsIrn: null,
+ eimsLastError: expect.objectContaining({ kind: "CONFIG" }),
+ });
+ expect(db.state).toMatchObject({
+ inFlightInvoiceId: null,
+ blockedReason: null,
+ previousIrn: null,
+ nextInvoiceCounter: 7,
+ });
+ });
+
+ it("a mapper failure after reservation (e.g. unmapped buyer country) also releases the reservation", async () => {
+ // Regression: toEimsInvoice/buildEimsContext used to sit outside the try/catch that calls
+ // settleFailure — a throw here left the reservation permanently orphaned (a real live incident:
+ // 500 on register, then every subsequent attempt 409'd "already in flight" until manually
+ // resolved). This never reaches postSigned at all — the mapper throws before submit() is called.
+ const db = new FakeDb([
+ invoiceRow({ company: { ...invoiceRow().company, country: "France" } as never }),
+ ]);
+ const postSigned = jest.fn();
+
+ // The mapper throws a plain Error (it's a pure function, not a NestJS layer) — that's the
+ // point: settleFailure must treat *any* non-EimsApiException as pre-wire, not just its own
+ // known exception types.
+ await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toThrow(
+ /no MoR country code mapping/,
+ );
+
+ expect(postSigned).not.toHaveBeenCalled();
+ expect(db.invoices.get(INVOICE_ID)).toMatchObject({
+ eimsStatus: EimsInvoiceStatus.Failed,
+ eimsLastError: expect.objectContaining({ kind: "LOCAL" }),
+ });
+ expect(db.state).toMatchObject({
+ inFlightInvoiceId: null,
+ blockedReason: null,
+ previousIrn: null,
+ nextInvoiceCounter: 7,
+ });
+ });
+
it("treats a success response with no IRN as a failed registration", async () => {
const db = new FakeDb([invoiceRow()]);
const postSigned = jest.fn().mockResolvedValue({ statusCode: 200, body: { irn: "" } });
diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts
index 2ca5f5ffd..a823e5ddf 100644
--- a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts
+++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts
@@ -26,7 +26,7 @@ import { toEimsInvoiceStatusView } from "./eims-invoice-view.util";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { EimsAuthService } from "./eims-auth.service";
import { EimsClientService } from "./eims-client.service";
-import { EimsApiException } from "./eims.errors";
+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";
@@ -123,27 +123,29 @@ export class EimsInvoiceRegistrationService {
const reservation = await this.reserve(invoiceId, session.systemNumber);
if (!reservation) return this.getEimsStatus(invoiceId);
- // The request can only be built now: InvoiceCounter and PreviousIrn come from the reservation.
- const request = toEimsInvoice(
- invoice,
- this.sellerCache.getSellerDetails(cfg),
- buildEimsContext(cfg, {
- // Allocated from the system state, not our invoiceNumber: MoR validates DocumentNumber
- // against ^(0|[1-9][0-9]{0,8})$, which "INV-20260807-00006" can never satisfy.
- documentNumber: reservation.documentNumber,
- invoiceCounter: reservation.invoiceCounter,
- previousIrn: reservation.previousIrn,
- session,
- documentType,
- reason: invoice.eimsReason,
- relatedDocument,
- }),
- );
-
let irn: string;
let ackDate: string | undefined;
let signedQR: string | undefined;
try {
+ // The request can only be built now: InvoiceCounter and PreviousIrn come from the
+ // reservation. Building it — and everything after — stays inside this try: a reservation is
+ // held from here on, and *any* failure past this point, mapper or wire, must release it
+ // through settleFailure rather than leave it orphaned as a permanent system-wide block.
+ const request = toEimsInvoice(
+ invoice,
+ this.sellerCache.getSellerDetails(cfg),
+ buildEimsContext(cfg, {
+ // Allocated from the system state, not our invoiceNumber: MoR validates DocumentNumber
+ // against ^(0|[1-9][0-9]{0,8})$, which "INV-20260807-00006" can never satisfy.
+ documentNumber: reservation.documentNumber,
+ invoiceCounter: reservation.invoiceCounter,
+ previousIrn: reservation.previousIrn,
+ session,
+ documentType,
+ reason: invoice.eimsReason,
+ relatedDocument,
+ }),
+ );
// Deliberately outside every transaction — no DB lock is held across the wire.
const result = await this.submit(request);
irn = result.irn;
@@ -469,6 +471,15 @@ export class EimsInvoiceRegistrationService {
* when two rejected self-test attempts deadlocked the sequence until a manual DB reset.
*
* An ambiguous result keeps both: MoR may have counted and stored the document.
+ *
+ * Any error that is *not* an `EimsApiException` is also deterministic, on a different basis:
+ * every error that actually touches the wire is normalized to `EimsApiException` before it gets
+ * here (`EimsClientService.send()`'s catch calls `toEimsApiException` on whatever the HTTP call
+ * threw). The try block this feeds covers request-building (`toEimsInvoice`/`buildEimsContext` —
+ * pure, no I/O) and `submit()`; nothing in that span can produce another exception shape by
+ * touching MoR. So a non-`EimsApiException` here — a mapper validation error (unmapped buyer
+ * country, say), `EimsConfigException` from a bad signing key, or a bug — failed strictly before
+ * any HTTP call went out, and releasing the reservation is always safe, never a guess.
*/
private async settleFailure(
invoiceId: string,
@@ -476,10 +487,12 @@ export class EimsInvoiceRegistrationService {
err: unknown,
): Promise {
const api = err instanceof EimsApiException ? err : null;
- const deterministic = api ? DETERMINISTIC_KINDS.has(api.kind) : false;
+ // Never touched the wire (see the doc comment above) — always safe to release, whatever it is.
+ 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 ?? "UNKNOWN",
+ kind: api?.kind ?? localKind,
message: (err as Error)?.message ?? "unknown error",
httpStatus: api?.httpStatus,
details: api?.details,
@@ -586,10 +599,14 @@ export class EimsInvoiceRegistrationService {
type: NotificationType.GENERIC,
priority: deterministic ? NotificationPriority.NORMAL : NotificationPriority.HIGH,
title: deterministic
- ? "EIMS rejected an invoice"
+ ? error.kind === "CONFIG" || error.kind === "LOCAL"
+ ? "EIMS filing failed before reaching MoR"
+ : "EIMS rejected an invoice"
: "EIMS filing unresolved — all further filing is blocked",
body: deterministic
- ? `MoR rejected the filing (${error.kind}): ${error.message}. The invoice is marked FAILED; correct it and file again.`
+ ? error.kind === "CONFIG" || error.kind === "LOCAL"
+ ? `${error.kind === "CONFIG" ? "EIMS is misconfigured" : "Filing failed locally"}: ${error.message}. Nothing was sent to MoR; fix it and file again.`
+ : `MoR rejected the filing (${error.kind}): ${error.message}. The invoice is marked FAILED; correct it and file again.`
: `A submission was sent but never acknowledged (${error.kind}). Its IRN is unknown, so no further invoice can be filed until it is resolved with MoR.`,
link: `/dashboard/invoices/${invoiceId}`,
data: { invoiceId, eimsStatus: status, kind: error.kind, action: "EIMS_FILING_FAILED" },
diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts
index 7d417551f..11eb214dc 100644
--- a/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts
+++ b/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts
@@ -5,6 +5,7 @@ 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 { CancelEimsRegistrationDto } from "./dto/cancel-eims-registration.dto";
import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto";
import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto";
@@ -89,6 +90,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" })
diff --git a/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts b/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts
index 8094d0e74..60941825c 100644
--- a/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts
+++ b/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts
@@ -89,6 +89,46 @@ 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;
+}
+
/** Persisted failure detail. Carries the gateway's own error fields only — never our envelope. */
export interface EimsInvoiceError {
kind: string;
diff --git a/apps/edr-freight-api/src/modules/eims/eims-signer.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-signer.service.spec.ts
index 5e408ddf7..f8210cab6 100644
--- a/apps/edr-freight-api/src/modules/eims/eims-signer.service.spec.ts
+++ b/apps/edr-freight-api/src/modules/eims/eims-signer.service.spec.ts
@@ -97,8 +97,14 @@ describe("EimsSignerService", () => {
});
describe("EimsCredentialsProvider", () => {
- const providerFor = (paths: { privateKeyPath?: string; certificatePath?: string }) =>
- new EimsCredentialsProvider({ get: () => paths } as unknown as ConfigService);
+ const providerFor = (cfg: {
+ privateKeyPath?: string;
+ certificatePath?: string;
+ privateKeyBase64?: string;
+ certificateBase64?: string;
+ privateKeyPem?: string;
+ certificatePem?: string;
+ }) => new EimsCredentialsProvider({ get: () => cfg } as unknown as ConfigService);
it("fails clearly when the key path is unset", () => {
expect(() => providerFor({}).getPrivateKey()).toThrow(/EIMS_PRIVATE_KEY_PATH is not set/);
@@ -115,4 +121,52 @@ describe("EimsCredentialsProvider", () => {
writeFileSync(emptyPath, "");
expect(() => providerFor({ certificatePath: emptyPath }).getCertificateBase64()).toThrow(/is empty/);
});
+
+ it("loads the key from inline base64, no file involved", () => {
+ const keyBase64 = readFileSync(keyPath).toString("base64");
+ const key = providerFor({ privateKeyBase64: keyBase64 }).getPrivateKey();
+ expect(key.asymmetricKeyType).toBe("rsa");
+ });
+
+ it("prefers inline base64 over the path when both are set", () => {
+ const keyBase64 = readFileSync(keyPath).toString("base64");
+ // A path that would fail if it were ever actually read.
+ const key = providerFor({ privateKeyBase64: keyBase64, privateKeyPath: join(dir, "nope.key") }).getPrivateKey();
+ expect(key.asymmetricKeyType).toBe("rsa");
+ });
+
+ it("loads the certificate from inline base64 as-is, no re-encoding", () => {
+ const certBase64 = Buffer.from(CERTIFICATE_FIXTURE, "utf8").toString("base64");
+ expect(providerFor({ certificateBase64: certBase64 }).getCertificateBase64()).toBe(certBase64);
+ });
+
+ it("fails with a decoded-bytes preview when the base64 doesn't decode to a PEM key", () => {
+ // Simulates the real failure this guards against: a truncated/mangled env var still decodes
+ // as *some* bytes, but not a key — OpenSSL's own error here gives no hint why.
+ const notAKey = Buffer.from("not actually a pem file", "utf8").toString("base64");
+ expect(() => providerFor({ privateKeyBase64: notAKey }).getPrivateKey()).toThrow(
+ /does not look like a PEM key.*23 bytes, starts with "not actually a pem file"/s,
+ );
+ });
+
+ it("loads the key from the raw PEM env var directly, no encoding step", () => {
+ const pem = readFileSync(keyPath).toString("utf8");
+ const key = providerFor({ privateKeyPem: pem }).getPrivateKey();
+ expect(key.asymmetricKeyType).toBe("rsa");
+ });
+
+ it("prefers the raw PEM var over base64 and path when all three are set", () => {
+ const pem = readFileSync(keyPath).toString("utf8");
+ const key = providerFor({
+ privateKeyPem: pem,
+ privateKeyBase64: Buffer.from("garbage").toString("base64"),
+ privateKeyPath: join(dir, "nope.key"),
+ }).getPrivateKey();
+ expect(key.asymmetricKeyType).toBe("rsa");
+ });
+
+ it("loads the certificate from the raw PEM env var, re-encoded to base64", () => {
+ const base64 = providerFor({ certificatePem: CERTIFICATE_FIXTURE }).getCertificateBase64();
+ expect(base64).toBe(Buffer.from(CERTIFICATE_FIXTURE, "utf8").toString("base64"));
+ });
});
diff --git a/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts b/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts
index 650a834b8..a55951db4 100644
--- a/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts
+++ b/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts
@@ -59,6 +59,10 @@ export const eimsConfig = (over: Partial = {}): EimsConfig => ({
systemType: EIMS_SYSTEM_TYPE,
privateKeyPath: "/dev/null",
certificatePath: "/dev/null",
+ privateKeyBase64: "",
+ certificateBase64: "",
+ privateKeyPem: "",
+ certificatePem: "",
httpTimeoutMs: 30_000,
tokenSkewMs: 45_000,
autoSubmit: false,
diff --git a/apps/edr-freight-api/src/modules/eims/eims.errors.ts b/apps/edr-freight-api/src/modules/eims/eims.errors.ts
index 3be21fdd3..853b32c29 100644
--- a/apps/edr-freight-api/src/modules/eims/eims.errors.ts
+++ b/apps/edr-freight-api/src/modules/eims/eims.errors.ts
@@ -10,7 +10,9 @@ export type EimsFailureKind =
| "FORBIDDEN"
| "RULE_VALIDATION"
| "SERVER"
- | "UNKNOWN";
+ | "UNKNOWN"
+ | "CONFIG"
+ | "LOCAL";
/** Raised when EIMS is disabled or its credential files are unusable. */
export class EimsConfigException extends ServiceUnavailableException {
diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts
index e9f3af958..aa363ad3d 100644
--- a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts
+++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts
@@ -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: [
diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.service.ts b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.service.ts
index 7cfbf81df..119019997 100644
--- a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.service.ts
+++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.service.ts
@@ -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,
) {}
@@ -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 {
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`,
diff --git a/apps/edr-freight-api/src/modules/payment-settings/dto/update-manual-payment-setting.dto.ts b/apps/edr-freight-api/src/modules/payment-settings/dto/update-manual-payment-setting.dto.ts
new file mode 100644
index 000000000..971de03cb
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/payment-settings/dto/update-manual-payment-setting.dto.ts
@@ -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;
+}
diff --git a/apps/edr-freight-api/src/modules/payment-settings/entities/manual-payment-setting.entity.ts b/apps/edr-freight-api/src/modules/payment-settings/entities/manual-payment-setting.entity.ts
new file mode 100644
index 000000000..a18f97279
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/payment-settings/entities/manual-payment-setting.entity.ts
@@ -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;
+}
diff --git a/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.controller.ts b/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.controller.ts
new file mode 100644
index 000000000..786d1f7ca
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.controller.ts
@@ -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);
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.service.ts b/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.service.ts
new file mode 100644
index 000000000..efb43fa91
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.service.ts
@@ -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,
+ ) {}
+
+ /** The settings row, created at the defaults on first access. */
+ async get(): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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;
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/payment-settings/payment-settings.module.ts b/apps/edr-freight-api/src/modules/payment-settings/payment-settings.module.ts
new file mode 100644
index 000000000..bcadfe340
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/payment-settings/payment-settings.module.ts
@@ -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 {}
diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/yard-positions.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/yard-positions.controller.ts
new file mode 100644
index 000000000..44ae27607
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/yard-positions.controller.ts
@@ -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;
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/yard-positions.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/yard-positions.dto.ts
new file mode 100644
index 000000000..b18648d78
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/rule-engine/dto/yard-positions.dto.ts
@@ -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[];
+}
diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/yard-position.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/yard-position.entity.ts
new file mode 100644
index 000000000..de12b5674
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/rule-engine/entities/yard-position.entity.ts
@@ -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;
+}
diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts
index 549a35fa2..97e89cc3b 100644
--- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts
+++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts
@@ -13,6 +13,7 @@ import { ShippingLinesController } from './controllers/shipping-lines.controller
import { WeightLimitRulesController } from './controllers/weight-limit-rules.controller';
import { YardDistancesController } from './controllers/yard-distances.controller';
import { YardsController } from './controllers/yards.controller';
+import { YardPositionsController } from './controllers/yard-positions.controller';
import { ApprovalRule } from './entities/approval-rule.entity';
import { CargoType } from './entities/cargo-type.entity';
@@ -28,6 +29,7 @@ import { Yard } from './entities/yard.entity';
import { YardDistance } from './entities/yard-distance.entity';
import { YardFacility } from './entities/yard-facility.entity';
import { YardLocation } from './entities/yard-location.entity';
+import { YardPosition } from './entities/yard-position.entity';
import { APPROVAL_RULES_REPOSITORY } from './interfaces/approval-rules.repository.interface';
import { CARGO_TYPES_REPOSITORY } from './interfaces/cargo-types.repository.interface';
@@ -65,6 +67,8 @@ import { WeightLimitRulesService } from './services/weight-limit-rules.service';
import { YardsService } from './services/yards.service';
import { YardDistancesService } from './services/yard-distances.service';
import { YardFacilitiesService } from './services/yard-facilities.service';
+import { YardPositionsService } from './services/yard-positions.service';
+import { YardScopeService } from './services/yard-scope.service';
import { RuleEngineService } from './rule-engine.service';
@@ -91,6 +95,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
YardDistance,
YardFacility,
YardLocation,
+ YardPosition,
ShippingLine,
Rate,
ApprovalRule,
@@ -116,6 +121,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
ServiceTypesController,
WeightLimitRulesController,
YardsController,
+ YardPositionsController,
YardDistancesController,
ShippingLinesController,
RatesController,
@@ -152,6 +158,8 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
YardsService,
YardDistancesService,
YardFacilitiesService,
+ YardPositionsService,
+ YardScopeService,
ShippingLinesService,
RatesService,
ApprovalRulesService,
@@ -168,6 +176,10 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
YardsService,
YardDistancesService,
YardFacilitiesService,
+ YardPositionsService,
+ // Exported so any module can narrow its yard queries through the one
+ // resolver — the module is @Global, so no import is needed to inject it.
+ YardScopeService,
ShippingLinesService,
RatesService,
ApprovalRulesService,
diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yard-positions.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yard-positions.service.ts
new file mode 100644
index 000000000..93f9752f6
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/rule-engine/services/yard-positions.service.ts
@@ -0,0 +1,194 @@
+import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
+import { DataSource, In, IsNull } from 'typeorm';
+
+import { YardPosition } from '../entities/yard-position.entity';
+import { Yard } from '../entities/yard.entity';
+
+/** A mapped desk, joined to its IAM position for display. */
+export interface YardPositionRow {
+ id: string;
+ yardId: string;
+ yardCode: string;
+ yardLabel: string;
+ positionId: string;
+ /** Localised name from `iam.positions.name` — null if the position is gone. */
+ positionName: { am?: string; en?: string } | null;
+ positionTypeKey: string | null;
+}
+
+/**
+ * The desk↔yard mapping behind yard access scoping.
+ *
+ * Reads always join `iam.positions` and drop soft-deleted rows: the mapping has
+ * no FK to IAM (see the migration), so a position deleted in the admin UI leaves
+ * an orphan row here. Dropping it on read means the orphan can never widen
+ * someone's scope — it just disappears.
+ */
+@Injectable()
+export class YardPositionsService {
+ constructor(private readonly dataSource: DataSource) {}
+
+ /** Mapping rows, optionally narrowed to one yard or one position. */
+ async list(filter: {
+ yardId?: string;
+ positionId?: string;
+ }): Promise {
+ const params: unknown[] = [];
+ const where: string[] = ['yp.deleted_at IS NULL', 'y.deleted_at IS NULL'];
+
+ if (filter.yardId) {
+ params.push(filter.yardId);
+ where.push(`yp.yard_id = $${params.length}`);
+ }
+ if (filter.positionId) {
+ params.push(filter.positionId);
+ where.push(`yp.position_id = $${params.length}`);
+ }
+
+ return this.dataSource.query(
+ `SELECT yp.id AS "id",
+ yp.yard_id AS "yardId",
+ y.code AS "yardCode",
+ y.label AS "yardLabel",
+ yp.position_id AS "positionId",
+ p.name AS "positionName",
+ pt.key AS "positionTypeKey"
+ FROM freight.yard_positions yp
+ JOIN freight.yards y ON y.id = yp.yard_id
+ -- INNER join: a mapping whose position was deleted grants nothing and
+ -- is not shown. The row stays for audit until someone re-saves the set.
+ JOIN iam.positions p ON p.id = yp.position_id AND p.deleted_at IS NULL
+ LEFT JOIN iam.position_types pt ON pt.id = p.position_type_id
+ WHERE ${where.join(' AND ')}
+ ORDER BY y.display_order ASC, y.label ASC, p.name->>'en' ASC`,
+ params,
+ );
+ }
+
+ /**
+ * Replace the yard's entire position set.
+ *
+ * Replace, not append — the admin UI submits the full multi-select value, so a
+ * partial payload would silently keep desks the user just unticked. Callers
+ * sending a delta will remove everything they omit.
+ */
+ async setPositionsForYard(
+ yardId: string,
+ positionIds: string[],
+ ): Promise {
+ await this.assertYardExists(yardId);
+ await this.assertPositionsExist(positionIds);
+
+ await this.dataSource.transaction(async (manager) => {
+ const repo = manager.getRepository(YardPosition);
+ await repo.delete({ yardId });
+ if (positionIds.length) {
+ await repo.insert(
+ [...new Set(positionIds)].map((positionId) => ({ yardId, positionId })),
+ );
+ }
+ });
+
+ return this.list({ yardId });
+ }
+
+ /** Replace the position's entire yard set. Same replace semantics. */
+ async setYardsForPosition(
+ positionId: string,
+ yardIds: string[],
+ ): Promise {
+ await this.assertPositionsExist([positionId]);
+ await this.assertYardsExist(yardIds);
+
+ await this.dataSource.transaction(async (manager) => {
+ const repo = manager.getRepository(YardPosition);
+ await repo.delete({ positionId });
+ if (yardIds.length) {
+ await repo.insert(
+ [...new Set(yardIds)].map((yardId) => ({ yardId, positionId })),
+ );
+ }
+ });
+
+ return this.list({ positionId });
+ }
+
+ /**
+ * Positions offered by the mapping picker.
+ *
+ * Reads `iam.positions` directly rather than going through IAM's
+ * `/positions/list/{unitId}`: that endpoint needs the caller to resolve a unit
+ * first, and the picker wants every desk that could staff a yard regardless of
+ * which unit it hangs under.
+ */
+ async listSelectablePositions(): Promise<
+ Array<{
+ id: string;
+ name: { am?: string; en?: string } | null;
+ positionTypeKey: string | null;
+ unitKey: string | null;
+ }>
+ > {
+ return this.dataSource.query(
+ `SELECT p.id AS "id",
+ p.name AS "name",
+ pt.key AS "positionTypeKey",
+ u.key AS "unitKey"
+ FROM iam.positions p
+ LEFT JOIN iam.position_types pt ON pt.id = p.position_type_id
+ LEFT JOIN iam.units u ON u.id = p.unit_id
+ WHERE p.deleted_at IS NULL
+ ORDER BY p.name->>'en' ASC`,
+ );
+ }
+
+ /** Yard ids mapped to any of these positions — the scope resolver's read. */
+ async yardIdsForPositions(positionIds: string[]): Promise {
+ if (!positionIds.length) return [];
+ const rows: { yardId: string }[] = await this.dataSource.query(
+ `SELECT DISTINCT yp.yard_id AS "yardId"
+ FROM freight.yard_positions yp
+ JOIN freight.yards y ON y.id = yp.yard_id AND y.deleted_at IS NULL
+ WHERE yp.deleted_at IS NULL
+ AND yp.position_id = ANY($1)`,
+ [positionIds],
+ );
+ return rows.map((r) => r.yardId);
+ }
+
+ private async assertYardExists(yardId: string): Promise {
+ const yard = await this.dataSource
+ .getRepository(Yard)
+ .findOne({ where: { id: yardId, deletedAt: IsNull() } });
+ if (!yard) throw new NotFoundException(`Yard ${yardId} not found`);
+ }
+
+ private async assertYardsExist(yardIds: string[]): Promise {
+ if (!yardIds.length) return;
+ const found = await this.dataSource
+ .getRepository(Yard)
+ .count({ where: { id: In(yardIds), deletedAt: IsNull() } });
+ if (found !== new Set(yardIds).size) {
+ throw new BadRequestException('One or more yards do not exist');
+ }
+ }
+
+ /**
+ * Validated in the service because the database cannot: there is no FK to
+ * `iam.positions`, so an unchecked payload would happily store a typo'd uuid
+ * that silently grants nothing and reads as a configuration bug later.
+ */
+ private async assertPositionsExist(positionIds: string[]): Promise {
+ if (!positionIds.length) return;
+ const unique = [...new Set(positionIds)];
+ const rows: { count: string }[] = await this.dataSource.query(
+ `SELECT COUNT(*)::text AS count
+ FROM iam.positions
+ WHERE id = ANY($1) AND deleted_at IS NULL`,
+ [unique],
+ );
+ if (Number(rows[0]?.count ?? 0) !== unique.length) {
+ throw new BadRequestException('One or more positions do not exist');
+ }
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yard-scope.service.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yard-scope.service.spec.ts
new file mode 100644
index 000000000..c9af80ad3
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/rule-engine/services/yard-scope.service.spec.ts
@@ -0,0 +1,139 @@
+import { ForbiddenException } from '@nestjs/common';
+
+import { YardScopeService } from './yard-scope.service';
+
+/**
+ * The resolver answers "which yards", never "may they act at all" — that stays
+ * with the permission guard. So a mapped desk is narrowed to its yards, and an
+ * unmapped one keeps the reach its permissions already gave it.
+ */
+describe('YardScopeService', () => {
+ const yardIdsForPositions = jest.fn();
+ const service = () =>
+ new YardScopeService({ yardIdsForPositions } as never);
+
+ const staff = (positionId: string, permissions: string[] = []) => ({
+ roles: [{ key: 'staff' }],
+ permissions: permissions.map((key) => ({ key })),
+ employee: { position: { id: positionId, permissions: [] } },
+ });
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ delete process.env.YARD_SCOPE_ENFORCE;
+ });
+
+ it('resolves a mapped position to its yards', async () => {
+ yardIdsForPositions.mockResolvedValue(['yard-kality', 'yard-mojo']);
+
+ const scope = await service().getScopedYardIds(staff('pos-officer'));
+
+ expect(scope).toEqual(['yard-kality', 'yard-mojo']);
+ expect(yardIdsForPositions).toHaveBeenCalledWith(['pos-officer']);
+ });
+
+ it('leaves an unmapped position unrestricted — permissions still gate the action', async () => {
+ yardIdsForPositions.mockResolvedValue([]);
+
+ expect(await service().getScopedYardIds(staff('pos-unmapped'))).toBeNull();
+ });
+
+ it('leaves a caller with no resolvable position unrestricted', async () => {
+ const noPosition = { roles: [{ key: 'staff' }], employee: { position: {} } };
+
+ expect(await service().getScopedYardIds(noPosition)).toBeNull();
+ expect(yardIdsForPositions).not.toHaveBeenCalled();
+ });
+
+ it('narrows nothing for an anonymous caller but grants nothing either', async () => {
+ expect(await service().getScopedYardIds(null)).toEqual([]);
+ });
+
+ it('returns unrestricted only for super admins and view_all holders', async () => {
+ const superAdmin = { roles: [{ key: 'super_admin' }] };
+ const hqDesk = staff('pos-occ', ['edr_freight_app:yards:view_all']);
+
+ expect(await service().getScopedYardIds(superAdmin)).toBeNull();
+ expect(await service().getScopedYardIds(hqDesk)).toBeNull();
+ expect(yardIdsForPositions).not.toHaveBeenCalled();
+ });
+
+ it('includes delegated positions — standing in must not lose the yard', async () => {
+ yardIdsForPositions.mockResolvedValue(['yard-kality']);
+
+ await service().getScopedYardIds({
+ roles: [{ key: 'staff' }],
+ employee: {
+ position: { id: 'pos-own' },
+ delegatedPositions: [{ id: 'pos-gelan-director' }],
+ },
+ });
+
+ expect(yardIdsForPositions).toHaveBeenCalledWith([
+ 'pos-own',
+ 'pos-gelan-director',
+ ]);
+ });
+
+ describe('listFilterYardIds', () => {
+ it('narrows nothing while shadow-logging', async () => {
+ yardIdsForPositions.mockResolvedValue(['yard-kality']);
+
+ expect(
+ await service().listFilterYardIds(staff('pos-officer'), undefined, 'list'),
+ ).toBeNull();
+ });
+
+ it('narrows to the mapped yards once enforcing', async () => {
+ process.env.YARD_SCOPE_ENFORCE = 'true';
+ yardIdsForPositions.mockResolvedValue(['yard-kality', 'yard-mojo']);
+
+ expect(
+ await service().listFilterYardIds(staff('pos-officer'), undefined, 'list'),
+ ).toEqual(['yard-kality', 'yard-mojo']);
+ });
+
+ it('keeps an in-scope yard filter as the caller asked', async () => {
+ process.env.YARD_SCOPE_ENFORCE = 'true';
+ yardIdsForPositions.mockResolvedValue(['yard-kality', 'yard-mojo']);
+
+ expect(
+ await service().listFilterYardIds(staff('pos-officer'), 'yard-mojo', 'list'),
+ ).toEqual(['yard-mojo']);
+ });
+
+ it('returns an empty set — not everything — for an out-of-scope yard filter', async () => {
+ process.env.YARD_SCOPE_ENFORCE = 'true';
+ yardIdsForPositions.mockResolvedValue(['yard-kality']);
+
+ expect(
+ await service().listFilterYardIds(staff('pos-officer'), 'yard-djibouti', 'list'),
+ ).toEqual([]);
+ });
+
+ it('never narrows an unmapped desk', async () => {
+ process.env.YARD_SCOPE_ENFORCE = 'true';
+ yardIdsForPositions.mockResolvedValue([]);
+
+ expect(
+ await service().listFilterYardIds(staff('pos-unmapped'), undefined, 'list'),
+ ).toBeNull();
+ });
+ });
+
+ it('only logs an out-of-scope yard until YARD_SCOPE_ENFORCE is set', async () => {
+ yardIdsForPositions.mockResolvedValue(['yard-kality']);
+ const shadow = service();
+
+ await expect(
+ shadow.assertYardInScope(staff('pos-officer'), 'yard-mojo', 'test'),
+ ).resolves.toBeUndefined();
+
+ process.env.YARD_SCOPE_ENFORCE = 'true';
+ const enforcing = service();
+
+ await expect(
+ enforcing.assertYardInScope(staff('pos-officer'), 'yard-mojo', 'test'),
+ ).rejects.toBeInstanceOf(ForbiddenException);
+ });
+});
diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yard-scope.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yard-scope.service.ts
new file mode 100644
index 000000000..0a7fbe2e0
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/rule-engine/services/yard-scope.service.ts
@@ -0,0 +1,186 @@
+import { ForbiddenException, Injectable, Logger } from "@nestjs/common";
+
+import { hasFreightPermission, isSuperAdmin } from "../../../common/freight-permission.util";
+import { FREIGHT_PERMS } from "../../../seed/freight-permissions.registry";
+import { YardPositionsService } from "./yard-positions.service";
+
+/**
+ * Caller shape the resolver reads — the `/auth/me` user in either of its two
+ * shapes. Structurally compatible with what `freight-permission.util` accepts,
+ * so the same object serves both the permission checks and the position walk.
+ */
+type PositionLike = {
+ id?: string;
+ permissions?: { key?: string }[];
+ positionType?: { key?: string } | null;
+};
+
+type ScopeUser = {
+ roles?: { key?: string }[];
+ permissions?: { key?: string }[];
+ employee?:
+ | {
+ position?: PositionLike;
+ delegatedPositions?: PositionLike[];
+ }
+ | { positions?: PositionLike[] }[]
+ | null;
+};
+
+/**
+ * Which yards a caller may touch.
+ *
+ * Scope follows the caller's ACTIVE position, not a union of every position they
+ * have ever held: the frontends already send `x-current-position-id` and the
+ * token snapshots that one position, so switching desks switches yards — which
+ * is what staff covering two yards actually do. Delegated positions are added on
+ * top, otherwise standing in for the Gelan director silently loses Gelan.
+ *
+ * `null` means unrestricted, and an UNMAPPED caller gets it. Scoping narrows a
+ * desk that has been given yards; it does not hand out access. Whether the
+ * caller may perform the action at all is the permission guard's job — this
+ * resolver only answers "which yards", so a desk with the permission and no
+ * mapping keeps the reach it had before the mapping existed.
+ *
+ * The trade-off is deliberate and worth knowing: an accidentally-cleared
+ * mapping widens access rather than blocking work, so the mapping is not a
+ * containment barrier on its own — the permission keys still are. Super admins
+ * and holders of `yards:view_all` are unrestricted regardless of mapping.
+ *
+ * ENFORCEMENT IS OFF until `YARD_SCOPE_ENFORCE=true`. Until then
+ * {@link assertYardInScope} logs what it would have blocked and returns. Flip it
+ * only once the mapping table is populated and the log is quiet — on an empty
+ * table, enforcing locks out every staff member at once.
+ */
+@Injectable()
+export class YardScopeService {
+ private readonly logger = new Logger(YardScopeService.name);
+
+ // ponytail: 60s cache keyed by the position-id set, no invalidation hook. A
+ // mapping change takes up to a minute to reach the resolver. Call
+ // `invalidate()` from the mutation if that lag ever matters.
+ private static readonly CACHE_TTL_MS = 60_000;
+ private readonly cache = new Map();
+
+ constructor(private readonly yardPositions: YardPositionsService) {}
+
+ /** True when the deny path is live; false while shadow-logging. */
+ get enforced(): boolean {
+ return process.env.YARD_SCOPE_ENFORCE === "false";
+ }
+
+ /** Yard ids the caller is scoped to, or `null` for unrestricted. */
+ async getScopedYardIds(user: ScopeUser | null | undefined): Promise {
+ // No user at all is an unauthenticated call the guards should already have
+ // rejected — narrow to nothing rather than trusting it.
+ if (!user) return [];
+ if (isSuperAdmin(user)) return null;
+ if (hasFreightPermission(user, FREIGHT_PERMS.yards.viewAll)) return null;
+
+ const positionIds = this.effectivePositionIds(user);
+ // No resolvable position — nothing to narrow by, so nothing is narrowed.
+ if (!positionIds.length) return null;
+
+ const key = positionIds.join(",");
+ const hit = this.cache.get(key);
+ if (hit && Date.now() - hit.at < YardScopeService.CACHE_TTL_MS) {
+ return hit.yardIds.length ? hit.yardIds : null;
+ }
+
+ const yardIds = await this.yardPositions.yardIdsForPositions(positionIds);
+ this.cache.set(key, { yardIds, at: Date.now() });
+ // Unmapped desk → unrestricted. Mapping narrows; absence of one does not.
+ return yardIds.length ? yardIds : null;
+ }
+
+ async isYardInScope(
+ user: ScopeUser | null | undefined,
+ yardId: string | null | undefined,
+ ): Promise {
+ if (!yardId) return true;
+ const scope = await this.getScopedYardIds(user);
+ return scope === null || scope.includes(yardId);
+ }
+
+ /**
+ * Gate an action on a yard. While `YARD_SCOPE_ENFORCE` is unset this only
+ * logs — wire it into write paths first and read filters second, so the
+ * shadow log shows what enforcement would break before it breaks it.
+ */
+ async assertYardInScope(
+ user: ScopeUser | null | undefined,
+ yardId: string | null | undefined,
+ context: string,
+ ): Promise {
+ if (await this.isYardInScope(user, yardId)) return;
+
+ const positions = this.effectivePositionIds(user).join(",") || "none";
+ if (!this.enforced) {
+ this.logger.warn(
+ `[yard-scope shadow] would block ${context}: yard=${yardId} positions=${positions}`,
+ );
+ return;
+ }
+ throw new ForbiddenException("This yard is outside your assigned yards");
+ }
+
+ /**
+ * Yard ids a list query should be narrowed to, or `null` for no narrowing.
+ *
+ * Returns an EMPTY array only when the caller explicitly asked for a yard
+ * outside their scope and enforcement is on — the caller should answer with an
+ * empty result rather than silently widening back to everything.
+ *
+ * While `YARD_SCOPE_ENFORCE` is unset this always returns `null` and logs what
+ * it would have narrowed, so the mapping can be populated against real traffic
+ * before it starts hiding rows.
+ */
+ async listFilterYardIds(
+ user: ScopeUser | null | undefined,
+ requestedYardId: string | null | undefined,
+ context: string,
+ ): Promise {
+ const scope = await this.getScopedYardIds(user);
+ if (scope === null) return null;
+
+ const outOfScope = !!requestedYardId && !scope.includes(requestedYardId);
+
+ if (!this.enforced) {
+ this.logger.warn(
+ `[yard-scope shadow] would narrow ${context} to [${scope.join(", ")}]` +
+ (outOfScope ? ` and reject yard=${requestedYardId}` : ""),
+ );
+ return null;
+ }
+
+ if (outOfScope) return [];
+ return requestedYardId ? [requestedYardId] : scope;
+ }
+
+ /** Drops the memoised scopes — call after editing the mapping. */
+ invalidate(): void {
+ this.cache.clear();
+ }
+
+ /** Active position plus any delegated ones, across both `employee` shapes. */
+ private effectivePositionIds(user: ScopeUser | null | undefined): string[] {
+ const ids = new Set();
+ const employee = user?.employee;
+ if (!employee) return [];
+
+ if (Array.isArray(employee)) {
+ for (const emp of employee) {
+ for (const position of emp.positions ?? []) {
+ if (position?.id) ids.add(position.id);
+ }
+ }
+ return [...ids];
+ }
+
+ if (employee.position?.id) ids.add(employee.position.id);
+ for (const delegated of employee.delegatedPositions ?? []) {
+ if (delegated?.id) ids.add(delegated.id);
+ }
+ return [...ids];
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts
index 4418eed3d..cb1958582 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts
@@ -1221,13 +1221,21 @@ export class BookingBatchService implements OnModuleInit {
const ledger = new WagonStockLedger(
stock.remainingByTypeId,
Math.max(1, budget.stops.length - 1),
+ stock.byYardId,
+ budget.stops,
);
+ // On a multi-yard consist the pool that matters is the one standing at
+ // the booking's own boarding yard — a type carried only in Mojo must not
+ // be advertised to a customer boarding at Dire.
+ const carriedAtBoardYard = (wagonTypeId: string): number => {
+ const boardYardId = stock.byYardId ? budget.stops[leg.fromEdge] : null;
+ if (boardYardId) return stock.byYardId?.get(boardYardId)?.get(wagonTypeId) ?? 0;
+ return stock.remainingByTypeId.get(wagonTypeId) ?? 0;
+ };
const byWagonType = allowed
.filter(
({ wagonTypeId }) =>
- stock.mode !== 'TRAIN' ||
- !wagonTypeId ||
- (stock.remainingByTypeId.get(wagonTypeId) ?? 0) > 0,
+ stock.mode !== 'TRAIN' || !wagonTypeId || carriedAtBoardYard(wagonTypeId) > 0,
)
.map(({ wagonTypeId, dims }) => {
const type = wagonTypeId ? typeById.get(wagonTypeId) : undefined;
@@ -4783,6 +4791,8 @@ export class BookingBatchService implements OnModuleInit {
return new WagonStockLedger(
stock.remainingByTypeId,
Math.max(1, budget.stops.length - 1),
+ stock.byYardId,
+ budget.stops,
);
}
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts
index 03535135d..9a20c0486 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts
@@ -1589,6 +1589,7 @@ export class TrainSchedulingService {
`Train ${builtTrain.code} is not at the origin yard yet; it must arrive before this departure dispatches`,
);
}
+ await this.assertRouteCoversWagonYards(builtTrain, route);
const conflict = await this.findTrainRouteDayConflict(
builtTrain.id,
route.id,
@@ -4274,12 +4275,26 @@ export class TrainSchedulingService {
.getRepository(Locomotive)
.update({ id: In(locoIds) }, { currentYardId: station.yardId });
}
+ // Only wagons the train has actually COLLECTED move with it. On a
+ // consist spread across yards (20 in Dire, 33 in Mojo), reaching Mojo
+ // moves the Dire wagons — the ones already aboard — and picks up the
+ // Mojo ones standing here. Wagons waiting at yards further down the
+ // line stay where they are until the train physically gets to them.
+ const passedYardIds = stations
+ .filter((s) => s.sequenceNo <= dto.sequenceNo)
+ .map((s) => s.yardId);
await manager
.getRepository(Wagon)
- .update(
- { currentTrainScheduleId: scheduleId },
- { currentYardId: station.yardId },
- );
+ .createQueryBuilder()
+ .update(Wagon)
+ .set({ currentYardId: station.yardId })
+ .where('current_train_schedule_id = :scheduleId', { scheduleId })
+ // A yard-less wagon has no "waiting further down the line" position
+ // to protect, so it rides along as it always did.
+ .andWhere('(current_yard_id IS NULL OR current_yard_id IN (:...passedYardIds))', {
+ passedYardIds,
+ })
+ .execute();
if (schedule.trainSet?.trainId) {
await manager
.getRepository(Train)
@@ -5286,12 +5301,26 @@ export class TrainSchedulingService {
const typeCodeById = new Map(wagonTypes.map((type) => [type.id, type.code]));
const counts = new Map();
+ // A built consist spread across several yards can only offer, at each yard,
+ // the wagons standing there. A single-yard consist keeps the original
+ // behaviour: the whole train counts wherever it currently sits.
+ const consistYards = builtTrainId
+ ? new Set(
+ wagons
+ .filter((w) => w.trainId === builtTrainId && w.currentYardId)
+ .map((w) => w.currentYardId as string),
+ )
+ : new Set();
+ const consistIsSplit = consistYards.size > 1;
+
for (const wagon of wagons) {
// Train-bound schedule: the built train's own consist IS the fleet — only
- // its wagons count (wherever they currently sit; they travel with the
- // train), and loose yard wagons never do.
+ // its wagons count, and loose yard wagons never do. A single-yard consist
+ // counts wherever it sits (it travels with the train); a split consist is
+ // counted at the yard each wagon actually stands in.
if (builtTrainId) {
if (wagon.trainId !== builtTrainId) continue;
+ if (consistIsSplit && wagon.currentYardId !== originYardId) continue;
} else {
// Schedule-scoped availability: pins held by OTHER schedules never
// consume a wagon here — the same physical wagon may serve the July 17
@@ -5651,12 +5680,23 @@ export class TrainSchedulingService {
// consist views draw the schedule exactly like the train builder; a schedule
// created with reverseWagonOrder pins back-to-front (physically-last wagon
// takes slot #1). Unsequenced wagons sort after every sequenced one.
+ const consistYards = new Set(
+ wagons
+ .filter((w) => w.trainId === builtTrainId && w.currentYardId)
+ .map((w) => w.currentYardId as string),
+ );
+ // Split consist: a slot boarding at a given yard must take a wagon that
+ // physically stands there — the train cannot load a Mojo wagon at Dire.
+ // A single-yard consist ignores this (the whole train is at one place).
+ const requiredYardId =
+ consistYards.size > 1 ? (slot.boardYardId ?? originYardId) : null;
const candidates = wagons
.filter(
(w) =>
w.trainId === builtTrainId &&
w.wagonTypeId === slot.wagonTypeId &&
- spanFree(w.id),
+ spanFree(w.id) &&
+ (!requiredYardId || w.currentYardId === requiredYardId),
)
.sort((a, b) => {
if (a.sequenceNumber == null || b.sequenceNumber == null) {
@@ -5825,14 +5865,28 @@ export class TrainSchedulingService {
});
const remainingByTypeId = new Map();
const codesByTypeId = new Map();
+ const byYardId = new Map>();
for (const wagon of wagons) {
remainingByTypeId.set(
wagon.wagonTypeId,
(remainingByTypeId.get(wagon.wagonTypeId) ?? 0) + 1,
);
if (wagon.wagonType) codesByTypeId.set(wagon.wagonTypeId, wagon.wagonType.code);
+ if (wagon.currentYardId) {
+ const perType = byYardId.get(wagon.currentYardId) ?? new Map();
+ perType.set(wagon.wagonTypeId, (perType.get(wagon.wagonTypeId) ?? 0) + 1);
+ byYardId.set(wagon.currentYardId, perType);
+ }
}
- return { mode: 'TRAIN', remainingByTypeId, codesByTypeId };
+ // Single-yard consist (the overwhelming majority): the whole train is
+ // offered at every boarding yard exactly as before — the per-yard split is
+ // only meaningful once the consist is genuinely spread across yards.
+ return {
+ mode: 'TRAIN',
+ remainingByTypeId,
+ codesByTypeId,
+ ...(byYardId.size > 1 ? { byYardId } : {}),
+ };
}
/**
@@ -6160,6 +6214,45 @@ export class TrainSchedulingService {
return saved;
}
+ /**
+ * A built train's wagons may stand in several yards. The route must pass
+ * through every one of them as origin or an intermediate stop — never only
+ * as the final destination (the train has to pick the wagons up en route).
+ */
+ private async assertRouteCoversWagonYards(train: Train, route: Route) {
+ const wagons = await this.dataSource.getRepository(Wagon).find({
+ where: { trainId: train.id },
+ select: { id: true, currentYardId: true },
+ });
+ const wagonYards = [...new Set(wagons.map((w) => w.currentYardId).filter((y): y is string => !!y))];
+ if (!wagonYards.length) return;
+
+ const milestones = await this.dataSource
+ .getRepository(RouteMilestone)
+ .find({ where: { routeId: route.id }, order: { sequenceNo: 'ASC' } });
+ const stops = milestones.length >= 2
+ ? milestones.map((m) => m.yardId)
+ : [route.originYardId, route.destinationYardId];
+ // Every stop except the last one is a pickup point.
+ const pickupYards = new Set(stops.slice(0, -1));
+
+ const uncovered = wagonYards.filter((y) => !pickupYards.has(y));
+ if (!uncovered.length) return;
+
+ const labels = await this.yardLabelMap(uncovered);
+ const destination = stops[stops.length - 1];
+ const detail = uncovered
+ .map((y) =>
+ y === destination
+ ? `${labels.get(y) ?? y} (only as the destination)`
+ : `${labels.get(y) ?? y} (not on route)`,
+ )
+ .join(', ');
+ throw new BadRequestException(
+ `Route ${formatRouteLabel(route)} does not pass through every yard where train ${train.code}'s wagons stand: ${detail}`,
+ );
+ }
+
private async getSchedulableRoute(routeId: string) {
const route = await this.dataSource.getRepository(Route).findOne({
where: { id: routeId },
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts
index f7db22506..29a67da45 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts
@@ -43,6 +43,16 @@ export type WagonStock = {
remainingByTypeId: Map;
/** Wagon-type code per id, for human-readable shortfall messages. */
codesByTypeId: Map;
+ /**
+ * Multi-yard consist only: yardId → (wagonTypeId → count) for the wagons
+ * standing at that yard. A train whose wagons are split across yards can
+ * only offer, at each boarding yard, the wagons physically standing there —
+ * a wagon waiting in Mojo is not bookable from Dire, and one picked up at
+ * Dire is not re-offered at Mojo. Absent (undefined) when every wagon sits
+ * in one yard, which keeps single-yard trains on the original whole-train
+ * math.
+ */
+ byYardId?: Map>;
};
export type FlexPlanResult = {
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.spec.ts
index 47823cddd..5815ce435 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.spec.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.spec.ts
@@ -68,3 +68,94 @@ describe('WagonStockLedger', () => {
expect(ledger.availableFor(['nw5'], { fromEdge: 2, toEdge: 3 })).toBe(10);
});
});
+
+describe('WagonStockLedger — multi-yard consist', () => {
+ // The reported case: a built train of 53 wagons, 20 standing in Dire and 33
+ // in Mojo. Each yard may only sell the wagons physically standing there.
+ const DIRE = 'yard-dire';
+ const MOJO = 'yard-mojo';
+ const ADDIS = 'yard-addis';
+ const STOPS = [DIRE, MOJO, ADDIS];
+ const EDGES = STOPS.length - 1;
+ const splitStock = () =>
+ new Map([
+ [DIRE, new Map([['nw5', 20]])],
+ [MOJO, new Map([['nw5', 33]])],
+ ]);
+ // Legs along Dire → Mojo → Addis.
+ const DIRE_TO_ADDIS = { fromEdge: 0, toEdge: 2 };
+ const MOJO_TO_ADDIS = { fromEdge: 1, toEdge: 2 };
+
+ const splitLedger = () =>
+ new WagonStockLedger(new Map([['nw5', 53]]), EDGES, splitStock(), STOPS);
+
+ it('offers each yard only the wagons standing there', () => {
+ const ledger = splitLedger();
+ expect(ledger.availableFor(['nw5'], DIRE_TO_ADDIS)).toBe(20);
+ expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(33);
+ });
+
+ it('keeps the yards independent — Dire bookings never eat Mojo stock', () => {
+ const ledger = splitLedger();
+ // A Dire booking rides the whole corridor, occupying the Mojo→Addis edge…
+ expect(ledger.consume(['nw5'], 20, DIRE_TO_ADDIS)).toBe(20);
+ expect(ledger.availableFor(['nw5'], DIRE_TO_ADDIS)).toBe(0);
+ // …but those are Dire's steel, so Mojo still has its own 33 to sell.
+ expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(33);
+ expect(ledger.consume(['nw5'], 33, MOJO_TO_ADDIS)).toBe(33);
+ expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(0);
+ });
+
+ it('never lends a free Dire wagon to a Mojo customer', () => {
+ const ledger = splitLedger();
+ // Only 5 of Dire's 20 sell; the other 15 ride past Mojo empty.
+ expect(ledger.consume(['nw5'], 5, DIRE_TO_ADDIS)).toBe(5);
+ // Mojo is still capped at its own 33 — the 15 empty Dire wagons are not
+ // offered here, exactly as the operator requires.
+ expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(33);
+ expect(ledger.consume(['nw5'], 40, MOJO_TO_ADDIS)).toBe(33);
+ });
+
+ it('offers nothing at the destination — there is nothing to pick up there', () => {
+ const ledger = splitLedger();
+ // A leg boarding at the last stop has no pool of its own.
+ expect(ledger.availableFor(['nw5'], { fromEdge: 2, toEdge: 2 })).toBe(0);
+ });
+
+ it('second example: Addis → Dire → Indode → Mojo → Djibouti', () => {
+ const [ADD, DIRE_2, INDODE, MOJO_2, DJIBOUTI] = [
+ 'yard-add',
+ 'yard-dire',
+ 'yard-indode',
+ 'yard-mojo',
+ 'yard-djibouti',
+ ];
+ const stops = [ADD, DIRE_2, INDODE, MOJO_2, DJIBOUTI];
+ const ledger = new WagonStockLedger(
+ new Map([['nw5', 53]]),
+ stops.length - 1,
+ new Map([
+ [DIRE_2, new Map([['nw5', 20]])],
+ [MOJO_2, new Map([['nw5', 33]])],
+ ]),
+ stops,
+ );
+ const to = (fromEdge: number) => ({ fromEdge, toEdge: stops.length - 1 });
+ // Addis: the train starts empty — nothing to sell.
+ expect(ledger.availableFor(['nw5'], to(0))).toBe(0);
+ // Dire: the 20 wagons waiting there.
+ expect(ledger.availableFor(['nw5'], to(1))).toBe(20);
+ // Indode: the same 20 wagons, which have moved with the train.
+ expect(ledger.availableFor(['nw5'], to(2))).toBe(0);
+ // Mojo: its own 33 only.
+ expect(ledger.availableFor(['nw5'], to(3))).toBe(33);
+ });
+
+ it('single-yard consist keeps the original whole-train behaviour', () => {
+ // No byYardId (the train is not split) — every leg sees the whole train,
+ // exactly as before this feature.
+ const ledger = new WagonStockLedger(new Map([['nw5', 53]]), EDGES);
+ expect(ledger.availableFor(['nw5'], DIRE_TO_ADDIS)).toBe(53);
+ expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(53);
+ });
+});
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.ts
index 0e4f6949d..bf5b935ad 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.ts
@@ -20,17 +20,53 @@ import type { CorridorLeg } from './corridor-capacity.util';
* Gelan→Adama never competes for stock with an export on Adama→Doraleh.
*/
export class WagonStockLedger {
+ /**
+ * Usage rows keyed by pool. A single-yard train has one pool (''), so this is
+ * exactly the original per-type accounting. A multi-yard consist keys by
+ * boarding yard as well, because the Dire wagons and the Mojo wagons are
+ * disjoint sets of steel: 5 Dire wagons riding the whole corridor occupy the
+ * Mojo→Addis edge, but they must not shrink what Mojo itself can offer.
+ */
private readonly usedPerEdge = new Map();
constructor(
private readonly remainingByTypeId: Map,
private readonly edgeCount: number,
+ /**
+ * Multi-yard consist only (see {@link WagonStock.byYardId}): the wagons
+ * standing at each yard. When present, a leg is served ONLY by the wagons
+ * standing at the yard it boards from — a Dire→Addis booking on a train
+ * whose wagons sit 20 in Dire and 33 in Mojo sees 20, and a Mojo→Addis
+ * booking sees 33, never the Dire wagons that ride past empty.
+ */
+ private readonly byYardId?: Map>,
+ /** Ordered corridor stops, parallel to the edges — maps an edge to its yard. */
+ private readonly stops: readonly string[] = [],
) {}
+ /** The yard a leg boards from, or '' when the train is not split across yards. */
+ private poolYardOf(leg: CorridorLeg): string {
+ if (!this.byYardId) return '';
+ return this.stops[leg.fromEdge] ?? '';
+ }
+
+ /** Usage-row key: one row per (pool, wagon type). */
+ private rowKey(wagonTypeId: string, leg: CorridorLeg): string {
+ const pool = this.poolYardOf(leg);
+ return pool ? `${pool}\u0000${wagonTypeId}` : wagonTypeId;
+ }
+
+ /** Wagons of one type offered at the yard a leg boards from. */
+ private totalForType(wagonTypeId: string, leg: CorridorLeg): number {
+ const pool = this.poolYardOf(leg);
+ if (!pool) return this.remainingByTypeId.get(wagonTypeId) ?? 0;
+ return this.byYardId?.get(pool)?.get(wagonTypeId) ?? 0;
+ }
+
/** Free wagons of ONE type on a leg: total minus its busiest edge within that leg. */
private availableForType(wagonTypeId: string, leg: CorridorLeg): number {
- const total = this.remainingByTypeId.get(wagonTypeId) ?? 0;
- const row = this.usedPerEdge.get(wagonTypeId);
+ const total = this.totalForType(wagonTypeId, leg);
+ const row = this.usedPerEdge.get(this.rowKey(wagonTypeId, leg));
if (!row) return total;
let busiest = 0;
for (let edge = leg.fromEdge; edge < leg.toEdge; edge += 1) {
@@ -69,10 +105,11 @@ export class WagonStockLedger {
if (!deepest) break;
const take = Math.min(outstanding, deepest.free);
- let row = this.usedPerEdge.get(deepest.id);
+ const key = this.rowKey(deepest.id, leg);
+ let row = this.usedPerEdge.get(key);
if (!row) {
row = new Array(this.edgeCount).fill(0);
- this.usedPerEdge.set(deepest.id, row);
+ this.usedPerEdge.set(key, row);
}
for (let edge = leg.fromEdge; edge < leg.toEdge; edge += 1) {
row[edge] = (row[edge] ?? 0) + take;
diff --git a/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts
index 7b0bdd3bd..08a424cdc 100644
--- a/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts
+++ b/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts
@@ -44,7 +44,7 @@ export class BuildTrainDto {
@ApiPropertyOptional({
type: [String],
format: 'uuid',
- description: 'Wagons to attach at build time, in consist order (must sit in the same yard)',
+ description: 'Wagons to attach at build time, in consist order (any yard)',
})
@IsOptional()
@IsArray()
diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts
index 0f377071c..4b94b2dcb 100644
--- a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts
+++ b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts
@@ -283,6 +283,10 @@ export class TrainBuilderService {
wagonNumber: wagon.wagonNumber,
sequenceNumber: wagon.sequenceNumber,
status: wagon.status,
+ currentYardId: wagon.currentYardId ?? null,
+ currentYard: wagon.currentYard
+ ? { id: wagon.currentYard.id, code: wagon.currentYard.code, label: wagon.currentYard.label }
+ : null,
wagonType: wagon.wagonType
? {
id: wagon.wagonType.id,
@@ -326,6 +330,27 @@ export class TrainBuilderService {
: null,
locomotives,
wagons,
+ // Where the consist physically stands. A train built from several yards
+ // only picks a yard's wagons up when it reaches that yard, and a customer
+ // boarding there can only book the wagons standing there — the schedule
+ // route must therefore cover every one of these yards before its
+ // destination.
+ wagonYards: [
+ ...wagons
+ .reduce((acc, wagon) => {
+ const id = wagon.currentYardId ?? 'UNASSIGNED';
+ const entry = acc.get(id) ?? {
+ yardId: wagon.currentYardId ?? null,
+ code: wagon.currentYard?.code ?? null,
+ label: wagon.currentYard?.label ?? null,
+ wagonCount: 0,
+ };
+ entry.wagonCount += 1;
+ acc.set(id, entry);
+ return acc;
+ }, new Map())
+ .values(),
+ ].sort((a, b) => b.wagonCount - a.wagonCount),
totals: {
wagonCount: wagons.length,
totalTareTons,
@@ -428,10 +453,13 @@ export class TrainBuilderService {
}
/**
- * Relocate the train to another yard. The consist moves as one unit: every
- * coupled locomotive and wagon follows to the new yard (so their current
- * yards always match the train's), and each wagon gets a movement-ledger row.
- * Blocked while the train is out on a dispatched run.
+ * Relocate the train to another yard. The locomotives always follow. Of the
+ * wagons, only those standing WITH the train move: on a consist spread
+ * across yards (20 in Dire, 33 waiting in Mojo), moving the train Dire→Mojo
+ * relocates the 20 it is actually pulling and leaves the Mojo wagons where
+ * they stand — the train collects those by arriving, not by this call.
+ * Each moved wagon gets a movement-ledger row. Blocked while the train is
+ * out on a dispatched run.
*/
async setYard(id: string, currentYardId: string) {
await this.dataSource.transaction(async (manager) => {
@@ -439,6 +467,7 @@ export class TrainBuilderService {
if (train.currentYardId === currentYardId) return;
const yard = await manager.getRepository(Yard).findOne({ where: { id: currentYardId } });
if (!yard) throw new NotFoundException(`Yard ${currentYardId} not found`);
+ const previousYardId = train.currentYardId ?? null;
await manager.getRepository(Train).update(train.id, { currentYardId: yard.id });
@@ -454,7 +483,17 @@ export class TrainBuilderService {
);
}
- const wagons = await manager.getRepository(Wagon).find({ where: { trainId: train.id } });
+ const allWagons = await manager
+ .getRepository(Wagon)
+ .find({ where: { trainId: train.id } });
+ // Wagons travelling with the train = those at the yard it is leaving.
+ // A yard-less wagon has no standing position of its own, so it follows.
+ const wagons = allWagons.filter(
+ (wagon) =>
+ wagon.currentYardId == null ||
+ previousYardId == null ||
+ wagon.currentYardId === previousYardId,
+ );
const now = new Date();
for (const wagon of wagons) {
if (wagon.currentYardId === yard.id) continue;
@@ -475,7 +514,7 @@ export class TrainBuilderService {
return this.getComposition(id);
}
- /** Append AVAILABLE wagons from the train's own yard to the consist. */
+ /** Append AVAILABLE, unassigned wagons (any yard) to the consist. */
async assignWagons(id: string, dto: AssignTrainWagonsDto, userId?: string | null) {
await this.dataSource.transaction(async (manager) => {
const train = await this.getEditableTrain(manager, id);
@@ -1037,11 +1076,8 @@ export class TrainBuilderService {
if (wagon.status !== WagonStatus.Available) {
throw new ConflictException(`Wagon ${wagon.wagonNumber} is not available (${wagon.status})`);
}
- if (wagon.currentYardId !== train.currentYardId) {
- throw new BadRequestException(
- `Wagon ${wagon.wagonNumber} is not in the train's yard; only wagons in the same yard can be attached`,
- );
- }
+ // Wagons may sit in any yard — the schedule's route must pass through
+ // every wagon yard before its destination (checked at scheduling time).
toAttach.push(wagon);
}
if (!toAttach.length) return [];
diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts
index b5a14f6f3..f2c76e283 100644
--- a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts
+++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts
@@ -342,8 +342,8 @@ export class WagonsService {
async assignToTrain(wagonId: string, dto: AssignWagonToTrainDto): Promise {
const wagon = await this.findById(wagonId);
- // Mirror train-builder attachWagons: only a truly free, available wagon in
- // the train's own yard can be coupled, and never onto a dispatched train.
+ // Mirror train-builder attachWagons: only a truly free, available wagon
+ // (any yard) can be coupled, and never onto a dispatched train.
if (wagon.trainId != null) {
throw new ConflictException(`Wagon ${wagon.wagonNumber} is already on another train`);
}
@@ -358,11 +358,6 @@ export class WagonsService {
`Train ${train.code} is out on a dispatched run; its composition is frozen until arrival`,
);
}
- if (wagon.currentYardId !== train.currentYardId) {
- throw new BadRequestException(
- `Wagon ${wagon.wagonNumber} is not in the train's yard; only wagons in the same yard can be attached`,
- );
- }
const maxSeq = await this.wagonRepo
.createQueryBuilder('w')
diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts
index 459b5009e..19d3b4b7a 100644
--- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts
@@ -38,15 +38,21 @@ export class WarehouseInventoryController {
@Get()
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'List warehouse inventory' })
- findAll(@Query() filter: FilterWarehouseInventoryDto) {
- return this.inventoryService.findAll(filter);
+ findAll(
+ @Query() filter: FilterWarehouseInventoryDto,
+ @CurrentUser() user: TCurrentUser,
+ ) {
+ return this.inventoryService.findAll(filter, user);
}
@Get('ready-for-loading')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'List inventory ready for loading' })
- findReadyForLoading(@Query() filter: FilterWarehouseInventoryDto) {
- return this.inventoryService.findReadyForLoading(filter);
+ findReadyForLoading(
+ @Query() filter: FilterWarehouseInventoryDto,
+ @CurrentUser() user: TCurrentUser,
+ ) {
+ return this.inventoryService.findReadyForLoading(filter, user);
}
@Get('inquiry')
diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts
index 7412f0269..126998bae 100644
--- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts
@@ -39,6 +39,7 @@ import {
import { SignaturesService } from '../signatures/signatures.service';
import { StampSettingsService } from '../stamp-settings/stamp-settings.service';
import { LogoSettingsService } from '../logo-settings/logo-settings.service';
+import { YardScopeService } from '../rule-engine/services/yard-scope.service';
import { logoImageCss, logoMarkup } from '../billing/documents/logo-markup.util';
import { sealClass, sealImageCss, sealMarkup } from '../billing/documents/seal-markup.util';
import { BulkInspectDto } from './dto/bulk-inspect.dto';
@@ -423,6 +424,7 @@ export class WarehouseInventoryService {
private readonly events: EventEmitter2,
private readonly stampSettings: StampSettingsService,
private readonly logoSettings: LogoSettingsService,
+ private readonly yardScope: YardScopeService,
) {}
/**
@@ -978,7 +980,16 @@ export class WarehouseInventoryService {
// ── Listing ────────────────────────────────────────────────────────────
- async findAll(filter: FilterWarehouseInventoryDto): Promise {
+ /**
+ * `user` drives yard access scoping: a desk mapped to yards sees only those
+ * yards' inventory. Optional so internal callers that are not serving a
+ * request (schedulers, other services) are unaffected — they pass nothing and
+ * get the unscoped list, which is what they had before.
+ */
+ async findAll(
+ filter: FilterWarehouseInventoryDto,
+ user?: unknown,
+ ): Promise {
const createdAt =
filter.dateFrom && filter.dateTo
? Between(new Date(filter.dateFrom), new Date(filter.dateTo))
@@ -1020,6 +1031,32 @@ export class WarehouseInventoryService {
});
}
+ // Yard scoping — applied to `base` before the search branch splits it, so
+ // both OR arms carry the constraint. A null result means "do not narrow".
+ //
+ // Scoped on `warehouse.stationId`, NOT on `inventory.yardId`: those are two
+ // different id spaces that share a name. `warehouse_inventory.yard_id` is a
+ // FK to `warehouse_yards` — a yard INSIDE a warehouse — while the desk↔yard
+ // mapping is against `freight.yards`, the network yard, which inventory
+ // reaches through `warehouses.station_id`. Filtering `yardId` against
+ // mapped network yards matches nothing and hides every row (observed: all
+ // 34 rows disappeared before this was corrected).
+ //
+ // `filter.yardId` is likewise a warehouse-yard id, so it is NOT passed as
+ // the requested yard here; `filter.facilityId` is the station-yard filter.
+ const scopedYardIds = await this.yardScope.listFilterYardIds(
+ user as never,
+ filter.facilityId,
+ 'warehouse-inventory list',
+ );
+ if (scopedYardIds) {
+ if (!scopedYardIds.length) return [];
+ base.warehouse = {
+ ...((base.warehouse as FindOptionsWhere) ?? {}),
+ stationId: scopedYardIds.length === 1 ? scopedYardIds[0] : In(scopedYardIds),
+ };
+ }
+
const search = filter.search?.trim();
const where: FindManyOptions['where'] = search
? [
@@ -1037,8 +1074,11 @@ export class WarehouseInventoryService {
return items;
}
- findReadyForLoading(filter: FilterWarehouseInventoryDto): Promise {
- return this.findAll({ ...filter, status: 'READY_FOR_LOADING' });
+ findReadyForLoading(
+ filter: FilterWarehouseInventoryDto,
+ user?: unknown,
+ ): Promise {
+ return this.findAll({ ...filter, status: 'READY_FOR_LOADING' }, user);
}
/**
diff --git a/apps/edr-freight-api/src/seed/edr-freight.seed.ts b/apps/edr-freight-api/src/seed/edr-freight.seed.ts
index 1798a8d5f..e4ab41954 100644
--- a/apps/edr-freight-api/src/seed/edr-freight.seed.ts
+++ b/apps/edr-freight-api/src/seed/edr-freight.seed.ts
@@ -242,6 +242,7 @@ export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [
"edr_freight_app:hierarchy_positions:view",
"edr_freight_app:hierarchy_employee_assignment:view",
"edr_freight_app:position_types:view",
+ "edr_freight_app:chat:view",
],
},
{
diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts
index 24be04f66..1e7a56094 100644
--- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts
+++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts
@@ -226,6 +226,14 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [
"edr_freight_app:bookings:wagon_cancellation_rebook",
"Rebook cancelled wagons for a customer",
),
+ // Shared-wagon gate: two customers' cargo on one wagon is a commercial call,
+ // so it is signed off separately from the ordinary booking approvals — and
+ // never by the GL user who created the pairing.
+ perm(
+ "a1000001-0001-4000-8000-000000000029",
+ "edr_freight_app:bookings:approve_consolidation",
+ "Approve shared-wagon consolidation",
+ ),
];
/**
@@ -459,6 +467,26 @@ export const GAP_CONTROLLER_PERMISSIONS: FreightPermissionSeed[] = [
),
];
+/**
+ * Yard access scoping. `yard_positions` maps desks to yards and the resolver
+ * (`YardScopeService`) narrows a caller to the yards their active position is
+ * mapped to. This key is the deliberate way out of that narrowing, for the HQ
+ * desks that are cross-yard by nature (OCC, CEO, rolling stock). Without it,
+ * "unmapped" would have to mean "sees everything", which is a bypass by
+ * accident rather than by grant.
+ *
+ * Editing the mapping itself needs no key of its own: it is yard configuration,
+ * so it rides on `rule_engine:yards:view` / `:update` like every other field on
+ * a yard.
+ */
+export const YARD_SCOPE_PERMISSIONS: FreightPermissionSeed[] = [
+ perm(
+ "f4a00001-0001-4000-8000-000000000001",
+ "edr_freight_app:yards:view_all",
+ "Access every yard (bypass yard scoping)",
+ ),
+];
+
/**
* Advanced backoffice resources — full CRUD + workflow-action keys.
* See docs/rbac/freight-backoffice-permissions.md. Additive only: the existing
@@ -523,6 +551,12 @@ export const SHIPPING_LINE_PERMISSIONS: FreightPermissionSeed[] = [
),
];
+// Internal chat (Matrix/Element) — sidebar visibility + manual reconcile trigger.
+export const CHAT_PERMISSIONS: FreightPermissionSeed[] = [
+ perm('c9a00001-0001-4000-8000-000000000001', 'edr_freight_app:chat:view', 'Open internal chat'),
+ perm('c9a00001-0001-4000-8000-000000000002', 'edr_freight_app:chat:sync', 'Re-run chat room/membership sync'),
+];
+
// D. Finance — payments + invoices
export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [
perm(
@@ -1465,6 +1499,16 @@ export const GRANULAR_SPLIT_PERMISSIONS: FreightPermissionSeed[] = [
"edr_freight_app:settings:exchange_rate:manage",
"Set the USD-ETB fallback rate",
),
+ perm(
+ "b4d00001-0001-4000-8000-000000000003",
+ "edr_freight_app:settings:manual_payment:view",
+ "View the manual (offline) payment channel settings",
+ ),
+ perm(
+ "b4d00001-0001-4000-8000-000000000004",
+ "edr_freight_app:settings:manual_payment:manage",
+ "Enable or disable manual invoice settlement per currency",
+ ),
perm(
"b4e00001-0001-4000-8000-000000000001",
"edr_freight_app:settings:contract_templates:view",
@@ -1641,6 +1685,7 @@ export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [
...REPORT_PERMISSIONS,
...CUSTOMER_PERMISSIONS,
...SHIPPING_LINE_PERMISSIONS,
+ ...CHAT_PERMISSIONS,
...FINANCE_PERMISSIONS,
...MILE_PERMISSIONS,
...FLEET_RAIL_PERMISSIONS,
@@ -1660,6 +1705,7 @@ export const BOOKING_RULE_ENGINE_PERMISSIONS = [
...CONTRACT_PERMISSIONS,
...RULE_ENGINE_PERMISSIONS,
...GAP_CONTROLLER_PERMISSIONS,
+ ...YARD_SCOPE_PERMISSIONS,
...ADVANCED_BACKOFFICE_PERMISSIONS,
];
@@ -1744,6 +1790,7 @@ export const FREIGHT_PERMS = {
wagonCancellationVoid: "edr_freight_app:bookings:wagon_cancellation_void",
wagonCancellationRebook:
"edr_freight_app:bookings:wagon_cancellation_rebook",
+ approveConsolidation: "edr_freight_app:bookings:approve_consolidation",
// Notification selectors, not route guards — see NOTIFICATION_PERMISSIONS.
getNotification: "edr_freight_app:bookings:get_notification",
clearanceGetNotification:
@@ -1837,6 +1884,10 @@ export const FREIGHT_PERMS = {
allocation: {
manage: "edr_freight_app:allocation:manage",
},
+ yards: {
+ /** Bypasses yard scoping entirely — see YARD_SCOPE_PERMISSIONS. */
+ viewAll: "edr_freight_app:yards:view_all",
+ },
customers: {
view: "edr_freight_app:customers:view",
create: "edr_freight_app:customers:create",
@@ -1872,6 +1923,10 @@ export const FREIGHT_PERMS = {
/** Reject any pending invoice request. */
invoiceReject: "edr_freight_app:shipping_line_credits:invoice_reject",
},
+ chat: {
+ view: 'edr_freight_app:chat:view',
+ sync: 'edr_freight_app:chat:sync',
+ },
payments: {
view: "edr_freight_app:payments:view",
},
@@ -2112,6 +2167,14 @@ export const FREIGHT_PERMS = {
view: "edr_freight_app:settings:exchange_rate:view",
manage: "edr_freight_app:settings:exchange_rate:manage",
},
+ // Whether Finance may settle invoices by hand, per currency. Split
+ // view/manage on purpose: Finance reads it (the worklist offers only the
+ // enabled currencies) but must not switch its own channel on — same
+ // maker-checker split as the other sensitive finance settings.
+ manualPayment: {
+ view: "edr_freight_app:settings:manual_payment:view",
+ manage: "edr_freight_app:settings:manual_payment:manage",
+ },
contractTemplates: {
view: "edr_freight_app:settings:contract_templates:view",
manage: "edr_freight_app:settings:contract_templates:manage",
@@ -2336,6 +2399,10 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.bookings.reject,
FREIGHT_PERMS.bookings.approveLineStaff,
FREIGHT_PERMS.bookings.rejectApproval,
+ // Shared-wagon gate: two customers' cargo on one wagon is a commercial
+ // call. Granted to the approver roles and NOT to GL — GL creates the
+ // pairing, so GL approving it would defeat the second pair of eyes.
+ FREIGHT_PERMS.bookings.approveConsolidation,
FREIGHT_PERMS.bookings.cancel,
FREIGHT_PERMS.bookings.wagonCancellationView,
FREIGHT_PERMS.bookings.wagonCancellationVoid,
@@ -2392,6 +2459,10 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.bookings.view,
FREIGHT_PERMS.bookings.approveDirector,
FREIGHT_PERMS.bookings.rejectApproval,
+ // Shared-wagon gate: two customers' cargo on one wagon is a commercial
+ // call. Granted to the approver roles and NOT to GL — GL creates the
+ // pairing, so GL approving it would defeat the second pair of eyes.
+ FREIGHT_PERMS.bookings.approveConsolidation,
FREIGHT_PERMS.bookings.generateContract,
FREIGHT_PERMS.contracts.view,
FREIGHT_PERMS.contracts.approveDirector,
@@ -2404,6 +2475,10 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.bookings.view,
FREIGHT_PERMS.bookings.approveCeo,
FREIGHT_PERMS.bookings.rejectApproval,
+ // Shared-wagon gate: two customers' cargo on one wagon is a commercial
+ // call. Granted to the approver roles and NOT to GL — GL creates the
+ // pairing, so GL approving it would defeat the second pair of eyes.
+ FREIGHT_PERMS.bookings.approveConsolidation,
FREIGHT_PERMS.contracts.view,
FREIGHT_PERMS.contracts.approveCeo,
...allRuleEngineViewKeys(),
@@ -2416,6 +2491,9 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.invoices.export,
// Manual settlement (bank transfer / counter) of USD and ETB invoices.
FREIGHT_PERMS.invoices.confirmOffline,
+ // Read-only: the worklist offers whichever currencies are switched on.
+ // Flipping the switch is deliberately NOT here — see `manualPayment`.
+ FREIGHT_PERMS.settings.manualPayment.view,
// Deliberately NOT granted here: invoices:eims_register, eims_resolve, eims_cancel,
// eims_receipt_register, eims:memo_issue. Automatic filing needs no human permission at all
// (the cron sweep runs as the system); these are the *manual* exceptional-operations
@@ -2479,6 +2557,10 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.bookings.reject,
FREIGHT_PERMS.bookings.approveLineStaff,
FREIGHT_PERMS.bookings.rejectApproval,
+ // Shared-wagon gate: two customers' cargo on one wagon is a commercial
+ // call. Granted to the approver roles and NOT to GL — GL creates the
+ // pairing, so GL approving it would defeat the second pair of eyes.
+ FREIGHT_PERMS.bookings.approveConsolidation,
FREIGHT_PERMS.bookings.cancel,
FREIGHT_PERMS.bookings.wagonCancellationView,
FREIGHT_PERMS.bookings.wagonCancellationVoid,
@@ -2497,6 +2579,12 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.contracts.suspend,
FREIGHT_PERMS.contracts.editDocument,
...BOOKING_DESK_NOTIFICATION_KEYS,
+ // Marketing follows up with the customer when a reviewer sends profile
+ // changes back, so they sit on the customer desk: read-only on the customer
+ // record (no verify/deactivate — the decision stays with the chief) plus the
+ // desk key the change-request pings are addressed to.
+ FREIGHT_PERMS.customers.view,
+ FREIGHT_PERMS.customers.getNotification,
],
orgManager: [...BOOKING_RULE_ENGINE_PERMISSION_KEYS],
} as const;
diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx
index c131af4bd..68b812f6c 100644
--- a/apps/edr-freight-web/backoffice/src/App.tsx
+++ b/apps/edr-freight-web/backoffice/src/App.tsx
@@ -19,6 +19,7 @@ import ForgotPasswordPage from "./pages/auth/ForgotPasswordPage";
import BookingContractPage from "./pages/bookings/BookingContractPage";
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
+import ConsolidationApprovalsPage from "./pages/bookings/ConsolidationApprovalsPage";
import NewBookingPage from "./pages/bookings/NewBookingPage";
import WagonCancellationsPage from "./pages/bookings/WagonCancellationsPage";
import ContractRequestsPage from "./pages/contracts/ContractRequestsPage";
@@ -86,6 +87,7 @@ import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPa
import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage";
import TradeAccessPage from "./pages/configuration/TradeAccessPage";
import ExchangeRateSettingsCard from "./pages/settings/ExchangeRateSettingsCard";
+import ManualPaymentSettingsCard from "./pages/settings/ManualPaymentSettingsCard";
import FirstMilePage from "./pages/operations/FirstMilePage";
import LastMilePage from "./pages/operations/LastMilePage";
import TrainDetailPage from "./pages/trains/TrainDetailPage";
@@ -115,6 +117,7 @@ import FaydaCallbackPage from "./pages/FaydaCallbackPage";
import { UserManagementRoutes } from "./user-management/route";
import SetPassword from "./shared/components/SetPassword";
import SupportInboxPage from "./pages/support/SupportInboxPage";
+import ChatLaunchPage from "./pages/chat/ChatLaunchPage";
import {
APP_TITLE,
buildSidebarSections,
@@ -298,6 +301,14 @@ const App = () => {
}
/>
+
+
+
+ }
+ />
{
}
/>
+ {/* Shared-wagon gate: consolidated pairs wait for a human decision
+ before either half reaches Operations. */}
+
+
+
+ }
+ />
{
}
/>
+
+
+
+
+
+ }
+ />
-
+
>
);
}
@@ -149,7 +156,13 @@ export function BookingActionsMenu({
-
+
);
}
@@ -158,10 +171,14 @@ function ActionDialog({
flow,
pendingAction,
onSuppressRowClick,
+ consolidationPartnerId,
+ consolidationPartnerReference,
}: {
flow: ReturnType;
pendingAction: ReturnType["pendingAction"];
onSuppressRowClick?: () => void;
+ consolidationPartnerId?: string | null;
+ consolidationPartnerReference?: string | null;
}) {
return (
);
}
diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx
index 6b8e4b650..246704aca 100644
--- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx
@@ -1,5 +1,7 @@
import type { ReactNode } from "react";
+import { Link2 } from "lucide-react";
import {
+ Alert,
Modal,
Group,
Stack,
@@ -37,6 +39,12 @@ interface BookingConfirmDialogProps {
isPending: boolean;
confirmDisabled?: boolean;
extra?: ReactNode;
+ /**
+ * Reference of the booking sharing this one's wagon. When set, the dialog
+ * warns that the decision lands on BOTH bookings — staff must not think they
+ * are acting on one.
+ */
+ pairedWithReference?: string | null;
}
export function BookingConfirmDialog({
@@ -52,6 +60,7 @@ export function BookingConfirmDialog({
isPending,
confirmDisabled = false,
extra,
+ pairedWithReference = null,
}: BookingConfirmDialogProps) {
if (!action || !action.confirmTitle) return null;
@@ -125,6 +134,21 @@ export function BookingConfirmDialog({
{action.confirmDescription}
)}
+ {pairedWithReference && (
+ }
+ >
+
+ This applies to {pairedWithReference} as well —
+ the two bookings share a wagon and are decided together. If either
+ fails, neither changes.
+
+
+ )}
{/* Body */}
diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/ConsolidationApprovalCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/ConsolidationApprovalCard.tsx
new file mode 100644
index 000000000..a5c74f52e
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/ConsolidationApprovalCard.tsx
@@ -0,0 +1,82 @@
+import { useQuery } from "@tanstack/react-query";
+import { Badge, Box, Group, Stack, Text } from "@mantine/core";
+import { Link2 } from "lucide-react";
+
+import { bookingsService } from "@/services/bookings.service";
+import { formatDateTime } from "@/lib/format";
+import { SectionCard } from "./SectionCard";
+
+const STATUS_COLOR: Record = {
+ PENDING: "yellow",
+ APPROVED: "teal",
+ REJECTED: "red",
+};
+
+/**
+ * Audit trail for this booking's shared wagon: every approval request against
+ * it, who decided, when, and why. Rendered only for a booking that is actually
+ * consolidated — there is nothing to show otherwise.
+ */
+export function ConsolidationApprovalCard({ bookingId }: { bookingId: string }) {
+ const { data } = useQuery({
+ queryKey: ["consolidation-approvals", "history", bookingId],
+ queryFn: () => bookingsService.consolidationApprovalHistory(bookingId),
+ enabled: Boolean(bookingId),
+ });
+
+ if (!data?.length) return null;
+
+ return (
+
+
+ {data.map((row) => (
+
+
+
+ {row.status}
+
+
+ {row.bookingReference ?? "—"} + {row.partnerBookingReference ?? "—"}
+
+
+
+
+ Requested {formatDateTime(row.requestedAt)}
+ {row.requestedBy ? ` by ${row.requestedBy}` : ""}
+
+
+ {row.decidedAt ? (
+
+ {row.status === "APPROVED" ? "Approved" : "Rejected"}{" "}
+ {formatDateTime(row.decidedAt)}
+ {row.decidedBy ? ` by ${row.decidedBy}` : ""}
+
+ ) : (
+
+ Waiting for a decision — neither booking reaches Operations until
+ this is approved.
+
+ )}
+
+ {row.decisionNote ? (
+
+ “{row.decisionNote}”
+
+ ) : null}
+
+ ))}
+
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/useBookingActionDialog.ts b/apps/edr-freight-web/backoffice/src/components/bookings/useBookingActionDialog.ts
index be7111745..24398bdf9 100644
--- a/apps/edr-freight-web/backoffice/src/components/bookings/useBookingActionDialog.ts
+++ b/apps/edr-freight-web/backoffice/src/components/bookings/useBookingActionDialog.ts
@@ -14,6 +14,19 @@ function isValidValidityDays(value: string): boolean {
return Number.isInteger(days) && days >= 1 && days <= 365;
}
+/**
+ * Decisions that must be applied to BOTH halves of a consolidated pair. The two
+ * bookings share one wagon: 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.
+ */
+const PAIRED_DECISIONS = {
+ accept: "accept",
+ cancel: "cancel",
+ operationAccept: "operationAccept",
+ requestChanges: "requestChanges",
+} as const;
+
export function useBookingActionDialog(
bookingId: string,
context: BookingActionContext,
@@ -52,6 +65,30 @@ export function useBookingActionDialog(
const onSuccess = () => closeDialog();
+ // A booking on a shared wagon routes the four pairable decisions through the
+ // paired endpoint, which applies them to both halves all-or-nothing. Every
+ // other action stays per booking.
+ const pairedDecision =
+ PAIRED_DECISIONS[pendingAction.id as keyof typeof PAIRED_DECISIONS];
+ if (context.consolidationPartnerId && pairedDecision) {
+ if (pairedDecision === "accept") {
+ const days = Number(inputValue.trim());
+ if (!Number.isInteger(days) || days < 1 || days > 365) return;
+ mutations.pairedDecision.mutate(
+ { decision: "accept", validityDays: days },
+ { onSuccess },
+ );
+ return;
+ }
+ mutations.pairedDecision.mutate(
+ pairedDecision === "cancel"
+ ? { decision: "cancel", reason: inputValue.trim() }
+ : { decision: pairedDecision, note: inputValue.trim() },
+ { onSuccess },
+ );
+ return;
+ }
+
switch (pendingAction.id) {
case "accept": {
const days = Number(inputValue.trim());
diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx
index 67e84a09f..ca55338cb 100644
--- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx
@@ -11,6 +11,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import {
ActionIcon,
Alert,
+ Badge,
Box,
Button,
Center,
@@ -40,6 +41,7 @@ import {
FileText,
FileUp,
Flame,
+ Link2,
MapPin,
Package,
Receipt,
@@ -57,7 +59,10 @@ import {
import { api } from "@/services/api";
import { PageContainer } from "@/components/page";
import { PageHeader } from "@/components/page/PageHeader";
-import { contractsService } from "@/services/contracts.service";
+import {
+ contractsService,
+ type ConsolidationCandidate,
+} from "@/services/contracts.service";
import { bookingsService } from "@/services/bookings.service";
import {
useContractCapacity,
@@ -80,6 +85,18 @@ import {
StepHeader,
StepLabel,
} from "./gl-booking-form/form-ui";
+import {
+ ConsolidationPartnerPanel,
+ emptyPartnerLine,
+} from "./gl-booking-form/ConsolidationPartnerPanel";
+import { ConsolidationPartnerPicker } from "./gl-booking-form/ConsolidationPartnerPicker";
+
+/**
+ * Container sizes offered on the parent-booking panel. Fixed rather than taken
+ * from this contract's scope: the parent booking is a different customer on a
+ * different contract, so its sizes are its own.
+ */
+const PARTNER_SIZES = ["20ft", "40ft"];
/** All booking-window times are communicated in East Africa Time. */
const EAT_TZ = "Africa/Addis_Ababa";
@@ -240,6 +257,14 @@ export default function GlCreateBookingForm() {
enabled: Boolean(copyFromParam),
});
+ // The booking being completed — used to name the customer on the price
+ // confirmation when a second booking's price is shown beside it.
+ const { data: completeBooking } = useQuery({
+ queryKey: ["gl-complete-booking", completeBookingId],
+ queryFn: () => bookingsService.getById(completeBookingId!),
+ enabled: Boolean(completeBookingId),
+ });
+
// Same window-gating the customer sees: booking is only allowed while a
// window is OPEN for one of the contract's routes. Intercity contracts are
// never window-gated — the shipment rides a passing train staff pick later.
@@ -290,6 +315,18 @@ export default function GlCreateBookingForm() {
const [withReturn, setWithReturn] = useState(false);
const [prefilled, setPrefilled] = useState(false);
const [priceOpen, setPriceOpen] = useState(false);
+ // ── Odd-20ft shared wagon (customs / Path B) ──────────────────────────────
+ // An odd 20ft total leaves one container unpaired. On a customs contract GL
+ // resolves that here by linking a second booking that is also odd — two odd
+ // counts always sum to even — completing both together onto the shared wagon.
+ const [consolidateOdd, setConsolidateOdd] = useState(false);
+ // Set once GL flips the toggle by hand, so the auto-on effect below never
+ // re-opens a panel GL deliberately closed.
+ const consolidateTouchedRef = useRef(false);
+ const [partnerPickerOpen, setPartnerPickerOpen] = useState(false);
+ const [partner, setPartner] = useState(null);
+ const [partnerLines, setPartnerLines] = useState([]);
+ const [partnerCargoDescription, setPartnerCargoDescription] = useState("");
const seededRef = useRef(false);
const returnSeededRef = useRef(false);
@@ -834,6 +871,48 @@ export default function GlCreateBookingForm() {
}, [isContainer, containerLines]);
const hasOdd20ft = ft20Total % 2 === 1;
+ // Only a customs (Path B) instance being COMPLETED by GL can use the shared
+ // wagon: it is GL, not the customer, who links the two bookings. Anything else
+ // keeps the historical hard block on odd 20ft.
+ const oddConsolidationAvailable = Boolean(
+ completeBookingId && isContainer && contract?.customsClearingEnabled,
+ );
+
+ // Auto-on: entering an odd 20ft total opens the consolidation panel by itself,
+ // once. GL can still switch it off — then odd is blocked exactly as before.
+ useEffect(() => {
+ if (!oddConsolidationAvailable) return;
+ if (consolidateTouchedRef.current) return;
+ if (hasOdd20ft) setConsolidateOdd(true);
+ }, [oddConsolidationAvailable, hasOdd20ft]);
+
+ // Clear the partner as soon as the panel closes or stops applying, so a
+ // leftover selection can never ride along into a plain single-booking submit.
+ useEffect(() => {
+ if (consolidateOdd && oddConsolidationAvailable) return;
+ setPartner(null);
+ setPartnerLines([]);
+ setPartnerCargoDescription("");
+ }, [consolidateOdd, oddConsolidationAvailable]);
+
+ const consolidationActive =
+ oddConsolidationAvailable && consolidateOdd && hasOdd20ft;
+
+ // Once a parent booking is linked, each booking's cargo is entered under its
+ // own labelled heading so it is clear which containers belong to whom.
+ const splitView = Boolean(consolidationActive && partner);
+
+ const candidatesQuery = useQuery({
+ queryKey: ["consolidation-candidates", id, completeBookingId],
+ queryFn: () =>
+ contractsService.listConsolidationCandidates(
+ id ?? "",
+ completeBookingId ?? "",
+ ),
+ enabled:
+ partnerPickerOpen && Boolean(id) && Boolean(completeBookingId),
+ });
+
const bulkUom = contract ? bulkUnitOfMeasure(contract) : "PER_TON";
const bulkErrors = useMemo(() => {
@@ -886,7 +965,64 @@ export default function GlCreateBookingForm() {
!cargoDescriptionError
: !bulkErrors.quantity && !bulkErrors.hazardous && !bulkErrors.reefer;
- const formValid = cargoValid && !hasOdd20ft && !dateError && !routeError;
+ // The unpaired 20ft container is resolved by the shared wagon, so with an
+ // active consolidation an odd total stops being a blocker; without one it
+ // blocks exactly as before.
+ const oddBlocksSubmit = hasOdd20ft && !consolidationActive;
+
+ // Partner side: a linked partner must be picked, carry an odd 20ft count of
+ // its own (odd + odd = even fills the wagon) and have complete unit details.
+ const partnerFt20Total = useMemo(() => {
+ if (!consolidationActive) return 0;
+ return partnerLines
+ .filter((l) => parseInt(l.containerSize, 10) === 20)
+ .reduce((sum, l) => sum + Number(l.quantity || 0), 0);
+ }, [consolidationActive, partnerLines]);
+
+ const partnerError = useMemo(() => {
+ if (!consolidationActive) return undefined;
+ if (!partner) return "Select the booking that shares this wagon.";
+ const totalQty = partnerLines.reduce(
+ (sum, l) => sum + Math.max(0, Number(l.quantity) || 0),
+ 0,
+ );
+ if (totalQty < 1) {
+ return `Enter the containers for ${partner.reference}.`;
+ }
+ if (partnerFt20Total % 2 === 0) {
+ return `${partner.reference} must also carry an odd number of 20ft containers so the two bookings fill whole wagons together (it has ${partnerFt20Total}).`;
+ }
+ const incomplete = partnerLines.some((line) => {
+ const qty = Number(line.quantity || 0);
+ return qty >= 1 && line.units.length < qty;
+ });
+ if (incomplete) {
+ return `Enter the container details for all of ${partner.reference}'s containers.`;
+ }
+ const badUnit = partnerLines.some((line) =>
+ line.units.some(
+ (u) =>
+ !ISO_CONTAINER_NUMBER_REGEX.test(u.containerNumber.trim().toUpperCase()) ||
+ !(Number(u.vgmTons) > 0),
+ ),
+ );
+ if (badUnit) {
+ return `Every ${partner.reference} container needs a valid container number and a VGM above 0.`;
+ }
+ if (!partnerCargoDescription.trim()) {
+ return `Describe the cargo carried in ${partner.reference}'s containers.`;
+ }
+ return undefined;
+ }, [
+ consolidationActive,
+ partner,
+ partnerLines,
+ partnerFt20Total,
+ partnerCargoDescription,
+ ]);
+
+ const formValid =
+ cargoValid && !oddBlocksSubmit && !dateError && !routeError && !partnerError;
/** The create-booking DTO from the current form state — shared by the
* authoritative price preview and the actual submit so what GL confirms is
@@ -953,6 +1089,44 @@ export default function GlCreateBookingForm() {
return payload;
};
+ /**
+ * Completion DTO for the partner half of a shared wagon. Route, day and train
+ * are deliberately copied from THIS booking: the two bookings ride the same
+ * wagon, so they must ride the same train on the same day. Only the cargo and
+ * the billing currency belong to the partner.
+ */
+ const buildPartnerPayload = (): Freight.CreateBookingUnderContractDto | null => {
+ if (!partner || !consolidationActive) return null;
+
+ const payload: Freight.CreateBookingUnderContractDto = {
+ paymentCurrency,
+ ...(scheduledDate
+ ? { scheduledDate: new Date(scheduledDate).toISOString() }
+ : {}),
+ ...(trainScheduleId ? { trainScheduleId } : {}),
+ ...(partnerCargoDescription.trim()
+ ? { cargoFreeText: partnerCargoDescription.trim() }
+ : {}),
+ containers: partnerLines
+ .filter((l) => Number(l.quantity) >= 1)
+ .map((l) => ({
+ containerSize: l.containerSize,
+ quantity: Number(l.quantity),
+ hazardousQuantity: Number(l.hazardousQuantity || 0) || undefined,
+ reeferQuantity: Number(l.reeferQuantity || 0) || undefined,
+ units: l.units.map((u) => ({
+ containerNumber: u.containerNumber.trim().toUpperCase(),
+ ...(u.sealNumber ? { sealNumber: u.sealNumber } : {}),
+ vgmTons: Number(u.vgmTons) || 0,
+ isHazardous: Boolean(u.isHazardous),
+ isReefer: Boolean(u.isReefer),
+ })),
+ })),
+ };
+
+ return payload;
+ };
+
// Authoritative price preview (same pricing pass the booking persists at
// create): rail freight + first/last mile + overweight + every surcharge,
// plus the hard-block checks (20ft pairing, max capacity, container numbers
@@ -964,6 +1138,22 @@ export default function GlCreateBookingForm() {
});
const validation = validateShipmentMutation.data ?? null;
+ // The partner is priced against ITS OWN contract, so the two totals shown in
+ // the confirm modal are each customer's real bill — nobody pays for the other.
+ const validatePartnerMutation = useMutation({
+ mutationFn: (input: {
+ contractId: string;
+ bookingId: string;
+ dto: Freight.CreateBookingUnderContractDto;
+ }) =>
+ contractsService.validateShipment(
+ input.contractId,
+ input.dto,
+ input.bookingId,
+ ),
+ });
+ const partnerValidation = validatePartnerMutation.data ?? null;
+
const serverTotal = useMemo(() => {
const items = validation?.lineItems;
if (!items?.length) return null;
@@ -1010,8 +1200,52 @@ export default function GlCreateBookingForm() {
};
}, [serverTotal, priceTotal, overweightSurchargeAmount]);
+ const partnerTotal = useMemo(() => {
+ const items = partnerValidation?.lineItems;
+ if (!items?.length) return null;
+ return {
+ currency: partnerValidation?.currency ?? "ETB",
+ lines: items.map((li) => ({
+ label: li.description,
+ unitPrice: li.unitAmount,
+ unit: li.unit.toLowerCase(),
+ quantity: li.quantity,
+ amount: li.amount,
+ })),
+ total:
+ partnerValidation?.totalAmount ?? items.reduce((s, l) => s + l.amount, 0),
+ };
+ }, [partnerValidation]);
+
+ // The partner half must clear the same hard blocks as this one — the pair is
+ // booked all-or-nothing, so a block on either side blocks both.
+ const partnerBlockers = useMemo(() => {
+ if (!consolidationActive || !partnerValidation) return [];
+ return [
+ ...(partnerValidation.pairingErrors ?? []),
+ ...(partnerValidation.capacityErrors ?? []),
+ ...(partnerValidation.containerClashErrors ?? []),
+ ...(partnerValidation.spaceErrors ?? []),
+ ];
+ }, [consolidationActive, partnerValidation]);
+
+ const completePairMutation = useMutation({
+ mutationFn: (input: {
+ payload: Freight.CreateBookingUnderContractDto;
+ partnerPayload: Freight.CreateBookingUnderContractDto;
+ partnerBookingId: string;
+ }) =>
+ contractsService.completeConsolidatedPair(id ?? "", completeBookingId ?? "", {
+ partnerBookingId: input.partnerBookingId,
+ booking: input.payload,
+ partner: input.partnerPayload,
+ }),
+ });
+
const submitPending =
- mutations.createBooking.isPending || mutations.completeBooking.isPending;
+ mutations.createBooking.isPending ||
+ mutations.completeBooking.isPending ||
+ completePairMutation.isPending;
// Block confirm until the authoritative server price is in hand — the client
// estimate is display-only; booking on it would confirm an un-validated,
@@ -1023,7 +1257,13 @@ export default function GlCreateBookingForm() {
capacityErrors.length > 0 ||
containerClashErrors.length > 0 ||
spaceErrors.length > 0 ||
- !serverTotal;
+ !serverTotal ||
+ // Same bar for the shared-wagon partner: its authoritative price must be in
+ // hand and its own hard blocks clear before either booking is confirmed.
+ (consolidationActive &&
+ (validatePartnerMutation.isPending ||
+ !partnerTotal ||
+ partnerBlockers.length > 0));
const openPriceModal = () => {
// Surface the per-field errors (portal-parity validation) instead of
@@ -1039,6 +1279,15 @@ export default function GlCreateBookingForm() {
validateShipmentMutation.reset();
validateShipmentMutation.mutate(payload);
}
+ validatePartnerMutation.reset();
+ const partnerPayload = buildPartnerPayload();
+ if (partnerPayload && partner?.contractId) {
+ validatePartnerMutation.mutate({
+ contractId: partner.contractId,
+ bookingId: partner.id,
+ dto: partnerPayload,
+ });
+ }
};
const handleSubmit = () => {
@@ -1054,6 +1303,25 @@ export default function GlCreateBookingForm() {
const payload = buildPayload();
if (!payload) return;
+ // Shared wagon: both halves complete together, all-or-nothing on the server.
+ if (consolidationActive && partner && completeBookingId) {
+ // A hard block on the partner's own price preview blocks the pair.
+ if (partnerBlockers.length > 0) return;
+ const partnerPayload = buildPartnerPayload();
+ if (!partnerPayload) return;
+ completePairMutation.mutate(
+ {
+ payload,
+ partnerPayload,
+ partnerBookingId: partner.id,
+ },
+ {
+ onSuccess: () => navigate(`/dashboard/clearance/${completeBookingId}`),
+ },
+ );
+ return;
+ }
+
if (completeBookingId) {
// Completion mode: cargo + day land on the already-cleared instance —
// the request was linked and accepted at submission time.
@@ -1347,6 +1615,18 @@ export default function GlCreateBookingForm() {
maxRows={4}
styles={fieldStyles}
/>
+ {/* With a parent booking linked, each booking's containers are
+ entered in its own labelled section, one after the other. */}
+ {splitView ? (
+
+
+ {completeBooking?.reference ?? "This booking"}
+
+
+ {completeBooking?.company?.name ?? "—"}
+
+
+ ) : null}
{containerLines.length === 0 ? (
This contract has no container sizes in scope.
@@ -1526,7 +1806,71 @@ export default function GlCreateBookingForm() {
))
)}
- {hasOdd20ft ? (
+ {hasOdd20ft && oddConsolidationAvailable ? (
+ }
+ title={`Odd number of 20ft containers (${ft20Total})`}
+ >
+
+
+ 20ft containers travel two per wagon, so one container here
+ is unpaired. On a customs booking you can pair it with
+ another customer's odd booking and complete both onto the
+ shared wagon — each booking is still priced and invoiced
+ separately.
+
+ {
+ consolidateTouchedRef.current = true;
+ setConsolidateOdd(e.currentTarget.checked);
+ }}
+ />
+ {consolidateOdd ? (
+
+ }
+ onClick={() => setPartnerPickerOpen(true)}
+ >
+ {partner
+ ? `Parent booking: ${partner.reference} — change`
+ : "Parent booking"}
+
+ {partner ? (
+ {
+ setPartner(null);
+ setPartnerLines([]);
+ setPartnerCargoDescription("");
+ }}
+ >
+ Remove
+
+ ) : null}
+
+ ) : (
+
+ With sharing off, book an even number of 20ft containers
+ — add one more or remove one (e.g. {ft20Total + 1} or{" "}
+ {ft20Total - 1} instead of {ft20Total}).
+
+ )}
+
+
+ ) : hasOdd20ft ? (
) : null}
+
+ {splitView && partner ? (
+ <>
+
+
+
+ {partner.reference}
+
+
+ {partner.companyName ?? "—"}
+
+
+
+ Parent booking — ships on the same day and train, billed to
+ its own customer.
+
+
+ >
+ ) : null}
) : (
@@ -1806,12 +2178,31 @@ export default function GlCreateBookingForm() {
>
Fix the highlighted fields before reviewing the price.
+ ) : partnerError ? (
+ // The review button is disabled while the parent booking is
+ // incomplete, so the click that would reveal the errors never
+ // lands — say what is outstanding without waiting for it.
+ }
+ mb="sm"
+ >
+ {partnerError}
+
) : null}
{/* Mantine tooltips get no pointer events from a disabled button,
so the wrapper carries the hover target. */}
@@ -1821,9 +2212,11 @@ export default function GlCreateBookingForm() {
radius="md"
leftSection={ }
onClick={openPriceModal}
- // Same hard block the customer portal applies at review time —
- // an unpaired 20ft can never be planned onto a wagon.
- disabled={hasOdd20ft}
+ // An unpaired 20ft can never be planned onto a wagon — unless
+ // a parent booking is linked to share it, which is what
+ // oddBlocksSubmit accounts for. The parent's own cargo must be
+ // complete too, or there is nothing to price.
+ disabled={oddBlocksSubmit || Boolean(partnerError)}
>
Review price & book
@@ -1833,6 +2226,24 @@ export default function GlCreateBookingForm() {
+ setPartnerPickerOpen(false)}
+ candidates={candidatesQuery.data ?? []}
+ isLoading={candidatesQuery.isLoading}
+ isError={candidatesQuery.isError}
+ onSelect={(candidate) => {
+ setPartner(candidate);
+ // Seed a 20ft and a 40ft line. The parent booking sits on its OWN
+ // contract, whose size scope need not match this one's, so the panel
+ // offers both sizes rather than mirroring this contract's scope; a
+ // size the parent does not ship is simply left at 0.
+ setPartnerLines(PARTNER_SIZES.map(emptyPartnerLine));
+ setPartnerCargoDescription("");
+ setPartnerPickerOpen(false);
+ }}
+ />
+
{
@@ -1984,6 +2395,18 @@ export default function GlCreateBookingForm() {
)}
+ {/* Whose bill this is. Only worth naming when a second booking is
+ on screen — on a lone booking there is nothing to confuse it with. */}
+ {consolidationActive && partner ? (
+
+
+ {completeBooking?.reference ?? "This booking"}
+
+
+ {completeBooking?.company?.name ?? contract.company?.name ?? "—"}
+
+
+ ) : null}
{displayTotal.lines.map((line, i) => (
@@ -2028,6 +2451,123 @@ export default function GlCreateBookingForm() {
+ {consolidationActive && partner ? (
+
+
+
+ {partner.reference}
+
+
+ {partner.companyName ?? "—"}
+
+
+
+ {validatePartnerMutation.isPending ? (
+
+
+
+ Pricing the partner booking…
+
+
+ ) : partnerBlockers.length > 0 ? (
+ }
+ title={`Cannot book ${partner.reference}`}
+ >
+
+ {partnerBlockers.map((msg, i) => (
+
+ {msg}
+
+ ))}
+
+ Both bookings are confirmed together, so this must be
+ fixed before either can be booked.
+
+
+
+ ) : partnerTotal ? (
+ <>
+
+ {partnerTotal.lines.map((line, i) => (
+
+
+
+ {line.label}
+
+
+ {line.quantity.toLocaleString()} ×{" "}
+ {line.unitPrice.toLocaleString()}{" "}
+ {partnerTotal.currency} ·{" "}
+ {formatRateUnit(line.unit)}
+
+
+
+ {line.amount.toLocaleString()}{" "}
+ {partnerTotal.currency}
+
+
+ ))}
+
+
+
+
+ Total
+
+
+ {partnerTotal.total.toLocaleString()}{" "}
+
+ {partnerTotal.currency}
+
+
+
+ >
+ ) : (
+
+ No price yet for the partner booking.
+
+ )}
+
+ ) : null}
+
+ {consolidationActive && partner ? (
+ }
+ >
+
+ These two bookings share one wagon but stay separate: each is
+ invoiced to its own customer and paid separately. Confirming
+ books both together — if either fails, neither is booked.
+
+
+ ) : null}
+
- {completeBookingId ? "Confirm & complete" : "Confirm & book"}
+ {consolidationActive && partner
+ ? "Confirm & book both"
+ : completeBookingId
+ ? "Confirm & complete"
+ : "Confirm & book"}
diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPanel.tsx
new file mode 100644
index 000000000..e52cce097
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPanel.tsx
@@ -0,0 +1,255 @@
+import { type KeyboardEvent } from "react";
+import {
+ Box,
+ Checkbox,
+ Group,
+ Stack,
+ Text,
+ TextInput,
+} from "@mantine/core";
+
+/**
+ * Container editor for the PARTNER half of a shared wagon. Deliberately a
+ * reduced version of the main form's editor: the partner contributes only cargo
+ * — route, shipment day and train are inherited from the booking it shares the
+ * wagon with, and hazardous/reefer/return counts are derived from the per-unit
+ * ticks rather than typed line totals.
+ */
+
+export interface PartnerUnitDraft {
+ containerNumber: string;
+ sealNumber: string;
+ vgmTons: string;
+ isHazardous: boolean;
+ isReefer: boolean;
+ isReturn: boolean;
+}
+
+export interface PartnerLineDraft {
+ containerSize: string;
+ quantity: string;
+ hazardousQuantity: string;
+ reeferQuantity: string;
+ returnQuantity: string;
+ units: PartnerUnitDraft[];
+}
+
+export function emptyPartnerUnit(): PartnerUnitDraft {
+ return {
+ containerNumber: "",
+ sealNumber: "",
+ vgmTons: "",
+ isHazardous: false,
+ isReefer: false,
+ isReturn: false,
+ };
+}
+
+export function emptyPartnerLine(size: string): PartnerLineDraft {
+ return {
+ containerSize: size,
+ quantity: "0",
+ hazardousQuantity: "0",
+ reeferQuantity: "0",
+ returnQuantity: "0",
+ units: [],
+ };
+}
+
+/** Quantities are magnitudes — swallow the minus key before it reaches the field. */
+const blockNegative = (event: KeyboardEvent) => {
+ if (event.key === "-") event.preventDefault();
+};
+
+/** Grow or shrink a line's unit rows to match its quantity. */
+function syncUnits(line: PartnerLineDraft, quantity: number): PartnerLineDraft {
+ const target = Math.max(0, Math.floor(quantity) || 0);
+ const units = [...line.units];
+ while (units.length < target) units.push(emptyPartnerUnit());
+ units.length = target;
+ return {
+ ...line,
+ units,
+ hazardousQuantity: String(units.filter((u) => u.isHazardous).length),
+ reeferQuantity: String(units.filter((u) => u.isReefer).length),
+ };
+}
+
+interface Props {
+ lines: PartnerLineDraft[];
+ onLinesChange: (lines: PartnerLineDraft[]) => void;
+ cargoDescription: string;
+ onCargoDescriptionChange: (value: string) => void;
+ /** Whether per-container hazardous / refrigerated ticks apply. */
+ showHazardous: boolean;
+ showReefer: boolean;
+ /** Surface field errors only after the operator tried to continue. */
+ showErrors: boolean;
+ error?: string;
+}
+
+export function ConsolidationPartnerPanel({
+ lines,
+ onLinesChange,
+ cargoDescription,
+ onCargoDescriptionChange,
+ showHazardous,
+ showReefer,
+ showErrors,
+ error,
+}: Props) {
+ const patchLine = (index: number, patch: Partial) => {
+ onLinesChange(
+ lines.map((line, i) => (i === index ? { ...line, ...patch } : line)),
+ );
+ };
+
+ const patchUnit = (
+ lineIndex: number,
+ unitIndex: number,
+ patch: Partial,
+ ) => {
+ onLinesChange(
+ lines.map((line, i) => {
+ if (i !== lineIndex) return line;
+ const units = line.units.map((unit, u) =>
+ u === unitIndex ? { ...unit, ...patch } : unit,
+ );
+ return {
+ ...line,
+ units,
+ hazardousQuantity: String(units.filter((u) => u.isHazardous).length),
+ reeferQuantity: String(units.filter((u) => u.isReefer).length),
+ };
+ }),
+ );
+ };
+
+ return (
+
+ {error && showErrors ? (
+
+ {error}
+
+ ) : null}
+
+ {lines.map((line, lineIdx) => (
+
+
+ {line.containerSize} containers
+
+
+ patchLine(lineIdx, { quantity: e.currentTarget.value })}
+ // Sync off the typed value, not the captured `line` — that snapshot
+ // still holds the pre-edit quantity and would write it back.
+ onBlur={(e) => {
+ const typed = e.currentTarget.value;
+ patchLine(lineIdx, {
+ ...syncUnits({ ...line, quantity: typed }, Number(typed || 0)),
+ quantity: typed,
+ });
+ }}
+ mb={12}
+ />
+
+ {line.units.map((unit, unitIdx) => (
+
+
+ Container {unitIdx + 1}
+
+
+
+ patchUnit(lineIdx, unitIdx, {
+ containerNumber: e.currentTarget.value.toUpperCase(),
+ })
+ }
+ />
+
+ patchUnit(lineIdx, unitIdx, {
+ sealNumber: e.currentTarget.value,
+ })
+ }
+ />
+ 0)
+ ? "Required."
+ : undefined
+ }
+ onChange={(e) =>
+ patchUnit(lineIdx, unitIdx, { vgmTons: e.currentTarget.value })
+ }
+ />
+
+ {showHazardous || showReefer ? (
+
+ {showHazardous ? (
+
+ patchUnit(lineIdx, unitIdx, {
+ isHazardous: e.currentTarget.checked,
+ })
+ }
+ />
+ ) : null}
+ {showReefer ? (
+
+ patchUnit(lineIdx, unitIdx, {
+ isReefer: e.currentTarget.checked,
+ })
+ }
+ />
+ ) : null}
+
+ ) : null}
+
+ ))}
+
+ ))}
+
+ onCargoDescriptionChange(e.currentTarget.value)}
+ />
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPicker.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPicker.tsx
new file mode 100644
index 000000000..c20c08994
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPicker.tsx
@@ -0,0 +1,137 @@
+import {
+ Alert,
+ Badge,
+ Box,
+ Button,
+ Center,
+ Group,
+ Loader,
+ Modal,
+ Stack,
+ Text,
+ ThemeIcon,
+} from "@mantine/core";
+import { AlertCircle, Link2 } from "lucide-react";
+
+import type { ConsolidationCandidate } from "@/services/contracts.service";
+
+/**
+ * Picker for the booking that shares this booking's wagon. The server has
+ * already narrowed the list to bookings that can legally pair — same route and
+ * direction, customs clearing, an odd 20ft count of their own and not already
+ * linked to someone else — so every row here is a valid choice.
+ */
+interface Props {
+ opened: boolean;
+ onClose: () => void;
+ candidates: ConsolidationCandidate[];
+ isLoading: boolean;
+ isError: boolean;
+ onSelect: (candidate: ConsolidationCandidate) => void;
+}
+
+export function ConsolidationPartnerPicker({
+ opened,
+ onClose,
+ candidates,
+ isLoading,
+ isError,
+ onSelect,
+}: Props) {
+ return (
+
+
+
+
+
+
+ Pick the parent booking
+
+
+ Customs bookings on the same route that also carry an odd number of
+ 20ft containers.
+
+
+
+ }
+ >
+ {isLoading ? (
+
+
+
+ ) : isError ? (
+ }
+ >
+ Could not load the candidate bookings. Close this and try again.
+
+ ) : candidates.length === 0 ? (
+ }
+ title="No booking available to share this wagon"
+ >
+
+ No other customs booking on this route currently carries an odd
+ number of 20ft containers. Either wait for one, or switch the
+ shared-wagon option off and book an even number of 20ft containers.
+
+
+ ) : (
+
+ {candidates.map((candidate) => (
+
+
+
+
+
+ {candidate.reference}
+
+
+ {candidate.status.replaceAll("_", " ")}
+
+
+
+ {candidate.companyName ?? "—"}
+ {candidate.tradeDirection
+ ? ` · ${candidate.tradeDirection}`
+ : ""}
+ {" · "}
+ {candidate.hasCargo
+ ? `${candidate.ft20Quantity} × 20ft`
+ : "cargo not entered yet"}
+
+
+ onSelect(candidate)}
+ >
+ Select
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx
index 65c4f25e3..7f4cf30ce 100644
--- a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx
@@ -20,11 +20,10 @@ import {
FileX2,
} from "lucide-react";
import { useState } from "react";
-import { useFileViewer } from "@edr/ui-common";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
-import { fetchViewableFile } from "@/services/files.service";
+import { openFileInNewTab } from "@/services/files.service";
import { api } from "@/services/api";
import type { Company } from "@/types/customer";
import { formatDate, humanize } from "./format";
@@ -227,7 +226,6 @@ export function ChangeRequestReview({ company }: { company: Company }) {
api.customers.requestChangeRequestChanges.mutationOptions(),
);
- const { view, viewer } = useFileViewer();
const [actionTarget, setActionTarget] = useState<{
id: string;
kind: "reject" | "request-changes";
@@ -350,10 +348,10 @@ export function ChangeRequestReview({ company }: { company: Company }) {
type="button"
size="sm"
onClick={() =>
- void fetchViewableFile(
+ openFileInNewTab(
c.fileId,
c.fileName ?? humanize(c.code),
- ).then(view)
+ )
}
style={{
textDecoration:
@@ -382,12 +380,7 @@ export function ChangeRequestReview({ company }: { company: Company }) {
component="button"
type="button"
size="sm"
- onClick={() =>
- void fetchViewableFile(
- fileId,
- `Document ${i + 1}`,
- ).then(view)
- }
+ onClick={() => openFileInNewTab(fileId, `Document ${i + 1}`)}
>
Document {i + 1}
@@ -421,10 +414,10 @@ export function ChangeRequestReview({ company }: { company: Company }) {
type="button"
size="sm"
onClick={() =>
- void fetchViewableFile(
+ openFileInNewTab(
c.fileId,
c.fileName ?? "License document",
- ).then(view)
+ )
}
style={{
textDecoration:
@@ -532,8 +525,6 @@ export function ChangeRequestReview({ company }: { company: Company }) {
-
- {viewer}
>
);
}
diff --git a/apps/edr-freight-web/backoffice/src/components/customers/CompanyTimeline.tsx b/apps/edr-freight-web/backoffice/src/components/customers/CompanyTimeline.tsx
index cd421d9bd..8469e4031 100644
--- a/apps/edr-freight-web/backoffice/src/components/customers/CompanyTimeline.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/customers/CompanyTimeline.tsx
@@ -1,9 +1,8 @@
import { Alert, Anchor, Badge, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { FilePlus2, FileX2, History } from "lucide-react";
-import { useFileViewer } from "@edr/ui-common";
-import { fetchViewableFile } from "@/services/files.service";
+import { openFileInNewTab } from "@/services/files.service";
import { api } from "@/services/api";
import type {
Company,
@@ -36,6 +35,12 @@ interface TimelineEntry {
at: string;
note?: string | null;
summary?: string;
+ /** Who filed the change (the customer, or staff editing during onboarding). */
+ requestedBy?: string | null;
+ /** When they filed it — the "asked" half of the ask/decide pair below. */
+ requestedAt?: string | null;
+ /** Who decided (approved / rejected / sent it back to marketing). */
+ decidedBy?: string | null;
fieldDiffs: FieldDiff[];
docDiffs: DocDiff[];
}
@@ -43,10 +48,18 @@ interface TimelineEntry {
const KIND_BADGE: Record = {
approved: { label: "Approved", color: "edr-green" },
rejected: { label: "Rejected", color: "red" },
- changes_requested: { label: "Changes requested", color: "yellow" },
+ // Sending a request back is what "reverted to marketing" means here: the
+ // request stays open and marketing owns the follow-up with the customer.
+ changes_requested: { label: "Sent back to marketing", color: "yellow" },
revision: { label: "Recorded", color: "blue" },
};
+/** "Requested by X" / "Reviewed by X", with the id-less case reading sanely. */
+function actorLine(verb: string, who?: string | null, when?: string | null) {
+ if (!who && !when) return null;
+ return `${verb}${who ? ` by ${who}` : ""}${when ? ` · ${formatDate(when)}` : ""}`;
+}
+
/**
* Pair adjacent remove-then-add intents into one before/after doc diff — a
* "replace" is always staged as `[{op:'remove'}, {op:'add'}]` pushed together
@@ -132,6 +145,9 @@ function fromChangeRequest(
kind: r.status as TimelineEntry["kind"],
at: r.reviewedAt ?? r.updatedAt,
note: r.note,
+ requestedBy: r.submittedByName,
+ requestedAt: r.submittedAt ?? r.createdAt,
+ decidedBy: r.reviewedByName,
fieldDiffs,
docDiffs,
};
@@ -156,6 +172,7 @@ function fromRevision(rev: CompanyRevision): TimelineEntry {
kind: "revision",
at: rev.createdAt,
summary: rev.summary,
+ requestedBy: rev.actorName,
fieldDiffs,
docDiffs,
};
@@ -170,7 +187,6 @@ function fromRevision(rev: CompanyRevision): TimelineEntry {
* single answer instead of two places to check.
*/
export function CompanyTimeline({ company }: { company: Company }) {
- const { view, viewer } = useFileViewer();
const changeRequestsQuery = useQuery(
api.customers.changeRequests.queryOptions({ input: { id: company.id } }),
);
@@ -186,7 +202,7 @@ export function CompanyTimeline({ company }: { company: Company }) {
].sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime());
const openFile = (file: { id: string; name: string }) =>
- void fetchViewableFile(file.id, file.name).then(view);
+ openFileInNewTab(file.id, file.name);
if (entries.length === 0) {
return (
@@ -205,6 +221,20 @@ export function CompanyTimeline({ company }: { company: Company }) {
{entries.map((entry) => {
const badge = KIND_BADGE[entry.kind];
+ const requestedLine = actorLine(
+ entry.kind === "revision" ? "Edited" : "Requested",
+ entry.requestedBy,
+ entry.requestedAt,
+ );
+ const decidedLine = actorLine(
+ entry.kind === "changes_requested"
+ ? "Sent back to marketing"
+ : entry.kind === "rejected"
+ ? "Rejected"
+ : "Approved",
+ entry.decidedBy,
+ entry.kind === "revision" ? null : entry.at,
+ );
return (
@@ -224,10 +254,33 @@ export function CompanyTimeline({ company }: { company: Company }) {
+ {/* Who asked, and who decided. Without this the feed said what
+ changed and when, but never named a person — the first thing
+ anyone auditing a returned request needs. */}
+ {(requestedLine || decidedLine) && (
+
+ {requestedLine && (
+
+ {requestedLine}
+
+ )}
+ {decidedLine && (
+
+ {decidedLine}
+
+ )}
+
+ )}
+
{entry.note && (
- Note: {entry.note}
+
+ {entry.kind === "changes_requested"
+ ? "What was asked for:"
+ : "Note:"}
+ {" "}
+ {entry.note}
)}
@@ -293,7 +346,6 @@ export function CompanyTimeline({ company }: { company: Company }) {
);
})}
- {viewer}
);
}
diff --git a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx
index 90dea5adb..3127a39f8 100644
--- a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx
@@ -114,6 +114,36 @@ export function CompanyNationalityBadge({
);
}
+/**
+ * The company's registration was typed, not fetched from eTrade — nothing in it
+ * has been checked against a licence. Loud on purpose: it is the one thing a
+ * reviewer must not miss about this customer. Two kinds of company land here
+ * for different reasons, and the badge names which.
+ */
+export function ManualRegistrationBadge({
+ cooperative,
+ investorLicence,
+}: {
+ cooperative?: boolean | null;
+ investorLicence?: boolean | null;
+}) {
+ if (!cooperative && !investorLicence) return null;
+ return (
+
+ {cooperative
+ ? "Manual entry · co-operative"
+ : "Manual entry · investment licence"}
+
+ );
+}
+
/**
* Profile chips for a company row: one chip per role (Importer / Exporter / …)
* carrying its reference code, colored by the profile's status (green active,
@@ -308,9 +338,11 @@ export function InvoiceStatusBadge({
/**
* Inline approval action buttons for a profile row.
- * Transitions: pending → approve / reject-with-note | rejected → approve (override) |
+ * Transitions: pending → approve / reject-with-note | rejected → undo-rejection (→ pending) |
* active → suspend | suspended → reactivate/blacklist | blacklisted → reinstate.
- * Rejecting captures a note the customer sees so they can fix and reapply.
+ * Rejecting captures a note the customer sees so they can fix and reapply — a
+ * rejected role is theirs to resubmit, so it cannot be approved from here until
+ * they do (the API refuses it); undoing the rejection is the only way back.
*
* `locked` (customer hasn't submitted onboarding) withholds the review decision
* only — there's no application to judge yet, and the API rejects the call
@@ -496,18 +528,33 @@ export function ProfileApprovalActions({
}
if (status === "rejected") {
- if (!canSet("active")) return null;
+ // No Approve here: the role is waiting on the customer to fix what was
+ // flagged and resubmit it, and the API refuses rejected → active outright.
+ // All that's left is undoing a rejection that shouldn't have happened,
+ // which puts the role back in the queue rather than into service.
+ if (!canSet("pending")) return null;
return (
- act("active")}
- >
- Approve
-
+
+
+ Awaiting customer resubmission
+
+
+ act("pending")}
+ >
+ Undo rejection
+
+
+
);
}
diff --git a/apps/edr-freight-web/backoffice/src/components/customers/index.ts b/apps/edr-freight-web/backoffice/src/components/customers/index.ts
index 6f869173c..864a89ae6 100644
--- a/apps/edr-freight-web/backoffice/src/components/customers/index.ts
+++ b/apps/edr-freight-web/backoffice/src/components/customers/index.ts
@@ -4,6 +4,7 @@ export {
CompanyStatusBadge,
CompanyTypeBadge,
InvoiceStatusBadge,
+ ManualRegistrationBadge,
PaymentStatusBadge,
ProfileApprovalActions,
ProfileChips,
diff --git a/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts b/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts
index 636310e38..691040c0a 100644
--- a/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts
+++ b/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts
@@ -71,6 +71,13 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
subtitle: "Manage your account and signature",
},
},
+ {
+ prefix: "/dashboard/chat",
+ meta: {
+ title: "Chat",
+ subtitle: "Internal messaging for EDR staff",
+ },
+ },
{
// Invoices, Payments, and USD Payments are tabs on one page now
// (FinanceHubPage); the header title itself is set per-tab there.
diff --git a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx
index 293e82450..fc5b90dbf 100644
--- a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx
@@ -12,6 +12,7 @@ import {
Image as ImageIcon,
LayoutDashboard,
LayoutGrid,
+ Link2,
MapPin,
Network,
Package,
@@ -32,6 +33,7 @@ import {
Users,
Wallet,
LifeBuoy,
+ MessageSquare,
TrainFront,
XCircle,
} from "lucide-react";
@@ -95,6 +97,14 @@ export const buildSidebarSections = (
icon: ,
permission: FREIGHT_PERMS.bookings.view,
},
+ // Shared-wagon gate: a consolidated pair waits for a human decision
+ // before either half reaches Operations.
+ {
+ label: "Shared wagon approvals",
+ href: "/dashboard/consolidation-approvals",
+ icon: ,
+ permission: FREIGHT_PERMS.bookings.approveConsolidation,
+ },
{
label: "Wagon cancellations",
href: "/dashboard/wagon-cancellations",
@@ -124,6 +134,12 @@ export const buildSidebarSections = (
icon: ,
permission: FREIGHT_PERMS.support.agentView,
},
+ {
+ label: "Chat",
+ href: "/dashboard/chat",
+ icon: ,
+ permission: FREIGHT_PERMS.chat.view,
+ },
...demoItems,
],
},
@@ -555,6 +571,11 @@ export const buildSidebarSections = (
href: "/dashboard/configuration/exchange-rate",
permission: FREIGHT_PERMS.settings.exchangeRate.view,
},
+ {
+ label: "Manual payments",
+ href: "/dashboard/configuration/manual-payments",
+ permission: FREIGHT_PERMS.settings.manualPayment.view,
+ },
],
},
{
diff --git a/apps/edr-freight-web/backoffice/src/components/trainBuilder/AvailableWagonsPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainBuilder/AvailableWagonsPanel.tsx
index 1bddb77cd..7077f3fdf 100644
--- a/apps/edr-freight-web/backoffice/src/components/trainBuilder/AvailableWagonsPanel.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/trainBuilder/AvailableWagonsPanel.tsx
@@ -4,34 +4,40 @@ import {
Button,
Checkbox,
Group,
+ Pagination,
ScrollArea,
Select,
Stack,
Text,
TextInput,
} from "@mantine/core";
+import { useDebouncedValue } from "@mantine/hooks";
import { useQuery } from "@tanstack/react-query";
-import { Plus, Search } from "lucide-react";
-import { useMemo, useState } from "react";
+import { MapPin, Plus, Search } from "lucide-react";
+import { memo, useCallback, useEffect, useMemo, useState } from "react";
+
+const PAGE_SIZE = 20;
import { api } from "@/services/api";
/**
- * AVAILABLE wagons standing in the train's own yard — the only ones that can
- * be coupled. Pick any number and append them to the consist.
+ * AVAILABLE, unassigned wagons from every yard — filtered and paged on the API,
+ * so the picker never page-walks the whole fleet into the browser.
*/
-export default function AvailableWagonsPanel({
- yardId,
- yardLabel,
+function AvailableWagonsPanel({
+ homeYardId,
onAssign,
assigning,
exportTrainNumber,
importTrainNumber,
}: AvailableWagonsPanelProps) {
const [search, setSearch] = useState("");
+ const [debouncedSearch] = useDebouncedValue(search, 300);
const [typeFilter, setTypeFilter] = useState("ALL");
+ const [yardFilter, setYardFilter] = useState("ALL");
const [runOnly, setRunOnly] = useState(false);
- const [selected, setSelected] = useState([]);
+ const [selected, setSelected] = useState>(() => new Set());
+ const [page, setPage] = useState(1);
// The train's own run, e.g. "8001-8002" — only offered when the train has one.
const runLabel = exportTrainNumber
@@ -39,86 +45,95 @@ export default function AvailableWagonsPanel({
: null;
const wagonsQuery = useQuery(
- api.wagons.list.queryOptions({
+ api.wagons.listPaged.queryOptions({
input: {
filters: {
status: Freight.WagonStatus.Available,
- currentYardId: yardId,
// Loose wagons only — one already on another train cannot be coupled.
unassigned: true,
+ search: debouncedSearch.trim() || undefined,
+ currentYardId: yardFilter === "ALL" ? undefined : yardFilter,
+ wagonTypeId: typeFilter === "ALL" ? undefined : typeFilter,
+ // Rostered to this train's run — the API matches either run column.
+ trainNumber: runOnly && exportTrainNumber ? exportTrainNumber : undefined,
+ page,
+ pageSize: PAGE_SIZE,
},
},
- enabled: Boolean(yardId),
+ // Keep the previous page on screen while the next one loads — otherwise
+ // paging and typing flash the list to "Loading wagons…" on every stroke.
+ placeholderData: (prev) => prev,
}),
);
- const wagons = useMemo(() => {
- const q = search.trim().toLowerCase();
- return (wagonsQuery.data ?? []).filter((wagon) => {
- if (typeFilter !== "ALL" && wagon.wagonTypeId !== typeFilter) return false;
- // Rostered to this train's run — match on the export run, which fixes the
- // import run anyway.
- if (runOnly && wagon.exportTrainNumber !== exportTrainNumber) return false;
- if (q && !wagon.wagonNumber.toLowerCase().includes(q)) return false;
- return true;
- });
- }, [wagonsQuery.data, search, typeFilter, runOnly, exportTrainNumber]);
+ const wagons = wagonsQuery.data?.items ?? [];
+ const total = wagonsQuery.data?.meta.total ?? 0;
+ const totalPages = Math.max(1, wagonsQuery.data?.meta.totalPages ?? 1);
- const runMatchCount = useMemo(
- () =>
- exportTrainNumber
- ? (wagonsQuery.data ?? []).filter(
- (w) => w.exportTrainNumber === exportTrainNumber,
- ).length
- : 0,
- [wagonsQuery.data, exportTrainNumber],
+ // Filters change → back to page 1 (and clamp when the list shrinks).
+ useEffect(() => {
+ setPage(1);
+ }, [debouncedSearch, typeFilter, yardFilter, runOnly]);
+ useEffect(() => {
+ if (page > totalPages) setPage(totalPages);
+ }, [page, totalPages]);
+
+ // Dropdowns come from the reference lists, not the current page — a yard or
+ // type must stay pickable even when this page holds none of it.
+ const yardsQuery = useQuery(api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }));
+ const wagonTypesQuery = useQuery(api.wagonTypes.list.queryOptions({ staleTime: 5 * 60_000 }));
+
+ const yardOptions = useMemo(() => {
+ const yards = [...(yardsQuery.data ?? [])].sort((a, b) =>
+ a.id === homeYardId ? -1 : b.id === homeYardId ? 1 : a.label.localeCompare(b.label),
+ );
+ return [
+ { value: "ALL", label: "All yards" },
+ ...yards.map((yard) => ({
+ value: yard.id,
+ label: `${yard.label}${yard.id === homeYardId ? " · train's yard" : ""}`,
+ })),
+ ];
+ }, [yardsQuery.data, homeYardId]);
+
+ const typeOptions = useMemo(
+ () => [
+ { value: "ALL", label: "All types" },
+ // e.g. "Flat wagon (NW5)" — name with its type code.
+ ...(wagonTypesQuery.data ?? []).map((type) => ({
+ value: type.id,
+ label: type.code ? `${type.name} (${type.code})` : type.name,
+ })),
+ ],
+ [wagonTypesQuery.data],
);
- const typeOptions = useMemo(() => {
- const byId = new Map();
- for (const wagon of wagonsQuery.data ?? []) {
- if (wagon.wagonType) {
- // e.g. "Flat wagon (NW5)" — name with its type code.
- byId.set(
- wagon.wagonType.id,
- wagon.wagonType.code
- ? `${wagon.wagonType.name} (${wagon.wagonType.code})`
- : wagon.wagonType.name,
- );
- }
- }
- return [
- { value: "ALL", label: "All types" },
- ...[...byId.entries()].map(([value, label]) => ({ value, label })),
- ];
- }, [wagonsQuery.data]);
+ const toggle = useCallback((wagonId: string, checked: boolean) => {
+ setSelected((prev) => {
+ const next = new Set(prev);
+ if (checked) next.add(wagonId);
+ else next.delete(wagonId);
+ return next;
+ });
+ }, []);
- const toggle = (wagonId: string, checked: boolean) => {
- setSelected((prev) =>
- checked ? [...prev, wagonId] : prev.filter((id) => id !== wagonId),
- );
- };
-
- const allSelected =
- wagons.length > 0 && wagons.every((w) => selected.includes(w.id));
- const someSelected = wagons.some((w) => selected.includes(w.id));
+ // Select-all covers this page only — the rest of the matches are not loaded.
+ const allSelected = wagons.length > 0 && wagons.every((w) => selected.has(w.id));
+ const someSelected = wagons.some((w) => selected.has(w.id));
const toggleAll = (checked: boolean) => {
setSelected((prev) => {
- if (checked) {
- const ids = new Set(prev);
- wagons.forEach((w) => ids.add(w.id));
- return [...ids];
- }
- const visible = new Set(wagons.map((w) => w.id));
- return prev.filter((id) => !visible.has(id));
+ const next = new Set(prev);
+ if (checked) wagons.forEach((w) => next.add(w.id));
+ else wagons.forEach((w) => next.delete(w.id));
+ return next;
});
};
const handleAssign = () => {
- if (!selected.length) return;
- onAssign(selected);
- setSelected([]);
+ if (!selected.size) return;
+ onAssign([...selected]);
+ setSelected(new Set());
};
return (
@@ -138,100 +153,102 @@ export default function AvailableWagonsPanel({
onChange={(v) => setTypeFilter(v ?? "ALL")}
/>
+ }
+ data={yardOptions}
+ value={yardFilter}
+ onChange={(v) => setYardFilter(v ?? "ALL")}
+ searchable
+ aria-label="Filter by yard"
+ />
{runLabel ? (
setRunOnly(e.currentTarget.checked)}
/>
) : null}
{wagons.length ? (
- toggleAll(e.currentTarget.checked)}
- />
+
+ toggleAll(e.currentTarget.checked)}
+ />
+ {selected.size ? (
+
+ {selected.size} selected
+
+ ) : null}
+
) : null}
-
+ {/* Previous results stay put while the next page loads (placeholderData),
+ so dim them rather than blanking the list. */}
+
{wagonsQuery.isLoading ? (
Loading wagons…
) : !wagons.length ? (
- No available wagons in {yardLabel ?? "this yard"}
+ No available wagons match
) : (
wagons.map((wagon) => (
-
- toggle(wagon.id, e.currentTarget.checked)}
- aria-label={`Select wagon ${wagon.wagonNumber}`}
- />
-
-
-
- {wagon.wagonNumber}
-
- {wagon.exportTrainNumber ? (
-
- {wagon.exportTrainNumber}
- {wagon.importTrainNumber ? `-${wagon.importTrainNumber}` : ""}
-
- ) : null}
-
-
- {wagon.wagonType
- ? `${wagon.wagonType.name} · ${wagon.wagonType.capacityTons ?? "—"}T cap`
- : "Unknown type"}
-
-
-
+ wagon={wagon}
+ selected={selected.has(wagon.id)}
+ homeYardId={homeYardId}
+ exportTrainNumber={exportTrainNumber}
+ onToggle={toggle}
+ />
))
)}
+ {totalPages > 1 ? (
+
+
+ {(page - 1) * PAGE_SIZE + 1}–{Math.min(page * PAGE_SIZE, total)} of {total}
+
+
+
+ ) : null}
+
}
- disabled={!selected.length}
+ disabled={!selected.size}
loading={assigning}
onClick={handleAssign}
>
- Add {selected.length ? `${selected.length} wagon${selected.length > 1 ? "s" : ""}` : "wagons"} to consist
+ Add {selected.size ? `${selected.size} wagon${selected.size > 1 ? "s" : ""}` : "wagons"} to consist
);
}
+/** Memoized: the workspace re-renders on every pending mutation. */
+export default memo(AvailableWagonsPanel);
+
export interface AvailableWagonsPanelProps {
- yardId: string;
- yardLabel?: string | null;
+ /** The train's own yard — sorted first and highlighted; not a restriction. */
+ homeYardId: string | null;
onAssign: (wagonIds: string[]) => void;
assigning: boolean;
/** This train's odd EXPORT run — drives the "only this run" filter. */
@@ -239,3 +256,81 @@ export interface AvailableWagonsPanelProps {
/** This train's even IMPORT run — label only; the export run does the matching. */
importTrainNumber?: string | null;
}
+
+/**
+ * One selectable wagon row. Memoized: the picker re-renders on every keystroke
+ * and every selection change, but a row only actually changes when its own
+ * checkbox flips — so a full page of rows stays untouched.
+ */
+const WagonOption = memo(function WagonOption({
+ wagon,
+ selected,
+ homeYardId,
+ exportTrainNumber,
+ onToggle,
+}: {
+ wagon: {
+ id: string;
+ wagonNumber: string;
+ currentYardId?: string | null;
+ currentYard?: { label?: string | null; code?: string | null } | null;
+ exportTrainNumber?: string | null;
+ importTrainNumber?: string | null;
+ wagonType?: { name?: string | null; capacityTons?: number | null } | null;
+ };
+ selected: boolean;
+ homeYardId: string | null;
+ exportTrainNumber?: string | null;
+ onToggle: (wagonId: string, checked: boolean) => void;
+}) {
+ return (
+
+ onToggle(wagon.id, e.currentTarget.checked)}
+ aria-label={`Select wagon ${wagon.wagonNumber}`}
+ />
+
+
+
+ {wagon.wagonNumber}
+
+ }
+ >
+ {wagon.currentYard?.label ?? wagon.currentYard?.code ?? "No yard"}
+
+ {wagon.exportTrainNumber ? (
+
+ {wagon.exportTrainNumber}
+ {wagon.importTrainNumber ? `-${wagon.importTrainNumber}` : ""}
+
+ ) : null}
+
+
+ {wagon.wagonType
+ ? `${wagon.wagonType.name} · ${wagon.wagonType.capacityTons ?? "—"}T cap`
+ : "Unknown type"}
+
+
+
+ );
+});
diff --git a/apps/edr-freight-web/backoffice/src/components/trainBuilder/ConsistWagonList.tsx b/apps/edr-freight-web/backoffice/src/components/trainBuilder/ConsistWagonList.tsx
index 2aab0ca10..101176042 100644
--- a/apps/edr-freight-web/backoffice/src/components/trainBuilder/ConsistWagonList.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/trainBuilder/ConsistWagonList.tsx
@@ -7,8 +7,8 @@ import {
type DropResult,
} from "@hello-pangea/dnd";
import { ActionIcon, Badge, Box, Group, Stack, Text, Tooltip } from "@mantine/core";
-import { GripVertical, Trash2, Wrench } from "lucide-react";
-import { type ReactNode } from "react";
+import { GripVertical, MapPin, Trash2, Wrench } from "lucide-react";
+import { memo, useCallback, useMemo, type ReactNode } from "react";
import { createPortal } from "react-dom";
import type { TrainCompositionWagon } from "@/services/trainBuilder.service";
@@ -32,7 +32,7 @@ const PortalAwareRow = ({
* The train's ordered wagon consist. Drag to reorder (persisted on drop),
* trash to detach a wagon back to the yard.
*/
-export default function ConsistWagonList({
+function ConsistWagonList({
wagons,
editable,
onReorder,
@@ -40,7 +40,7 @@ export default function ConsistWagonList({
onMaintenance,
busy = false,
}: ConsistWagonListProps) {
- const onDragEnd = (result: DropResult) => {
+ const onDragEnd = useCallback((result: DropResult) => {
if (!result.destination) return;
const from = result.source.index;
const to = result.destination.index;
@@ -49,7 +49,18 @@ export default function ConsistWagonList({
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved!);
onReorder(next.map((w) => w.id));
- };
+ }, [wagons, onReorder]);
+
+ // Legend of the types actually coupled, in consist order — the colour code is
+ // only readable if the row tints are keyed somewhere.
+ const legend = useMemo(
+ () => [
+ ...new Map(
+ wagons.filter((w) => w.wagonType).map((w) => [w.wagonType!.code, w.wagonType!]),
+ ).values(),
+ ],
+ [wagons],
+ );
if (!wagons.length) {
return (
@@ -59,16 +70,6 @@ export default function ConsistWagonList({
);
}
- // Legend of the types actually coupled, in consist order — the colour code is
- // only readable if the row tints are keyed somewhere.
- const legend = [
- ...new Map(
- wagons
- .filter((w) => w.wagonType)
- .map((w) => [w.wagonType!.code, w.wagonType!]),
- ).values(),
- ];
-
return (
@@ -118,6 +119,9 @@ export default function ConsistWagonList({
);
}
+/** Memoized: a 40-wagon consist re-renders every row otherwise. */
+export default memo(ConsistWagonList);
+
export interface ConsistWagonListProps {
wagons: TrainCompositionWagon[];
editable: boolean;
@@ -128,7 +132,7 @@ export interface ConsistWagonListProps {
busy?: boolean;
}
-function WagonRow({
+const WagonRow = memo(function WagonRow({
wagon,
index,
dragProvided,
@@ -191,6 +195,11 @@ function WagonRow({
{wagon.wagonType.code}
) : null}
+ {wagon.currentYard ? (
+ }>
+ {wagon.currentYard.label ?? wagon.currentYard.code}
+
+ ) : null}
{wagon.wagonType
@@ -227,4 +236,4 @@ function WagonRow({
);
-}
+});
diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx
index 6a89af519..79bc9ece2 100644
--- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx
@@ -1,4 +1,4 @@
-import { useMemo } from "react";
+import { memo, useMemo } from "react";
import { Box, Group, Paper, Progress, Stack, Text, Tooltip } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { Box as BoxIcon, Container as ContainerIcon, Fuel, Gauge, TrainFront } from "lucide-react";
@@ -132,7 +132,7 @@ function Coupler() {
);
}
-function LocomotiveCar({
+const LocomotiveCar = memo(function LocomotiveCar({
code,
name,
maxPullWeightTons,
@@ -260,7 +260,7 @@ function LocomotiveCar({
);
-}
+});
const CONTAINER_GRADIENTS = [
"linear-gradient(180deg, var(--mantine-color-cyan-5), var(--mantine-color-cyan-7))",
@@ -271,7 +271,7 @@ const CONTAINER_BORDERS = [
"var(--mantine-color-blue-8)",
];
-function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
+const WagonCar = memo(function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
// GROSS on both sides: cargo + tare vs rated payload + tare.
const grossTons = round1(wagon.assignedWeightTons + wagon.tareWeightTons);
const maxGrossTons = round1(wagon.capacityTons + wagon.tareWeightTons);
@@ -468,7 +468,7 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
);
-}
+});
/** Railway track: two rails over evenly-spaced sleepers. */
function TrackBed() {
@@ -520,7 +520,7 @@ function TrackBed() {
);
}
-export function TrainCompositionDiagram({
+export const TrainCompositionDiagram = memo(function TrainCompositionDiagram({
locomotive,
locomotives,
wagons,
@@ -818,7 +818,7 @@ export function TrainCompositionDiagram({
);
-}
+});
function LegendDot({ color, label }: { color: string; label: string }) {
return (
diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
index 4f2001a29..745ab7fd8 100644
--- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts
+++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
@@ -57,6 +57,10 @@ export const URL_CONSTANTS = {
BASE: "/exchange-settings",
},
+ MANUAL_PAYMENT_SETTINGS: {
+ BASE: "/payment-settings/manual",
+ },
+
AUDIT_LOGS: {
BASE: "/audit",
},
@@ -187,6 +191,17 @@ export const URL_CONSTANTS = {
BY_ID: (id: string) => `/bookings/${id}`,
QUEUE: (queue: string) => `/bookings/queues/${queue}`,
STAFF_ACCEPT: (id: string) => `/bookings/${id}/staff/accept`,
+ // Consolidated pair: one staff decision applied to both halves at once.
+ PAIRED_DECISION: (id: string) => `/bookings/${id}/paired-decision`,
+ // Shared-wagon approval gate: a consolidated pair waits for a human
+ // decision before either half reaches Operations.
+ CONSOLIDATION_APPROVAL_QUEUE: "/bookings/consolidation-approvals/queue",
+ CONSOLIDATION_APPROVAL_HISTORY: (id: string) =>
+ `/bookings/${id}/consolidation-approvals`,
+ CONSOLIDATION_APPROVE: (approvalId: string) =>
+ `/bookings/consolidation-approvals/${approvalId}/approve`,
+ CONSOLIDATION_REJECT: (approvalId: string) =>
+ `/bookings/consolidation-approvals/${approvalId}/reject`,
STAFF_REQUEST_CHANGES: (id: string) =>
`/bookings/${id}/staff/request-changes`,
STAFF_REJECT: (id: string) => `/bookings/${id}/staff/reject`,
@@ -313,6 +328,12 @@ export const URL_CONSTANTS = {
AWAITING_SHIPMENT: "/contracts/awaiting-shipment",
BOOKINGS_COMPLETE: (id: string, bookingId: string) =>
`/contracts/${id}/bookings/${bookingId}/complete`,
+ // Odd-20ft shared-wagon consolidation (customs/Path B): candidates GL may
+ // link, and the all-or-nothing completion of both halves together.
+ CONSOLIDATION_CANDIDATES: (id: string, bookingId: string) =>
+ `/contracts/${id}/bookings/${bookingId}/consolidation-candidates`,
+ BOOKINGS_COMPLETE_CONSOLIDATED: (id: string, bookingId: string) =>
+ `/contracts/${id}/bookings/${bookingId}/complete-consolidated`,
VALIDATE_SHIPMENT: (id: string) => `/contracts/${id}/validate-shipment`,
CAPACITY: (id: string) => `/contracts/${id}/capacity`,
// Shipment requests (GENERAL + customs, Path B): customer → GL queue → booking.
diff --git a/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts b/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts
index 5600f6cfb..eba31130c 100644
--- a/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts
+++ b/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts
@@ -59,6 +59,9 @@ export type BookingActionContext = Pick<
| "reference"
| "schedulingStatus"
| "customsClearingEnabled"
+ // Set when this booking shares a wagon: the pairable staff decisions then
+ // apply to both halves at once rather than to this booking alone.
+ | "consolidationPartnerId"
>;
const ALLOCATABLE_SCHEDULING_STATUSES = new Set([
diff --git a/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts b/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts
index 07dbae7ce..cd3cef570 100644
--- a/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts
+++ b/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts
@@ -102,6 +102,11 @@ export const BOOKING_STATUS_STYLES: Record = {
label: "Operation Changes",
color: "bg-orange-50 text-orange-700 border-orange-200",
},
+ // Shared-wagon gate: held for a human decision before reaching Operations.
+ CONSOLIDATION_APPROVAL_PENDING: {
+ label: "Wagon Approval",
+ color: "bg-amber-50 text-amber-700 border-amber-200",
+ },
OPERATION_PRICE_PENDING_CONFIRM: {
label: "Price Confirm",
color: "bg-amber-50 text-amber-700 border-amber-200",
@@ -309,6 +314,9 @@ export const BOOKING_LIST_TABS = [
"OPERATION_REQUEST_PENDING",
"OPERATION_CHANGES_REQUESTED",
"OPERATION_PRICE_PENDING_CONFIRM",
+ // Held at the shared-wagon gate — still an ops-review-stage booking, it
+ // just needs the pairing signed off before Operations can act on it.
+ "CONSOLIDATION_APPROVAL_PENDING",
],
},
{
diff --git a/apps/edr-freight-web/backoffice/src/features/chat/chatApi.ts b/apps/edr-freight-web/backoffice/src/features/chat/chatApi.ts
new file mode 100644
index 000000000..65522aafd
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/features/chat/chatApi.ts
@@ -0,0 +1,13 @@
+import { api } from "@/auth/http";
+
+/**
+ * Internal chat (Matrix/Element) REST calls. Just the one endpoint — Chat
+ * itself is a separate app (chat.edr.et); this backoffice only ever asks for
+ * a fresh sign-in link into it.
+ */
+export const chatApi = {
+ getSsoUrl: async (): Promise => {
+ const { data } = await api.get<{ url: string }>("/chat/sso");
+ return data.url;
+ },
+};
diff --git a/apps/edr-freight-web/backoffice/src/hooks/bookings/useBookings.ts b/apps/edr-freight-web/backoffice/src/hooks/bookings/useBookings.ts
index 2fce8aa8f..64e993207 100644
--- a/apps/edr-freight-web/backoffice/src/hooks/bookings/useBookings.ts
+++ b/apps/edr-freight-web/backoffice/src/hooks/bookings/useBookings.ts
@@ -121,7 +121,38 @@ export function useBookingMutations(bookingId: string) {
onError: (error) => toast.error(parseApiError(error, "Failed to cancel booking")),
});
+ /**
+ * One staff decision applied to both halves of a consolidated pair. Both
+ * bookings are invalidated on success so whichever tab is open reflects the
+ * new state immediately.
+ */
+ const pairedDecision = useMutation({
+ mutationFn: (payload: {
+ decision: "accept" | "cancel" | "operationAccept" | "requestChanges";
+ reason?: string;
+ note?: string;
+ validityDays?: number;
+ }) => {
+ const { decision, ...options } = payload;
+ return bookingsService.pairedDecision(bookingId, decision, options);
+ },
+ onSuccess: (data) => {
+ toast.success("Applied to both bookings on the shared wagon");
+ void invalidateBookingDetail(qc, data.booking.id);
+ void invalidateBookingDetail(qc, data.partner.id);
+ },
+ onError: (error) => {
+ toast.error(
+ parseApiError(error, "Failed to apply the decision to both bookings"),
+ );
+ // Nothing should have committed (the server runs both halves in one
+ // transaction), but refetch so the UI never shows a stale guess.
+ void invalidateBookingDetail(qc, bookingId);
+ },
+ });
+
const isPending =
+ pairedDecision.isPending ||
staffAccept.isPending ||
requestChanges.isPending ||
staffReject.isPending ||
@@ -134,6 +165,7 @@ export function useBookingMutations(bookingId: string) {
cancel.isPending;
return {
+ pairedDecision,
staffAccept,
requestChanges,
staffReject,
diff --git a/apps/edr-freight-web/backoffice/src/hooks/useManualPaymentSettings.ts b/apps/edr-freight-web/backoffice/src/hooks/useManualPaymentSettings.ts
new file mode 100644
index 000000000..b49f33376
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/hooks/useManualPaymentSettings.ts
@@ -0,0 +1,39 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useTranslation } from "react-i18next";
+import { toast } from "sonner";
+
+import {
+ manualPaymentSettingsService,
+ type ManualPaymentSettings,
+} from "@/services/manualPaymentSettings.service";
+import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
+
+export const MANUAL_PAYMENT_SETTINGS_KEY = ["manualPaymentSettings"];
+
+export const useManualPaymentSettingsQuery = () =>
+ useQuery({
+ queryKey: MANUAL_PAYMENT_SETTINGS_KEY,
+ queryFn: () => manualPaymentSettingsService.get(),
+ staleTime: 60_000,
+ });
+
+export const useUpdateManualPaymentSettings = () => {
+ const queryClient = useQueryClient();
+ const { t } = useTranslation();
+ const { handleError } = useErrorHandler(t);
+
+ return useMutation({
+ mutationFn: (
+ patch: Partial>,
+ ) => manualPaymentSettingsService.update(patch),
+ onSuccess: (data) => {
+ queryClient.setQueryData(MANUAL_PAYMENT_SETTINGS_KEY, data);
+ // The Manual Payments worklist only lists enabled currencies.
+ queryClient.invalidateQueries({ queryKey: ["invoices"] });
+ toast.success(
+ t("manualPaymentSettings.updated", "Manual payment settings updated"),
+ );
+ },
+ onError: handleError,
+ });
+};
diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts
index fe4d4fff9..de43dff41 100644
--- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts
+++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts
@@ -33,6 +33,10 @@ export const FREIGHT_PERMS = {
staffUsers: {
view: "edr_freight_app:staff:users:view",
},
+ chat: {
+ view: "edr_freight_app:chat:view",
+ sync: "edr_freight_app:chat:sync",
+ },
bookings: {
view: "edr_freight_app:bookings:view",
create: "edr_freight_app:bookings:create",
@@ -57,6 +61,7 @@ export const FREIGHT_PERMS = {
wagonCancellationVoid: "edr_freight_app:bookings:wagon_cancellation_void",
wagonCancellationRebook:
"edr_freight_app:bookings:wagon_cancellation_rebook",
+ approveConsolidation: "edr_freight_app:bookings:approve_consolidation",
},
contracts: {
view: "edr_freight_app:contracts:view",
@@ -372,6 +377,12 @@ export const FREIGHT_PERMS = {
view: "edr_freight_app:settings:exchange_rate:view",
manage: "edr_freight_app:settings:exchange_rate:manage",
},
+ // Whether Finance may settle invoices by hand, per currency. Finance holds
+ // `view` (the worklist offers only enabled currencies); `manage` is admin.
+ manualPayment: {
+ view: "edr_freight_app:settings:manual_payment:view",
+ manage: "edr_freight_app:settings:manual_payment:manage",
+ },
contractTemplates: {
view: "edr_freight_app:settings:contract_templates:view",
manage: "edr_freight_app:settings:contract_templates:manage",
diff --git a/apps/edr-freight-web/backoffice/src/lib/queryClient.test.ts b/apps/edr-freight-web/backoffice/src/lib/queryClient.test.ts
new file mode 100644
index 000000000..81a91d8ee
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/lib/queryClient.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, it } from "vitest";
+
+import { queryClient } from "./queryClient";
+
+/**
+ * The MutationCache seeds `meta.updates` entries into the cache and then skips
+ * exactly those keys when running `meta.invalidates`. Getting that skip wrong
+ * silently reintroduces the refetch it exists to avoid, so it is worth pinning.
+ */
+const runOnSuccess = (meta: Record, data: unknown, variables: unknown) => {
+ const handler = (queryClient.getMutationCache() as unknown as {
+ config: {
+ onSuccess?: (
+ data: unknown,
+ variables: unknown,
+ context: unknown,
+ mutation: { meta?: Record },
+ ) => void;
+ };
+ }).config.onSuccess;
+ handler?.(data, variables, undefined, { meta });
+};
+
+describe("MutationCache updates/invalidates", () => {
+ it("writes the mutation response into the seeded key and leaves it fresh", () => {
+ const seededKey = ["train-builder", "composition", "t1"] as const;
+ const siblingKey = ["train-builder", "list", {}] as const;
+
+ queryClient.setQueryData(seededKey, { code: "STALE" });
+ queryClient.setQueryData(siblingKey, { items: [] });
+
+ const response = { code: "FRESH" };
+ runOnSuccess(
+ {
+ updates: (_v: unknown, d: unknown) => [[seededKey, d]],
+ invalidates: () => [["train-builder"]],
+ },
+ response,
+ { id: "t1" },
+ );
+
+ // Seeded key holds the response, and was NOT invalidated back to stale.
+ expect(queryClient.getQueryData(seededKey)).toStrictEqual(response);
+ expect(queryClient.getQueryState(seededKey)?.isInvalidated).toBe(false);
+
+ // Its siblings under the same root still get invalidated.
+ expect(queryClient.getQueryState(siblingKey)?.isInvalidated).toBe(true);
+ });
+
+ it("invalidates everything when a mutation declares no updates", () => {
+ const key = ["train-builder", "composition", "t2"] as const;
+ queryClient.setQueryData(key, { code: "X" });
+
+ runOnSuccess({ invalidates: () => [["train-builder"]] }, undefined, undefined);
+
+ expect(queryClient.getQueryState(key)?.isInvalidated).toBe(true);
+ });
+});
diff --git a/apps/edr-freight-web/backoffice/src/lib/queryClient.ts b/apps/edr-freight-web/backoffice/src/lib/queryClient.ts
index ee072b330..e372e3255 100644
--- a/apps/edr-freight-web/backoffice/src/lib/queryClient.ts
+++ b/apps/edr-freight-web/backoffice/src/lib/queryClient.ts
@@ -1,6 +1,6 @@
import { MutationCache, QueryClient } from "@tanstack/react-query";
-import type { InvalidatesMeta } from "@/utils/endpoint";
+import type { InvalidatesMeta, UpdatesMeta } from "@/utils/endpoint";
/**
* Single app-wide React Query client (do not nest additional providers).
@@ -14,13 +14,37 @@ import type { InvalidatesMeta } from "@/utils/endpoint";
export const queryClient = new QueryClient({
mutationCache: new MutationCache({
onSuccess: (data, variables, _context, mutation) => {
+ // Seed first: endpoints that return the entity they just changed write it
+ // straight into its cache key, so the screen updates from the response
+ // instead of round-tripping for data it already holds.
+ const updates = mutation.meta?.updates as UpdatesMeta | undefined;
+ const seeded: readonly unknown[][] = [];
+ if (typeof updates === "function") {
+ for (const [queryKey, value] of updates(variables, data)) {
+ queryClient.setQueryData(queryKey, value);
+ seeded.push(queryKey as unknown[]);
+ }
+ }
+
const invalidates = mutation.meta?.invalidates as
| InvalidatesMeta
| undefined;
if (typeof invalidates !== "function") return;
for (const queryKey of invalidates(variables, data)) {
- void queryClient.invalidateQueries({ queryKey });
+ void queryClient.invalidateQueries({
+ queryKey,
+ // A seeded key already holds the authoritative value from this very
+ // response — invalidating it would refetch it right back.
+ predicate: seeded.length
+ ? (query) =>
+ !seeded.some(
+ (key) =>
+ key.length === query.queryKey.length &&
+ key.every((part, i) => Object.is(part, query.queryKey[i])),
+ )
+ : undefined,
+ });
}
},
// Mutation failures are surfaced globally by the axios interceptor in
diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx
index 62c26276a..f0783ab82 100644
--- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx
@@ -9,6 +9,7 @@ import {
FolderOpen,
Layers,
LayoutGrid,
+ Link2,
Milestone,
MoreHorizontal,
Package,
@@ -20,6 +21,7 @@ import {
} from "lucide-react";
import {
ActionIcon,
+ Alert,
Badge,
Box,
Button,
@@ -47,6 +49,7 @@ import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge
import { NextStepBanner } from "@/components/bookings/NextStepBanner";
import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import { ConsolidationWaitingBanner } from "@/components/bookings/detail/ConsolidationWaitingBanner";
+import { ConsolidationApprovalCard } from "@/components/bookings/detail/ConsolidationApprovalCard";
import {
detailStyles,
BookingRouteServiceCard,
@@ -79,14 +82,50 @@ export default function BookingRequestDetailPage() {
const [searchParams, setSearchParams] = useSearchParams();
// Deep-link from a warehouse fee invoice → this booking's warehouse section.
useScrollToHash();
+
+ // Consolidated pair: `?booking=` swaps the WHOLE page over to the
+ // other half of the shared wagon. Everything below — KPIs, stepper, the
+ // overview/orders/documents/trucks sub-tabs, the action toolbar — then reads
+ // from the selected booking, so each half gets its own complete detail page
+ // under a top-level tab. The URL id stays put so Back still works.
+ const selectedId = searchParams.get("booking") || id;
const {
data: booking,
isLoading,
isError,
refetch,
isFetching,
- } = useBookingDetail(id);
- const mutations = useBookingMutations(id ?? "");
+ } = useBookingDetail(selectedId);
+ const mutations = useBookingMutations(selectedId ?? "");
+
+ // The pair is discovered from whichever half is on screen: each booking
+ // carries a reference to the other.
+ const routeBookingId = id ?? "";
+ const partnerId = booking?.consolidationPartnerId ?? null;
+ const isPaired = Boolean(partnerId);
+ const viewingPartner = selectedId !== routeBookingId;
+ // Tab identities: the booking named by the URL is always the first tab, the
+ // other half the second — regardless of which one is currently displayed.
+ const firstTabId = routeBookingId;
+ const secondTabId = viewingPartner ? selectedId : partnerId;
+
+ // Only for the tab label (reference + customer) — the displayed half is
+ // loaded above. Skipped entirely when the booking is not part of a pair.
+ const { data: otherBooking } = useBookingDetail(
+ secondTabId && secondTabId !== selectedId ? secondTabId : undefined,
+ );
+ const firstTabBooking = viewingPartner ? otherBooking : booking;
+ const secondTabBooking = viewingPartner ? booking : otherBooking;
+
+ const selectBooking = (bookingId: string) => {
+ const next = new URLSearchParams(searchParams);
+ if (bookingId === routeBookingId) next.delete("booking");
+ else next.set("booking", bookingId);
+ // Switching booking resets the sub-tab: the other half has its own content
+ // and may not even have the tab that was open (e.g. Orders).
+ next.delete("tab");
+ setSearchParams(next, { replace: true });
+ };
if (isLoading) {
return (
@@ -349,6 +388,48 @@ export default function BookingRequestDetailPage() {
/>
+ {/* Consolidated pair: one tab per booking, switching the ENTIRE page
+ below. The overview/orders/documents/trucks tabs further down are
+ sub-tabs of whichever booking is selected here. */}
+ {isPaired && secondTabId ? (
+ value && selectBooking(value)}
+ variant="pills"
+ radius="md"
+ >
+
+ }>
+
+
+ {firstTabBooking?.reference ?? "Booking"}
+
+
+ {firstTabBooking?.company?.name ?? "—"}
+
+
+
+ }>
+
+
+ {secondTabBooking?.reference ?? "Partner booking"}
+
+
+ {secondTabBooking?.company?.name ?? "—"}
+
+
+
+
+
+ ) : null}
+
+ {isPaired ? (
+
+ These two bookings share one wagon. Accepting or cancelling applies
+ to both; each is invoiced and paid separately.
+
+ ) : null}
+
{booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? (
@@ -381,6 +462,21 @@ export default function BookingRequestDetailPage() {
)}
+ {booking.status === "CONSOLIDATION_APPROVAL_PENDING" && (
+ }
+ title="Waiting for shared-wagon approval"
+ >
+
+ This booking shares a wagon with another customer's booking.
+ Both are held here until the pairing is approved — neither reaches
+ Operations before then.
+
+
+ )}
+
{/* LEFT — primary content, split into tabs to keep each view focused.
The Documents tab is always present, so the tab bar always renders. */}
@@ -455,6 +551,8 @@ export default function BookingRequestDetailPage() {
that train and its clock must be readable before the approve
button. */}
+ {/* Renders itself only when this booking has a shared wagon. */}
+
(refData?.service ?? []).map((s) => ({ value: s.id, label: s.name })),
+ [refData],
+ );
+
// Deep links land here pre-filtered (?statuses=A,B&tradeDirection=IMPORT) —
// the header's document-review alarm opens exactly the undecided requests
// it is counting down for. No sync effect needed any more: controls.values
@@ -147,6 +157,7 @@ export default function BookingRequestsPage() {
options: filterOptions(TRADE_DIRECTION_OPTIONS),
},
{ key: "freightType", label: "Freight", type: "enum", multiple: false, options: FREIGHT_TYPE_OPTIONS },
+ { key: "serviceTypeId", label: "Service", type: "enum", multiple: false, options: serviceTypeOptions },
{ key: "paymentStatus", label: "Payment", type: "enum", multiple: false, options: PAYMENT_STATUS_OPTIONS, secondary: true },
{
// Wins over the `paymentStatus` filter above — the queue is by
@@ -172,7 +183,7 @@ export default function BookingRequestsPage() {
toParams: dateRangeParams("scheduledFrom", "scheduledTo"),
},
],
- [filterOptions, yardOptions],
+ [filterOptions, yardOptions, serviceTypeOptions],
);
const controls = useFilters(bookingFilterDefs, { defaultSort: "createdAt:DESC", pageSize: 10 });
@@ -200,10 +211,28 @@ export default function BookingRequestsPage() {
// Search is applied server-side (via the `search` filter param) — no
// client-side filtering here.
- const rows = useMemo(
- () => (data?.items ?? []).map(toBookingListRow),
- [data?.items],
- );
+ const rows = useMemo(() => {
+ const mapped = (data?.items ?? []).map(toBookingListRow);
+ // Consolidated pairs share one wagon and are decided together, so they show
+ // as ONE row. Keep the half that appears first in the current sort and hang
+ // the other on it as `pairedWith`; the row renders both bookings' details
+ // and opens the detail page, where each half gets its own tab.
+ const byId = new Map(mapped.map((row) => [row.id, row]));
+ const absorbed = new Set();
+ const merged: BookingListRow[] = [];
+ for (const row of mapped) {
+ if (absorbed.has(row.id)) continue;
+ const partnerId = row.consolidationPartnerId;
+ const partner = partnerId ? byId.get(partnerId) : undefined;
+ if (partner && !absorbed.has(partner.id)) {
+ absorbed.add(partner.id);
+ merged.push({ ...row, pairedWith: partner });
+ continue;
+ }
+ merged.push(row);
+ }
+ return merged;
+ }, [data?.items]);
const total = data?.total ?? 0;
const hasSearch = controls.searchText.trim().length > 0;
@@ -321,6 +350,22 @@ export default function BookingRequestsPage() {
) : null}
+ {/* Shared wagon: the second booking rides in the same row, so the
+ operator sees both customers before opening the pair. */}
+ {b.pairedWith ? (
+
+
+
+
+ {b.pairedWith.reference}
+
+
+
+
+ {b.pairedWith.customerLabel}
+
+
+ ) : null}
);
diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/ConsolidationApprovalsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/ConsolidationApprovalsPage.tsx
new file mode 100644
index 000000000..1aebe90c3
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/pages/bookings/ConsolidationApprovalsPage.tsx
@@ -0,0 +1,284 @@
+import { useState } from "react";
+import { Link } from "react-router-dom";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import {
+ Alert,
+ Badge,
+ Box,
+ Button,
+ Center,
+ Group,
+ Loader,
+ Modal,
+ Paper,
+ Stack,
+ Text,
+ Textarea,
+ ThemeIcon,
+} from "@mantine/core";
+import { AlertCircle, Check, Clock, Link2, X } from "lucide-react";
+import toast from "react-hot-toast";
+
+import { PageContainer, PageHeader } from "@/components/page";
+import {
+ bookingsService,
+ type ConsolidationApprovalRow,
+} from "@/services/bookings.service";
+import { formatDateTime } from "@/lib/format";
+import { extractErrorMessage } from "@/utils/errorExtractor";
+
+const QUEUE_KEY = ["consolidation-approvals", "queue"];
+
+/**
+ * Review queue for shared-wagon pairings.
+ *
+ * A booking that fills its own wagons goes straight to Operations. A
+ * consolidated one waits here: two customers' cargo rides one physical wagon
+ * under two separate invoices, so a person signs off on the pairing first.
+ * Approving releases BOTH bookings to Operations; rejecting sends BOTH back to
+ * GL with the reason.
+ */
+export default function ConsolidationApprovalsPage() {
+ const qc = useQueryClient();
+ const [decision, setDecision] = useState<{
+ row: ConsolidationApprovalRow;
+ kind: "approve" | "reject";
+ } | null>(null);
+ const [note, setNote] = useState("");
+
+ const {
+ data: rows,
+ isLoading,
+ isError,
+ } = useQuery({
+ queryKey: QUEUE_KEY,
+ queryFn: () => bookingsService.consolidationApprovalQueue(),
+ });
+
+ const close = () => {
+ setDecision(null);
+ setNote("");
+ };
+
+ const decide = useMutation({
+ mutationFn: () => {
+ if (!decision) throw new Error("No pairing selected");
+ return decision.kind === "approve"
+ ? bookingsService.approveConsolidation(decision.row.id, note.trim() || undefined)
+ : bookingsService.rejectConsolidation(decision.row.id, note.trim());
+ },
+ onSuccess: () => {
+ toast.success(
+ decision?.kind === "approve"
+ ? "Shared wagon approved — both bookings sent to Operations"
+ : "Shared wagon rejected — both bookings returned to GL",
+ );
+ void qc.invalidateQueries({ queryKey: QUEUE_KEY });
+ close();
+ },
+ onError: (error) =>
+ toast.error(extractErrorMessage(error, "Could not record the decision")),
+ });
+
+ // A rejection has to tell GL what to fix, so the reason is mandatory there.
+ const confirmDisabled =
+ decide.isPending || (decision?.kind === "reject" && !note.trim());
+
+ return (
+
+
+
+ {isLoading ? (
+
+
+
+ ) : isError ? (
+ }>
+ Could not load the approval queue.
+
+ ) : !rows?.length ? (
+ }>
+ Nothing waiting for approval.
+
+ ) : (
+
+ {rows.map((row) => (
+
+
+
+
+
+
+
+
+ Shared wagon
+
+
+ Awaiting approval
+
+
+
+
+
+
+
+
+
+
+
+ Requested {formatDateTime(row.requestedAt)}
+ {row.scheduledDate
+ ? ` · ships ${formatDateTime(row.scheduledDate)}`
+ : ""}
+
+
+
+
+
+ }
+ onClick={() => {
+ setDecision({ row, kind: "approve" });
+ setNote("");
+ }}
+ >
+ Approve
+
+ }
+ onClick={() => {
+ setDecision({ row, kind: "reject" });
+ setNote("");
+ }}
+ >
+ Reject
+
+
+
+
+ ))}
+
+ )}
+
+ {
+ if (!decide.isPending) close();
+ }}
+ centered
+ radius="lg"
+ title={
+
+ {decision?.kind === "approve"
+ ? "Approve this shared wagon?"
+ : "Reject this shared wagon?"}
+
+ }
+ >
+
+
+ {decision?.kind === "approve"
+ ? "Both bookings leave the gate together and continue to Operations. Each is still invoiced and paid separately."
+ : "Both bookings go back to GL as “changes requested” with your reason. Neither reaches Operations."}
+
+
+
+
+
+ );
+}
+
+/** One half of the wagon: its reference (linked) and whose cargo it is. */
+function BookingSide({
+ id,
+ reference,
+ company,
+}: {
+ id: string;
+ reference?: string | null;
+ company?: string | null;
+}) {
+ return (
+
+
+ {reference ?? "—"}
+
+
+ {company ?? "—"}
+
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx
index 48ce552a8..39a0e9d77 100644
--- a/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx
@@ -27,6 +27,7 @@ import {
import type { Freight } from "@edr/types";
import { ClearanceOpsTabs } from "@/components/contracts/ClearanceOpsTabs";
+import { BookingChangesRequestedAlert } from "@/components/contracts/BookingChangesRequestedAlert";
import { PageContainer, PageHeader, KpiStrip } from "@/components/page";
import type { KpiItem } from "@/components/page";
import {
@@ -134,6 +135,20 @@ export default function DocumentClearanceDetailPage() {
hasPermission(user, FREIGHT_PERMS.contracts.createBooking) &&
!isDjiboutiGl(user);
+ // Operations sent this GL-created booking back for changes. Customs bookings
+ // are never self-booked (see BookingChangesRequestedAlert) — the note and the
+ // resubmit belong here, on the page GL works from, not the customer's portal.
+ const bookingNeedsChanges = booking?.status === "OPERATION_CHANGES_REQUESTED";
+ const isGlBookingOwner =
+ hasPermission(user, FREIGHT_PERMS.contracts.createBooking) &&
+ !isDjiboutiGl(user);
+ const changeRequestNote =
+ [...(booking?.reviewNotes ?? [])]
+ .filter((n) => n.type === "CHANGES_REQUESTED")
+ .sort(
+ (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
+ )[0]?.note ?? null;
+
const docsPhaseComplete =
clearance?.milestones?.some(
(m) => m.milestoneCode === "DOCUMENTS_APPROVED" && m.status === "COMPLETED",
@@ -279,6 +294,22 @@ export default function DocumentClearanceDetailPage() {
}
/>
+ {bookingNeedsChanges ? (
+ void refetch()}
+ />
+ ) : null}
+
{requestedLines ? (
diff --git a/apps/edr-freight-web/backoffice/src/pages/chat/ChatLaunchPage.tsx b/apps/edr-freight-web/backoffice/src/pages/chat/ChatLaunchPage.tsx
new file mode 100644
index 000000000..78f0e7c36
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/pages/chat/ChatLaunchPage.tsx
@@ -0,0 +1,78 @@
+import { Alert, Button, Card, Center, Stack, Text } from "@mantine/core";
+import { MessageSquare, TriangleAlert } from "lucide-react";
+import { useState } from "react";
+
+import { PageContainer, PageHeader } from "@/components/page";
+import { chatApi } from "@/features/chat/chatApi";
+
+/**
+ * Chat itself lives at chat.edr.et (Element), not in this app — this page's
+ * only job is a one-click sign-in link into it. No iframe: Element's own CSP
+ * refuses to be framed.
+ *
+ * The link is minted per click, never on mount and never cached: Synapse's
+ * login_token is single-use and expires in 5 minutes, and Element reports a
+ * spent one as "Incorrect username and/or password". A held-onto url is
+ * therefore wrong on the second click, on a remount served from cache, and on
+ * any click more than 5 minutes after the page loaded.
+ */
+export default function ChatLaunchPage() {
+ const [state, setState] = useState<"idle" | "loading" | "error">("idle");
+
+ const open = async () => {
+ // Opened before the await so it still counts as the user's click — a
+ // window.open() after it is treated as a popup and blocked.
+ //
+ // No "noopener" in the features: passing it makes window.open return null,
+ // which would leave this blank tab orphaned and send Element into the
+ // current tab instead. Clearing .opener on the handle does the same job.
+ const tab = window.open("", "_blank");
+ if (tab) tab.opener = null;
+ setState("loading");
+ try {
+ const url = await chatApi.getSsoUrl();
+ if (tab) tab.location.replace(url);
+ else window.location.assign(url); // popup blocked — go in this tab
+ setState("idle");
+ } catch {
+ tab?.close();
+ setState("error");
+ }
+ };
+
+ return (
+
+
+
+
+
+ {state === "error" && (
+ }
+ color="red"
+ title="Couldn't get a sign-in link"
+ variant="light"
+ >
+ Something went wrong reaching chat. Try again.
+
+ )}
+
+
+
+
+ Opens EDR Chat in a new tab, already signed in as you.
+
+ }
+ >
+ Open EDR Chat
+
+
+
+
+
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx
index db57819d5..9cbc0e5e0 100644
--- a/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx
@@ -3,34 +3,28 @@ import {
ActionIcon,
Box,
Card,
- Group,
- Select,
Stack,
Text,
- TextInput,
ThemeIcon,
} from "@mantine/core";
-import { DatePickerInput } from "@mantine/dates";
-import { getDateRangePresets } from "@/components/common/dateRangePresets";
-import { useDebouncedValue } from "@mantine/hooks";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
-import { FileText, Inbox, RefreshCw, Search, Ship, User, X } from "lucide-react";
-import { useCallback, useMemo, useState } from "react";
+import { FileText, Inbox, RefreshCw, Ship, User } from "lucide-react";
+import { useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink";
import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { PageContainer, PageHeader } from "@/components/page";
-import { bookingsService } from "@/services/bookings.service";
+import { bookingsService, type BookingListFilter } from "@/services/bookings.service";
import type { BookingDetail } from "@/types/booking";
import {
Badge,
DataTable,
DataTableFooter,
- usePagination,
type ColumnDef,
} from "@edr/ui-common";
+import { dateRangeParams, FilterBar, useFilters, type FilterDef } from "@/components/filters";
/**
* Operations "Clearance Documents" hub — the worklist for self-clearance
@@ -43,16 +37,14 @@ import {
const PAGE_SIZE = 10;
/**
- * Status filter options (values = `statuses` param). FULLY_EXECUTED is the
- * post-approval status of intercity (domestic) bookings — kept in the list as
- * history, otherwise an approved intercity row vanishes from the hub.
+ * The hub's baseline scope — FULLY_EXECUTED is the post-approval status of
+ * intercity (domestic) bookings, kept in as history so an approved intercity
+ * row doesn't just vanish. Sent whenever the Status pill has no narrower pick.
*/
-const BOOKING_STATUS_OPTIONS = [
- {
- value:
- "AWAITING_DOCUMENTS,DOCUMENTS_UNDER_REVIEW,CLEARANCE_READY,FULLY_EXECUTED",
- label: "All statuses",
- },
+const DEFAULT_STATUSES =
+ "AWAITING_DOCUMENTS,DOCUMENTS_UNDER_REVIEW,CLEARANCE_READY,FULLY_EXECUTED";
+
+const STATUS_OPTIONS = [
{ value: "AWAITING_DOCUMENTS", label: "Awaiting documents" },
{ value: "DOCUMENTS_UNDER_REVIEW", label: "Under review" },
{ value: "CLEARANCE_READY", label: "Clearance ready" },
@@ -81,77 +73,59 @@ const CUSTOMER_KIND_OPTIONS = [
{ value: "CUSTOMER", label: "Customer" },
];
-function startOfDayIso(d: Date): string {
- const x = new Date(d);
- x.setHours(0, 0, 0, 0);
- return x.toISOString();
-}
-
-function endOfDayIso(d: Date): string {
- const x = new Date(d);
- x.setHours(23, 59, 59, 999);
- return x.toISOString();
-}
-
export default function ClearanceDocumentsPage() {
const navigate = useNavigate();
- const [query, setQuery] = useState("");
- const [debouncedQuery] = useDebouncedValue(query, 300);
- const [bookingStatuses, setBookingStatuses] = useState(
- BOOKING_STATUS_OPTIONS[0].value,
- );
const { filterOptions } = useMyTradeAccess();
- const [directionFilter, setDirectionFilter] = useState(null);
- const [freightTypeFilter, setFreightTypeFilter] = useState(null);
- const [ownershipFilter, setOwnershipFilter] = useState(null);
- const [customerKindFilter, setCustomerKindFilter] = useState(null);
- const [createdFrom, setCreatedFrom] = useState(null);
- const [createdTo, setCreatedTo] = useState(null);
- const { pagination, setPagination } = usePagination({ pageSize: PAGE_SIZE });
- const search = debouncedQuery.trim() || undefined;
+ const filterDefs: FilterDef[] = useMemo(
+ () => [
+ {
+ key: "status",
+ label: "Status",
+ type: "enum",
+ multiple: false,
+ options: STATUS_OPTIONS,
+ // No pick ⇒ no `statuses` param at all; the query fills in
+ // DEFAULT_STATUSES itself, same as the old Select's "All statuses" row.
+ toParams: ({ v }) => ({ statuses: v[0] }),
+ },
+ {
+ key: "tradeDirection",
+ label: "Direction",
+ type: "enum",
+ multiple: false,
+ options: filterOptions(TRADE_DIRECTION_OPTIONS),
+ },
+ { key: "freightType", label: "Freight", type: "enum", multiple: false, options: FREIGHT_TYPE_OPTIONS },
+ { key: "customerKind", label: "Booked by", type: "enum", multiple: false, options: CUSTOMER_KIND_OPTIONS },
+ { key: "isGovernment", label: "Ownership", type: "enum", multiple: false, options: OWNERSHIP_OPTIONS },
+ {
+ key: "created",
+ label: "Created",
+ type: "date",
+ toParams: dateRangeParams("createdFrom", "createdTo"),
+ },
+ ],
+ [filterOptions],
+ );
- const resetPage = useCallback(() => {
- setPagination({ pageIndex: 0, pageSize: PAGE_SIZE });
- }, [setPagination]);
+ const controls = useFilters(filterDefs, { pageSize: PAGE_SIZE });
- const page = pagination.pageIndex + 1;
+ const filter: BookingListFilter = useMemo(
+ () => ({
+ ...(controls.params as unknown as BookingListFilter),
+ // Self-clearance instances carry bookingType=ONE_TIME whatever their
+ // contract kind, so customsClearingEnabled=false + the status scope
+ // above are what isolate exactly this worklist.
+ customsClearingEnabled: "false",
+ statuses: (controls.params.statuses as string | undefined) ?? DEFAULT_STATUSES,
+ }),
+ [controls.params],
+ );
const bookingsQuery = useQuery({
- queryKey: [
- "clearance-documents",
- "bookings",
- bookingStatuses,
- directionFilter,
- freightTypeFilter,
- ownershipFilter,
- customerKindFilter,
- createdFrom,
- createdTo,
- page,
- search,
- ],
- queryFn: () =>
- // Self-clearance instances carry bookingType=ONE_TIME whatever their
- // contract kind, so customsClearingEnabled=false + the three per-booking
- // clearance statuses are what isolate exactly this worklist.
- bookingsService.list({
- statuses: bookingStatuses,
- customsClearingEnabled: "false",
- page,
- pageSize: PAGE_SIZE,
- search,
- ...(directionFilter ? { tradeDirection: directionFilter } : {}),
- ...(freightTypeFilter ? { freightType: freightTypeFilter } : {}),
- ...(ownershipFilter
- ? { isGovernment: ownershipFilter as "true" | "false" }
- : {}),
- ...(customerKindFilter
- ? { customerKind: customerKindFilter as "SHIPPING_LINE" | "CUSTOMER" }
- : {}),
- ...(createdFrom ? { createdFrom: startOfDayIso(createdFrom) } : {}),
- ...(createdTo ? { createdTo: endOfDayIso(createdTo) } : {}),
- }),
+ queryKey: ["clearance-documents", "bookings", filter],
+ queryFn: () => bookingsService.list(filter),
placeholderData: keepPreviousData,
});
@@ -256,7 +230,6 @@ export default function ClearanceDocumentsPage() {
);
const total = bookingsQuery.data?.total ?? 0;
- const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
const showEmpty =
!bookingsQuery.isLoading && !bookingsQuery.isError && bookingRows.length === 0;
const tableStatus = bookingsQuery.isLoading
@@ -288,116 +261,12 @@ export default function ClearanceDocumentsPage() {
-
- }
- value={query}
- onChange={(e) => {
- setQuery(e.target.value);
- resetPage();
- }}
- rightSection={
- query && (
- {
- setQuery("");
- resetPage();
- }}
- >
-
-
- )
- }
- style={{ flex: 1, minWidth: "200px" }}
- radius="lg"
- />
- {
- setBookingStatuses(v ?? BOOKING_STATUS_OPTIONS[0].value);
- resetPage();
- }}
- allowDeselect={false}
- radius="lg"
- w={220}
- aria-label="Filter by status"
- />
-
-
- {
- setDirectionFilter(v);
- resetPage();
- }}
- clearable
- radius="lg"
- style={{ minWidth: 130 }}
- aria-label="Filter by direction"
- />
- {
- setFreightTypeFilter(v);
- resetPage();
- }}
- clearable
- radius="lg"
- style={{ minWidth: 140 }}
- aria-label="Filter by freight type"
- />
- {
- setCustomerKindFilter(v);
- resetPage();
- }}
- clearable
- radius="lg"
- style={{ minWidth: 140 }}
- aria-label="Filter by booked by"
- />
- {
- setOwnershipFilter(v);
- resetPage();
- }}
- clearable
- radius="lg"
- style={{ minWidth: 140 }}
- aria-label="Filter by ownership"
- />
- {
- setCreatedFrom(from ? new Date(from) : null);
- setCreatedTo(to ? new Date(to) : null);
- resetPage();
- }}
- presets={getDateRangePresets()}
- clearable
- radius="lg"
- style={{ minWidth: 220 }}
- aria-label="Created date range"
- />
-
+
{showEmpty ? (
@@ -420,18 +289,7 @@ export default function ClearanceDocumentsPage() {
state: { from: "/dashboard/contracts/clearance-documents" },
})
}
- pagination={{
- pageIndex: pagination.pageIndex,
- pageSize: pagination.pageSize,
- pageCount,
- totalCount: total,
- }}
- tableOptions={{
- state: { pagination },
- onPaginationChange: setPagination,
- manualPagination: true,
- pageCount,
- }}
+ {...controls.tableProps(total)}
containerClassName="border-0 shadow-none bg-transparent [&_th]:max-w-[100px] [&_td]:max-w-[100px] [&_td]:break-words"
footer={DataTableFooter}
/>
diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx
index cf87a1e1b..aa8b5ed8f 100644
--- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx
@@ -115,6 +115,15 @@ export default function ContractRequestsPage() {
[yardRefs],
);
+ // Service-type options — same reference-data payload the booking form uses.
+ const { data: refData } = useQuery(
+ api.bookings.referenceData.queryOptions({ staleTime: 5 * 60_000 }),
+ );
+ const serviceTypeOptions = useMemo(
+ () => (refData?.service ?? []).map((s) => ({ value: s.id, label: s.name })),
+ [refData],
+ );
+
// Static shape only (no facet counts) — this is what useFilters needs to
// parse the URL and build API params. Counts are attached separately below,
// for rendering only, once the summary query (which itself depends on
@@ -143,6 +152,13 @@ export default function ContractRequestsPage() {
multiple: false,
options: FREIGHT_TYPE_OPTIONS,
},
+ {
+ key: "serviceTypeId",
+ label: "Service",
+ type: "enum",
+ multiple: false,
+ options: serviceTypeOptions,
+ },
{
key: "paymentCurrency",
label: "Currency",
@@ -170,7 +186,7 @@ export default function ContractRequestsPage() {
toParams: ({ v }) => ({ originYardId: v[0], destinationYardId: v[1] }),
},
],
- [filterOptions, yardOptions],
+ [filterOptions, yardOptions, serviceTypeOptions],
);
const controls = useFilters(filterDefs, {
diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx
index b395cc710..a81f07c8b 100644
--- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx
@@ -23,6 +23,7 @@ import {
Banknote,
Contact,
Download,
+ ExternalLink,
Eye,
FileSignature,
FileText,
@@ -49,6 +50,7 @@ import {
CompanyTimeline,
CompanyTypeBadge,
InvoiceStatusBadge,
+ ManualRegistrationBadge,
PaymentStatusBadge,
PersonCard,
ProfileApprovalActions,
@@ -69,7 +71,7 @@ import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import {
downloadBookingFile,
- fetchViewableFile,
+ openFileInNewTab,
} from "@/services/files.service";
import { api } from "@/services/api";
import type {
@@ -81,12 +83,7 @@ import type {
} from "@/types/customer";
import { hasSubmittedOnboarding, isOnboardingDraft } from "@/types/customer";
import type { Invoice } from "@/types/invoice";
-import {
- DataTable,
- useFileViewer,
- usePagination,
- type ColumnDef,
-} from "@edr/ui-common";
+import { DataTable, usePagination, type ColumnDef } from "@edr/ui-common";
import type { Freight } from "@edr/types";
/** Plain-text summary of the company's eTrade-sourced record, downloaded client-side (eTrade returns data, not a document). */
@@ -146,7 +143,6 @@ const POA_DELEGATION_PENDING_CODE = "poa_delegation_letter_pending";
export default function CustomerDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
- const { view, viewer } = useFileViewer();
const { user } = useAuth();
const { data: company, isLoading } = useQuery(
@@ -271,9 +267,7 @@ export default function CustomerDetailPage() {
variant="subtle"
color="gray"
aria-label={`View ${f.name}`}
- onClick={() =>
- void fetchViewableFile(f.id, f.name).then(view)
- }
+ onClick={() => openFileInNewTab(f.id, f.name)}
>
@@ -282,9 +276,7 @@ export default function CustomerDetailPage() {
type="button"
size="xs"
lineClamp={1}
- onClick={() =>
- void fetchViewableFile(f.id, f.name).then(view)
- }
+ onClick={() => openFileInNewTab(f.id, f.name)}
style={{
maxWidth: 170,
textAlign: "left",
@@ -339,7 +331,7 @@ export default function CustomerDetailPage() {
),
},
],
- [view, canReview],
+ [canReview],
);
const bookingColumns: ColumnDef[] = useMemo(
@@ -522,9 +514,7 @@ export default function CustomerDetailPage() {
aria-label="View"
data-stop-row-click
onClick={() =>
- void fetchViewableFile(row.original.id, row.original.name).then(
- view,
- )
+ openFileInNewTab(row.original.id, row.original.name)
}
>
@@ -564,7 +554,7 @@ export default function CustomerDetailPage() {
),
},
],
- [view, canRequestDocChange],
+ [canRequestDocChange],
);
const paymentColumns: ColumnDef[] = useMemo(
@@ -755,6 +745,10 @@ export default function CustomerDetailPage() {
) : (
)}
+
}
@@ -803,6 +797,25 @@ export default function CustomerDetailPage() {
)}
+ {/* Nothing below came from eTrade for these customers. A
+ co-operative holds no trade licence at all; a foreign investor's
+ comes from the Investment Commission, not the trade registry.
+ Either way every registration field was typed, and the reviewer
+ is the only check there is. */}
+ {(company.cooperative || company.investorLicence) && (
+ }
+ title="Registration entered by hand — not verified against eTrade"
+ >
+ {company.cooperative
+ ? "This company onboarded as a co-operative union or farm, which holds no trade licence, so eTrade had no record to look its TIN up in. The company name, registration and address below are the customer's own statement. Check them against the Co-operative Registration Certificate on the Documents tab before approving."
+ : "This company onboarded on a foreign investment licence, so we could not look its TIN up on eTrade. The company name, registration and address below are the customer's own statement. Check them against the Investment Licence on the Documents tab before approving."}
+
+ )}
+
@@ -1163,9 +1178,7 @@ export default function CustomerDetailPage() {
lineClamp={1}
style={{ flex: 1, textAlign: "left" }}
onClick={() =>
- void fetchViewableFile(doc.id, doc.name).then(
- view,
- )
+ openFileInNewTab(doc.id, doc.name)
}
>
{doc.name}
@@ -1176,9 +1189,7 @@ export default function CustomerDetailPage() {
color="gray"
aria-label={`Preview ${doc.name}`}
onClick={() =>
- void fetchViewableFile(doc.id, doc.name).then(
- view,
- )
+ openFileInNewTab(doc.id, doc.name)
}
>
@@ -1280,6 +1291,23 @@ export default function CustomerDetailPage() {
{/* DOCUMENTS */}
+ {/* Reviewing a customer means reading every document, so offer the
+ whole set at once — each opens in its own tab. The loop is
+ synchronous inside the click handler on purpose: that is what
+ keeps the browser treating all of them as user-initiated. */}
+
+ }
+ disabled={documents.length === 0}
+ onClick={() =>
+ documents.forEach((d) => openFileInNewTab(d.id, d.name))
+ }
+ >
+ Open all {documents.length > 0 && `(${documents.length})`}
+
+
+
- void fetchViewableFile(f.id, f.name).then(view)
- }
+ onClick={() => openFileInNewTab(f.id, f.name)}
size="xs"
style={{
textDecoration:
@@ -1424,7 +1450,6 @@ export default function CustomerDetailPage() {
onClose={() => setChangeRequestDoc(null)}
/>
- {viewer}
);
}
diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx
index 91bb72fdc..3825f0165 100644
--- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx
@@ -28,6 +28,7 @@ import { useNavigate } from "react-router-dom";
import {
CompanyNationalityBadge,
CompanyStatusBadge,
+ ManualRegistrationBadge,
ProfileChips,
formatDate,
} from "@/components/customers";
@@ -142,6 +143,10 @@ export default function CustomersPage() {
{c.name}
+
TIN {c.tin}
diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx
index d837ee712..23dc6b76b 100644
--- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx
@@ -1,7 +1,5 @@
import type { ColumnDef } from "@edr/ui-common";
-import { Box, Button, Card, Container, Group, Modal, Select, Stack, Text, TextInput, Title } from "@mantine/core";
-import { DatePickerInput } from "@mantine/dates";
-import { getDateRangePresets } from "@/components/common/dateRangePresets";
+import { Box, Button, Card, Container, Group, Modal, SegmentedControl, Select, Stack, Text, TextInput, Title } from "@mantine/core";
import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
@@ -13,7 +11,7 @@ import {
FREIGHT_PERMS,
} from "@/lib/permissions";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
-import { Inbox, Plus, Warehouse } from "lucide-react";
+import { Inbox, LayoutGrid, Plus, Table2, Warehouse } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Link, Navigate, useLocation } from "react-router-dom";
@@ -21,13 +19,12 @@ import FleetCardGrid from "@/components/fleet/FleetCardGrid";
import FleetFormDialog from "@/components/fleet/FleetFormDialog";
import FleetHistoryModal from "@/components/fleet/FleetHistoryModal";
import FleetRecordActions from "@/components/fleet/FleetRecordActions";
-import FleetToolbar from "@/components/fleet/FleetToolbar";
import { matchesDayRange } from "@/hooks/useListControls";
import WagonMovementHistoryModal from "@/components/fleet/WagonMovementHistoryModal";
import WagonStatusActions from "@/components/wagons/WagonStatusActions";
import WagonYardWorkspaceModal from "@/components/wagons/WagonYardWorkspaceModal";
import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat";
-import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
+import { useFleetViewMode, type FleetViewMode } from "@/components/fleet/useFleetViewMode";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { useToast } from "@/hooks/use-toast";
import {
@@ -43,11 +40,46 @@ import {
type FleetListFilters,
type FleetRecord,
} from "@/services/fleet/fleet.service";
-import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
-import { useDebouncedValue } from "@mantine/hooks";
+import { DataTable, DataTableFooter } from "@edr/ui-common";
+import { dateRangeParams, FilterBar, useFilters, type FilterDef, type FilterOption } from "@/components/filters";
const DEFAULT_SLUG: FleetResourceSlug = "locomotives";
+const SERVER_FILTERED_SLUGS: FleetResourceSlug[] = ["wagons", "locomotives", "vehicles", "drivers"];
+
+// trains/containers/cargoes have no server-side `listFilters` config (see
+// resources.ts) — they get a plain client-only Status filter instead, off a
+// fixed enum rather than "whatever status happens to exist in the currently
+// loaded rows" (which would create a circular dependency: filterDefs feeds
+// useFilters, which feeds the query that produces those rows).
+const TRAIN_STATUS_OPTIONS: FilterOption[] = [
+ { value: "AVAILABLE", label: "Available" },
+ { value: "SCHEDULED", label: "Scheduled" },
+ { value: "IN_SERVICE", label: "In service" },
+ { value: "UNDER_MAINTENANCE", label: "Under maintenance" },
+ { value: "OUT_OF_SERVICE", label: "Out of service" },
+ { value: "DEACTIVATED", label: "Deactivated" },
+];
+const CONTAINER_STATUS_OPTIONS: FilterOption[] = [
+ { value: "AVAILABLE", label: "Available" },
+ { value: "LOADED", label: "Loaded" },
+ { value: "IN_TRANSIT", label: "In transit" },
+ { value: "MAINTENANCE", label: "Maintenance" },
+ { value: "DAMAGED", label: "Damaged" },
+];
+const CARGO_STATUS_OPTIONS: FilterOption[] = [
+ { value: "PENDING", label: "Pending" },
+ { value: "LOADED", label: "Loaded" },
+ { value: "IN_TRANSIT", label: "In transit" },
+ { value: "DELIVERED", label: "Delivered" },
+ { value: "UNLOADED", label: "Unloaded" },
+];
+const FALLBACK_STATUS_OPTIONS: Partial> = {
+ trains: TRAIN_STATUS_OPTIONS,
+ containers: CONTAINER_STATUS_OPTIONS,
+ cargoes: CARGO_STATUS_OPTIONS,
+};
+
const FleetResourcePage = () => {
const location = useLocation();
const slug = getFleetSlugFromPath(location.pathname) ?? DEFAULT_SLUG;
@@ -70,18 +102,9 @@ const FleetResourcePage = () => {
hasPermission(user, FREIGHT_PERMS.wagons.transferFulfill) ||
hasPermission(user, FREIGHT_PERMS.wagons.transferHistoryAll);
- const { pagination, setPagination } = usePagination({ pageSize: 10 });
- const [search, setSearch] = useState("");
- const [debouncedSearch] = useDebouncedValue(search, 300);
// Wagons and locomotives page in the database; the rest still list in full
// and page in the browser (see `pagedHandlers` in fleet.service).
const serverPaged = isFleetServerPaginated(slug);
- const [statusFilter, setStatusFilter] = useState("ALL");
- // Registration date range. Server-side list filters (status/yard/train) are
- // applied by the API; this narrows what comes back, alongside search.
- const [dateFrom, setDateFrom] = useState(null);
- const [dateTo, setDateTo] = useState(null);
- const [listFilterValues, setListFilterValues] = useState>({});
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState(null);
const [removeTarget, setRemoveTarget] = useState(null);
@@ -95,77 +118,6 @@ const FleetResourcePage = () => {
const [wagonWorkspaceOpen, setWagonWorkspaceOpen] = useState(false);
const { viewMode, setViewMode } = useFleetViewMode(slug);
- const serverListFilters = useMemo((): FleetListFilters | undefined => {
- const serverFilteredSlugs: FleetResourceSlug[] = ["wagons", "locomotives", "vehicles", "drivers"];
- if (!serverFilteredSlugs.includes(slug)) return undefined;
- const filters: FleetListFilters = {};
- const status = listFilterValues.status;
- const currentYardId = listFilterValues.currentYardId;
- const availability = listFilterValues.availability;
- const trainNumber = listFilterValues.trainNumber;
- const trainId = listFilterValues.trainId;
- if (status && status !== "ALL") {
- (filters as { status?: string }).status = status;
- }
- if (currentYardId && currentYardId !== "ALL") {
- filters.currentYardId = currentYardId;
- }
- if (availability && availability !== "ALL") {
- (filters as { availability?: string }).availability = availability;
- }
- if (trainNumber && trainNumber !== "ALL") {
- (filters as { trainNumber?: string }).trainNumber = trainNumber;
- }
- if (trainId && trainId !== "ALL") {
- filters.trainId = trainId;
- }
- // Wagons only: narrow the fleet to one wagon type (the API filters on it).
- const wagonTypeId = listFilterValues.wagonTypeId;
- if (wagonTypeId && wagonTypeId !== "ALL") {
- (filters as { wagonTypeId?: string }).wagonTypeId = wagonTypeId;
- }
- // The plain locomotives list has no server-side search — its page window
- // does, so the term is only sent on the paginated path.
- if ((serverPaged || slug !== "locomotives") && debouncedSearch.trim()) {
- filters.search = debouncedSearch.trim();
- }
- return filters;
- }, [slug, listFilterValues, debouncedSearch, serverPaged]);
-
- // On the server-paged path the page window, the search and the registration
- // date range are all resolved by the API — nothing is filtered client-side.
- const pagedFilters = useMemo(
- (): FleetListFilters => ({
- ...serverListFilters,
- page: pagination.pageIndex + 1,
- pageSize: pagination.pageSize,
- ...(dateFrom ? { createdFrom: dateFrom } : {}),
- ...(dateTo ? { createdTo: dateTo } : {}),
- }),
- [serverListFilters, pagination.pageIndex, pagination.pageSize, dateFrom, dateTo],
- );
-
- const listQuery = useQuery({
- ...api.fleet.list.queryOptions({ input: { slug, filters: serverListFilters } }),
- enabled: !serverPaged,
- });
- const pagedQuery = useQuery({
- ...api.fleet.listPaged.queryOptions({ input: { slug, filters: pagedFilters } }),
- enabled: serverPaged,
- placeholderData: keepPreviousData,
- });
-
- const activeQuery = serverPaged ? pagedQuery : listQuery;
- const { isLoading, isError, error } = activeQuery;
- const allRows = useMemo(
- () => (serverPaged ? (pagedQuery.data?.items ?? []) : (listQuery.data ?? [])),
- [serverPaged, pagedQuery.data, listQuery.data],
- );
- const create = useMutation(api.fleet.create.mutationOptions());
- const update = useMutation(api.fleet.update.mutationOptions());
- const remove = useMutation(api.fleet.remove.mutationOptions());
- const purge = useMutation(api.fleet.purge.mutationOptions());
-
const { data: wagonTypes = [], isLoading: wagonTypesLoading } = useQuery(
api.wagonTypes.list.queryOptions(),
);
@@ -202,40 +154,8 @@ const FleetResourcePage = () => {
enabled: slug === "wagons",
});
- useEffect(() => {
- setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
- setSearch("");
- setStatusFilter("ALL");
- setListFilterValues({});
- }, [slug, setPagination]);
-
- useEffect(() => {
- setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
- }, [search, listFilterValues, dateFrom, dateTo, setPagination]);
-
- const hasStatusColumn = Boolean(config?.columns.some((col) => col.accessorKey === "status"));
const usesServerListFilters = Boolean(config?.listFilters?.length);
- const statusFilterOptions = useMemo(() => {
- if (!hasStatusColumn || usesServerListFilters) return [];
- if (slug === "vehicles" || slug === "drivers") {
- return [
- { value: "ALL", label: "All statuses" },
- { value: "ACTIVE", label: "Active" },
- { value: "INACTIVE", label: "Inactive" },
- ];
- }
- const statuses = new Set(
- allRows
- .map((row) => String((row as unknown as Record).status ?? ""))
- .filter(Boolean),
- );
- return [
- { value: "ALL", label: "All statuses" },
- ...[...statuses].sort().map((status) => ({ value: status, label: status })),
- ];
- }, [allRows, hasStatusColumn, usesServerListFilters, slug]);
-
const dynamicOptions = useMemo(() => {
const wagonTypeOpts = (wagonTypes as Array<{ id: string; code: string; name?: string }>).map(
(t) => ({ value: t.id, label: `${t.code}${t.name ? ` - ${t.name}` : ""}` }),
@@ -291,25 +211,78 @@ const FleetResourcePage = () => {
};
}, [wagonTypes, containerTypes, cargoTypes, truckTypes, wagons, containers, yards, trains]);
- const listFilterSelects = useMemo(() => {
- if (!config?.listFilters?.length) return null;
- return config.listFilters.map((filter) => {
- const dynamicOpts = filter.dynamicOptions
- ? (dynamicOptions[filter.dynamicOptions] ?? [])
- : [];
- const staticOpts =
- filter.options?.map((opt) => ({ value: opt.value, label: opt.label })) ?? [];
- const opts = filter.dynamicOptions ? dynamicOpts : staticOpts;
- return {
- ...filter,
- value: listFilterValues[filter.key] ?? "ALL",
- data: [
- { value: "ALL", label: filter.allLabel ?? `All ${filter.label.toLowerCase()}` },
- ...opts,
- ],
- };
- });
- }, [config?.listFilters, listFilterValues, dynamicOptions]);
+ // One pill per configured server list filter (status/yard/wagon type/train…),
+ // built off `config.listFilters` — same source the old plain `` row
+ // read, just reshaped into FilterDefs. Falls back to a plain client-only
+ // Status filter for the 3 slugs with no server-side list filters at all.
+ const filterDefs: FilterDef[] = useMemo(() => {
+ const dateDef: FilterDef = {
+ key: "created",
+ label: "Registered",
+ type: "date",
+ secondary: true,
+ toParams: dateRangeParams("createdFrom", "createdTo"),
+ };
+ if (config?.listFilters?.length) {
+ return [
+ ...config.listFilters.map((filter): FilterDef => ({
+ key: filter.key,
+ label: filter.label,
+ type: "enum",
+ multiple: false,
+ options: filter.dynamicOptions
+ ? (dynamicOptions[filter.dynamicOptions] ?? [])
+ : (filter.options ?? []),
+ })),
+ dateDef,
+ ];
+ }
+ const fallback = FALLBACK_STATUS_OPTIONS[slug];
+ return fallback
+ ? [{ key: "status", label: "Status", type: "enum", multiple: false, options: fallback }, dateDef]
+ : [dateDef];
+ }, [config, dynamicOptions, slug]);
+
+ const controls = useFilters(filterDefs, { pageSize: 10 });
+
+ // On the server-paged path the page window, the search and the registration
+ // date range are all resolved by the API — nothing is filtered client-side.
+ // `controls.params` already carries every filter's mapped param name (status/
+ // currentYardId/wagonTypeId/… default to `{key: value}`, "created" maps to
+ // createdFrom/createdTo) plus search/page/pageSize — it IS the paged filter
+ // object; the unpaged one is the same minus pagination and the date range
+ // (which stays client-only for the non-server-paged slugs, see below).
+ const serverListFilters = useMemo((): FleetListFilters | undefined => {
+ if (!SERVER_FILTERED_SLUGS.includes(slug)) return undefined;
+ const { page: _page, pageSize: _pageSize, createdFrom: _cf, createdTo: _ct, ...rest } = controls.params;
+ return rest as FleetListFilters;
+ }, [slug, controls.params]);
+
+ const pagedFilters = useMemo(
+ (): FleetListFilters => controls.params as unknown as FleetListFilters,
+ [controls.params],
+ );
+
+ const listQuery = useQuery({
+ ...api.fleet.list.queryOptions({ input: { slug, filters: serverListFilters } }),
+ enabled: !serverPaged,
+ });
+ const pagedQuery = useQuery({
+ ...api.fleet.listPaged.queryOptions({ input: { slug, filters: pagedFilters } }),
+ enabled: serverPaged,
+ placeholderData: keepPreviousData,
+ });
+
+ const activeQuery = serverPaged ? pagedQuery : listQuery;
+ const { isLoading, isError, error } = activeQuery;
+ const allRows = useMemo(
+ () => (serverPaged ? (pagedQuery.data?.items ?? []) : (listQuery.data ?? [])),
+ [serverPaged, pagedQuery.data, listQuery.data],
+ );
+ const create = useMutation(api.fleet.create.mutationOptions());
+ const update = useMutation(api.fleet.update.mutationOptions());
+ const remove = useMutation(api.fleet.remove.mutationOptions());
+ const purge = useMutation(api.fleet.purge.mutationOptions());
useEffect(() => {
registerFleetOptionLabels("wagonTypeId", dynamicOptions.wagonTypes);
@@ -349,16 +322,22 @@ const FleetResourcePage = () => {
// The API already applied every filter and cut the page — re-filtering here
// would drop rows the server deliberately returned.
if (serverPaged) return allRows;
- const term = search.trim().toLowerCase();
return allRows.filter((row) => {
const record = row as unknown as Record;
// The date range applies even when the API already filtered the list —
// it is not one of the server-side filters.
- if (!matchesDayRange(record.createdAt, dateFrom, dateTo)) return false;
- if (usesServerListFilters) return true;
- if (statusFilter !== "ALL" && String(record.status ?? "") !== statusFilter) {
+ const created = controls.values.created;
+ if (created && !matchesDayRange(record.createdAt, created.v[0]?.slice(0, 10) ?? null, created.v[1]?.slice(0, 10) ?? null)) {
return false;
}
+ // Every other filter (status/yard/wagon type/…) was already applied
+ // server-side for these slugs — re-checking here against a plain field
+ // equality would be wrong for one (a wagon's "trainNumber" filter
+ // matches either of two DIFFERENT columns server-side, not one).
+ if (usesServerListFilters) return true;
+ const status = controls.values.status;
+ if (status && String(record.status ?? "") !== status.v[0]) return false;
+ const term = controls.searchText.trim().toLowerCase();
if (!term) return true;
return config.searchKeys.some((key) =>
String(record[key] ?? "")
@@ -366,19 +345,23 @@ const FleetResourcePage = () => {
.includes(term),
);
});
- }, [allRows, search, statusFilter, config, usesServerListFilters, dateFrom, dateTo, serverPaged]);
+ }, [allRows, config, usesServerListFilters, controls.values, controls.searchText, serverPaged]);
const totalCount = serverPaged
? (pagedQuery.data?.meta.total ?? 0)
: filteredRows.length;
const pageCount = serverPaged
? Math.max(1, pagedQuery.data?.meta.totalPages ?? 1)
- : Math.max(1, Math.ceil(filteredRows.length / pagination.pageSize));
+ : Math.max(1, Math.ceil(filteredRows.length / controls.pageSize));
const pagedRows = useMemo(() => {
if (serverPaged) return filteredRows;
- const start = pagination.pageIndex * pagination.pageSize;
- return filteredRows.slice(start, start + pagination.pageSize);
- }, [filteredRows, pagination.pageIndex, pagination.pageSize, serverPaged]);
+ const start = (controls.page - 1) * controls.pageSize;
+ return filteredRows.slice(start, start + controls.pageSize);
+ }, [filteredRows, controls.page, controls.pageSize, serverPaged]);
+
+ // Same {pagination, tableOptions} shape DataTable takes directly; FleetCardGrid
+ // (not a DataTable) just needs the raw pieces out of it below.
+ const { pagination: dtPagination, tableOptions: dtTableOptions } = controls.tableProps(totalCount);
const columns = useMemo((): ColumnDef[] => {
if (!config) return [];
@@ -604,77 +587,41 @@ const FleetResourcePage = () => {
-
- {
- setDateFrom(from);
- setDateTo(to);
- }}
- presets={getDateRangePresets()}
- clearable
- size="sm"
- radius="lg"
- w={240}
- />
- {listFilterSelects ? (
-
- {listFilterSelects.map((filter) => (
- {
- setListFilterValues((prev) => ({
- ...prev,
- [filter.key]: value ?? "ALL",
- }));
- setPagination((prev) => ({ ...prev, pageIndex: 0 }));
- }}
- size="sm"
- radius="lg"
- w={200}
- searchable={filter.data.length > 8}
- comboboxProps={{ withinPortal: true }}
- styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
- />
- ))}
-
- ) : hasStatusColumn && statusFilterOptions.length > 1 ? (
-
- Status:
-
- {[{ value: "ALL", label: "All" }, ...statusFilterOptions].map((option) => (
- setStatusFilter(option.value)}
- >
- {option.label}
-
- ))}
-
-
- ) : null}
-
- }
- />
+ viewId={`fleet-${slug}`}
+ >
+ setViewMode(value as FleetViewMode)}
+ size="sm"
+ radius="lg"
+ data={[
+ {
+ value: "table",
+ label: (
+
+
+ Table
+
+ ),
+ },
+ {
+ value: "cards",
+ label: (
+
+
+ Cards
+
+ ),
+ },
+ ]}
+ styles={{ root: { background: "var(--mantine-color-gray-1)" } }}
+ />
+
{viewMode === "table" ? (
@@ -696,18 +643,8 @@ const FleetResourcePage = () => {
: undefined
}
emptyMessage={`No ${itemLabel} found`}
- pagination={{
- pageIndex: pagination.pageIndex,
- pageSize: pagination.pageSize,
- pageCount,
- totalCount,
- }}
- tableOptions={{
- manualPagination: true,
- pageCount,
- state: { pagination },
- onPaginationChange: setPagination,
- }}
+ pagination={dtPagination}
+ tableOptions={dtTableOptions}
containerClassName="border-0 shadow-none bg-transparent"
footer={({ table, pagination: footerPagination }) => (
{
rows={pagedRows}
status={tableStatus}
emptyMessage={`No ${itemLabel} found`}
- pagination={pagination}
+ pagination={{ pageIndex: controls.page - 1, pageSize: controls.pageSize }}
pageCount={pageCount}
totalCount={totalCount}
- onPaginationChange={setPagination}
+ onPaginationChange={dtTableOptions!.onPaginationChange!}
onEdit={
canUpdate
? (record) => {
diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/FinanceHubPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/FinanceHubPage.tsx
index d5ac83987..6465be167 100644
--- a/apps/edr-freight-web/backoffice/src/pages/invoices/FinanceHubPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/invoices/FinanceHubPage.tsx
@@ -1,8 +1,9 @@
import { Tabs } from "@mantine/core";
-import { Landmark, Receipt } from "lucide-react";
+import { Banknote, DollarSign, Receipt } from "lucide-react";
import { useSearchParams } from "react-router-dom";
import { useAuth } from "@/auth/useAuth";
+import { useManualPaymentSettingsQuery } from "@/hooks/useManualPaymentSettings";
import { PageContainer, PageHeader } from "@/components/page";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
@@ -16,7 +17,9 @@ import UsdPaymentsPanel from "./UsdPaymentsPage";
* before, and just doesn't render if the user lacks it.
*
* The Payments tab was removed; its summary (total collected, ETB/USD) now
- * lives as a card at the top of the Invoices tab instead.
+ * lives as a card at the top of the Invoices tab instead. Manual payments are
+ * split into one tab per currency — ETB keeps the original `?tab=manual-payments`
+ * key so existing links and the old redirect still land somewhere valid.
*/
const TABS = [
{
@@ -30,13 +33,25 @@ const TABS = [
},
{
key: "manual-payments",
- label: "Manual Payments",
- icon: Landmark,
+ label: "Manual Payments (ETB)",
+ icon: Banknote,
// Same gate as Invoices, not a dedicated key — mirrors the old route.
permission: FREIGHT_PERMS.invoices.view,
+ /** Hidden unless manual settlement is switched on for this currency. */
+ manualCurrency: "ETB",
subtitle:
- "Import and export invoices in USD or ETB that Finance settles by hand (bank transfer or counter). Upload the customer's slip and confirm the payment before the pay window closes.",
- Panel: UsdPaymentsPanel,
+ "Import and export invoices in ETB that Finance settles by hand (bank transfer or counter). Upload the customer's slip and confirm the payment before the pay window closes.",
+ Panel: () => ,
+ },
+ {
+ key: "manual-payments-usd",
+ label: "Manual Payments (USD)",
+ icon: DollarSign,
+ permission: FREIGHT_PERMS.invoices.view,
+ manualCurrency: "USD",
+ subtitle:
+ "Import and export invoices in USD that Finance settles by hand (bank transfer or counter). Upload the customer's slip and confirm the payment before the pay window closes.",
+ Panel: () => ,
},
] as const;
@@ -46,7 +61,18 @@ export default function FinanceHubPage() {
const { user } = useAuth();
const [searchParams, setSearchParams] = useSearchParams();
- const visibleTabs = TABS.filter((tab) => hasPermission(user, tab.permission));
+ // A currency whose manual-payment channel is switched off has no tab at all
+ // — the list would be empty and every confirmation refused.
+ const { data: manualSettings } = useManualPaymentSettingsQuery();
+ const manualEnabled = (currency: "ETB" | "USD") =>
+ !manualSettings ||
+ (currency === "ETB" ? manualSettings.etbEnabled : manualSettings.usdEnabled);
+
+ const visibleTabs = TABS.filter(
+ (tab) =>
+ hasPermission(user, tab.permission) &&
+ (!("manualCurrency" in tab) || manualEnabled(tab.manualCurrency)),
+ );
const requested = searchParams.get("tab");
const active: TabKey =
visibleTabs.find((tab) => tab.key === requested)?.key ??
diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/UsdPaymentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/UsdPaymentsPage.tsx
index 93098ff32..5b4da0f43 100644
--- a/apps/edr-freight-web/backoffice/src/pages/invoices/UsdPaymentsPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/invoices/UsdPaymentsPage.tsx
@@ -27,6 +27,7 @@ import {
} from "@/components/customers";
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
import { useAuth } from "@/auth/useAuth";
+import { useManualPaymentSettingsQuery } from "@/hooks/useManualPaymentSettings";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { api } from "@/services/api";
import type { OfflineUsdInvoice } from "@/types/invoice";
@@ -144,7 +145,11 @@ function ConfirmCell({
* Finance settles by hand; confirming records the payment the same way an
* online payment would, so the booking advances identically.
*/
-export default function UsdPaymentsPanel() {
+export default function UsdPaymentsPanel({
+ currency,
+}: {
+ currency: "USD" | "ETB";
+}) {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
@@ -152,7 +157,6 @@ export default function UsdPaymentsPanel() {
const [statusFilter, setStatusFilter] = useState<"" | Freight.InvoiceStatus>(
"",
);
- const [currency, setCurrency] = useState<"" | "USD" | "ETB">("");
const [confirming, setConfirming] = useState(null);
const [slip, setSlip] = useState(null);
const [reference, setReference] = useState("");
@@ -163,13 +167,23 @@ export default function UsdPaymentsPanel() {
FREIGHT_PERMS.invoices.confirmOffline,
);
+ // Manual settlement is switched on per currency in Configuration → Manual
+ // payments. FinanceHubPage hides the tab for a disabled currency; this is
+ // the fallback for a direct `?tab=` link, and the API refuses regardless.
+ const { data: manualSettings } = useManualPaymentSettingsQuery();
+ const currencyEnabled = manualSettings
+ ? currency === "ETB"
+ ? manualSettings.etbEnabled
+ : manualSettings.usdEnabled
+ : true;
+
const filter = useMemo(
() => ({
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
search: debouncedQuery,
status: statusFilter || undefined,
- currency: currency || undefined,
+ currency,
}),
[
pagination.pageIndex,
@@ -180,9 +194,10 @@ export default function UsdPaymentsPanel() {
],
);
- const { data, isLoading, isError, refetch, isFetching } = useQuery(
- api.invoices.listOfflineUsd.queryOptions({ input: { filter } }),
- );
+ const { data, isLoading, isError, refetch, isFetching } = useQuery({
+ ...api.invoices.listOfflineUsd.queryOptions({ input: { filter } }),
+ enabled: currencyEnabled,
+ });
const confirm = useMutation(api.invoices.confirmOffline.mutationOptions());
@@ -289,20 +304,6 @@ export default function UsdPaymentsPanel() {
);
},
},
- {
- id: "currency",
- header: "Currency",
- cell: ({ row }) => (
-
- {row.original.currency}
-
- ),
- },
{
id: "status",
header: "Status",
@@ -342,11 +343,12 @@ export default function UsdPaymentsPanel() {
meta: { headerClassName: "text-right", cellClassName: "text-right" },
cell: ({ row }) => {
if (row.original.status === "PAID" || !canConfirm) return null;
+ if (!currencyEnabled) return null;
return ;
},
},
],
- [canConfirm, navigate],
+ [canConfirm, currencyEnabled, navigate],
);
return (
@@ -376,20 +378,6 @@ export default function UsdPaymentsPanel() {
style={{ flex: 1, minWidth: "240px" }}
radius="lg"
/>
- {
- setCurrency(v === "all" ? "" : (v as "USD" | "ETB"));
- setPagination((prev) => ({ ...prev, pageIndex: 0 }));
- }}
- data={[
- { label: "All", value: "all" },
- { label: "ETB", value: "ETB" },
- { label: "USD", value: "USD" },
- ]}
- />
navigate(`/dashboard/invoices/${row.id}`)}
emptyMessage={
- debouncedQuery
- ? "No invoices match your search."
- : "No invoices awaiting manual payment confirmation."
+ !currencyEnabled
+ ? `Manual payment is switched off for ${currency} invoices. Enable it in Configuration → Manual payments.`
+ : debouncedQuery
+ ? "No invoices match your search."
+ : `No ${currency} invoices awaiting manual payment confirmation.`
}
error={
isError
diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx
index 688ed3ce2..a83a90fb4 100644
--- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx
@@ -26,6 +26,7 @@ import { PageContainer, PageHeader } from "@/components/page";
import ManageRuleEngineOrderDialog from "@/components/ruleEngine/ManageRuleEngineOrderDialog";
import PriorityRuleApprovalsSection from "@/pages/ruleEngine/PriorityRuleApprovalsSection";
import RateApprovalsSection from "@/pages/ruleEngine/RateApprovalsSection";
+import { YardDesksModal } from "@/pages/ruleEngine/YardDesksModal";
import { nextPriorityRangeStart } from "@/pages/ruleEngine/priorityRuleRange";
import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid";
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
@@ -167,6 +168,10 @@ const RuleEngineResourcePage = () => {
null,
);
const [chainOpen, setChainOpen] = useState(false);
+ // Yards only: which desks work at this yard (input to yard access scoping).
+ const [desksYard, setDesksYard] = useState | null>(
+ null,
+ );
const [orderDialogOpen, setOrderDialogOpen] = useState(false);
const { viewMode, setViewMode } = useRuleEngineViewMode(
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
@@ -566,6 +571,17 @@ const RuleEngineResourcePage = () => {
cell: ({ row }) => (
- {/* Stats Cards */}
+ {/* Stats — counts come from the API under the active filters, not the visible page */}
Total Logs
-
{stats.total}
+
{total}
Created
-
{stats.creates}
+
{creates.data?.total ?? '—'}
Updated
-
{stats.updates}
+
{updates.data?.total ?? '—'}
Deleted
-
{stats.deletes}
+
{deletes.data?.total ?? '—'}
{/* Filters */}
-
-
-
Search (User/Entity ID)
+
+
+ Search (User / Entity ID)
setFilters({ ...filters, search: e.target.value })}
@@ -206,11 +276,11 @@ export default function AuditLogsPage() {
onChange={(e) => setFilters({ ...filters, action: e.target.value })}
>
All Actions
- Create
- Update
- Delete
- Login
- Logout
+ {actionOptions.map((a) => (
+
+ {a}
+
+ ))}
@@ -221,55 +291,42 @@ export default function AuditLogsPage() {
onChange={(e) => setFilters({ ...filters, entityType: e.target.value })}
>
All Types
-
- Station
- Route
- Route Stop
- Train
- Train Schedule
- Coach
- Coach Type
- Seat Class
- Fare Rule
- Route Fare Rule
- Segment Fare Rule
- Baggage Allowance
-
-
- Booking
- Payment
- Ticket
- Seat
- Seat Block
-
-
- User
- Agent
- Passenger
-
-
- Notification
- Promotion
- Loyalty
- Wallet
- Fraud Alert
- Fraud Rule
-
+ {entityTypeOptions.map((t) => (
+
+ {t}
+
+ ))}
-
-
setFilters({ search: '', action: '', entityType: '' })}
- className="w-full"
- >
- Clear Filters
-
+
+ From
+ setFilters({ ...filters, from: e.target.value })}
+ />
+
+ To
+ setFilters({ ...filters, to: e.target.value })}
+ />
+
+
+
+
setFilters({ search: '', action: '', entityType: '', from: '', to: '' })}
+ >
+ Clear Filters
+
- {/* Data Table */}
+ {/* Pagination */}
+
+
+ {total === 0
+ ? 'No results'
+ : `Showing ${page * PAGE_SIZE + 1}–${Math.min((page + 1) * PAGE_SIZE, total)} of ${total}`}
+
+
+
setPage((p) => Math.max(0, p - 1))}
+ >
+ Previous
+
+
+ Page {page + 1} of {pageCount}
+
+
= pageCount}
+ onClick={() => setPage((p) => p + 1)}
+ >
+ Next
+
+
+
+
{/* Details Modal */}
{ setShowDetailsModal(false); setSelectedLog(null); }}
+ onClose={() => {
+ setShowDetailsModal(false);
+ setSelectedLog(null);
+ }}
title="Audit Log Details"
size="xl"
>
- {selectedLog && (() => {
- const l = selectedLog;
- const actionColor: Record = {
- CREATE: 'from-emerald-600 to-emerald-700',
- UPDATE: 'from-blue-600 to-blue-700',
- DELETE: 'from-red-600 to-red-700',
- LOGIN: 'from-violet-600 to-violet-700',
- LOGOUT: 'from-gray-600 to-gray-700',
- };
- const gradient = actionColor[l.action] || 'from-gray-600 to-gray-700';
+ {selectedLog &&
+ (() => {
+ const l: AuditLog = selectedLog;
+ const gradient = CREATIVE_ACTIONS.has(l.action)
+ ? 'from-emerald-600 to-emerald-700'
+ : DESTRUCTIVE_ACTIONS.has(l.action)
+ ? 'from-red-600 to-red-700'
+ : 'from-blue-600 to-blue-700';
- const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
-
-
{label}
-
{value || '—'}
-
- );
-
- const SectionHeader = ({ title }: { title: string }) => (
-
- {title}
-
- );
-
- return (
-
-
-
-
-
-
{l.entityType}
-
{formatDateTime(l.createdAt)}
-
-
-
-
-
User
-
{l.user?.fullName || 'System'}
-
-
-
IP Address
-
{l.ipAddress || 'N/A'}
-
-
+ const Field = ({
+ label,
+ value,
+ mono = false,
+ truncate = false,
+ }: {
+ label: string;
+ value?: string;
+ mono?: boolean;
+ truncate?: boolean;
+ }) => (
+
+
{label}
+
+ {value || '—'}
+
+ );
-
-
-
-
-
-
-
-
+ const SectionHeader = ({ title }: { title: string }) => (
+
+
+ {title}
+
+ );
+
+ return (
+
+
+
+
+
+
+ {l.entityType}
+
+
{formatDateTime(l.createdAt)}
+
-
+
+
+
User
+
{actorName(l)}
+
+
+
IP Address
+
{l.ipAddress || 'N/A'}
+
+
+
+
+
+
- {l.user && (
- )}
- {(l.ipAddress || l.userAgent) && (
-
-
-
-
-
-
User Agent
-
{l.userAgent || '—'}
+ {(l.ipAddress || l.userAgent) && (
+
+
+
+
+
+
User Agent
+
+ {l.userAgent || '—'}
+
+
-
-
- )}
+
+ )}
+
+ {(l.oldData || l.newData) && (
+
+
+
+ {l.oldData && (
+
+
+ ← Before
+
+
+ {formatJsonData(l.oldData)}
+
+
+ )}
+ {l.newData && (
+
+
+ → After
+
+
+ {formatJsonData(l.newData)}
+
+
+ )}
+
+
+ )}
- {(l.oldData || l.newData) && (
-
-
- {l.oldData && (
-
-
← Before
-
- {formatJsonData(l.oldData)}
-
-
- )}
- {l.newData && (
-
-
→ After
-
- {formatJsonData(l.newData)}
-
-
- )}
+
+
+
- )}
+
-
+
+
{
+ setShowDetailsModal(false);
+ setSelectedLog(null);
+ }}
+ >
+ Close
+
+
-
-
-
{ setShowDetailsModal(false); setSelectedLog(null); }}>Close
-
-
- );
- })()}
+ );
+ })()}
);
}
+
+export default function AuditLogsPage() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/edr-passenger-web/backoffice/src/app/login/page.tsx b/apps/edr-passenger-web/backoffice/src/app/login/page.tsx
index 4f0098792..899e8cd11 100644
--- a/apps/edr-passenger-web/backoffice/src/app/login/page.tsx
+++ b/apps/edr-passenger-web/backoffice/src/app/login/page.tsx
@@ -20,13 +20,15 @@ const features = [
];
export default function LoginPage() {
- const [email, setEmail] = useState('');
+ // Accepts an email address, phone number, or username — sent to the IAM in
+ // the `email` field either way (the backend contract does not change).
+ const [identifier, setIdentifier] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [isMounted, setIsMounted] = useState(false);
- const [emailFocused, setEmailFocused] = useState(false);
+ const [identifierFocused, setIdentifierFocused] = useState(false);
const [passwordFocused, setPasswordFocused] = useState(false);
const [view, setView] = useState<'login' | 'forgot'>('login');
@@ -47,7 +49,7 @@ export default function LoginPage() {
setLoading(true);
setError('');
try {
- await login(email, password);
+ await login(identifier.trim(), password);
router.push('/dashboard');
} catch (err: any) {
const msg = err.message || err.response?.data?.message || '';
@@ -152,26 +154,29 @@ export default function LoginPage() {
- {/* Email field */}
+ {/* Identifier field — email, phone number, or username */}
@@ -209,7 +214,7 @@ export default function LoginPage() {
{ setView('forgot'); setForgotIdentifier(email); setError(''); }}
+ onClick={() => { setView('forgot'); setForgotIdentifier(identifier); setError(''); }}
className="text-xs font-medium text-[rgb(20,113,76)] hover:underline"
>
Forgot password?
@@ -220,9 +225,9 @@ export default function LoginPage() {
{/* Submit */}
{
- const query = new URLSearchParams(params as Record).toString();
+ // Drop empty filters so a blank search box doesn't send `search=` and match nothing.
+ const entries = Object.entries(params ?? {}).filter(
+ ([, v]) => v !== undefined && v !== null && v !== '',
+ );
+ const query = new URLSearchParams(entries as [string, string][]).toString();
const response = await apiClient.get(`/audit/logs${query ? `?${query}` : ''}`);
if (response?.data) {
return Array.isArray(response.data) ? { items: response.data } : response;
@@ -388,6 +392,7 @@ export const auditApi = {
return Array.isArray(response) ? { items: response } : response;
},
getLog: (id: string) => apiClient.get(`/audit/logs/${id}`),
+ getVocabulary: () => apiClient.get('/audit/vocabulary'),
};
// Live Tracking API
diff --git a/apps/edr-passenger-web/backoffice/src/lib/auth-store.ts b/apps/edr-passenger-web/backoffice/src/lib/auth-store.ts
index 05e47039d..aadd86057 100644
--- a/apps/edr-passenger-web/backoffice/src/lib/auth-store.ts
+++ b/apps/edr-passenger-web/backoffice/src/lib/auth-store.ts
@@ -18,7 +18,8 @@ interface AuthState {
token: string | null;
refreshToken: string | null;
isAuthenticated: boolean;
- login: (email: string, password: string) => Promise;
+ /** `identifier` may be an email, phone number or username — always sent as `email`. */
+ login: (identifier: string, password: string) => Promise;
logout: () => void;
setUser: (user: AdminUser, token: string) => void;
initialize: () => void;
@@ -52,9 +53,10 @@ export const useAuthStore = create((set, get) => ({
}
},
- login: async (email: string, password: string) => {
- // Step 1: IAM login — returns token + refreshToken only
- const loginRes = await axios.post(`${API_URL}/v1/auth/login`, { email, password });
+ login: async (identifier: string, password: string) => {
+ // Step 1: IAM login — returns token + refreshToken only.
+ // The IAM accepts an email, phone number or username in the `email` field.
+ const loginRes = await axios.post(`${API_URL}/v1/auth/login`, { email: identifier, password });
const loginData = loginRes.data?.data ?? loginRes.data;
const { token, refreshToken } = loginData;
if (!token) throw new Error('No token received from server');
diff --git a/apps/edr-passenger-web/backoffice/src/types/edr.ts b/apps/edr-passenger-web/backoffice/src/types/edr.ts
index 1b5ca7a1e..fa864eb71 100644
--- a/apps/edr-passenger-web/backoffice/src/types/edr.ts
+++ b/apps/edr-passenger-web/backoffice/src/types/edr.ts
@@ -301,7 +301,11 @@ export interface FraudRule {
// Audit Types
export interface AuditLog {
id: string;
- userId?: string;
+ /** IAM id of the staff member who performed the action. The API field is `iamUserId`. */
+ iamUserId?: string;
+ /** Actor's name and phone, denormalized by the API at write time. */
+ userName?: string;
+ userPhone?: string;
action: string;
entityType: string;
entityId?: string;
diff --git a/apps/edr-passenger-web/portal/src/app/excess-baggage/pay/[token]/page.tsx b/apps/edr-passenger-web/portal/src/app/excess-baggage/pay/[token]/page.tsx
index 209651756..73976fcda 100644
--- a/apps/edr-passenger-web/portal/src/app/excess-baggage/pay/[token]/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/excess-baggage/pay/[token]/page.tsx
@@ -1,14 +1,17 @@
"use client";
-import { useMemo, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useQuery, useMutation } from "@tanstack/react-query";
import { apiClient } from "@/lib/api-client";
import { PaymentMethod } from "@/types";
import {
AlertCircle,
+ Check,
CheckCircle,
+ Copy,
CreditCard,
+ KeyRound,
Landmark,
Loader2,
Smartphone,
@@ -22,6 +25,28 @@ const getIconForMethod = (methodId: string) => {
return Smartphone;
};
+// WALLET is an internal balance debit with no excess-baggage path — the API refuses it, so it is
+// never offered here.
+const UNSUPPORTED_METHODS = ["WALLET"];
+
+// Push-debit methods charge an account we must know before initiating: CAC Bank SMSes a one-time
+// password to it, eBirr pushes a USSD PIN prompt to it. Neither opens a hosted page that could
+// collect the number afterwards, so it is asked for up front.
+const requiresPayerMobile = (method: string | null) =>
+ method === "CAC_BANK" || method === "EBIRR";
+
+// DJF has no minor unit; ETB and USD are quoted to cents. Matches the API's charge-side rounding,
+// so the quote renders exactly the figure the provider will debit.
+const formatAmount = (amount: number, currency: string) =>
+ amount.toFixed(currency.toUpperCase() === "DJF" ? 0 : 2);
+
+interface AmountQuote {
+ chargeId: string;
+ method: string;
+ currency: string;
+ amount: number;
+}
+
export default function ExcessBaggagePayPage() {
const { token } = useParams<{ token: string }>();
const router = useRouter();
@@ -29,6 +54,26 @@ export default function ExcessBaggagePayPage() {
const [isProcessing, setIsProcessing] = useState(false);
const [paymentError, setPaymentError] = useState(null);
+ // Push-debit (CAC Bank / eBirr): collect the payer's mobile before initiating, then — for CAC —
+ // the OTP the bank SMSes to it.
+ const [phoneModalOpen, setPhoneModalOpen] = useState(false);
+ const [payerMobile, setPayerMobile] = useState("");
+ const [phoneError, setPhoneError] = useState(null);
+ const [otpModalOpen, setOtpModalOpen] = useState(false);
+ const [otpCode, setOtpCode] = useState("");
+ const [otpMessage, setOtpMessage] = useState(null);
+ const [otpError, setOtpError] = useState(null);
+ const [pushMessage, setPushMessage] = useState(null);
+
+ // CBE bill: no redirect and no OTP — the payer walks away with a bill number and pays it at a
+ // branch/app later, so the page shows the number and watches for settlement.
+ const [billAction, setBillAction] = useState<{
+ billReference: string;
+ instructions?: string;
+ expiresAt?: string;
+ } | null>(null);
+ const [billCopied, setBillCopied] = useState(false);
+
const { data: charge, isLoading: loadingCharge, error: chargeError } = useQuery({
queryKey: ["excessBaggageCharge", token],
queryFn: () => apiClient.get(`/excess-baggage/pay/${token}`),
@@ -45,24 +90,104 @@ export default function ExcessBaggagePayPage() {
enabled: !!charge,
});
- const amountDisplay = useMemo(() => {
- const amountMinor = Number(charge?.totalMinor ?? charge?.amountMinor ?? 0);
- return (amountMinor / 100).toFixed(2);
- }, [charge]);
+ const availableMethods = useMemo(
+ () =>
+ paymentMethods.filter(
+ (m) => m.enabled && !UNSUPPORTED_METHODS.includes(m.type),
+ ),
+ [paymentMethods],
+ );
- const currency = charge?.currency ?? charge?.booking?.currency ?? "ETB";
+ // The charge is always booked in ETB; this is what it costs before a method is chosen.
+ const chargeCurrency = charge?.currency ?? charge?.booking?.currency ?? "ETB";
+ const chargeAmount = useMemo(
+ () => Number(charge?.totalMinor ?? charge?.amountMinor ?? 0) / 100,
+ [charge],
+ );
+
+ // Each method settles in its own currency (WAAFI/DMONEY in DJF, CARD in USD, Ethiopian wallets
+ // in ETB), so the price has to be re-quoted server-side whenever the selection changes — the
+ // stored ETB total is not what a Djiboutian wallet would debit.
+ const {
+ data: quote,
+ isFetching: fetchingQuote,
+ error: quoteError,
+ } = useQuery({
+ queryKey: ["excessBaggageAmount", token, selectedMethod],
+ queryFn: () =>
+ apiClient.get(
+ `/excess-baggage/pay/${token}/amount?method=${selectedMethod}`,
+ ),
+ enabled: !!token && !!selectedMethod,
+ retry: false,
+ staleTime: 30_000,
+ });
+
+ // A quote is only usable once it belongs to the method currently selected — otherwise it is a
+ // leftover from the previous selection and would price the payment in the wrong currency.
+ const quoteReady = !fetchingQuote && quote?.method === selectedMethod;
+
+ const displayCurrency = selectedMethod
+ ? (quote?.currency ?? "")
+ : chargeCurrency;
+ const displayAmount = selectedMethod ? quote?.amount : chargeAmount;
+ const amountLabel =
+ quoteReady && displayAmount != null
+ ? `${displayCurrency} ${formatAmount(displayAmount, displayCurrency)}`
+ : !selectedMethod && displayAmount != null
+ ? `${chargeCurrency} ${formatAmount(displayAmount, chargeCurrency)}`
+ : null;
+
+ // Never let Pay fire against a price the payer has not been shown.
+ const awaitingQuote = !!selectedMethod && !quoteReady;
const payMutation = useMutation({
- mutationFn: (method: string) =>
+ mutationFn: (vars: { method: string; payerAccount?: string }) =>
apiClient.post(`/excess-baggage/pay/${token}/initiate`, {
- method,
+ method: vars.method,
platform: "web",
+ ...(vars.payerAccount ? { payerAccount: vars.payerAccount } : {}),
}),
onSuccess: (data: any) => {
- if (data?.clientAction?.type === "REDIRECT") {
- window.location.href = data.clientAction.url;
+ const action = data?.clientAction;
+
+ if (action?.type === "REDIRECT") {
+ window.location.href = action.url;
return;
}
+
+ // CAC Bank: no redirect — the bank SMS'd an OTP. Collect it here and confirm.
+ if (action?.type === "COLLECT_OTP") {
+ setOtpMessage(action.message ?? "Enter the OTP sent to your phone");
+ setOtpCode("");
+ setOtpError(null);
+ setOtpModalOpen(true);
+ setIsProcessing(false);
+ return;
+ }
+
+ // CBE: the bill now exists in CBE's system. Nothing to navigate to — show the number.
+ if (action?.type === "SHOW_BILL_REFERENCE") {
+ setBillAction({
+ billReference: action.billReference,
+ instructions: action.instructions,
+ expiresAt: action.expiresAt,
+ });
+ setBillCopied(false);
+ setIsProcessing(false);
+ return;
+ }
+
+ // eBirr: the PIN prompt was pushed to the payer's handset; there is nothing to navigate to.
+ if (action?.type === "AWAIT_PUSH") {
+ setPushMessage(
+ action.message ??
+ `Approve the payment on your phone${action.payerAccountMasked ? ` (${action.payerAccountMasked})` : ""}.`,
+ );
+ setIsProcessing(false);
+ return;
+ }
+
router.push(`/excess-baggage/pay/${token}/result`);
},
onError: (err: any) => {
@@ -71,11 +196,92 @@ export default function ExcessBaggagePayPage() {
},
});
- const handlePay = () => {
+ // CAC Bank OTP confirmation. A 200 means the debit settled; a 400 is a wrong/expired OTP —
+ // keep the modal open so the payer can re-enter it (the intent stays open).
+ const otpMutation = useMutation({
+ mutationFn: (otp: string) =>
+ apiClient.post(`/excess-baggage/pay/${token}/confirm`, { otp }),
+ onSuccess: () => {
+ setOtpModalOpen(false);
+ router.push(`/excess-baggage/pay/${token}/result`);
+ },
+ onError: (err: any) => {
+ setOtpError(
+ err?.response?.data?.message ??
+ err?.message ??
+ "Invalid or expired OTP. Please try again.",
+ );
+ },
+ });
+
+ const startPayment = (mobile?: string) => {
if (!selectedMethod) return;
setIsProcessing(true);
setPaymentError(null);
- payMutation.mutate(selectedMethod);
+ payMutation.mutate({
+ method: selectedMethod,
+ payerAccount: requiresPayerMobile(selectedMethod)
+ ? mobile?.trim()
+ : undefined,
+ });
+ };
+
+ const handlePay = () => {
+ if (!selectedMethod || awaitingQuote) return;
+ setPaymentError(null);
+
+ if (requiresPayerMobile(selectedMethod)) {
+ // Prefill with the number the charge was raised against, but leave it editable — the
+ // handset paying is often not the one the booking was made under.
+ if (!payerMobile.trim() && charge?.contactPhone) {
+ setPayerMobile(charge.contactPhone);
+ }
+ setPhoneError(null);
+ setPhoneModalOpen(true);
+ return;
+ }
+
+ startPayment();
+ };
+
+ const submitPhone = () => {
+ if (!payerMobile.trim()) {
+ setPhoneError("Please enter your mobile number");
+ return;
+ }
+ setPhoneModalOpen(false);
+ startPayment(payerMobile);
+ };
+
+ // While a bill or a pushed PIN prompt is outstanding, watch the charge. Settlement happens
+ // server-side — a CBE teller, or the provider's webhook — so the browser has no other signal.
+ // Success is only ever claimed from this, never from a client-side guess.
+ const watching = !!billAction || !!pushMessage;
+ const { data: liveStatus } = useQuery<{ status: string; paid: boolean }>({
+ queryKey: ["excessBaggageStatus", token],
+ queryFn: () =>
+ apiClient.get<{ status: string; paid: boolean }>(
+ `/excess-baggage/pay/${token}/status`,
+ ),
+ enabled: !!token && watching,
+ refetchInterval: 5_000,
+ });
+
+ useEffect(() => {
+ if (watching && liveStatus?.paid) {
+ router.push(`/excess-baggage/pay/${token}/result`);
+ }
+ }, [watching, liveStatus?.paid, router, token]);
+
+ const copyBillReference = async () => {
+ if (!billAction) return;
+ try {
+ await navigator.clipboard.writeText(billAction.billReference);
+ setBillCopied(true);
+ setTimeout(() => setBillCopied(false), 2000);
+ } catch {
+ /* clipboard unavailable — the number is still shown on screen */
+ }
};
if (loadingCharge) {
@@ -112,10 +318,25 @@ export default function ExcessBaggagePayPage() {
Amount due
-
- {currency} {amountDisplay}
-
+ {amountLabel ? (
+ {amountLabel}
+ ) : quoteError ? (
+ —
+ ) : (
+
+ )}
+ {selectedMethod && quoteReady && displayCurrency !== chargeCurrency && (
+
+ Converted from {chargeCurrency} {formatAmount(chargeAmount, chargeCurrency)} at today's rate
+
+ )}
+ {quoteError && (
+
+ {(quoteError as any)?.response?.data?.message ??
+ "This payment method is unavailable right now. Please choose another."}
+
+ )}
Weight
{charge.excessWeightKg ?? "—"} kg
@@ -131,7 +352,7 @@ export default function ExcessBaggagePayPage() {
) : (
- {paymentMethods.filter((m) => m.enabled).map((method) => {
+ {availableMethods.map((method) => {
const Icon = getIconForMethod(method.type);
const isSelected = selectedMethod === method.type;
return (
@@ -166,17 +387,181 @@ export default function ExcessBaggagePayPage() {
{isProcessing ? (
Processing...
+ ) : quoteError ? (
+ "Choose another payment method"
+ ) : awaitingQuote ? (
+
+ Calculating amount...
+
) : (
- `Pay ${currency} ${amountDisplay}`
+ `Pay ${amountLabel ?? ""}`.trim()
)}
+
+ {/* CBE bill — show the number; confirmation only ever comes from the status poll */}
+ {billAction && (
+
+
+
+
+
Pay at CBE
+
+
+ {billAction.instructions ??
+ "Pay this bill at any CBE branch, the CBE Birr app, mobile banking or USSD."}
+
+
+
+ {billAction.billReference}
+
+
+ {billCopied ? : }
+ {billCopied ? "Copied" : "Copy"}
+
+
+
+
+ Amount: ETB {formatAmount(chargeAmount, "ETB")}
+
+ {billAction.expiresAt && (
+
+ Pay before:{" "}
+
+ {new Date(billAction.expiresAt).toLocaleString()}
+
+
+ )}
+
+
+
+ Waiting for payment confirmation — this page updates automatically once CBE
+ confirms your payment.
+
+
setBillAction(null)}
+ className="btn-secondary w-full py-2.5 mt-4"
+ >
+ Close
+
+
+
+ )}
+
+ {/* eBirr: the PIN prompt is on the payer's handset — nothing to navigate to. */}
+ {pushMessage && (
+
+
+
+
Check your phone
+
{pushMessage}
+
+
+ )}
+
+ {/* Push-debit methods (CAC Bank, eBirr) — collect payer mobile before initiating */}
+ {phoneModalOpen && (
+
+
+
+
+
Your mobile number
+
+
+ {selectedMethod === "EBIRR"
+ ? "eBirr will prompt this number for your PIN to authorize the payment. Make sure it's the phone you have with you."
+ : "CAC Bank will send a one-time password to this number to authorize the payment."}
+
+
{ setPayerMobile(e.target.value); setPhoneError(null); }}
+ onKeyDown={(e) => { if (e.key === "Enter") submitPhone(); }}
+ placeholder={selectedMethod === "EBIRR" ? "09XX XXX XXX" : "77 XX XX XX"}
+ className="w-full px-3 py-3 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:border-primary focus:ring-1 focus:ring-primary outline-none"
+ />
+ {phoneError && (
+
⚠️ {phoneError}
+ )}
+
+ setPhoneModalOpen(false)}
+ className="btn-secondary flex-1 py-2.5"
+ >
+ Cancel
+
+
+ Continue
+
+
+
+
+ )}
+
+ {/* CAC Bank OTP entry */}
+ {otpModalOpen && (
+
+
+
+
+
Enter OTP
+
+
{otpMessage}
+
{ setOtpCode(e.target.value.replace(/\D/g, "")); setOtpError(null); }}
+ onKeyDown={(e) => { if (e.key === "Enter" && otpCode.trim() && !otpMutation.isPending) otpMutation.mutate(otpCode.trim()); }}
+ placeholder="Enter code"
+ maxLength={10}
+ className="w-full text-center tracking-[0.4em] text-lg font-semibold px-3 py-3 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:border-primary focus:ring-1 focus:ring-primary outline-none"
+ />
+ {otpError && (
+
⚠️ {otpError}
+ )}
+
+ setOtpModalOpen(false)}
+ disabled={otpMutation.isPending}
+ className="btn-secondary flex-1 py-2.5"
+ >
+ Cancel
+
+ otpCode.trim() && otpMutation.mutate(otpCode.trim())}
+ disabled={otpMutation.isPending || !otpCode.trim()}
+ className="btn-primary flex-1 py-2.5 disabled:opacity-50 disabled:cursor-not-allowed"
+ >
+ {otpMutation.isPending ? (
+
+ Verifying...
+
+ ) : (
+ "Confirm payment"
+ )}
+
+
+
+
+ )}
);
diff --git a/apps/edr-passenger-web/portal/src/app/pay-balance/[token]/page.tsx b/apps/edr-passenger-web/portal/src/app/pay-balance/[token]/page.tsx
index fb2135cd3..da45893e5 100644
--- a/apps/edr-passenger-web/portal/src/app/pay-balance/[token]/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/pay-balance/[token]/page.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useQuery, useMutation } from "@tanstack/react-query";
import { apiClient } from "@/lib/api-client";
@@ -8,7 +8,10 @@ import { resolvePaymentRedirectUrl } from "@/lib/payment-redirect";
import { PaymentMethod } from "@/types";
import {
Loader2,
+ Check,
+ Copy,
CreditCard,
+ KeyRound,
Smartphone,
Wallet,
Landmark,
@@ -23,6 +26,28 @@ const getIconForMethod = (methodId: string) => {
return Smartphone;
};
+// WALLET is an internal balance debit with no supplementary-charge path — the API refuses it, so
+// it is never offered here.
+const UNSUPPORTED_METHODS = ["WALLET"];
+
+// Push-debit methods charge an account we must know before initiating: CAC Bank SMSes a one-time
+// password to it, eBirr pushes a USSD PIN prompt to it. Neither opens a hosted page that could
+// collect the number afterwards, so it is asked for up front.
+const requiresPayerMobile = (method: string | null) =>
+ method === "CAC_BANK" || method === "EBIRR";
+
+// DJF has no minor unit; ETB and USD are quoted to cents. Matches the API's charge-side rounding,
+// so the quote renders exactly the figure the provider will debit.
+const formatAmount = (amount: number, currency: string) =>
+ amount.toFixed(currency.toUpperCase() === "DJF" ? 0 : 2);
+
+interface AmountQuote {
+ chargeId: string;
+ method: string;
+ currency: string;
+ amount: number;
+}
+
export default function PayBalancePage() {
const { token } = useParams<{ token: string }>();
const router = useRouter();
@@ -30,6 +55,26 @@ export default function PayBalancePage() {
const [isProcessing, setIsProcessing] = useState(false);
const [paymentError, setPaymentError] = useState(null);
+ // Push-debit (CAC Bank / eBirr): collect the payer's mobile before initiating, then — for CAC —
+ // the OTP the bank SMSes to it.
+ const [phoneModalOpen, setPhoneModalOpen] = useState(false);
+ const [payerMobile, setPayerMobile] = useState("");
+ const [phoneError, setPhoneError] = useState(null);
+ const [otpModalOpen, setOtpModalOpen] = useState(false);
+ const [otpCode, setOtpCode] = useState("");
+ const [otpMessage, setOtpMessage] = useState(null);
+ const [otpError, setOtpError] = useState(null);
+ const [pushMessage, setPushMessage] = useState(null);
+
+ // CBE bill: no redirect and no OTP — the payer walks away with a bill number and pays it at a
+ // branch/app later, so the page shows the number and watches for settlement.
+ const [billAction, setBillAction] = useState<{
+ billReference: string;
+ instructions?: string;
+ expiresAt?: string;
+ } | null>(null);
+ const [billCopied, setBillCopied] = useState(false);
+
const { data: charge, isLoading: loadingCharge, error: chargeError } = useQuery({
queryKey: ["supplementary-charge", token],
queryFn: () => apiClient.get(`/payments/supplementary/by-token/${token}`),
@@ -45,18 +90,102 @@ export default function PayBalancePage() {
enabled: !!charge,
});
+ const availableMethods = useMemo(
+ () =>
+ paymentMethods.filter(
+ (m) => m.enabled && !UNSUPPORTED_METHODS.includes(m.type),
+ ),
+ [paymentMethods],
+ );
+
+ // The charge is raised in ETB; this is what it costs before a method is chosen.
+ const chargeCurrency = charge?.currency ?? "ETB";
+ const chargeAmount = useMemo(
+ () => Number(charge?.amountMinor ?? 0) / 100,
+ [charge],
+ );
+
+ // Each method settles in its own currency (WAAFI/DMONEY in DJF, CARD in USD, Ethiopian wallets
+ // in ETB), so the price has to be re-quoted server-side whenever the selection changes — the
+ // stored ETB amount is not what a Djiboutian wallet would debit.
+ const {
+ data: quote,
+ isFetching: fetchingQuote,
+ error: quoteError,
+ } = useQuery({
+ queryKey: ["supplementaryAmount", token, selectedMethod],
+ queryFn: () =>
+ apiClient.get(
+ `/payments/supplementary/by-token/${token}/amount?method=${selectedMethod}`,
+ ),
+ enabled: !!token && !!selectedMethod,
+ retry: false,
+ staleTime: 30_000,
+ });
+
+ // A quote is only usable once it belongs to the method currently selected — otherwise it is a
+ // leftover from the previous selection and would price the payment in the wrong currency.
+ const quoteReady = !fetchingQuote && quote?.method === selectedMethod;
+ const displayCurrency = selectedMethod ? (quote?.currency ?? "") : chargeCurrency;
+ const displayAmount = selectedMethod ? quote?.amount : chargeAmount;
+ const amountLabel =
+ quoteReady && displayAmount != null
+ ? `${displayCurrency} ${formatAmount(displayAmount, displayCurrency)}`
+ : !selectedMethod && displayAmount != null
+ ? `${chargeCurrency} ${formatAmount(displayAmount, chargeCurrency)}`
+ : null;
+
+ // Never let Pay fire against a price the payer has not been shown.
+ const awaitingQuote = !!selectedMethod && !quoteReady;
+
const payMutation = useMutation({
- mutationFn: (method: string) =>
+ mutationFn: (vars: { method: string; payerAccount?: string }) =>
apiClient.post(`/payments/supplementary/by-token/${token}/pay`, {
- method,
+ method: vars.method,
platform: "web",
+ ...(vars.payerAccount ? { payerAccount: vars.payerAccount } : {}),
}),
onSuccess: (data: any) => {
- if (data?.clientAction?.type === "REDIRECT") {
- window.location.href = resolvePaymentRedirectUrl(data.clientAction.url);
+ const action = data?.clientAction;
+
+ if (action?.type === "REDIRECT") {
+ window.location.href = resolvePaymentRedirectUrl(action.url);
return;
}
- // Immediate success (e.g. wallet)
+
+ // CAC Bank: no redirect — the bank SMS'd an OTP. Collect it here and confirm.
+ if (action?.type === "COLLECT_OTP") {
+ setOtpMessage(action.message ?? "Enter the OTP sent to your phone");
+ setOtpCode("");
+ setOtpError(null);
+ setOtpModalOpen(true);
+ setIsProcessing(false);
+ return;
+ }
+
+ // CBE: the bill now exists in CBE's system. Nothing to navigate to — show the number.
+ if (action?.type === "SHOW_BILL_REFERENCE") {
+ setBillAction({
+ billReference: action.billReference,
+ instructions: action.instructions,
+ expiresAt: action.expiresAt,
+ });
+ setBillCopied(false);
+ setIsProcessing(false);
+ return;
+ }
+
+ // eBirr: the PIN prompt was pushed to the payer's handset; there is nothing to navigate to.
+ if (action?.type === "AWAIT_PUSH") {
+ setPushMessage(
+ action.message ??
+ `Approve the payment on your phone${action.payerAccountMasked ? ` (${action.payerAccountMasked})` : ""}.`,
+ );
+ setIsProcessing(false);
+ return;
+ }
+
+ // Immediate success
router.push(`/pay-balance/${token}/success`);
},
onError: (err: any) => {
@@ -67,11 +196,90 @@ export default function PayBalancePage() {
},
});
- const handlePay = () => {
+ // CAC Bank OTP confirmation. A 200 means the debit settled; a 400 is a wrong/expired OTP —
+ // keep the modal open so the payer can re-enter it (the intent stays open).
+ const otpMutation = useMutation({
+ mutationFn: (otp: string) =>
+ apiClient.post(`/payments/supplementary/by-token/${token}/confirm`, { otp }),
+ onSuccess: () => {
+ setOtpModalOpen(false);
+ router.push(`/pay-balance/${token}/success`);
+ },
+ onError: (err: any) => {
+ setOtpError(
+ err?.response?.data?.message ??
+ err?.message ??
+ "Invalid or expired OTP. Please try again.",
+ );
+ },
+ });
+
+ const startPayment = (mobile?: string) => {
if (!selectedMethod) return;
setIsProcessing(true);
setPaymentError(null);
- payMutation.mutate(selectedMethod);
+ payMutation.mutate({
+ method: selectedMethod,
+ payerAccount: requiresPayerMobile(selectedMethod) ? mobile?.trim() : undefined,
+ });
+ };
+
+ const handlePay = () => {
+ if (!selectedMethod || awaitingQuote) return;
+ setPaymentError(null);
+
+ if (requiresPayerMobile(selectedMethod)) {
+ // Prefill with the number the charge was raised against, but leave it editable — the
+ // handset paying is often not the one the booking was made under.
+ if (!payerMobile.trim() && charge?.booking?.contactPhone) {
+ setPayerMobile(charge.booking.contactPhone);
+ }
+ setPhoneError(null);
+ setPhoneModalOpen(true);
+ return;
+ }
+
+ startPayment();
+ };
+
+ const submitPhone = () => {
+ if (!payerMobile.trim()) {
+ setPhoneError("Please enter your mobile number");
+ return;
+ }
+ setPhoneModalOpen(false);
+ startPayment(payerMobile);
+ };
+
+ // While a bill or a pushed PIN prompt is outstanding, watch the charge. Settlement happens
+ // server-side — a CBE teller, or the provider's webhook — so the browser has no other signal.
+ // Success is only ever claimed from this, never from a client-side guess.
+ const watching = !!billAction || !!pushMessage;
+ const { data: liveStatus } = useQuery<{ status: string; paid: boolean }>({
+ queryKey: ["supplementaryStatus", token],
+ queryFn: () =>
+ apiClient.get<{ status: string; paid: boolean }>(
+ `/payments/supplementary/by-token/${token}/status`,
+ ),
+ enabled: !!token && watching,
+ refetchInterval: 5_000,
+ });
+
+ useEffect(() => {
+ if (watching && liveStatus?.paid) {
+ router.push(`/pay-balance/${token}/success`);
+ }
+ }, [watching, liveStatus?.paid, router, token]);
+
+ const copyBillReference = async () => {
+ if (!billAction) return;
+ try {
+ await navigator.clipboard.writeText(billAction.billReference);
+ setBillCopied(true);
+ setTimeout(() => setBillCopied(false), 2000);
+ } catch {
+ /* clipboard unavailable — the number is still shown on screen */
+ }
};
if (loadingCharge) {
@@ -95,8 +303,6 @@ export default function PayBalancePage() {
);
}
- const amountDisplay = (charge.amountMinor / 100).toFixed(2);
- const currency = charge.currency ?? "ETB";
return (
@@ -122,8 +328,25 @@ export default function PayBalancePage() {
)}
Amount due
- {currency} {amountDisplay}
+ {amountLabel ? (
+ {amountLabel}
+ ) : quoteError ? (
+ —
+ ) : (
+
+ )}
+ {selectedMethod && quoteReady && displayCurrency !== chargeCurrency && (
+
+ Converted from {chargeCurrency} {formatAmount(chargeAmount, chargeCurrency)} at today's rate
+
+ )}
+ {quoteError && (
+
+ {(quoteError as any)?.response?.data?.message ??
+ "This payment method is unavailable right now. Please choose another."}
+
+ )}
{/* Payment methods */}
@@ -136,7 +359,7 @@ export default function PayBalancePage() {
) : (
- {paymentMethods.filter((m) => m.enabled).map((method) => {
+ {availableMethods.map((method) => {
const Icon = getIconForMethod(method.type);
const isSelected = selectedMethod === method.type;
return (
@@ -173,19 +396,180 @@ export default function PayBalancePage() {
{isProcessing ? (
Processing...
+ ) : quoteError ? (
+ "Choose another payment method"
+ ) : awaitingQuote ? (
+
+ Calculating amount...
+
) : (
- `Pay ${currency} ${amountDisplay}`
+ `Pay ${amountLabel ?? ""}`.trim()
)}
🔒 Secure & encrypted payment
+
+ {/* CBE bill — show the number; confirmation only ever comes from the status poll */}
+ {billAction && (
+
+
+
+
+
Pay at CBE
+
+
+ {billAction.instructions ??
+ "Pay this bill at any CBE branch, the CBE Birr app, mobile banking or USSD."}
+
+
+
+ {billAction.billReference}
+
+
+ {billCopied ? : }
+ {billCopied ? "Copied" : "Copy"}
+
+
+
+
+ Amount: ETB {formatAmount(chargeAmount, "ETB")}
+
+ {billAction.expiresAt && (
+
+ Pay before:{" "}
+
+ {new Date(billAction.expiresAt).toLocaleString()}
+
+
+ )}
+
+
+
+ Waiting for payment confirmation — this page updates automatically once CBE
+ confirms your payment.
+
+
setBillAction(null)}
+ className="btn-secondary w-full py-2.5 mt-4"
+ >
+ Close
+
+
+
+ )}
+
+ {/* eBirr: the PIN prompt is on the payer's handset — nothing to navigate to. */}
+ {pushMessage && (
+
+
+
+
Check your phone
+
{pushMessage}
+
+
+ )}
+
+ {/* Push-debit methods (CAC Bank, eBirr) — collect payer mobile before initiating */}
+ {phoneModalOpen && (
+
+
+
+
+
Your mobile number
+
+
+ {selectedMethod === "EBIRR"
+ ? "eBirr will prompt this number for your PIN to authorize the payment. Make sure it's the phone you have with you."
+ : "CAC Bank will send a one-time password to this number to authorize the payment."}
+
+
{ setPayerMobile(e.target.value); setPhoneError(null); }}
+ onKeyDown={(e) => { if (e.key === "Enter") submitPhone(); }}
+ placeholder={selectedMethod === "EBIRR" ? "09XX XXX XXX" : "77 XX XX XX"}
+ className="w-full px-3 py-3 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:border-primary focus:ring-1 focus:ring-primary outline-none"
+ />
+ {phoneError && (
+
⚠️ {phoneError}
+ )}
+
+ setPhoneModalOpen(false)} className="btn-secondary flex-1 py-2.5">
+ Cancel
+
+
+ Continue
+
+
+
+
+ )}
+
+ {/* CAC Bank OTP entry */}
+ {otpModalOpen && (
+
+
+
+
+
Enter OTP
+
+
{otpMessage}
+
{ setOtpCode(e.target.value.replace(/\D/g, "")); setOtpError(null); }}
+ onKeyDown={(e) => { if (e.key === "Enter" && otpCode.trim() && !otpMutation.isPending) otpMutation.mutate(otpCode.trim()); }}
+ placeholder="Enter code"
+ maxLength={10}
+ className="w-full text-center tracking-[0.4em] text-lg font-semibold px-3 py-3 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:border-primary focus:ring-1 focus:ring-primary outline-none"
+ />
+ {otpError && (
+
⚠️ {otpError}
+ )}
+
+ setOtpModalOpen(false)}
+ disabled={otpMutation.isPending}
+ className="btn-secondary flex-1 py-2.5"
+ >
+ Cancel
+
+ otpCode.trim() && otpMutation.mutate(otpCode.trim())}
+ disabled={otpMutation.isPending || !otpCode.trim()}
+ className="btn-primary flex-1 py-2.5 disabled:opacity-50 disabled:cursor-not-allowed"
+ >
+ {otpMutation.isPending ? (
+
+ Verifying...
+
+ ) : (
+ "Confirm payment"
+ )}
+
+
+
+
+ )}
);
diff --git a/docker-compose.yaml b/docker-compose.yaml
index 5d5faa2de..3250e4f45 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -49,6 +49,7 @@ services:
args:
TURBO_FILTER: "@edr/freight-portal"
APP_PATH: apps/edr-freight-web/portal
+ VITE_ENV: ${VITE_ENV:-}
VITE_API_URL: ${VITE_API_URL:-}
VITE_BASE_API_URL: ${VITE_BASE_API_URL:-}
VITE_USER_MANAGEMENT_BASE: ${VITE_USER_MANAGEMENT_BASE:-}
@@ -116,3 +117,35 @@ services:
env_file:
- apps/edr-payment-api/.env
restart: always
+
+ # Internal employee chat (freight backoffice). No federation, no public
+ # registration — see infrastructure/matrix/synapse/homeserver.yaml.tmpl.
+ synapse:
+ build:
+ context: infrastructure/matrix/synapse
+ ports:
+ - "${SYNAPSE_PORT:-8008}:8008"
+ env_file:
+ - infrastructure/matrix/synapse/.env
+ volumes:
+ - matrix-data:/data
+ # Local dev: Postgres runs in the separate docker-compose.db.dev.yml
+ # project (different docker network) and is only reachable from here via
+ # the host's published port — see MATRIX_DB_HOST in synapse/.env.example.
+ extra_hosts:
+ - "host.docker.internal:host-gateway"
+ restart: always
+
+ element-web:
+ build:
+ context: infrastructure/matrix/element
+ ports:
+ - "${ELEMENT_WEB_PORT:-8080}:80"
+ # config.json is rendered at container start, not baked — one image serves
+ # dev, staging and prod. See infrastructure/matrix/element/.env.example.
+ env_file:
+ - infrastructure/matrix/element/.env
+ restart: always
+
+volumes:
+ matrix-data:
diff --git a/docs/MAP.md b/docs/MAP.md
new file mode 100644
index 000000000..d6aa76322
--- /dev/null
+++ b/docs/MAP.md
@@ -0,0 +1,96 @@
+# Repo map — start here
+
+Routing table for "where does X live". Read this before a repo-wide grep. Paths are from
+the repo root. The rules and traps are in [`../CLAUDE.md`](../CLAUDE.md); this file only
+answers *where*.
+
+## Pick your stack first
+
+| If you are working on… | Code lives in | Stack |
+| --------------------------------- | --------------------------------- | -------------------- |
+| Freight API / business logic | `apps/edr-freight-api/src` | NestJS + TypeORM |
+| Freight customer UI | `apps/edr-freight-web/portal` | React + Vite + Mantine v9 |
+| Freight staff UI | `apps/edr-freight-web/backoffice` | React + Vite + Mantine v9 |
+| Passenger API | `apps/edr-passenger-api` | NestJS + **Prisma** |
+| Passenger UI | `apps/edr-passenger-web/*` | **Next.js** |
+| Payments (intents, webhooks) | `apps/edr-payment-api` | NestJS + TypeORM |
+| Gateway integrations | `packages/payment-providers` | — |
+| A shared type or enum | `packages/types/src` | rebuild after editing |
+| A shared React component | `packages/ui-common/src` | — |
+| A Nest decorator/filter/base class| `packages/api-common/src` | — |
+
+## Freight API entry points
+
+| File | What it is |
+| --- | --- |
+| `src/main.ts` | Boot, port (`PORT`, falls back to 3001), global pipes |
+| `src/app.module.ts` | Every module is registered here — the index of the API |
+| `src/config/database.config.ts` | Connection, pooler `search_path` handling, `iamEntities` list |
+| `src/data-source.ts` | Standalone DataSource used by migrations only (no `autoLoadEntities`) |
+| `src/migrations/` | TypeORM migrations (39 files; check for timestamp clashes) |
+| `src/seed/freight-permissions.registry.ts` | Every freight permission; declare before use |
+| `src/scripts/` | One-off and `seed:*` scripts — several write real rows |
+
+## Freight modules by domain
+
+All under `apps/edr-freight-api/src/modules/`.
+
+| Domain | Modules |
+| --- | --- |
+| **Booking & commercial** | `bookings` `contracts` `contract-templates` `consignments` `cargoes` `companies` `user-trade-access` `transit-agents` `shipping-lines` |
+| **Warehouse & yard** | `warehouses` `facilities` `container-management` |
+| **Rail operations** | `trains` `train-schedules` `train-scheduling` `train-sets` `wagons` `wagon-types` `locomotives` `routes` `scheduling` `scheduling-reschedule` `interchange-documents` |
+| **Road / first & last mile** | `first-mile` `last-mile` `last-mile-requests` `drivers` `vehicles` `truck-types` `fleet-history` `fuel` `maintenance` `gps-tracking` `tracking` |
+| **Money** | `billing` `payment` `exchange-settings` |
+| **Identity & access** | `auth` `otp` `verifayda` `audit` |
+| **Documents & files** | `files` `file-upload-settings` `signatures` `stamp-settings` `logo-settings` `minio` |
+| **Comms** | `notifications` `notification-inbox` `support-chat` `support-content` |
+| **Ops & admin** | `backoffice` `overview` `reports` `dropdown-settings` `rule-engine` `compliance` `procurement` `incidents` `import-operations` `eims` `ai` `health` |
+
+Each module follows `module → controller → service → repository`, with `entities/` and
+`dto/` alongside.
+
+## Freight web
+
+Pages live in `src/pages/`, roughly mirroring the API domains.
+
+- **portal** (customer): `bookings` `contracts` `consignments` `billing` `payments`
+ `tracking` `accounts` `customers` `shipping-line` `support` `settings`, plus
+ `MyPortalPage/`, `MySignaturePage.tsx`, `EDRFreightLandingPage.tsx`.
+- **backoffice** (staff): `bookings` `contracts` `contract_templates` `consignments`
+ `customers` `billing` `invoices` `documents` `fleet` `dashboard` `admin` `configuration`
+ `auth` `ai`, plus many single-file pages (`AuditLogsPage.tsx`, `ActivityLogPage.tsx`,
+ `BulkUploadPage.tsx`, `ContentManagementPage.tsx`, …).
+
+Shared components and theme come from `@edr/ui-common` — check there before writing one.
+
+## Tests
+
+| Suite | Location | Run with |
+| --- | --- | --- |
+| Unit / spec | beside the code, `*.spec.ts` | `pnpm --filter @edr/freight-api test` |
+| Freight e2e (Cypress, containerized) | `e2e/freight` | `pnpm e2e:freight:ci` |
+| Passenger e2e | `e2e/` + `e2e/run.sh` | `pnpm test:e2e:passenger` |
+| UI e2e (Playwright) | `e2e-ui/` | `pnpm test:e2e:ui` |
+| Integration | `integration/` | `pnpm it:up`, `pnpm it:test` |
+
+The freight e2e stack has no host Xvfb — use `ci` (containerized), not `run`/`open`.
+Its compose project name is fixed (`edr-freight-e2e`), so only one can run on this
+machine at a time.
+
+## Documents
+
+| Doc | Covers | Trust |
+| --- | --- | --- |
+| `../CLAUDE.md` | The contract: rules, traps, definition of done | Current — fix in the same PR if wrong |
+| `docs/TESTING.md` | Test strategy | — |
+| `docs/e2e-test-matrix.md`, `docs/ui-e2e-test-matrix.md` | Coverage matrices | — |
+| `docs/ISSUES.md`, `docs/SOLUTIONS.md` | Running log of problems and fixes | Historical |
+| `docs/uploads.md` | File upload handling | — |
+| `docs/qa/edr-freight-qa-test-plan.md` | QA test plan | — |
+| `../DEPLOYMENT.md` | Deploy process | — |
+| `../ITMLS_DB_Design.md`, `../orgstructure.md` | Design notes | Historical |
+| `../E2E_TEST_REPORT.md`, `../checkpoint.md` | Point-in-time snapshots | **Stale by design — dated artifacts, not references** |
+
+Root-level `*.sql` and `*.dump` files are ad-hoc data snapshots, not part of the schema.
+Migrations are the only source of truth for schema.
diff --git a/e2e/freight/cypress/e2e/flows/onboarding-utils.ts b/e2e/freight/cypress/e2e/flows/onboarding-utils.ts
new file mode 100644
index 000000000..93d193ac7
--- /dev/null
+++ b/e2e/freight/cypress/e2e/flows/onboarding-utils.ts
@@ -0,0 +1,385 @@
+/**
+ * Shared machinery for the onboarding journeys (portal → backoffice).
+ *
+ * The wizard's five form steps are `company → owner → representation →
+ * contact → documents`, preceded by the nationality/role phase. Three routes
+ * run through them:
+ *
+ * ordinary eTrade answers for the TIN; the registration is read-only.
+ * investor a foreign company on an Investment Commission licence — eTrade
+ * holds nothing, the registration is typed, the per-role business
+ * licence is still owed.
+ * co-op a union or farm — eTrade holds nothing, the registration is
+ * typed, and no business licence exists to ask for.
+ *
+ * The eTrade mock picks its answer from the TIN's leading digit (see
+ * etrade-mock/server.js), so a spec chooses "verified" or "nothing on file" by
+ * choosing its number — `etradeTin()` / `noLicenceTin()` / `unknownTin()`.
+ */
+
+/**
+ * Every manual-route journey deliberately looks up a TIN eTrade holds nothing
+ * for, and the API answers 400. The portal handles that outcome on screen (the
+ * "nothing on file" alert is the whole point) but leaves the rejected request
+ * unhandled at the promise level, and Cypress fails a test on any unhandled
+ * rejection from the app. Ignored here rather than per spec: it is the expected
+ * response to a request these journeys make on purpose, in every one of them.
+ *
+ * Narrow on purpose — only the 400. A 500, or anything else the app throws,
+ * still fails the test.
+ */
+Cypress.on("uncaught:exception", (err) => {
+ if (/Request failed with status code 400/.test(err.message)) return false;
+ return true;
+});
+
+const apiUrl = () => Cypress.env("apiUrl") as string;
+
+export const portalUrl = () => Cypress.env("portalUrl") as string;
+
+/* ------------------------------------------------------------------ */
+/* Field helpers */
+/* ------------------------------------------------------------------ */
+
+/**
+ * Fill a labelled Mantine input (label[for] → input id).
+ *
+ * The input is resolved fresh for every action rather than captured once.
+ * Each wizard step persists and re-seeds asynchronously, and when a field
+ * remounts Mantine mints a NEW generated id — so a subject captured a command
+ * earlier can already be stale. Going label → for → element each time always
+ * addresses what is on the page now.
+ */
+export function fill(label: string | RegExp, value: string) {
+ const input = () =>
+ cy
+ .contains("label", label)
+ .invoke("attr", "for")
+ .then((id) => cy.get(`[id="${id}"]`));
+
+ input().clear({ force: true });
+ input().type(value, { force: true });
+}
+
+/**
+ * Fill an input that has no — the company step renders TIN and VAT
+ * inside StepSection cards (the heading is the card's title, not a label), and
+ * both share the "0012345678" placeholder, so they are addressable only by
+ * aria-label.
+ */
+export function fillAria(ariaSelector: string, value: string) {
+ const selector = `.mantine-Modal-content ${ariaSelector}`;
+ cy.get(selector).clear({ force: true });
+ cy.get(selector).type(value, { force: true });
+}
+
+/** The wizard's phone inputs (react-phone-number-input, type=tel). */
+export function fillPhone(index: number, national: string) {
+ const selector = '.mantine-Modal-content input[type="tel"]';
+ cy.get(selector).eq(index).clear({ force: true });
+ cy.get(selector).eq(index).type(national, { force: true });
+}
+
+/** The wizard's own Continue / Submit button (never the page's). */
+export function wizardClick(label: string | RegExp) {
+ cy.get(".mantine-Modal-content").contains("button", label).click();
+}
+
+/* ------------------------------------------------------------------ */
+/* Identities */
+/* ------------------------------------------------------------------ */
+
+/** A TIN eTrade answers for: registration + one business licence. */
+export const etradeTin = (stamp: number) => `1${String(stamp).slice(-9)}`;
+/** A TIN eTrade knows, but which holds no trade licence (co-op / investor). */
+export const noLicenceTin = (stamp: number) => `9${String(stamp).slice(-9)}`;
+/** A TIN eTrade has never heard of at all. */
+export const unknownTin = (stamp: number) => `8${String(stamp).slice(-9)}`;
+/** VAT is 10–11 digits and must not collide with the TIN. */
+export const vatNumber = (stamp: number) => `2${String(stamp + 7).slice(-9)}`;
+
+export const SIGNUP_PASSWORD = "Password@e2e1";
+
+export interface Signup {
+ email: string;
+ /** Ethiopian mobile, national format (9 + 8 digits). */
+ phoneNational: string;
+}
+
+/** A unique signup identity for this run, namespaced per journey. */
+export function signupIdentity(prefix: string, stamp: number): Signup {
+ return {
+ email: `e2e.${prefix}.${stamp}@example.com`,
+ phoneNational: `9${String(stamp).slice(-8)}`,
+ };
+}
+
+/* ------------------------------------------------------------------ */
+/* Journey steps */
+/* ------------------------------------------------------------------ */
+
+/**
+ * /signup → OTP (read from the DB; delivery is off in e2e) → signed in on
+ * /portal with the onboarding wizard already open on the nationality step.
+ */
+export function signupCustomer(who: Signup, firstName = "Onboard") {
+ cy.visit(`${portalUrl()}/signup`);
+
+ fill(/^First name/, firstName);
+ fill(/^Last name/, "Tester");
+ fill(/^Email/, who.email);
+ cy.get('input[type="tel"]').first().type(who.phoneNational, { force: true });
+ fill(/^Password/, SIGNUP_PASSWORD);
+ fill(/^Confirm password/, SIGNUP_PASSWORD);
+ cy.contains("button", "Continue").click();
+
+ cy.contains("Verify", { timeout: 15000 }).should("be.visible");
+ cy.getOtp(who.email).then((otp) => cy.typeOtp(otp));
+ cy.contains("button", "Verify & create account").click();
+
+ cy.location("pathname", { timeout: 20000 }).should("eq", "/portal");
+ cy.contains("Where is your company registered?", { timeout: 15000 }).should(
+ "be.visible",
+ );
+}
+
+/**
+ * Fill the "Registration details" block the manual routes type by hand. Region
+ * is a Mantine Select; the rest are plain inputs.
+ */
+export function typeRegistration(companyName: string) {
+ fill(/^Company Name/, companyName);
+ cy.mantineSelect("Region", "Addis Ababa");
+ fill(/^Zone/, "Zone 1");
+ fill(/^Woreda/, "Woreda 1");
+ fill(/^Kebele/, "Kebele 1");
+}
+
+/**
+ * Attach a PDF to the next empty dropzone on the documents step.
+ *
+ * Not positional: SmartFileInput swaps its dropzone for the file row once
+ * something is attached, so the input disappears from the DOM and every later
+ * index shifts under you. "The first one still asking for a file" is the only
+ * stable address, and it walks the step in render order — company documents
+ * first, then one card per operational profile.
+ */
+export function attachNextFile() {
+ cy.get('.mantine-Modal-content input[type="file"]')
+ .first()
+ .selectFile("cypress/fixtures/docs/license.pdf", { force: true });
+}
+
+/**
+ * Drive the whole investor wizard, signup included, and submit it.
+ *
+ * Shared because two specs need the same customer sitting on the far side of
+ * onboarding: the one that is about the journey, and the one that is about
+ * what happens afterwards. Driving the real UI rather than posting the
+ * equivalent API calls keeps the second spec honest — it starts from a company
+ * the product itself produced.
+ *
+ * `beforeSubmit` runs on the documents step, with everything attached and the
+ * submit button still unpressed: the one place a caller can assert about a
+ * state that does not survive submission.
+ */
+export function completeInvestorOnboarding(
+ who: Signup,
+ stamp: number,
+ opts: { companyName?: string; beforeSubmit?: () => void } = {},
+) {
+ const companyName = opts.companyName ?? `E2E Investor Holdings ${stamp}`;
+
+ signupCustomer(who, "Investor");
+
+ cy.contains("button", "Foreign Company").click();
+ cy.contains("We operate on a foreign investment licence").click();
+ cy.contains("button", "Importer").click();
+ wizardClick("Continue");
+
+ cy.contains("Confirm your VAT number", { timeout: 20000 }).should(
+ "be.visible",
+ );
+ fillAria('[aria-label^="TIN Number"]', noLicenceTin(stamp));
+ cy.contains("Nothing on file at eTrade for this TIN", {
+ timeout: 20000,
+ }).should("be.visible");
+ typeRegistration(companyName);
+ fillAria('[aria-label="VAT Number"]', vatNumber(stamp));
+ wizardClick("Continue");
+
+ // No licence, so no eTrade manager: every owner field is typed, and the
+ // "your licence listed no manager" hint has no business appearing.
+ cy.contains("Company Owner", { timeout: 20000 }).should("be.visible");
+ cy.contains("Your eTrade licence didn't list a manager").should("not.exist");
+ fill(/^Owner's Name/, "Investor Owner");
+ fill(/^Owner's Email/, `owner.${stamp}@example.com`);
+ fillPhone(0, "911234570");
+ wizardClick("Continue");
+
+ cy.contains("Does anyone hold power of attorney for this company?", {
+ timeout: 20000,
+ }).should("be.visible");
+ cy.contains("button", "No, the owner acts for us").click();
+ // Identity is not proven yet, so the step must hold. Asserted as "we did not
+ // advance" rather than on the alert text: answering the declaration refetches
+ // the profile, which re-seeds the form, and the form's watch subscription
+ // clears the alert on any value change — so the message is real but lives for
+ // a few milliseconds.
+ wizardClick("Continue");
+ cy.contains("Who Acts For You").should("be.visible");
+ cy.contains("Contact Person").should("not.exist");
+ // The alternative a foreign company gets and an Ethiopian one does not.
+ cy.contains("Use a passport instead").click();
+ fill(/Passport Number/, `P${String(stamp).slice(-7)}`);
+ wizardClick("Continue");
+
+ cy.contains("Contact Person", { timeout: 20000 }).should("be.visible");
+ fill(/^Name$/, "Investor Contact");
+ fillPhone(0, "911234571");
+ wizardClick("Continue");
+
+ cy.contains("Upload Importer Business license file(s)", {
+ timeout: 20000,
+ }).should("be.visible");
+ attachNextFile();
+ attachNextFile();
+ opts.beforeSubmit?.();
+ wizardClick("Submit for review");
+ cy.contains("You're all set", { timeout: 30000 }).should("be.visible");
+}
+
+/* ------------------------------------------------------------------ */
+/* Database + API */
+/* ------------------------------------------------------------------ */
+
+export interface CompanyRow {
+ id: string;
+ name: string;
+ status: string;
+ nationality: string | null;
+ attributes: Record | null;
+ licence_number: string | null;
+ region: string | null;
+ etrade_phone: string | null;
+ onboarding_step: string | null;
+ onboarding_completed: boolean;
+ email: string;
+}
+
+const COMPANY_SQL = `
+ SELECT c.id, c.name, c.status, c.nationality, c.attributes,
+ c.licence_number, c.region, c.etrade_phone,
+ ep.onboarding_step, ep.onboarding_completed, u.email
+ FROM freight.companies c
+ JOIN freight.external_profiles ep ON ep.company_id = c.id
+ JOIN iam.users u ON u.id = ep.user_id`;
+
+/** The company behind one signup email. */
+export function companyByEmail(email: string) {
+ return cy
+ .task<{ rows: CompanyRow[] }>("db:query", {
+ sql: `${COMPANY_SQL} WHERE u.email = $1`,
+ params: [email],
+ })
+ .then(({ rows }) => {
+ expect(rows, `company for ${email}`).to.have.length(1);
+ return cy.wrap(rows[0], { log: false });
+ });
+}
+
+/**
+ * The most recent journey of one kind, resolved from the DB rather than from
+ * module state: Cypress re-evaluates the spec bundle on every cross-origin
+ * visit, so anything held in a module variable is gone by the backoffice test.
+ */
+export function latestJourney(emailPrefix: string) {
+ // Scoped to THIS cypress run. `run:stamp` is minted by the node plugin, which
+ // outlives the bundle re-evaluation a cross-origin visit causes — unlike
+ // anything held in module state. Without the cutoff a journey that failed
+ // mid-way would silently hand the backoffice tests a previous run's company
+ // and pass on it.
+ return cy.task("run:stamp", null, { log: false }).then((runStamp) =>
+ cy
+ .task<{ rows: CompanyRow[] }>("db:query", {
+ sql: `${COMPANY_SQL}
+ WHERE u.email LIKE $1
+ AND c.created_at >= to_timestamp($2::bigint / 1000.0)
+ ORDER BY c.created_at DESC LIMIT 1`,
+ params: [`e2e.${emailPrefix}.%`, runStamp],
+ })
+ .then(({ rows }) => {
+ expect(rows, `journey for e2e.${emailPrefix}.* in this run`).to.have.length(
+ 1,
+ );
+ return cy.wrap(rows[0], { log: false });
+ }),
+ );
+}
+
+/** A portal access token for a customer account. */
+export function portalToken(email: string, password = SIGNUP_PASSWORD) {
+ return cy.apiLogin(email, password, "portal").then((body) => body.token);
+}
+
+/** cy.request against the freight API with a bearer token. */
+export function apiRequest(
+ token: string,
+ method: "GET" | "POST" | "PATCH",
+ path: string,
+ body?: Record,
+ failOnStatusCode = true,
+) {
+ return cy.request({
+ method,
+ url: `${apiUrl()}${path}`,
+ headers: { Authorization: `Bearer ${token}` },
+ body,
+ failOnStatusCode,
+ });
+}
+
+/* ------------------------------------------------------------------ */
+/* Backoffice */
+/* ------------------------------------------------------------------ */
+
+/** Open a customer's detail page from the backoffice list, by company name. */
+export function openCustomer(companyName: string) {
+ cy.visit("/dashboard/customers");
+ cy.get('input[placeholder*="Search by company"]').type(companyName);
+ cy.contains(companyName, { timeout: 15000 }).click();
+}
+
+/**
+ * Approve the pending role profile on the open customer page. Once active the
+ * row's action flips to "Suspend", which is what proves the write landed.
+ */
+export function approveFirstProfile() {
+ cy.contains("button", "Approve", { timeout: 15000 }).click();
+ cy.contains("button", "Suspend", { timeout: 15000 }).should("be.visible");
+}
+
+/** Assert the role profile went active and the company with it. */
+export function expectProfileActive(companyName: string, type = "importer") {
+ return cy
+ .task<{
+ rows: Array<{
+ status: string;
+ reference: string | null;
+ company_status: string;
+ }>;
+ }>("db:query", {
+ sql: `SELECT p.status, p.reference, c.status AS company_status
+ FROM freight.company_profiles p
+ JOIN freight.companies c ON c.id = p.company_id
+ WHERE c.name = $1 AND p.type = $2`,
+ params: [companyName, type],
+ })
+ .then(({ rows }) => {
+ expect(rows, `${type} profile`).to.have.length(1);
+ expect(rows[0].status).to.eq("active");
+ expect(rows[0].reference, "minted reference").to.be.a("string").and.not.be
+ .empty;
+ expect(rows[0].company_status).to.eq("active");
+ });
+}
diff --git a/e2e/freight/cypress/e2e/flows/onboarding.cy.ts b/e2e/freight/cypress/e2e/flows/onboarding.cy.ts
deleted file mode 100644
index 437c0e92e..000000000
--- a/e2e/freight/cypress/e2e/flows/onboarding.cy.ts
+++ /dev/null
@@ -1,276 +0,0 @@
-/**
- * Full customer onboarding journey, both apps:
- *
- * 1. portal — /signup form → OTP (read from DB, delivery is off in e2e)
- * → account created → onboarding wizard (nationality/role →
- * company → personnel → contact → PoA → documents incl. the
- * per-role business license) → "Submit for review"
- * 2. backoffice — staff (chief, holds edr_freight_app:admin) approves the
- * importer profile on /dashboard/customers/:id
- * 3. portal — the new customer is active: contract wizard reachable
- *
- * Tests are sequential steps of ONE journey (fresh unique user per run), so
- * retries are disabled — a mid-journey retry would replay a non-idempotent
- * step against already-advanced state.
- *
- * NOTE: switching origin between tests (portal 5373 ↔ backoffice 5383)
- * reloads the spec bundle and resets module state — later tests resolve the
- * journey's user/company from the DB instead of module variables.
- */
-
-import { completeFaydaVerification } from "./import-utils";
-
-const stamp = Date.now();
-const email = `e2e.onboard.${stamp}@example.com`;
-// Ethiopian mobile: 9 + 8 digits, unique per run.
-const phoneNational = `9${String(stamp).slice(-8)}`;
-const signupPassword = "Password@e2e1";
-const tin = String(stamp).slice(-10).padStart(10, "1");
-const vat = String(stamp + 1).slice(-10).padStart(10, "2");
-
-const portal = () => Cypress.env("portalUrl") as string;
-
-/** The journey's company/user = the latest e2e.onboard.* signup in the DB. */
-function latestOnboardJourney() {
- return cy.task<{ rows: Array<{ name: string; email: string }> }>("db:query", {
- sql: `SELECT c.name, u.email
- FROM freight.companies c
- JOIN freight.external_profiles ep ON ep.company_id = c.id
- JOIN iam.users u ON u.id = ep.user_id
- WHERE u.email LIKE 'e2e.onboard.%'
- ORDER BY c.created_at DESC LIMIT 1`,
- });
-}
-
-/**
- * Fill a labelled Mantine input (label[for] → input id).
- *
- * The input is resolved fresh for every action rather than captured once.
- * Each wizard step persists and re-seeds asynchronously, and when a field
- * remounts Mantine mints a NEW generated id — so both a subject and an id
- * captured a command earlier can be stale by the time the next command runs.
- * Going label → for → element each time always addresses what's on the page
- * now.
- */
-function fill(label: string | RegExp, value: string) {
- const input = () =>
- cy
- .contains("label", label)
- .invoke("attr", "for")
- .then((id) => cy.get(`[id="${id}"]`));
-
- input().clear({ force: true });
- input().type(value, { force: true });
-}
-
-/**
- * Fill an input that has no — the wizard's company step renders its
- * fields inside StepSection cards (the heading is the card's title, not a
- * label), so they're reachable only by aria-label. Both TIN and VAT share the
- * "0012345678" placeholder, which is why this matches on aria-label instead.
- */
-function fillAria(ariaSelector: string, value: string) {
- const selector = `.mantine-Modal-content ${ariaSelector}`;
- cy.get(selector).clear({ force: true });
- cy.get(selector).type(value, { force: true });
-}
-
-/** The wizard's phone inputs (react-phone-number-input, type=tel). */
-function fillPhone(index: number, national: string) {
- const selector = '.mantine-Modal-content input[type="tel"]';
- cy.get(selector).eq(index).clear({ force: true });
- cy.get(selector).eq(index).type(national, { force: true });
-}
-
-describe("customer onboarding journey", { retries: 0 }, () => {
- it("signs up with OTP and completes the onboarding wizard", () => {
- cy.visit(`${portal()}/signup`);
-
- fill(/^First name/, "Onboard");
- fill(/^Last name/, "Tester");
- fill(/^Email/, email);
- cy.get('input[type="tel"]').first().type(phoneNational, { force: true });
- fill(/^Password/, signupPassword);
- fill(/^Confirm password/, signupPassword);
- cy.contains("button", "Continue").click();
-
- // OTP stage — the code is generated + stored even though delivery is off.
- cy.contains("Verify", { timeout: 15000 }).should("be.visible");
- cy.getOtp(email).then((otp) => cy.typeOtp(otp));
- cy.contains("button", "Verify & create account").click();
-
- // Signed in → /portal → wizard auto-opens on the nationality/role step.
- cy.location("pathname", { timeout: 20000 }).should("eq", "/portal");
- cy.contains("Where is your company registered?", { timeout: 15000 }).should(
- "be.visible",
- );
- cy.contains("button", "Ethiopian Company").click();
- cy.contains("button", "Importer").click();
- cy.get(".mantine-Modal-content").contains("button", "Continue").click();
-
- // Ethiopian companies gate the "Owner identity" step on Fayda
- // verification — a real eSignet redirect + SMS OTP flow that can't run in
- // e2e. Complete it via the API against fayda-mock-e2e (the profile this
- // attaches to was just created by the nationality/role step above).
- // The wizard already fetched `identity` once when this step mounted, and
- // completing verification out-of-band skips the redirect that would
- // normally remount everything — so reload to force a fresh fetch. Wizard
- // progress resumes server-side, so this doesn't lose the nationality/role
- // step just completed.
- completeFaydaVerification("owner");
- cy.reload();
- cy.contains("Confirm your VAT number", { timeout: 20000 }).should(
- "be.visible",
- );
-
- // The owner's identity is the verification's output, never typed: the
- // panel must show it verified and render the name/phone/email that came
- // back from fayda-mock-e2e. Asserting the mock's own values is the only
- // way to prove the payload travelled Fayda → API → UI rather than the
- // panel simply flipping a "verified" flag.
- cy.get(".mantine-Modal-content").within(() => {
- cy.contains("Fayda verified").should("be.visible");
- cy.contains("Abebe Bekele").should("be.visible");
- cy.contains("+251911223344").should("be.visible");
- cy.contains("abebe.bekele@example.com").should("be.visible");
- });
-
- // The verified sub is what locks the owner's fields server-side, so check
- // it actually landed on the company rather than trusting the panel alone.
- cy.task<{ rows: Array<{ owner_fayda_sub: string | null }> }>("db:query", {
- sql: `SELECT c.attributes->>'ownerFaydaSub' AS owner_fayda_sub
- FROM freight.companies c
- JOIN freight.external_profiles ep ON ep.company_id = c.id
- JOIN iam.users u ON u.id = ep.user_id
- WHERE u.email = $1`,
- params: [email],
- }).then(({ rows }) => {
- expect(rows, "company row").to.have.length(1);
- expect(rows[0].owner_fayda_sub, "owner Fayda sub").to.eq(
- "e2e-fayda-sub-0001",
- );
- });
-
- // Company step. TIN auto-triggers the eTrade lookup once it's a full 10
- // digits (mocked in e2e — see docker-compose.e2e.yaml's etrade-mock-e2e).
- // A successful lookup locks Company Name/Region/Zone/Woreda/Kebele/House
- // No as read-only (ETradeCompanyCard) — nothing left to type there, and
- // Company Email/Phone/Location were dropped from this step entirely (the
- // Fayda-verified owner supplies contact details now). By label, not
- // placeholder: the VAT Number field on this same step shares the TIN
- // field's "0012345678" placeholder, so a placeholder selector matches 2.
- fillAria('[aria-label^="TIN Number"]', tin);
- cy.contains("Verified with eTrade", { timeout: 15000 }).should(
- "be.visible",
- );
- // handleETradeDataLoaded sets several fields in sequence (name, region,
- // zone, woreda, kebele, houseNo) — each a render, still settling right
- // after the badge appears. Typing into VAT immediately raced one of
- // those and detached mid-type; let it finish before touching the form.
- cy.wait(500);
- fillAria('[aria-label="VAT Number"]', vat);
- cy.get(".mantine-Modal-content").contains("button", "Continue").click();
-
- // Personnel (general manager).
- fill(/^Name/, "General Manager");
- fill(/^Email/, `gm.${stamp}@example.com`);
- fillPhone(0, "911234568");
- cy.get(".mantine-Modal-content").contains("button", "Continue").click();
-
- // Contact person.
- fill(/^Name/, "Contact Person");
- fillPhone(0, "911234569");
- cy.get(".mantine-Modal-content").contains("button", "Continue").click();
-
- // PoA — optional for an importer, and left unverified here. The DARS
- // delegation paper authorises the representative the verification names,
- // so with no verified PoA there is nothing for it to authorise: the
- // upload must not be offered, and the step must not block on it. Asserted
- // as "no file input on this step" rather than by label, so a reworded
- // document setting doesn't turn a real regression into a passing test.
- cy.get(".mantine-Modal-content")
- .contains("Power of Attorney")
- .should("be.visible");
- cy.get('.mantine-Modal-content input[type="file"]').should("not.exist");
- cy.get(".mantine-Modal-content").contains("button", "Continue").click();
-
- // Documents: no company docs are configured in e2e, but every role needs
- // a business license.
- cy.contains("Business license", { timeout: 15000 }).should("be.visible");
- cy.get('.mantine-Modal-content input[type="file"]')
- .first()
- .selectFile("cypress/fixtures/docs/license.pdf", { force: true });
- cy.get(".mantine-Modal-content")
- .contains("button", "Submit for review")
- .click();
-
- cy.contains("You're all set", { timeout: 30000 }).should("be.visible");
-
- // DB cross-check: submitted, awaiting approval.
- cy.task<{ rows: Array<{ status: string; onboarding_completed: boolean }> }>(
- "db:query",
- {
- sql: `SELECT c.status, ep.onboarding_completed
- FROM freight.companies c
- JOIN freight.external_profiles ep ON ep.company_id = c.id
- JOIN iam.users u ON u.id = ep.user_id
- WHERE u.email = $1`,
- params: [email],
- },
- ).then(({ rows }) => {
- expect(rows, "company row").to.have.length(1);
- expect(rows[0].status).to.eq("pending");
- expect(rows[0].onboarding_completed).to.eq(true);
- });
- });
-
- it("backoffice staff approves the submitted importer profile", () => {
- cy.loginBackoffice("chief@edr.local");
- cy.visit("/dashboard/customers");
-
- latestOnboardJourney().then(({ rows }) => {
- expect(rows, "onboarded company").to.have.length(1);
- const company = rows[0].name;
-
- cy.get('input[placeholder*="Search by company"]').type(company);
- cy.contains(company, { timeout: 15000 }).click();
-
- // Role profiles table → approve the pending importer profile. Once
- // active, the row's action flips to "Suspend".
- cy.contains("button", "Approve", { timeout: 15000 }).click();
- cy.contains("button", "Suspend", { timeout: 15000 }).should("be.visible");
-
- cy.task<{ rows: Array<{ status: string; reference: string | null; company_status: string }> }>(
- "db:query",
- {
- sql: `SELECT p.status, p.reference, c.status AS company_status
- FROM freight.company_profiles p
- JOIN freight.companies c ON c.id = p.company_id
- WHERE c.name = $1 AND p.type = 'importer'`,
- params: [company],
- },
- ).then(({ rows: profiles }) => {
- expect(profiles, "importer profile").to.have.length(1);
- expect(profiles[0].status).to.eq("active");
- expect(profiles[0].reference, "minted reference").to.be.a("string").and
- .not.be.empty;
- expect(profiles[0].company_status).to.eq("active");
- });
- });
- });
-
- it("the approved customer can reach the contract wizard", () => {
- latestOnboardJourney().then(({ rows }) => {
- cy.loginPortal(rows[0].email, signupPassword);
- });
- cy.visitPortal("/contracts/new");
-
- // No "Awaiting Approval" gate — the wizard's first step renders.
- cy.contains("label", "Operation Type", { timeout: 15000 }).should(
- "be.visible",
- );
- cy.contains("Awaiting Approval").should("not.exist");
- });
-});
-
-export {};
diff --git a/e2e/freight/cypress/e2e/flows/onboarding_cooperative.cy.ts b/e2e/freight/cypress/e2e/flows/onboarding_cooperative.cy.ts
new file mode 100644
index 000000000..d94c12b58
--- /dev/null
+++ b/e2e/freight/cypress/e2e/flows/onboarding_cooperative.cy.ts
@@ -0,0 +1,161 @@
+/**
+ * A co-operative union or farm: a TIN, but no business licence at all.
+ *
+ * It reaches the same typed-registration route as a foreign investor and the
+ * same backoffice flag, and differs from it in three ways the wizard has to
+ * enforce rather than merely explain — it is always Ethiopian, it cannot hold
+ * the freight-forwarder role, and it owes no per-role business licence. Its own
+ * document set stands in for the trade licence.
+ *
+ * One journey across both apps; retries off (see onboarding_ethiopian.cy.ts).
+ */
+
+import { completeFaydaVerification } from "./import-utils";
+import {
+ apiRequest,
+ attachNextFile,
+ companyByEmail,
+ fill,
+ fillAria,
+ fillPhone,
+ latestJourney,
+ noLicenceTin,
+ openCustomer,
+ portalToken,
+ signupCustomer,
+ signupIdentity,
+ typeRegistration,
+ vatNumber,
+ wizardClick,
+} from "./onboarding-utils";
+
+const stamp = Date.now();
+const who = signupIdentity("coop", stamp);
+
+describe("onboarding — co-operative union or farm", { retries: 0 }, () => {
+ it("types its registration, owes no business licence, and submits", () => {
+ signupCustomer(who, "Cooperative");
+
+ // Before the box: both nationalities and all three roles are on offer.
+ cy.contains("button", "Foreign Company").should("be.visible");
+ cy.contains("button", "Freight Forwarder").should("be.visible");
+
+ cy.contains("We're a co-operative union or farm").click();
+
+ // A co-op is registered in Ethiopia by the co-operative promotion agency,
+ // holds no licence, and cannot forward freight — so none of those choices
+ // are offered rather than refused later by the API.
+ cy.contains("button", "Foreign Company").should("not.exist");
+ cy.contains("button", "Freight Forwarder").should("not.exist");
+ cy.contains("We operate on a foreign investment licence").should("not.exist");
+
+ cy.contains("button", "Importer").click();
+ wizardClick("Continue");
+
+ // ── Company step ────────────────────────────────────────────────────
+ cy.contains("Confirm your VAT number", { timeout: 20000 }).should(
+ "be.visible",
+ );
+ fillAria('[aria-label^="TIN Number"]', noLicenceTin(stamp));
+ cy.contains("Nothing on file at eTrade for this TIN", {
+ timeout: 20000,
+ }).should("be.visible");
+
+ typeRegistration(`E2E Farmers Union ${stamp}`);
+ fillAria('[aria-label="VAT Number"]', vatNumber(stamp));
+ wizardClick("Continue");
+
+ // ── Owner step ──────────────────────────────────────────────────────
+ cy.contains("Company Owner", { timeout: 20000 }).should("be.visible");
+ fill(/^Owner's Name/, "Union Chairperson");
+ fill(/^Owner's Email/, `chair.${stamp}@example.com`);
+ fillPhone(0, "911234572");
+ wizardClick("Continue");
+
+ // ── Representation step ─────────────────────────────────────────────
+ cy.contains("Does anyone hold power of attorney for this company?", {
+ timeout: 20000,
+ }).should("be.visible");
+ cy.contains("button", "No, the owner acts for us").click();
+
+ // Ethiopian, so Fayda is the only route — the passport alternative belongs
+ // to foreign companies.
+ cy.contains("Use a passport instead").should("not.exist");
+ completeFaydaVerification("owner");
+ cy.reload();
+ cy.get(".mantine-Modal-content", { timeout: 30000 }).within(() => {
+ cy.contains("Fayda verified").should("be.visible");
+ });
+ wizardClick("Continue");
+
+ // ── Contact step ────────────────────────────────────────────────────
+ cy.contains("Contact Person", { timeout: 20000 }).should("be.visible");
+ fill(/^Name$/, "Union Contact");
+ fillPhone(0, "911234573");
+ wizardClick("Continue");
+
+ // ── Documents step ──────────────────────────────────────────────────
+ // The co-operative set replaces the nationality one, and the per-role
+ // licence cards are not rendered at all — offering a slot nothing can fill
+ // reads as an unfinishable step.
+ cy.contains("Upload Documents", { timeout: 20000 }).should("be.visible");
+ cy.contains("Upload Importer Business license file(s)").should("not.exist");
+ cy.get('.mantine-Modal-content input[type="file"]').should(
+ "have.length",
+ 1,
+ );
+
+ portalToken(who.email).then((token) =>
+ apiRequest(token, "GET", "/api/companies/onboarding/requirements").then(
+ (res) => {
+ expect(res.body.data.documentSettingCode).to.eq(
+ "company_onboarding_documents_cooperative",
+ );
+ expect(res.body.data.cooperative).to.eq(true);
+ expect(res.body.data.investorLicence).to.eq(false);
+ // The requirement is lifted, not merely hidden on screen.
+ const outstanding: string[] = res.body.data.outstanding;
+ expect(
+ outstanding.filter((o) => /business license/i.test(o)),
+ "no licence is owed",
+ ).to.have.length(0);
+ },
+ ),
+ );
+
+ attachNextFile();
+ wizardClick("Submit for review");
+ cy.contains("You're all set", { timeout: 30000 }).should("be.visible");
+
+ companyByEmail(who.email).then((company) => {
+ expect(company.status).to.eq("pending");
+ expect(company.onboarding_completed).to.eq(true);
+ expect(company.nationality).to.eq("ethiopian");
+ expect((company.attributes ?? {})["cooperative"]).to.eq(true);
+ expect((company.attributes ?? {})["investorLicence"]).to.be.undefined;
+ expect(company.licence_number, "no eTrade licence").to.be.null;
+ });
+ });
+
+ it("the backoffice flags it as a co-operative", () => {
+ cy.loginBackoffice("chief@edr.local");
+
+ latestJourney("coop").then((company) => {
+ openCustomer(company.name);
+
+ cy.contains("Manual entry · co-operative").should("be.visible");
+ cy.contains("Registration entered by hand — not verified against eTrade")
+ .should("be.visible");
+ cy.contains(
+ "Check them against the Co-operative Registration Certificate",
+ ).should("be.visible");
+ cy.contains("Co-operative union / farm (no trade licence)").should(
+ "be.visible",
+ );
+ // The two manual routes must not be confused with one another.
+ cy.contains("Manual entry · investment licence").should("not.exist");
+ });
+ });
+});
+
+export {};
diff --git a/e2e/freight/cypress/e2e/flows/onboarding_ethiopian.cy.ts b/e2e/freight/cypress/e2e/flows/onboarding_ethiopian.cy.ts
new file mode 100644
index 000000000..e6f08cc95
--- /dev/null
+++ b/e2e/freight/cypress/e2e/flows/onboarding_ethiopian.cy.ts
@@ -0,0 +1,180 @@
+/**
+ * The ordinary onboarding journey, both apps — the baseline every other
+ * onboarding spec is a deviation from:
+ *
+ * 1. portal signup + OTP → nationality/role → company (eTrade lookup) →
+ * owner → representation (Fayda) → contact → documents →
+ * "Submit for review"
+ * 2. backoffice staff approve the importer profile; the customer carries NO
+ * manual-entry flag, because eTrade answered for its TIN
+ * 3. portal the approved customer reaches the contract wizard
+ *
+ * Sequential steps of ONE journey, so retries are off — a mid-journey retry
+ * would replay a non-idempotent step against already-advanced state. Switching
+ * origin between tests (portal ↔ backoffice) re-evaluates the spec bundle and
+ * wipes module state, so the later tests resolve the journey from the DB.
+ */
+
+import { completeFaydaVerification } from "./import-utils";
+import {
+ attachNextFile,
+ companyByEmail,
+ etradeTin,
+ expectProfileActive,
+ fill,
+ fillAria,
+ fillPhone,
+ latestJourney,
+ noLicenceTin,
+ openCustomer,
+ approveFirstProfile,
+ signupCustomer,
+ signupIdentity,
+ vatNumber,
+ wizardClick,
+ SIGNUP_PASSWORD,
+} from "./onboarding-utils";
+
+const stamp = Date.now();
+const who = signupIdentity("ethiopian", stamp);
+
+describe("onboarding — Ethiopian company, eTrade verified", { retries: 0 }, () => {
+ it("completes the wizard and submits for review", () => {
+ signupCustomer(who, "Ethiopian");
+
+ cy.contains("button", "Ethiopian Company").click();
+ // An Ethiopian company is never offered the investment licence — that is a
+ // foreign company's document, and the API refuses the pair.
+ cy.contains("We operate on a foreign investment licence").should("not.exist");
+ cy.contains("button", "Importer").click();
+ wizardClick("Continue");
+
+ // ── Company step ────────────────────────────────────────────────────
+ cy.contains("Confirm your VAT number", { timeout: 20000 }).should(
+ "be.visible",
+ );
+
+ // A TIN eTrade knows but which holds no trade licence is a dead end for an
+ // ordinary company: the alert is red, and Continue must refuse rather than
+ // carry unverified registration data forward.
+ fillAria('[aria-label^="TIN Number"]', noLicenceTin(stamp));
+ cy.contains("No matching business record", { timeout: 20000 }).should(
+ "be.visible",
+ );
+ wizardClick("Continue");
+ cy.contains("We need to confirm your TIN with eTrade before continuing.").should(
+ "be.visible",
+ );
+
+ // The real one. A successful lookup fills and locks company name, region,
+ // zone, woreda, kebele and house number (ETradeCompanyCard).
+ fillAria('[aria-label^="TIN Number"]', etradeTin(stamp));
+ cy.contains("Verified with eTrade", { timeout: 20000 }).should("be.visible");
+ // handleETradeDataLoaded sets a dozen fields in sequence — each a render.
+ // Typing into VAT immediately races one of those and detaches mid-type.
+ cy.wait(500);
+ fillAria('[aria-label="VAT Number"]', vatNumber(stamp));
+ wizardClick("Continue");
+
+ // ── Owner step ──────────────────────────────────────────────────────
+ // Name and phone came from the licence and are read-only; eTrade carries
+ // no email, so that one is asked for.
+ cy.contains("Company Owner", { timeout: 20000 }).should("be.visible");
+ fill(/^Owner's Email/, `owner.${stamp}@example.com`);
+ wizardClick("Continue");
+
+ // ── Representation step ─────────────────────────────────────────────
+ cy.contains("Does anyone hold power of attorney for this company?", {
+ timeout: 20000,
+ }).should("be.visible");
+ cy.contains("button", "No, the owner acts for us").click();
+
+ // An Ethiopian company has no passport alternative — Fayda or nothing.
+ cy.contains("Use a passport instead").should("not.exist");
+ // A real eSignet redirect + SMS OTP can't run in e2e, so complete it
+ // against fayda-mock-e2e via the API. The step already fetched `identity`
+ // when it mounted, and completing out-of-band skips the redirect that
+ // would remount everything — reload to force a fresh fetch. Wizard
+ // progress is server-side, so nothing already answered is lost.
+ completeFaydaVerification("owner");
+ cy.reload();
+
+ cy.get(".mantine-Modal-content", { timeout: 30000 }).within(() => {
+ cy.contains("Fayda verified").should("be.visible");
+ // The mock's own payload — proof it travelled Fayda → API → UI rather
+ // than a flag simply flipping.
+ cy.contains("Abebe Bekele").should("be.visible");
+ });
+
+ // The verified sub is what locks the owner's fields server-side.
+ companyByEmail(who.email).then((company) => {
+ expect(
+ (company.attributes ?? {})["ownerFaydaSub"],
+ "owner Fayda sub",
+ ).to.eq("e2e-fayda-sub-0001");
+ });
+ wizardClick("Continue");
+
+ // ── Contact step ────────────────────────────────────────────────────
+ cy.contains("Contact Person", { timeout: 20000 }).should("be.visible");
+ fill(/^Name$/, "Contact Person");
+ fillPhone(0, "911234569");
+ wizardClick("Continue");
+
+ // ── Documents step ──────────────────────────────────────────────────
+ // One company document is seeded per set on a fresh e2e database
+ // (FileUploadSettingsSeeder gives a new set exactly its first field), and
+ // every operational profile owes a business licence.
+ cy.contains("Upload Importer Business license file(s)", {
+ timeout: 20000,
+ }).should("be.visible");
+ attachNextFile();
+ attachNextFile();
+ wizardClick("Submit for review");
+
+ cy.contains("You're all set", { timeout: 30000 }).should("be.visible");
+
+ companyByEmail(who.email).then((company) => {
+ expect(company.status).to.eq("pending");
+ expect(company.onboarding_completed).to.eq(true);
+ expect(company.nationality).to.eq("ethiopian");
+ // eTrade answered, so neither manual-entry flag is set and the licence
+ // it returned is on file.
+ const attributes = company.attributes ?? {};
+ expect(attributes["investorLicence"]).to.be.undefined;
+ expect(attributes["cooperative"]).to.be.undefined;
+ expect(company.licence_number, "eTrade licence").to.eq("LIC-E2E-0001");
+ });
+ });
+
+ it("backoffice approves it, with no manual-entry flag anywhere", () => {
+ cy.loginBackoffice("chief@edr.local");
+
+ latestJourney("ethiopian").then((company) => {
+ openCustomer(company.name);
+
+ // The badge and banner belong to companies whose registration was typed.
+ // This one's came from eTrade, so neither may appear.
+ cy.contains("Manual entry").should("not.exist");
+ cy.contains("Registration entered by hand").should("not.exist");
+ cy.contains("eTrade trade licence").should("be.visible");
+
+ approveFirstProfile();
+ expectProfileActive(company.name);
+ });
+ });
+
+ it("the approved customer reaches the contract wizard", () => {
+ latestJourney("ethiopian").then((company) => {
+ cy.loginPortal(company.email, SIGNUP_PASSWORD);
+ });
+ cy.visitPortal("/contracts/new");
+
+ cy.contains("label", "Operation Type", { timeout: 20000 }).should(
+ "be.visible",
+ );
+ cy.contains("Awaiting Approval").should("not.exist");
+ });
+});
+
+export {};
diff --git a/e2e/freight/cypress/e2e/flows/onboarding_guards.cy.ts b/e2e/freight/cypress/e2e/flows/onboarding_guards.cy.ts
new file mode 100644
index 000000000..a4f1ab365
--- /dev/null
+++ b/e2e/freight/cypress/e2e/flows/onboarding_guards.cy.ts
@@ -0,0 +1,171 @@
+/**
+ * The states onboarding must refuse, and the one it must undo.
+ *
+ * Two halves. The API guards are cheap `cy.request` checks against the seeded
+ * demo customer — combinations the portal never offers, which is exactly why
+ * they have to be refused server-side rather than merely hidden. The second
+ * half is the expensive one and the reason this spec exists at all: going back
+ * in the wizard and un-ticking the investment licence has to cost what the
+ * settings switch costs, or a company finishes onboarding on registration data
+ * nobody verified, with no flag left to say so.
+ */
+
+import {
+ apiRequest,
+ companyByEmail,
+ fillAria,
+ portalToken,
+ signupCustomer,
+ signupIdentity,
+ typeRegistration,
+ noLicenceTin,
+ vatNumber,
+ wizardClick,
+} from "./onboarding-utils";
+
+const stamp = Date.now();
+const who = signupIdentity("toggle", stamp);
+
+/** The seeded demo customer: an ordinary company that came through eTrade. */
+const DEMO_CUSTOMER = "user@gmail.com";
+const demoPassword = () => Cypress.env("demoPassword") as string;
+
+describe("onboarding — refused combinations", { retries: 0 }, () => {
+ it("refuses an investment licence for an Ethiopian company", () => {
+ portalToken(DEMO_CUSTOMER, demoPassword()).then((token) =>
+ apiRequest(
+ token,
+ "POST",
+ "/api/companies/onboarding/start",
+ {
+ companyType: "customer",
+ roles: ["importer"],
+ nationality: "ethiopian",
+ investorLicence: true,
+ },
+ false,
+ ).then((res) => {
+ expect(res.status).to.eq(400);
+ expect(JSON.stringify(res.body)).to.contain(
+ "Only a foreign company can onboard on an investment licence",
+ );
+ }),
+ );
+ });
+
+ it("refuses a co-operative that also claims an investment licence", () => {
+ // Ethiopian on purpose: a co-op sent as foreign is refused by the older
+ // co-operative guard, which would pass this test without the new one ever
+ // running. Ethiopian gets past that guard and lands on this one.
+ portalToken(DEMO_CUSTOMER, demoPassword()).then((token) =>
+ apiRequest(
+ token,
+ "POST",
+ "/api/companies/onboarding/start",
+ {
+ companyType: "customer",
+ roles: ["importer"],
+ nationality: "ethiopian",
+ cooperative: true,
+ investorLicence: true,
+ },
+ false,
+ ).then((res) => {
+ expect(res.status).to.eq(400);
+ expect(JSON.stringify(res.body)).to.contain(
+ "it cannot also onboard on a foreign investment licence",
+ );
+ }),
+ );
+ });
+
+ it("refuses to switch a company that never took the route", () => {
+ portalToken(DEMO_CUSTOMER, demoPassword()).then((token) =>
+ apiRequest(
+ token,
+ "POST",
+ "/api/companies/onboarding/revert-to-etrade",
+ undefined,
+ false,
+ ).then((res) => {
+ expect(res.status).to.eq(400);
+ expect(JSON.stringify(res.body)).to.contain(
+ "already registered through eTrade",
+ );
+ }),
+ );
+ });
+});
+
+describe("onboarding — un-ticking the box mid-wizard", { retries: 0 }, () => {
+ it("clears the typed registration and reopens on the company step", () => {
+ signupCustomer(who, "Toggle");
+
+ cy.contains("button", "Foreign Company").click();
+ cy.contains("We operate on a foreign investment licence").click();
+ cy.contains("button", "Importer").click();
+ wizardClick("Continue");
+
+ cy.contains("Confirm your VAT number", { timeout: 20000 }).should(
+ "be.visible",
+ );
+
+ // A TIN eTrade knows but which holds no trade licence — the manual route's
+ // own shape. (A TIN eTrade has never heard of surfaces as an outage rather
+ // than as "nothing on file": the API wraps its 404 as "Failed to fetch",
+ // which ETradeInfo reads as unreachable. Different message, different
+ // test.)
+ fillAria('[aria-label^="TIN Number"]', noLicenceTin(stamp));
+ cy.contains("Nothing on file at eTrade for this TIN", {
+ timeout: 20000,
+ }).should("be.visible");
+ typeRegistration(`E2E Toggle Trading ${stamp}`);
+ fillAria('[aria-label="VAT Number"]', vatNumber(stamp));
+ wizardClick("Continue");
+
+ // The typed registration is now on file.
+ cy.contains("Company Owner", { timeout: 20000 }).should("be.visible");
+ companyByEmail(who.email).then((company) => {
+ expect(company.region, "typed address saved").to.eq("Addis Ababa");
+ expect((company.attributes ?? {})["investorLicence"]).to.eq(true);
+ });
+
+ // Back to the company step, then back again to the nationality phase.
+ wizardClick("Back");
+ cy.contains("Confirm your VAT number", { timeout: 20000 }).should(
+ "be.visible",
+ );
+ wizardClick("Back");
+ // `exist`, not `visible`: the heading scrolls under the modal's sticky
+ // header, and where the dialog happens to be scrolled says nothing about
+ // whether we are back on the nationality phase. The checkbox below is the
+ // thing this test actually needs to reach.
+ cy.contains("Where is your company registered?", { timeout: 20000 }).should(
+ "exist",
+ );
+
+ // Change of mind: this company is an ordinary foreign company after all.
+ cy.contains("We operate on a foreign investment licence").click();
+ wizardClick("Continue");
+
+ // Whatever was typed under the flag is gone, and the resume target is the
+ // company step — not the furthest step reached, which would skip the
+ // eTrade lookup the customer has just opted back into.
+ companyByEmail(who.email).then((company) => {
+ expect((company.attributes ?? {})["investorLicence"]).to.eq(false);
+ expect(company.region, "typed address cleared").to.be.null;
+ expect(company.licence_number).to.be.null;
+ expect(company.etrade_phone).to.be.null;
+ expect(company.onboarding_step).to.eq("company");
+ });
+
+ cy.contains("Confirm your VAT number", { timeout: 20000 }).should(
+ "be.visible",
+ );
+ // No typed-registration block any more: eTrade owns these fields again.
+ cy.contains("Nothing on file at eTrade for this TIN").should("not.exist");
+ cy.contains("Registration details").should("not.exist");
+ });
+});
+
+export {};
diff --git a/e2e/freight/cypress/e2e/flows/onboarding_investor.cy.ts b/e2e/freight/cypress/e2e/flows/onboarding_investor.cy.ts
new file mode 100644
index 000000000..50ae4ce36
--- /dev/null
+++ b/e2e/freight/cypress/e2e/flows/onboarding_investor.cy.ts
@@ -0,0 +1,123 @@
+/**
+ * A foreign company onboarding on an Ethiopian Investment Commission licence.
+ *
+ * The Commission licenses it, not the trade registry, so eTrade holds no
+ * record for its TIN: the registration is typed, the company is flagged
+ * `investorLicence`, and the backoffice is told in as many words that nothing
+ * on the screen was verified against a licence. What does NOT change is the
+ * business licence per operational profile — an investor holds one, unlike a
+ * co-operative — so the documents step still asks for it.
+ *
+ * The wizard itself is driven by `completeInvestorOnboarding`, which carries
+ * the step-by-step assertions (the blue "nothing on file" alert, the absent
+ * eTrade manager, the refusal to continue on an unproven identity) because
+ * they hold for every investor run. What lives here is what is specific to
+ * this journey: the document requirements, the persisted flag, and the
+ * backoffice's treatment of it.
+ *
+ * One journey across both apps; retries off (see onboarding_ethiopian.cy.ts).
+ */
+
+import {
+ apiRequest,
+ approveFirstProfile,
+ companyByEmail,
+ completeInvestorOnboarding,
+ expectProfileActive,
+ latestJourney,
+ portalToken,
+ signupIdentity,
+ SIGNUP_PASSWORD,
+} from "./onboarding-utils";
+
+const stamp = Date.now();
+const who = signupIdentity("investor", stamp);
+
+describe("onboarding — foreign investor, no eTrade record", { retries: 0 }, () => {
+ it("types its registration and submits for review", () => {
+ completeInvestorOnboarding(who, stamp, {
+ // On the documents step, everything attached, nothing submitted yet.
+ beforeSubmit: () => {
+ portalToken(who.email).then((token) =>
+ apiRequest(
+ token,
+ "GET",
+ "/api/companies/onboarding/requirements",
+ ).then((res) => {
+ // The foreign set applies unchanged — it already asks for the
+ // investment licence itself, so no third set exists for this case.
+ expect(res.body.data.documentSettingCode).to.eq(
+ "company_onboarding_documents_foreign",
+ );
+ expect(res.body.data.investorLicence, "investorLicence flag").to.eq(
+ true,
+ );
+ expect(res.body.data.cooperative).to.eq(false);
+ // The one thing this route does NOT share with a co-operative.
+ expect(
+ res.body.data.licenseProfiles,
+ "per-role licence still tracked",
+ ).to.have.length(1);
+ }),
+ );
+ },
+ });
+
+ companyByEmail(who.email).then((company) => {
+ expect(company.status).to.eq("pending");
+ expect(company.onboarding_completed).to.eq(true);
+ expect(company.nationality).to.eq("foreign");
+ expect((company.attributes ?? {})["investorLicence"]).to.eq(true);
+ expect(company.name).to.contain("E2E Investor Holdings");
+ // Typed, not fetched: the address is what the customer entered, and no
+ // licence number exists at all.
+ expect(company.region).to.eq("Addis Ababa");
+ expect(company.licence_number, "no eTrade licence").to.be.null;
+ });
+ });
+
+ it("the backoffice flags the typed registration, then approves", () => {
+ cy.loginBackoffice("chief@edr.local");
+
+ latestJourney("investor").then((company) => {
+ cy.visit("/dashboard/customers");
+ cy.get('input[placeholder*="Search by company"]').type(company.name);
+ cy.contains(company.name, { timeout: 20000 }).should("be.visible");
+
+ // The list is where a reviewer first meets this customer, so the flag
+ // has to be there and not only on the detail page.
+ cy.contains("Manual entry · investment licence").should("be.visible");
+ cy.contains(company.name).click();
+
+ cy.contains("Manual entry · investment licence").should("be.visible");
+ cy.contains(
+ "Registration entered by hand — not verified against eTrade",
+ ).should("be.visible");
+ cy.contains("Check them against the Investment Licence").should(
+ "be.visible",
+ );
+ cy.contains(
+ "Foreign investment licence — typed by the customer, not from eTrade",
+ ).should("be.visible");
+ cy.contains("Manual entry · co-operative").should("not.exist");
+
+ // Flagging is advisory: approval itself is not blocked.
+ approveFirstProfile();
+ expectProfileActive(company.name);
+ });
+ });
+
+ it("the approved investor reaches the contract wizard", () => {
+ latestJourney("investor").then((company) => {
+ cy.loginPortal(company.email, SIGNUP_PASSWORD);
+ });
+ cy.visitPortal("/contracts/new");
+
+ cy.contains("label", "Operation Type", { timeout: 20000 }).should(
+ "be.visible",
+ );
+ cy.contains("Awaiting Approval").should("not.exist");
+ });
+});
+
+export {};
diff --git a/e2e/freight/cypress/e2e/flows/onboarding_switch_back.cy.ts b/e2e/freight/cypress/e2e/flows/onboarding_switch_back.cy.ts
new file mode 100644
index 000000000..e6c903e89
--- /dev/null
+++ b/e2e/freight/cypress/e2e/flows/onboarding_switch_back.cy.ts
@@ -0,0 +1,169 @@
+/**
+ * Giving the investment-licence route back.
+ *
+ * A company that ticked the box by mistake, or that has since been registered
+ * with the trade registry, switches from Settings → Company. It is a
+ * re-application rather than a settings edit, and this spec is about proving
+ * that literally: the typed registration is cleared (nothing on file was ever
+ * checked against a licence), the company returns to pending, onboarding
+ * reopens on the company step — and only after a real eTrade lookup does the
+ * backoffice stop flagging it.
+ *
+ * Each test is one leg of a single journey, in order, retries off. The
+ * portal ↔ backoffice hops re-evaluate the spec bundle, so every leg resolves
+ * the company from the database rather than from module state.
+ */
+
+import {
+ approveFirstProfile,
+ companyByEmail,
+ completeInvestorOnboarding,
+ etradeTin,
+ expectProfileActive,
+ fillAria,
+ latestJourney,
+ openCustomer,
+ signupIdentity,
+ wizardClick,
+ SIGNUP_PASSWORD,
+} from "./onboarding-utils";
+
+const stamp = Date.now();
+const who = signupIdentity("switchback", stamp);
+
+describe("onboarding — switching back to eTrade registration", { retries: 0 }, () => {
+ it("onboards on an investment licence", () => {
+ completeInvestorOnboarding(who, stamp, {
+ companyName: `E2E Switchback Trading ${stamp}`,
+ });
+
+ companyByEmail(who.email).then((company) => {
+ expect((company.attributes ?? {})["investorLicence"]).to.eq(true);
+ expect(company.region, "typed address").to.eq("Addis Ababa");
+ });
+ });
+
+ it("is approved by the backoffice", () => {
+ cy.loginBackoffice("chief@edr.local");
+ latestJourney("switchback").then((company) => {
+ openCustomer(company.name);
+ cy.contains("Manual entry · investment licence").should("be.visible");
+ approveFirstProfile();
+ expectProfileActive(company.name);
+ });
+ });
+
+ it("switches back from settings, which clears the typed registration", () => {
+ latestJourney("switchback").then((company) => {
+ cy.loginPortal(company.email, SIGNUP_PASSWORD);
+ });
+ cy.visitPortal("/settings");
+
+ cy.contains("Registration source", { timeout: 20000 }).should("be.visible");
+ cy.contains("button", "Switch to eTrade registration").click();
+
+ // The confirmation has to state the cost outright — this is the screen
+ // that decides whether the customer knows they are re-applying.
+ cy.contains("Switch to eTrade registration?").should("be.visible");
+ cy.contains("The registration details you typed are cleared").should(
+ "be.visible",
+ );
+ cy.contains("Your company goes back to pending").should("be.visible");
+ cy.contains("button", "Switch and re-apply").click();
+
+ cy.contains("Registration source", { timeout: 20000 }).should("not.exist");
+
+ latestJourney("switchback").then((company) => {
+ expect((company.attributes ?? {})["investorLicence"]).to.be.undefined;
+ expect(company.status).to.eq("pending");
+ expect(company.onboarding_completed).to.eq(false);
+ // The wizard treats a populated registration as a lookup that already
+ // passed, so leaving any of it behind would walk the customer straight
+ // past the eTrade step this switch exists to reach.
+ expect(company.region, "typed address cleared").to.be.null;
+ expect(company.licence_number).to.be.null;
+ expect(company.etrade_phone).to.be.null;
+ expect(company.onboarding_step, "resume target").to.eq("company");
+ });
+ });
+
+ it("re-runs onboarding through eTrade and resubmits", () => {
+ latestJourney("switchback").then((company) => {
+ cy.loginPortal(company.email, SIGNUP_PASSWORD);
+ });
+ cy.visitPortal("/portal");
+
+ // Reopened on the company step — not on the furthest step reached before.
+ cy.contains("Confirm your VAT number", { timeout: 30000 }).should(
+ "be.visible",
+ );
+ // The typed registration section is gone: this is an ordinary company now.
+ cy.contains("Nothing on file at eTrade for this TIN").should("not.exist");
+
+ // Wait for the profile to rehydrate before typing. The company still holds
+ // the TIN it typed under the flag (the switch clears the registration, not
+ // the tax number), and RHF re-seeds the field when the refetch lands — so a
+ // TIN typed into an empty-looking field is silently replaced by the stored
+ // one, and the lookup then runs against the wrong number.
+ cy.get('.mantine-Modal-content [aria-label="VAT Number"]')
+ .should("not.have.value", "");
+ fillAria('[aria-label^="TIN Number"]', etradeTin(stamp));
+ cy.get('.mantine-Modal-content [aria-label^="TIN Number"]').should(
+ "have.value",
+ etradeTin(stamp),
+ );
+ cy.contains("Verified with eTrade", { timeout: 20000 }).should("be.visible");
+ cy.wait(500);
+ wizardClick("Continue");
+
+ // Owner, representation and contact are already satisfied server-side —
+ // the switch keeps everything except the registration — so each step only
+ // needs advancing.
+ cy.contains("Company Owner", { timeout: 20000 }).should("be.visible");
+ wizardClick("Continue");
+ cy.contains("The owner acts for the company", { timeout: 20000 }).should(
+ "be.visible",
+ );
+ wizardClick("Continue");
+ cy.contains("Contact Person", { timeout: 20000 }).should("be.visible");
+ wizardClick("Continue");
+
+ // Documents and the licence were uploaded before the switch and survive
+ // it, so the step is already satisfied.
+ cy.contains("Upload Importer Business license file(s)", {
+ timeout: 20000,
+ }).should("be.visible");
+ wizardClick("Submit for review");
+ cy.contains("You're all set", { timeout: 30000 }).should("be.visible");
+
+ latestJourney("switchback").then((company) => {
+ expect(company.onboarding_completed).to.eq(true);
+ expect((company.attributes ?? {})["investorLicence"]).to.be.undefined;
+ // eTrade answered this time, and its licence is on file.
+ expect(company.licence_number).to.eq("LIC-E2E-0001");
+ });
+ });
+
+ it("the backoffice no longer flags it", () => {
+ cy.loginBackoffice("chief@edr.local");
+ latestJourney("switchback").then((company) => {
+ openCustomer(company.name);
+
+ cy.contains("eTrade trade licence").should("be.visible");
+ cy.contains("Manual entry").should("not.exist");
+ cy.contains("Registration entered by hand").should("not.exist");
+ });
+ });
+
+ it("offers nothing to switch to a customer who came through eTrade", () => {
+ // The seeded demo customer onboarded the ordinary way, so the card must
+ // not be on its settings page at all.
+ cy.loginPortal();
+ cy.visitPortal("/settings");
+
+ cy.contains("Operational Services", { timeout: 20000 }).should("be.visible");
+ cy.contains("Registration source").should("not.exist");
+ });
+});
+
+export {};
diff --git a/e2e/freight/etrade-mock/server.js b/e2e/freight/etrade-mock/server.js
index 95f43ded2..f146637a7 100644
--- a/e2e/freight/etrade-mock/server.js
+++ b/e2e/freight/etrade-mock/server.js
@@ -9,10 +9,30 @@
// TLS_CERT_PATH/TLS_KEY_PATH before starting this — nothing shaped like a
// key/cert is committed here.
//
-// Every TIN resolves to the same canned company — these specs don't care
-// about per-TIN business logic, only that the lookup succeeds so the rest
-// of the company-info form (name, address, manager) auto-fills instead of
-// staying gated behind a "No matching business record" alert.
+// A TIN resolves to the same canned company whatever its digits, EXCEPT for
+// the two failure shapes the onboarding specs need — chosen by the TIN's
+// leading digit so a spec picks its outcome by picking its number, with no
+// per-spec stubbing and no state in this process:
+//
+// 1xxxxxxxxx (default) registration + one business licence → "Verified with eTrade"
+// 9xxxxxxxxx registration, but `Businesses: []` → the API's resolveCompanyData
+// returns businessInfo: null,
+// so /fetch-etrade-info 400s
+// with "couldn't find a
+// business license for this
+// TIN". This is the real
+// co-operative / foreign-
+// investor case: the TIN is
+// registered, the trade
+// licence is not.
+// 8xxxxxxxxx 404 on the registration lookup → getCompanyInfoByTin throws,
+// same 400 to the portal by a
+// different route (eTrade knows
+// nothing about this TIN at all).
+//
+// Both failures reach the portal as a 400, which ETradeInfo renders as its
+// `notFound` branch: a red dead end for an ordinary company, and the blue
+// "that's expected" alert for one that types its registration.
const https = require("node:https");
const fs = require("node:fs");
@@ -25,6 +45,11 @@ const options = {
),
};
+/** No trade licence on file for this TIN (a co-operative, a foreign investor). */
+const TIN_WITHOUT_LICENCE = "9";
+/** eTrade holds no registration whatsoever for this TIN. */
+const TIN_UNKNOWN = "8";
+
function companyInfo(tin) {
return {
Tin: tin,
@@ -101,8 +126,21 @@ const server = https.createServer(options, (req, res) => {
/^\/api\/Registration\/GetRegistrationInfoByTin\/([^/]+)\/en$/,
);
if (req.method === "GET" && regMatch) {
+ const tin = regMatch[1];
+
+ if (tin.startsWith(TIN_UNKNOWN)) {
+ res.writeHead(404).end();
+ return;
+ }
+
+ const info = companyInfo(tin);
+ // Registered, but holding no trade licence. The API reads `Businesses`
+ // rather than the HTTP status to decide this, so an empty array is the
+ // honest shape — not an error.
+ if (tin.startsWith(TIN_WITHOUT_LICENCE)) info.Businesses = [];
+
res.writeHead(200, { "content-type": "application/json" });
- res.end(JSON.stringify(companyInfo(regMatch[1])));
+ res.end(JSON.stringify(info));
return;
}
diff --git a/infrastructure/docker/Dockerfile.web b/infrastructure/docker/Dockerfile.web
index 1e26bf643..714e49d33 100644
--- a/infrastructure/docker/Dockerfile.web
+++ b/infrastructure/docker/Dockerfile.web
@@ -30,6 +30,7 @@ ARG NEXT_PUBLIC_API_URL
ARG VITE_GOOGLE_MAPS_API_KEY
ARG VITE_POSTHOG_KEY
ARG VITE_POSTHOG_HOST
+ARG VITE_ENV
ENV VITE_API_URL=${VITE_API_URL}
ENV VITE_BASE_API_URL=${VITE_BASE_API_URL}
ENV VITE_USER_MANAGEMENT_BASE=${VITE_USER_MANAGEMENT_BASE}
@@ -37,6 +38,8 @@ ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
ENV VITE_GOOGLE_MAPS_API_KEY=${VITE_GOOGLE_MAPS_API_KEY}
ENV VITE_POSTHOG_KEY=${VITE_POSTHOG_KEY}
ENV VITE_POSTHOG_HOST=${VITE_POSTHOG_HOST}
+# dev|staging only — gates the Fayda/OTP bypass. Unset in prod.
+ENV VITE_ENV=${VITE_ENV}
RUN if [ -z "$VITE_API_URL" ] || [ -z "$VITE_BASE_API_URL" ] || [ -z "$VITE_USER_MANAGEMENT_BASE" ]; then \
echo "ERROR: VITE_API_URL, VITE_BASE_API_URL, and VITE_USER_MANAGEMENT_BASE must all be set" && \
diff --git a/infrastructure/matrix/element/40-element-config.sh b/infrastructure/matrix/element/40-element-config.sh
new file mode 100644
index 000000000..9826a5fc7
--- /dev/null
+++ b/infrastructure/matrix/element/40-element-config.sh
@@ -0,0 +1,18 @@
+#!/bin/sh
+# Renders /app/config.json from config.json.tmpl at container start.
+#
+# The homeserver URL and server_name differ per environment, and config.json is
+# read by the browser rather than the build, so baking it into the image would
+# mean one image per environment. Dropped into /docker-entrypoint.d, which the
+# upstream nginx entrypoint runs (in lexical order) before starting nginx —
+# no ENTRYPOINT override, so the image's own startup work still happens.
+set -eu
+
+: "${MATRIX_PUBLIC_BASEURL:?MATRIX_PUBLIC_BASEURL is required}"
+: "${MATRIX_SERVER_NAME:?MATRIX_SERVER_NAME is required}"
+: "${ELEMENT_PUBLIC_URL:?ELEMENT_PUBLIC_URL is required}"
+
+envsubst '${MATRIX_PUBLIC_BASEURL} ${MATRIX_SERVER_NAME} ${ELEMENT_PUBLIC_URL}' \
+ < /app/config.json.tmpl > /app/config.json
+
+echo "element-config: homeserver ${MATRIX_PUBLIC_BASEURL} (${MATRIX_SERVER_NAME})"
diff --git a/infrastructure/matrix/element/Dockerfile b/infrastructure/matrix/element/Dockerfile
new file mode 100644
index 000000000..330098af6
--- /dev/null
+++ b/infrastructure/matrix/element/Dockerfile
@@ -0,0 +1,23 @@
+# syntax=docker/dockerfile:1
+#
+# EDR internal chat web client. Unmodified upstream Element Web + our public,
+# non-secret config (homeserver URL, branding) and the SSO handoff page.
+# Pin the tag; never float on `latest`.
+FROM ghcr.io/element-hq/element-web:v1.11.108
+
+COPY config.json.tmpl /app/config.json.tmpl
+COPY sso.html /app/sso.html
+# Replaces the upstream manifest, which names the app "Element" and advertises
+# the Play/App Store builds under related_applications. Those apps cannot log
+# in here — this deployment has no password login and no SSO provider, only the
+# JWT handoff from freight-api — so pointing staff at them is a dead end.
+COPY manifest.json /app/manifest.json
+COPY 40-element-config.sh /docker-entrypoint.d/40-element-config.sh
+
+# The image runs as uid 101 (nginx) but ships /app root-owned, so the startup
+# hook could not write the rendered config without this.
+USER root
+RUN chmod +x /docker-entrypoint.d/40-element-config.sh \
+ && touch /app/config.json \
+ && chown nginx:nginx /app/config.json
+USER nginx
diff --git a/infrastructure/matrix/element/config.json.tmpl b/infrastructure/matrix/element/config.json.tmpl
new file mode 100644
index 000000000..f08900601
--- /dev/null
+++ b/infrastructure/matrix/element/config.json.tmpl
@@ -0,0 +1,18 @@
+{
+ "default_server_config": {
+ "m.homeserver": {
+ "base_url": "${MATRIX_PUBLIC_BASEURL}",
+ "server_name": "${MATRIX_SERVER_NAME}"
+ }
+ },
+ "brand": "EDR Chat",
+ "permalink_prefix": "${ELEMENT_PUBLIC_URL}",
+ "disable_guests": true,
+ "disable_3pid_login": true,
+ "disable_custom_urls": true,
+ "default_theme": "light",
+ "mobile_guide_toast": false,
+ "settingDefaults": {
+ "UIFeature.registration": false
+ }
+}
diff --git a/infrastructure/matrix/element/manifest.json b/infrastructure/matrix/element/manifest.json
new file mode 100644
index 000000000..75f5f33e6
--- /dev/null
+++ b/infrastructure/matrix/element/manifest.json
@@ -0,0 +1,12 @@
+{
+ "name": "EDR Chat",
+ "short_name": "EDR Chat",
+ "display": "standalone",
+ "theme_color": "#0dbd8b",
+ "start_url": "index.html",
+ "icons": [
+ { "src": "/vector-icons/150.png", "sizes": "150x150", "type": "image/png" },
+ { "src": "/vector-icons/300.png", "sizes": "300x300", "type": "image/png" },
+ { "src": "/vector-icons/1024.png", "sizes": "1024x1024", "type": "image/png" }
+ ]
+}
diff --git a/infrastructure/matrix/element/sso.html b/infrastructure/matrix/element/sso.html
new file mode 100644
index 000000000..77f378122
--- /dev/null
+++ b/infrastructure/matrix/element/sso.html
@@ -0,0 +1,49 @@
+
+
+
+
+
+ Signing in to EDR Chat…
+
+
+
+
+
diff --git a/infrastructure/matrix/synapse/Dockerfile b/infrastructure/matrix/synapse/Dockerfile
new file mode 100644
index 000000000..ec424e41f
--- /dev/null
+++ b/infrastructure/matrix/synapse/Dockerfile
@@ -0,0 +1,15 @@
+# syntax=docker/dockerfile:1
+#
+# EDR internal chat homeserver. Unmodified upstream Synapse + our config
+# template — no source build. Pin the tag; never float on `latest`.
+FROM ghcr.io/element-hq/synapse:v1.140.0
+
+RUN apt-get update && apt-get install -y --no-install-recommends gettext-base \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY homeserver.yaml.tmpl /synapse/homeserver.yaml.tmpl
+COPY log.config /synapse/log.config
+COPY docker-entrypoint.sh /synapse/docker-entrypoint.sh
+RUN chmod +x /synapse/docker-entrypoint.sh
+
+ENTRYPOINT ["/synapse/docker-entrypoint.sh"]
diff --git a/infrastructure/matrix/synapse/docker-entrypoint.sh b/infrastructure/matrix/synapse/docker-entrypoint.sh
new file mode 100644
index 000000000..674bf7947
--- /dev/null
+++ b/infrastructure/matrix/synapse/docker-entrypoint.sh
@@ -0,0 +1,20 @@
+#!/bin/sh
+# Renders homeserver.yaml from the template using the runtime env (so
+# MATRIX_JWT_SECRET / DB password / registration_shared_secret come from the
+# service's .env file, never get baked into the image), then hands off to the
+# upstream Synapse image's own entrypoint.
+set -eu
+
+mkdir -p /data
+envsubst \
+ '${MATRIX_SERVER_NAME} ${MATRIX_PUBLIC_BASEURL} ${MATRIX_DB_USER} ${MATRIX_DB_PASSWORD} ${MATRIX_DB_NAME} ${MATRIX_DB_HOST} ${MATRIX_DB_PORT} ${MATRIX_JWT_SECRET} ${MATRIX_REGISTRATION_SHARED_SECRET}' \
+ < /synapse/homeserver.yaml.tmpl > /data/homeserver.yaml
+
+# start.py's `run` mode (the implicit default we hit below) gosu's straight
+# into uid 991 with no chown — it only chowns /data in its `generate` /
+# `migrate_config` modes, which we skip by providing our own pre-rendered
+# config. Without this, 991 can't write its signing key on first boot.
+chown -R 991:991 /data
+
+export SYNAPSE_CONFIG_PATH=/data/homeserver.yaml
+exec /start.py "$@"
diff --git a/infrastructure/matrix/synapse/homeserver.yaml.tmpl b/infrastructure/matrix/synapse/homeserver.yaml.tmpl
new file mode 100644
index 000000000..efb7372ac
--- /dev/null
+++ b/infrastructure/matrix/synapse/homeserver.yaml.tmpl
@@ -0,0 +1,113 @@
+# EDR internal chat — Synapse homeserver config.
+#
+# Rendered to /data/homeserver.yaml at container start by docker-entrypoint.sh
+# (envsubst over this template) so secrets come from the runtime env file,
+# never baked into the image — same convention as freight-api's .env.
+#
+# server_name is PERMANENT: it is baked into every user id and event and
+# cannot change without wiping the server. Do not repoint this at a
+# different value after go-live.
+server_name: "${MATRIX_SERVER_NAME}"
+public_baseurl: "${MATRIX_PUBLIC_BASEURL}"
+pid_file: /data/homeserver.pid
+
+listeners:
+ - port: 8008
+ tls: false
+ type: http
+ x_forwarded: true
+ resources:
+ - names: [client, federation]
+ compress: false
+
+database:
+ name: psycopg2
+ args:
+ user: "${MATRIX_DB_USER}"
+ password: "${MATRIX_DB_PASSWORD}"
+ dbname: "${MATRIX_DB_NAME}"
+ host: "${MATRIX_DB_HOST}"
+ port: ${MATRIX_DB_PORT}
+ cp_min: 5
+ cp_max: 10
+
+media_store_path: /data/media_store
+max_upload_size: 50M
+
+log_config: "/synapse/log.config"
+
+# Internal comms tool: no federation, no open registration, no E2EE-by-default.
+# ponytail: E2EE off — turn on per-room (HR/legal) if compliance asks.
+federation_domain_whitelist: []
+enable_registration: false
+encryption_enabled_by_default_for_room_type: "off"
+
+# Turning rooms' encryption off above is not enough on its own: Element still
+# bootstraps cross-signing on a user's first login, and from then on gates
+# EVERY later login behind "Verify this device" (MatrixChat: crossSigningIsSetUp
+# -> Views.COMPLETE_SECURITY). Nobody on this deployment can clear that gate —
+# each SSO click is a brand-new device, so there is never a second verified
+# device to accept the request, and resetting the identity needs UIA, which
+# password_config.enabled: false makes impossible.
+#
+# This tells Element encryption is off here, so it skips the bootstrap
+# (shouldSkipSetupEncryption) and the gate is never armed. Only helps accounts
+# that have no cross-signing keys yet — anyone already bootstrapped keeps
+# hitting the gate until their keys are cleared.
+extra_well_known_client_content:
+ io.element.e2ee:
+ default: false
+ force_disable: true
+ secure_backup_required: false
+
+# Employees authenticate via freight-api's SSO handoff, never a Matrix
+# password prompt. This is the entire auth story for this deployment.
+password_config:
+ enabled: false
+
+jwt_config:
+ enabled: true
+ secret: "${MATRIX_JWT_SECRET}"
+ algorithm: "HS256"
+ issuer: "edr-freight-api"
+ audiences: ["matrix"]
+ # Matches the `name` claim chat-sso.service.ts puts in the JWT — only read
+ # on first login (auto-registration), never updates it on later logins.
+ display_name_claim: "name"
+
+# Consumes the login_token minted by freight-api's SSO endpoint via
+# POST /_matrix/client/v1/login/get_token (issued against an existing,
+# already-JWT-authenticated session — not a bare password grant).
+login_via_existing_session:
+ enabled: true
+ require_ui_auth: false
+ token_timeout: 5m
+
+# Bootstrap-only: used once by ops to register the first admin account
+# (register_new_matrix_user against /_synapse/admin/v1/register), whose
+# access token becomes MATRIX_ADMIN_TOKEN for freight-api's provisioning
+# service. Rotate/remove after bootstrap if desired — nothing else depends
+# on shared-secret registration once the admin account exists.
+registration_shared_secret: "${MATRIX_REGISTRATION_SHARED_SECRET}"
+
+trusted_key_servers: []
+suppress_key_server_warning: true
+
+report_stats: false
+
+# Synapse's default rc_login is sized to defend against internet-facing
+# password brute-forcing. That threat doesn't exist on this deployment —
+# password login is off (see password_config above), and the only path in
+# requires a freight-api-signed JWT — so the default is mostly just
+# punishing legitimate rapid logins from the same office/NAT IP or normal
+# page-refresh retries. Loosened, not disabled, to keep some ceiling.
+rc_login:
+ address:
+ per_second: 100
+ burst_count: 200
+ account:
+ per_second: 100
+ burst_count: 200
+ failed_attempts:
+ per_second: 100
+ burst_count: 200
diff --git a/infrastructure/matrix/synapse/log.config b/infrastructure/matrix/synapse/log.config
new file mode 100644
index 000000000..0894e974f
--- /dev/null
+++ b/infrastructure/matrix/synapse/log.config
@@ -0,0 +1,25 @@
+# Log straight to stdout — the container runtime (docker compose logs / the
+# self-hosted runner's log collection) owns rotation and retention, matching
+# how every other app container in this repo logs.
+version: 1
+
+formatters:
+ precise:
+ format: "%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(message)s"
+
+handlers:
+ console:
+ class: logging.StreamHandler
+ formatter: precise
+ stream: ext://sys.stdout
+
+loggers:
+ synapse.storage.SQL:
+ # SQL queries are DEBUG-only noise; leave at INFO unless diagnosing.
+ level: INFO
+
+root:
+ level: INFO
+ handlers: [console]
+
+disable_existing_loggers: false
diff --git a/scripts/deploy/sync-env-from-env-manager.sh b/scripts/deploy/sync-env-from-env-manager.sh
index 6a405ab28..0835f86d9 100644
--- a/scripts/deploy/sync-env-from-env-manager.sh
+++ b/scripts/deploy/sync-env-from-env-manager.sh
@@ -29,6 +29,12 @@ declare -A SERVICE_ENV_TARGET=(
["passenger_portal"]="apps/edr-passenger-web/portal/.env"
["passenger_backoffice"]="apps/edr-passenger-web/backoffice/.env"
["payment_api"]="apps/edr-payment-api/.env"
+ ["synapse"]="infrastructure/matrix/synapse/.env"
+ # element_web holds no secrets, but its config.json is rendered at container
+ # start from MATRIX_PUBLIC_BASEURL / MATRIX_SERVER_NAME / ELEMENT_PUBLIC_URL
+ # (see infrastructure/matrix/element), so it needs an env file like the rest —
+ # plus the PORT line every service env is required to carry.
+ ["element_web"]="infrastructure/matrix/element/.env"
)
for service in "$@"; do
diff --git a/scripts/deploy/sync-env-from-server.sh b/scripts/deploy/sync-env-from-server.sh
index 69f7eef56..4fb7fbe6b 100644
--- a/scripts/deploy/sync-env-from-server.sh
+++ b/scripts/deploy/sync-env-from-server.sh
@@ -31,6 +31,11 @@ declare -A SERVICE_ENV_TARGET=(
["passenger-portal"]="apps/edr-passenger-web/portal/.env"
["passenger-backoffice"]="apps/edr-passenger-web/backoffice/.env"
["payment-api"]="apps/edr-payment-api/.env"
+ ["synapse"]="infrastructure/matrix/synapse/.env"
+ # element-web holds no secrets, but its config.json is rendered at container
+ # start from MATRIX_PUBLIC_BASEURL / MATRIX_SERVER_NAME / ELEMENT_PUBLIC_URL,
+ # and the sync step needs a PORT= line to compute ELEMENT_WEB_PORT anyway.
+ ["element-web"]="infrastructure/matrix/element/.env"
)
for service in "$@"; do