Merge pull request #1363 from Tria-plc/dev

Merge dev to edr-dev
This commit is contained in:
Abubeker Yasin
2026-08-20 14:09:08 +03:00
committed by GitHub
1105 changed files with 119919 additions and 20385 deletions

View File

@@ -44,6 +44,8 @@ jobs:
"passenger-portal"
"passenger-backoffice"
"payment-api"
"synapse"
"element-web"
)
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
@@ -85,6 +87,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))
@@ -120,7 +126,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"
;;

9
.gitignore vendored
View File

@@ -53,3 +53,12 @@ RUNNING_LOCALLY.md
# Generated per-shard compose file for the integration suite (it.mjs).
integration/.it-shards.yaml
# private keys / certificates (EIMS INSA credentials and anything like them) — never commit
*.key
*.pem
*.pem.txt
*.p12
*.pfx
*.crt
secrets/
certs/

376
CLAUDE.md
View File

@@ -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 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 |
| `pnpm lint` | Lint everything |
| `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<T>` 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<Entity>` from
`@edr/api-common`. Services inject the repository class, never `Repository<T>` 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.<area>.<action>)`.
- 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/<feature>/` with `entities/`, `dto/`, and the four `<feature>.{module,controller,service,repository}.ts` files.
1. Create `modules/<feature>/` with `entities/`, `dto/`, and the four
`<feature>.{module,controller,service,repository}.ts` files.
2. The entity extends `BaseEntity` from `@edr/api-common`.
3. The repository extends `BaseRepository<Entity>` from `@edr/api-common`.
4. The service injects the repository class (not `Repository<T>` 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/<Name>/<Name>.tsx` and `src/components/<Name>/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=<each touched package>` 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.

View File

@@ -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<Entity>` from
`@edr/api-common`. Services inject the repository class, never `Repository<T>` 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.<area>.<action>)`.
- 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/<feature>/` with `entities/`, `dto/`, and the four
`<feature>.{module,controller,service,repository}.ts` files.
2. The entity extends `BaseEntity` from `@edr/api-common`.
3. The repository extends `BaseRepository<Entity>` from `@edr/api-common`.
4. The service injects the repository class (not `Repository<T>` 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/<Name>/<Name>.tsx` and `src/components/<Name>/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=<each touched package>` 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.

View File

@@ -1,4 +1,10 @@
# Copy to .env for local/docker compose (not committed).
# Set to "dev" or "staging" to bypass OTP (fixed code 000000 also accepted),
# payment (invoice auto-marked paid on initiate, no gateway call) and Fayda
# (canned verified profile, no eSignet call). Leave unset in production.
ENV=
PORT=3001
# @tria-plc/auditlog's client interceptor stamps every AuditLog row's
# `application` from this env var directly, bypassing MezgebModule.forRoot's
@@ -76,6 +82,12 @@ SEED_EDR_ORG=true
SEED_FREIGHT_STAFF=true
SEED_EXPORT_DJIBOUTI_INTERCHANGE_DEMO=false
# Limits GET /staff/users to employees of this IAM organization (iam.organizations.key).
# Unset = every employee. A key matching no organization returns no users.
# Dev seed key: edr_freight
# Production: ETHIO_DJIBOUTI_STANDARD_GAUGE_RAILWAY_SHARE_COMPANY_001
FREIGHT_ORG_KEY=edr_freight
# MinIO (used by @tria-plc/iamapi-common for file storage)
MINIO_ENDPOINT=localhost
MINIO_PORT=9000
@@ -126,3 +138,103 @@ EMAIL_QUEUE=email_queue
# Shared secret for service-to-service calls (payment microservice <-> freight).
# Required at boot; set ALLOW_UNAUTH_INTERNAL=true instead ONLY for local dev.
SERVICE_AUTH_TOKEN=change-me
# ── MoR EIMS e-invoicing (core.mor.gov.et) ─────────────────────────────────
# Disabled by default; every EIMS call fails fast with EIMS_NOT_CONFIGURED until enabled.
EIMS_ENABLED=false
EIMS_BASE_URL=https://core.mor.gov.et
EIMS_CLIENT_ID=
EIMS_CLIENT_SECRET=
EIMS_API_KEY=
EIMS_TIN=
# Source-system identity comes from the access token's systemNumber/systemType claims.
# Setting these turns them into expected-value checks: a mismatch against the token fails
# fast rather than one side silently winning. Leave empty to take the gateway's word.
EIMS_SYSTEM_NUMBER=
EIMS_SYSTEM_TYPE=
# Absolute paths to the INSA-issued credentials. Keep them OUTSIDE the repo; the file
# patterns are gitignored, but a path outside the working tree is safer still.
# The certificate is transmitted as base64 of this file's exact bytes — do not convert it.
EIMS_PRIVATE_KEY_PATH=
EIMS_CERTIFICATE_PATH=
# Optional tuning
EIMS_HTTP_TIMEOUT_MS=30000
EIMS_TOKEN_SKEW_SECONDS=45
# ── EIMS invoice registration (required only to register invoices) ─────────
# Seller identity: EDR's own legal details are not modelled anywhere in the DB.
# Region and Wereda are MoR *codes* (e.g. 13 / 574), not names.
EIMS_SELLER_LEGAL_NAME=
EIMS_SELLER_VAT_NUMBER=
EIMS_SELLER_PHONE=
EIMS_SELLER_EMAIL=
EIMS_SELLER_REGION=
EIMS_SELLER_WEREDA=
# Optional seller address parts; sent as null when unset.
EIMS_SELLER_CITY=
EIMS_SELLER_SUBCITY=
EIMS_SELLER_HOUSE_NUMBER=
EIMS_SELLER_LOCALITY=
# Tax treatment — REQUIRES FINANCE SIGN-OFF. The application models no tax at all
# (invoice.taxAmount is always 0), so nothing here is defaulted: registration fails
# locally, naming the missing variables, until these are set.
# Required, and deliberately unset here: the choice is a tax position, not a default.
# MoR's enum (from its own 400): TOT10 TOT2 VAT15 VWHT TWHT VATEX VATWH WHOP2 WTHOI VAT0 VWTH
# Finance confirmed 2026-08-12: VATEX (exempt) for EDR's freight business — set in .env.
EIMS_TAX_CODE=
EIMS_TAX_RATE_PERCENT=0
EIMS_EXCISE_TAX_VALUE=0
EIMS_INCOME_WITHHOLD_VALUE=0
EIMS_TRANSACTION_WITHHOLD_VALUE=0
# Per-chargeType override, for an invoice whose lines need different MoR tax treatment (e.g. a
# zero-rated freight line next to a taxed accessorial) — IRC-P01 compliance-test material.
# A charge type not listed here falls back to EIMS_TAX_CODE / EIMS_TAX_RATE_PERCENT above.
# EIMS_TAX_CODE_BY_CHARGE_TYPE and EIMS_TAX_RATE_BY_CHARGE_TYPE must list the same charge types.
EIMS_TAX_CODE_BY_CHARGE_TYPE=
EIMS_TAX_RATE_BY_CHARGE_TYPE=
# Same mechanism; charge types not listed fall back to EIMS_EXCISE_TAX_VALUE / 0 respectively.
EIMS_EXCISE_BY_CHARGE_TYPE=
EIMS_DISCOUNT_BY_CHARGE_TYPE=
# Document classification and payment presentation.
EIMS_TRANSACTION_TYPE=B2B
# Lowercase constant: MoR's oneOf branches require exactly 'goods' or 'service'.
EIMS_NATURE_OF_SUPPLIES=service
EIMS_PAYMENT_MODE=CASH
EIMS_PAYMENT_TERM=IMMIDIATE
EIMS_UNIT_DEFAULT=PCS
# MoR numeric country code for the buyer; our companies store the country name.
EIMS_BUYER_COUNTRY_CODE=
# Buyer region name -> MoR numeric code. companies.region holds names; MoR wants ^[0-9]{1,3}$.
# An unmapped region fails locally rather than being filed with a guess.
EIMS_BUYER_REGION_CODES=Addis Ababa=13
# Same mechanism for Wereda. MoR has never named a Wereda regex in an error (only Region's is
# confirmed), so this is precautionary — but an unmapped name still fails locally, not filed as a guess.
EIMS_BUYER_WEREDA_CODES=
EIMS_CASHIER_NAME=
EIMS_SALESPERSON_NAME=
# Automatic filing of issued invoices (@Cron sweep, one invoice per tick).
# Independent of EIMS_ENABLED on purpose: authentication can be live long before
# filing is. Both must be true before anything is submitted automatically.
EIMS_AUTO_SUBMIT=false
EIMS_AUTO_SUBMIT_CRON=0 */5 * * * *
# MoR rejects documents older than 3 days; the sweep will not attempt those.
EIMS_AUTO_SUBMIT_MAX_AGE_DAYS=3
# ── Internal chat (Matrix/Element) ──────────────────────────────────────────
# Disabled by default; /chat/sso and the nightly room/membership reconcile are
# no-ops until enabled. See infrastructure/matrix/.
MATRIX_ENABLED=false
# Synapse URL reachable from this container (docker-compose service DNS in
# prod, e.g. http://synapse:8008 — NOT the public https://matrix.edr.et).
MATRIX_BASE_URL=http://localhost:8008
# Synapse's own public_baseurl — what Element itself is configured to call.
# Only used to seed the sso.html handoff page's localStorage.
MATRIX_PUBLIC_BASE_URL=https://matrix.edr.et
MATRIX_CHAT_WEB_URL=https://chat.edr.et
MATRIX_SERVER_NAME=matrix.edr.et
# Must exactly match infrastructure/matrix/synapse/.env's MATRIX_JWT_SECRET —
# this is the whole trust boundary for the SSO handoff.
MATRIX_JWT_SECRET=
# access_token of a Synapse server-admin account. Bootstrap it once via
# infrastructure/matrix/synapse's MATRIX_REGISTRATION_SHARED_SECRET (see that
# file's comments) — this app never touches the shared secret itself.
MATRIX_ADMIN_TOKEN=

View File

@@ -0,0 +1,9 @@
import pg from 'pg';
import fs from 'fs';
const env = Object.fromEntries(fs.readFileSync('.env','utf8').split('\n').filter(l=>/^[A-Z_]+=/.test(l)).map(l=>{const i=l.indexOf('=');return [l.slice(0,i),l.slice(i+1).replace(/^"|"$/g,'')]}));
const c = new pg.Client({host:env.DB_HOST,port:+env.DB_PORT,database:env.DB_NAME,user:env.DB_USER,password:env.DB_PASSWORD});
await c.connect();
const sql = process.argv[2];
const r = await c.query(sql);
console.log(JSON.stringify(r.rows,null,1));
await c.end();

View File

@@ -0,0 +1,639 @@
/**
* Freight API — every state-changing endpoint (POST / PUT / PATCH / DELETE).
*
* Shape: "<METHOD> <path>": [title, method, entity]
*
* Keyed by method + path rather than path alone: 50 paths serve more than one
* method (PATCH and DELETE on /api/contracts/:id, for example), so a path-only
* key would collide and drop those endpoints.
*
* Paths include the global prefix `api` (see app.setGlobalPrefix in src/main.ts).
* Titles come from each route's @ApiOperation summary, falling back to a
* humanized handler name where a route has none.
*
* Excludes the AI Assist and Account entities.
* Generated from the controllers under src/ — 488 endpoints.
*/
const AUDIT_ENDPOINTS = {
// Approval Rule
"POST /api/approval-rules": ["Create an approval rule step", "POST", "Approval Rule"],
"PATCH /api/approval-rules/:id": ["Update an approval rule", "PATCH", "Approval Rule"],
"DELETE /api/approval-rules/:id": ["Soft-delete an approval rule", "DELETE", "Approval Rule"],
"POST /api/approval-rules/:id/move-order": ["Move an approval step up or down within its chain", "POST", "Approval Rule"],
"POST /api/approval-rules/reorder": ["Bulk reorder approval steps within a chain", "POST", "Approval Rule"],
// Booking
"POST /api/bookings": ["Create a new freight booking (DRAFT)", "POST", "Booking"],
"POST /api/bookings/:bookingId/allocate-containers": ["Allocate containers to vehicles", "POST", "Booking"],
"PATCH /api/bookings/:id": ["Update booking", "PATCH", "Booking"],
"DELETE /api/bookings/:id": ["Soft-delete DRAFT booking", "DELETE", "Booking"],
"POST /api/bookings/:id/cancel": ["Cancel booking", "POST", "Booking"],
"POST /api/bookings/:id/cancel-hold": ["Customer cancels an unpaid hold (SELECTED_FOR_BATCH → CANCELLED);", "POST", "Booking"],
"POST /api/bookings/:id/clearance/declaration": ["GL ET uploads customs declaration on booking (GENERAL customs)", "POST", "Booking"],
"POST /api/bookings/:id/clearance/delivery-order": ["Upload Booking Delivery Order", "POST", "Booking"],
"POST /api/bookings/:id/clearance/documents": ["Customer uploads clearance documents (fieldname = document key)", "POST", "Booking"],
"POST /api/bookings/:id/clearance/draft-declaration": ["GL ET sends a draft customs declaration (multi-file) with an estimated price for the customer to review", "POST", "Booking"],
"POST /api/bookings/:id/clearance/draft-declaration/accept": ["Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia", "POST", "Booking"],
"POST /api/bookings/:id/clearance/draft-declaration/change": ["Customer requests a change to the draft customs declaration with a reason — GL Ethiopia sends a corrected draft (repeatable)", "POST", "Booking"],
"POST /api/bookings/:id/clearance/duty": ["GL ET sets duty/tax on booking with notice attachment", "POST", "Booking"],
"POST /api/bookings/:id/clearance/duty-slip": ["Customer uploads duty/tax payment slip on booking", "POST", "Booking"],
"POST /api/bookings/:id/clearance/export-release": ["Confirm Booking Export Release", "POST", "Booking"],
"POST /api/bookings/:id/clearance/finalize": ["GL finalizes clearance (requires 100% approved) → CLEARANCE_READY", "POST", "Booking"],
"POST /api/bookings/:id/clearance/finalize-pre-clearance": ["GL ET finalizes import pre-clearance on booking", "POST", "Booking"],
"POST /api/bookings/:id/clearance/output-documents": ["GL uploads customs output documents (IM4/EX3/…)", "POST", "Booking"],
"POST /api/bookings/:id/clearance/proceed": ["Customer requests operation with a schedule day", "POST", "Booking"],
"POST /api/bookings/:id/clearance/release-order": ["Upload Booking Release Order", "POST", "Booking"],
"POST /api/bookings/:id/clearance/review": ["GL reviews a clearance document (Approve | Query)", "POST", "Booking"],
"POST /api/bookings/:id/clearance/ro-amendment": ["Request Booking RO Amendment", "POST", "Booking"],
"POST /api/bookings/:id/clearance/transit-assignee/assign": ["GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns", "POST", "Booking"],
"POST /api/bookings/:id/clearance/transit-assignee/request": ["GL ET asks GL Djibouti to name the transit officer — required before the import customs declaration", "POST", "Booking"],
"POST /api/bookings/:id/clearance/transit-permit": ["Upload Booking Transit Permit", "POST", "Booking"],
"POST /api/bookings/:id/confirm-submit": ["Confirm submit after price change", "POST", "Booking"],
"POST /api/bookings/:id/consolidation": ["Request freight consolidation", "POST", "Booking"],
"DELETE /api/bookings/:id/consolidation": ["Remove consolidation pairing", "DELETE", "Booking"],
"POST /api/bookings/:id/contract/generate": ["Generate contract PDF from template", "POST", "Booking"],
"POST /api/bookings/:id/contract/sign": ["Apply digital signature (customer or staff)", "POST", "Booking"],
"POST /api/bookings/:id/customer-cancel": ["Customer cancels their own booking before payment — no cancellation fee", "POST", "Booking"],
"POST /api/bookings/:id/customer-truck-assignment": ["Customer assigns external truck and driver for terminal pickup", "POST", "Booking"],
"POST /api/bookings/:id/customer-trucks": ["Add a customer self-haul truck carrying 12 of the booking containers", "POST", "Booking"],
"PATCH /api/bookings/:id/customer-trucks/:assignmentId": ["Edit a not-yet-arrived customer truck (plate/driver/type + containers)", "PATCH", "Booking"],
"DELETE /api/bookings/:id/customer-trucks/:assignmentId": ["Remove a not-yet-arrived customer truck from a booking", "DELETE", "Booking"],
"POST /api/bookings/:id/customer-trucks/:assignmentId/depart": ["Register an import truck leaving: containers loaded + weighed gross (staff)", "POST", "Booking"],
"POST /api/bookings/:id/customer-trucks/:assignmentId/load": ["Truck_dispatch: load selected containers onto a truck (staff)", "POST", "Booking"],
"POST /api/bookings/:id/customer-trucks/bulk": ["Bulk add customer trucks from array payload (Excel parsed)", "POST", "Booking"],
"POST /api/bookings/:id/customer/sign": ["Customer digital signature (deprecated — use POST contract/sign)", "POST", "Booking"],
"POST /api/bookings/:id/documents": ["Upload documents for a booking (DRAFT only)", "POST", "Booking"],
"PATCH /api/bookings/:id/export-handover-mode": ["Export only: choose direct truck-to-train (no warehouse, no GRN) or warehouse first", "PATCH", "Booking"],
"POST /api/bookings/:id/generate-grn": ["Generate a GRN over the received containers (all received, or a subset) — one GRN per batch", "POST", "Booking"],
"POST /api/bookings/:id/generate-price": ["Generate price preview (DRAFT or CHANGES_REQUESTED)", "POST", "Booking"],
"POST /api/bookings/:id/government-expedite": ["Expedite government booking to PAID / ELIGIBLE for scheduling", "POST", "Booking"],
"POST /api/bookings/:id/marketing/approve": ["Staff contract signature and fully execute (use contract/sign STAFF preferred)", "POST", "Booking"],
"POST /api/bookings/:id/operation/review": ["Operations reviews an operation request: ACCEPT (→ batch pool),", "POST", "Booking"],
"POST /api/bookings/:id/operations/complete": ["Mark completed", "POST", "Booking"],
"POST /api/bookings/:id/operations/start-transit": ["Mark in transit", "POST", "Booking"],
"POST /api/bookings/:id/reject": ["Customer reject price estimate", "POST", "Booking"],
"POST /api/bookings/:id/staff/accept": ["Staff accept intake → set contract validity window + start approval chain", "POST", "Booking"],
"POST /api/bookings/:id/staff/reject": ["Staff final reject", "POST", "Booking"],
"POST /api/bookings/:id/staff/request-changes": ["Staff return booking for customer updates", "POST", "Booking"],
"POST /api/bookings/:id/submit": ["Customer submit booking", "POST", "Booking"],
"POST /api/bookings/:id/wagon-cancellations": ["Request a partial wagon cancellation on a PAID booking — opens the cancellation-fee invoice; wagons are released only once the fee settles", "POST", "Booking"],
"POST /api/bookings/:id/wagon-cancellations/preview": ["Preview the fee/credit of a partial wagon cancellation (no writes)", "POST", "Booking"],
"POST /api/bookings/wagon-cancellations/:cancellationId/rebook": ["Rebook a wagon-cancellation credit: pick a shipment day only — the new booking is created under the contract and marked PAID (freight already paid; contract must still be valid)", "POST", "Booking"],
"POST /api/bookings/wagon-cancellations/:cancellationId/withdraw": ["Withdraw a fee-pending wagon cancellation (owner, or staff with the void permission)", "POST", "Booking"],
// Cargo
"POST /api/cargoes": ["Create a new cargo", "POST", "Cargo"],
"PATCH /api/cargoes/:id": ["Update a cargo", "PATCH", "Cargo"],
"DELETE /api/cargoes/:id": ["Delete a cargo", "DELETE", "Cargo"],
"POST /api/cargoes/:id/deliver": ["Mark cargo as delivered", "POST", "Cargo"],
"POST /api/cargoes/:id/load": ["Load cargo into a container", "POST", "Cargo"],
"POST /api/cargoes/:id/unload": ["Unload cargo from container", "POST", "Cargo"],
// Cargo Type
"POST /api/cargo-types": ["Create a cargo type", "POST", "Cargo Type"],
"PATCH /api/cargo-types/:id": ["Update a cargo type", "PATCH", "Cargo Type"],
"DELETE /api/cargo-types/:id": ["Soft-delete a cargo type", "DELETE", "Cargo Type"],
"POST /api/cargo-types/:id/move-order": ["Move a cargo type up or down in display order", "POST", "Cargo Type"],
"POST /api/cargo-types/reorder": ["Bulk reorder cargo types by ID list", "POST", "Cargo Type"],
// Company
"POST /api/companies": ["Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter)", "POST", "Company"],
"POST /api/companies/:companyId/documents": ["Upload documents for a company (onboarding)", "POST", "Company"],
"POST /api/companies/:companyId/profiles": ["Add a profile (employee) to a company", "POST", "Company"],
"PATCH /api/companies/:id": ["Update a company", "PATCH", "Company"],
"DELETE /api/companies/:id": ["Soft-delete a company", "DELETE", "Company"],
"POST /api/companies/change-requests/:id/approve": ["Approve a pending profile change request (applies the changes)", "POST", "Company"],
"POST /api/companies/change-requests/:id/reject": ["Reject a pending profile change request with a note", "POST", "Company"],
"POST /api/companies/change-requests/:id/request-changes": ["Ask for specific changes on a pending request without rejecting it (row stays open, next edit appends to it)", "POST", "Company"],
"POST /api/companies/company-profile": ["Create a single operational profile for the current user's company. The role starts pending and does not become the active mode", "POST", "Company"],
"POST /api/companies/company-profiles": ["Add operational profile(s) (importer/exporter/forwarder) to the current user's company", "POST", "Company"],
"POST /api/companies/company-profiles/:profileId/license": ["Add business-license document(s) to a profile. For an approved company", "POST", "Company"],
"DELETE /api/companies/company-profiles/:profileId/license/:fileId": ["Remove a business-license file (staged for review on an approved company)", "DELETE", "Company"],
"POST /api/companies/company-profiles/:profileId/license/:fileId/replace": ["Replace a business-license file with a newly uploaded one (staged for", "POST", "Company"],
"POST /api/companies/company-profiles/:profileId/reapply": ["Resubmit a rejected operational role for approval (→ pending)", "POST", "Company"],
"PATCH /api/companies/company-profiles/:profileId/status": ["Update a company profile's approval status", "PATCH", "Company"],
"POST /api/companies/create": ["Create a company with its associated external profile (onboarding)", "POST", "Company"],
"POST /api/companies/documents/:fileId/request-change": ["Ask the customer to correct one uploaded document", "POST", "Company"],
"POST /api/companies/fetch-etrade-info": ["Fetch company info from eTrade by TIN", "POST", "Company"],
"POST /api/companies/identity/fayda/complete": ["Bind a completed Fayda verification to the company's owner or Power of Attorney", "POST", "Company"],
"DELETE /api/companies/identity/fayda/poa": ["Remove the company's Power of Attorney — the verified identity, its details and the delegation paper together", "DELETE", "Company"],
"DELETE /api/companies/identity/gm": ["Clear the General Manager's identity — the \\\"same as owner\\\" declaration or a verification, and the details either wrote", "DELETE", "Company"],
"POST /api/companies/identity/gm/same-as-owner": ["Declare the General Manager is the company's owner, copying the owner's verified identity across", "POST", "Company"],
"POST /api/companies/identity/poa/same-as-owner": ["Declare the Power of Attorney is the company's owner, copying the owner's identity across", "POST", "Company"],
"DELETE /api/companies/identity/poa/same-as-owner": ["Undo the Power of Attorney \\\"same as owner\\\" declaration and the identity it copied, leaving the representative open to be verified in their own right", "DELETE", "Company"],
"PATCH /api/companies/onboarding-step": ["Persist the user's current onboarding wizard step", "PATCH", "Company"],
"POST /api/companies/onboarding/complete": ["Mark the current user's onboarding as complete", "POST", "Company"],
"POST /api/companies/onboarding/start": ["Begin onboarding: create a draft company + profile + role(s) so later steps can save incrementally", "POST", "Company"],
"POST /api/companies/poa-delegation": ["Upload the Power of Attorney delegation letter, replacing any existing one", "POST", "Company"],
"DELETE /api/companies/poa-delegation/:fileId": ["Remove the Power of Attorney delegation letter (staged for review on an approved company)", "DELETE", "Company"],
"PATCH /api/companies/profile": ["Update profile (flattened settings page)", "PATCH", "Company"],
// Compliance
"POST /api/compliance": ["Create a compliance record", "POST", "Compliance"],
"PATCH /api/compliance/:id": ["Update a compliance record", "PATCH", "Compliance"],
"DELETE /api/compliance/:id": ["Soft-delete a compliance record", "DELETE", "Compliance"],
// Consignment
"POST /api/consignments": ["Create a new consignment", "POST", "Consignment"],
// Container
"POST /api/containers": ["Create a new container", "POST", "Container"],
"PATCH /api/containers/:id": ["Update a container", "PATCH", "Container"],
"DELETE /api/containers/:id": ["Delete a container", "DELETE", "Container"],
"POST /api/containers/:id/assign-wagon": ["Assign container to a wagon", "POST", "Container"],
"POST /api/containers/:id/unassign-wagon": ["Unassign container from wagon", "POST", "Container"],
// Container Type
"POST /api/container-types": ["Create a container type", "POST", "Container Type"],
"PATCH /api/container-types/:id": ["Update a container type", "PATCH", "Container Type"],
"DELETE /api/container-types/:id": ["Soft-delete a container type", "DELETE", "Container Type"],
"POST /api/container-types/:id/move-order": ["Move a container type up or down in display order", "POST", "Container Type"],
"POST /api/container-types/reorder": ["Bulk reorder container types by ID list", "POST", "Container Type"],
// Contract
"POST /api/contracts": ["Create a new contract (DRAFT) with routes + cargo scope", "POST", "Contract"],
"PATCH /api/contracts/:id": ["Update contract", "PATCH", "Contract"],
"DELETE /api/contracts/:id": ["Soft-delete DRAFT contract", "DELETE", "Contract"],
"POST /api/contracts/:id/approval-steps/:stepId/approve": ["Approve one approval step in sequence", "POST", "Contract"],
"POST /api/contracts/:id/approval-steps/:stepId/reject": ["Reject one approval step — to the customer (terminal → REJECTED) or, via returnToStepId, back to an earlier approver (chain re-runs from there)", "POST", "Contract"],
"POST /api/contracts/:id/booking-requests": ["Customer submits a shipment request on a GENERAL customs contract", "POST", "Contract"],
"POST /api/contracts/:id/bookings": ["Create a shipment booking under a contract — Path A (customer) or Path B (GL Ethiopia)", "POST", "Contract"],
"POST /api/contracts/:id/bookings/:bookingId/complete": ["Complete an initiated booking after Operations finalized its clearance — cargo + binding day, window and departure checks, pricing and invoicing", "POST", "Contract"],
"POST /api/contracts/:id/bookings/initiate": ["Initiate a bare booking instance under an import/export contract (ONE_TIME or GENERAL) — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS). ONE_TIME customs instances are opened by the customer (or GL); GENERAL customs comes from a shipment request", "POST", "Contract"],
"POST /api/contracts/:id/cancel": ["Customer cancels their own contract (blocked while a booking is live)", "POST", "Contract"],
"POST /api/contracts/:id/clearance/declaration": ["GL ET uploads customs declaration documents (multi-file)", "POST", "Contract"],
"POST /api/contracts/:id/clearance/delivery-order": ["GL DJ uploads Delivery Order (import) with vessel arrival + DO collected dates", "POST", "Contract"],
"POST /api/contracts/:id/clearance/documents": ["Customer uploads clearance documents (fieldname = document key)", "POST", "Contract"],
"POST /api/contracts/:id/clearance/documents/:fileKey/replace": ["GL replaces a clearance document in place (reason required) — the previous version is kept in the file history and the new one needs approving", "POST", "Contract"],
"POST /api/contracts/:id/clearance/duty": ["GL ET sets duty/tax requirement and advises amount with notice attachment", "POST", "Contract"],
"POST /api/contracts/:id/clearance/duty-slip": ["Customer uploads duty/tax payment slip on contract", "POST", "Contract"],
"POST /api/contracts/:id/clearance/duty/dispute": ["Customer disputes the advised duty/tax with a reason — reopens the step so GL Ethiopia can re-advise (repeatable)", "POST", "Contract"],
"POST /api/contracts/:id/clearance/export-release": ["GL ET confirms export release after declaration", "POST", "Contract"],
"POST /api/contracts/:id/clearance/finalize": ["GL ET finalizes clearance → CLEARANCE_READY_FOR_BOOKING (legacy)", "POST", "Contract"],
"POST /api/contracts/:id/clearance/finalize-export-clearance": ["GL ET finalizes export clearance after post-booking transit permit upload", "POST", "Contract"],
"POST /api/contracts/:id/clearance/finalize-pre-clearance": ["GL ET finalizes import pre-clearance — unlocks Djibouti DO upload", "POST", "Contract"],
"POST /api/contracts/:id/clearance/ops-finalize": ["Operations finalizes self-clearance → customer may create the booking", "POST", "Contract"],
"POST /api/contracts/:id/clearance/ops-review": ["Operations reviews a customer self-clearance document (Approve | Query)", "POST", "Contract"],
"POST /api/contracts/:id/clearance/output-documents": ["GL uploads customs output documents (IM4/EX3/…) pre-booking", "POST", "Contract"],
"POST /api/contracts/:id/clearance/release-order": ["GL DJ uploads Release Order + vessel departure date (export)", "POST", "Contract"],
"POST /api/contracts/:id/clearance/review": ["GL ET reviews a clearance document (Approve | Query)", "POST", "Contract"],
"POST /api/contracts/:id/clearance/ro-amendment": ["GL DJ requests port amendment when RO vessel window is too short", "POST", "Contract"],
"POST /api/contracts/:id/clearance/transit-assignee/assign": ["GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns", "POST", "Contract"],
"POST /api/contracts/:id/clearance/transit-assignee/request": ["GL ET asks GL Djibouti to name the transit officer — required before the customs declaration", "POST", "Contract"],
"POST /api/contracts/:id/clearance/transit-permit": ["GL ET uploads import transit permit documents (multi-file)", "POST", "Contract"],
"POST /api/contracts/:id/confirm-submit": ["Confirm submit after a price change", "POST", "Contract"],
"POST /api/contracts/:id/contract/generate": ["Generate contract document → CONTRACT_READY", "POST", "Contract"],
"POST /api/contracts/:id/contract/send-signing-otp": ["Send the sudo-mode signing OTP to the contract company's registered phone (server picks the number)", "POST", "Contract"],
"POST /api/contracts/:id/contract/sign": ["Apply digital signature (customer or staff/director/ceo)", "POST", "Contract"],
"PUT /api/contracts/:id/document/articles": ["Edit this contract\\'s document articles only (per-contract; never touches the six shared templates)", "PUT", "Contract"],
"POST /api/contracts/:id/documents": ["Upload intake documents for a contract (DRAFT only)", "POST", "Contract"],
"POST /api/contracts/:id/generate-price": ["Generate unit-rate breakdown (no totals at contract phase)", "POST", "Contract"],
"POST /api/contracts/:id/milestones/:code/complete": ["GL marks a pre-booking (contract) milestone complete", "POST", "Contract"],
"POST /api/contracts/:id/renew": ["Create a renewal draft linked via renewalOfId", "POST", "Contract"],
"POST /api/contracts/:id/resume": ["Staff lift a suspension — contract returns to its prior status", "POST", "Contract"],
"POST /api/contracts/:id/staff/accept": ["Staff accept → set validity window + start approval chain", "POST", "Contract"],
"POST /api/contracts/:id/staff/reject": ["Staff reject contract", "POST", "Contract"],
"POST /api/contracts/:id/staff/request-changes": ["Staff return contract for customer updates", "POST", "Contract"],
"POST /api/contracts/:id/submit": ["Customer submit contract (freezes contract_rate_snapshots)", "POST", "Contract"],
"POST /api/contracts/:id/suspend": ["Staff freeze a signed contract (reversible, any post-signature step)", "POST", "Contract"],
"POST /api/contracts/:id/validate-shipment": ["Pre-create validation + authoritative price preview: full booking price breakdown (rail, first/last mile, surcharges), overweight lines and 20ft weight-pairing errors for a shipment payload (no booking created)", "POST", "Contract"],
"POST /api/contracts/booking-requests/:reqId/accept": ["GL marks a shipment request accepted + links the created booking", "POST", "Contract"],
"POST /api/contracts/booking-requests/:reqId/cancel": ["Customer cancels their own pending shipment request", "POST", "Contract"],
"POST /api/contracts/booking-requests/:reqId/reject": ["GL rejects a shipment request", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/documents": ["GL uploads post-booking operational documents (DO/RO/T1/…)", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/duty": ["GL ET advises duty & tax amount + declaration serial", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/duty-slip": ["Customer uploads the duty/tax payment slip", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/final-invoice": ["GL DJ raises the post-offload final invoice (amount + invoice document)", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/final-invoice-slip": ["Customer attaches the payment slip for the final invoice", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/final-invoice/approve": ["Customer approves the drafted final invoice — unlocks the payment slip", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/final-invoice/confirm": ["GL (ET or DJ) confirms the payment slip — settles the final invoice", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/incidents": ["GL DJ logs a cargo exception with photo evidence", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/milestones/:code/complete": ["GL / Ops / Terminal marks a post-booking milestone complete", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/risk": ["GL ET assigns a customs risk level (GREEN/YELLOW/RED)", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/second-duty": ["GL ET advises (or skips) the post-arrival additional duty/tax round (import)", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/second-duty-slip": ["Customer attaches the additional duty/tax payment slip", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/station-assign": ["GL station manager routes the shipment + binds staff", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/t1-close": ["Close (accept) the T1 set — GL ET after arrival (import) / GL DJ after gate pass (export)", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/t1-documents": ["GL Djibouti uploads T1 transit documents (multi-file) after wagon allocation; locked once the train departs", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/transport-document": ["GL ET uploads export transit permit documents (multi-file)", "POST", "Contract"],
"POST /api/gl-exchange/:entityId": ["Share a document with the other GL desk", "POST", "Contract"],
"PATCH /api/gl-exchange/documents/:documentId": ["Uploader edits a shared document (title, visibility, file)", "PATCH", "Contract"],
"DELETE /api/gl-exchange/documents/:documentId": ["Uploader removes a shared document", "DELETE", "Contract"],
// Contract Template
"POST /api/contract-templates": ["Create a bulk contract template for a (cargo type, customs option) pair", "POST", "Contract Template"],
"PATCH /api/contract-templates/:code": ["Update template metadata (name, title, recitals, active flag)", "PATCH", "Contract Template"],
"DELETE /api/contract-templates/:code": ["Delete a staff-created bulk template (system templates refuse)", "DELETE", "Contract Template"],
"POST /api/contract-templates/:code/articles": ["Add an article to the template", "POST", "Contract Template"],
"PUT /api/contract-templates/:code/articles": ["Replace the full ordered article list (used for reorder)", "PUT", "Contract Template"],
"PATCH /api/contract-templates/:code/articles/:articleId": ["Update an article's title or body", "PATCH", "Contract Template"],
"DELETE /api/contract-templates/:code/articles/:articleId": ["Remove an article from the template", "DELETE", "Contract Template"],
"POST /api/contract-templates/:code/preview": ["Render an HTML preview of the template against mock contract data", "POST", "Contract Template"],
// Driver
"POST /api/drivers": ["Create a new driver", "POST", "Driver"],
"PATCH /api/drivers/:id": ["Update a driver", "PATCH", "Driver"],
"DELETE /api/drivers/:id": ["Delete a driver", "DELETE", "Driver"],
"POST /api/drivers/:id/documents": ["Upload driver documents (code driver_docs)", "POST", "Driver"],
"DELETE /api/drivers/:id/documents/:fileId": ["Delete a driver document", "DELETE", "Driver"],
// Dropdown Setting
"POST /api/dropdown-settings": ["Create a new dropdown setting", "POST", "Dropdown Setting"],
"PATCH /api/dropdown-settings/:id": ["Update a dropdown setting's metadata", "PATCH", "Dropdown Setting"],
"DELETE /api/dropdown-settings/:id": ["Soft-delete a dropdown setting", "DELETE", "Dropdown Setting"],
"POST /api/dropdown-settings/:id/options": ["Append a single option to a setting", "POST", "Dropdown Setting"],
"PUT /api/dropdown-settings/:id/options": ["Replace the full option list for a setting", "PUT", "Dropdown Setting"],
"PATCH /api/dropdown-settings/options/:optionId": ["Update a single option", "PATCH", "Dropdown Setting"],
"DELETE /api/dropdown-settings/options/:optionId": ["Soft-delete a single option", "DELETE", "Dropdown Setting"],
// EIMS Invoice
"POST /api/invoices/:id/eims/register": ["Register the invoice with MoR EIMS. Idempotent — an invoice that already has an IRN is returned unchanged", "POST", "EIMS Invoice"],
"POST /api/invoices/:id/eims/resolve": ["Resolve an unacknowledged submission: record the IRN confirmed with MoR, or discard it. Clears the system-wide block", "POST", "EIMS Invoice"],
"POST /api/invoices/:id/eims/verify": ["Verify the invoice's stored IRN against EIMS", "POST", "EIMS Invoice"],
// Exchange Setting
"PATCH /api/exchange-settings": ["Set the USD→ETB fallback by hand (used only while CBE is unreachable)", "PATCH", "Exchange Setting"],
// Facility
"POST /api/facilities": ["Create a new facility", "POST", "Facility"],
"PATCH /api/facilities/:id": ["Update a facility", "PATCH", "Facility"],
"DELETE /api/facilities/:id": ["Delete a facility (soft delete)", "DELETE", "Facility"],
// Fayda Verification
"POST /api/fayda/verification/start": ["Start a VeriFayda 2.0 verification session", "POST", "Fayda Verification"],
// File Upload Setting
"POST /api/file-upload-settings": ["Create a new file upload setting", "POST", "File Upload Setting"],
"PATCH /api/file-upload-settings/:id": ["Update a file upload setting's metadata", "PATCH", "File Upload Setting"],
"DELETE /api/file-upload-settings/:id": ["Soft-delete a file upload setting", "DELETE", "File Upload Setting"],
"POST /api/file-upload-settings/:id/fields": ["Append a single field to a setting", "POST", "File Upload Setting"],
"PUT /api/file-upload-settings/:id/fields": ["Replace the full field list for a setting", "PUT", "File Upload Setting"],
"PATCH /api/file-upload-settings/fields/:fieldId": ["Update a single field", "PATCH", "File Upload Setting"],
"DELETE /api/file-upload-settings/fields/:fieldId": ["Soft-delete a single field", "DELETE", "File Upload Setting"],
// First Mile
"POST /api/first-mile": ["Create a first-mile leg", "POST", "First Mile"],
"PATCH /api/first-mile/:id": ["Update a first-mile leg", "PATCH", "First Mile"],
"DELETE /api/first-mile/:id": ["Soft-delete a first-mile leg", "DELETE", "First Mile"],
"POST /api/first-mile/:id/distances": ["Set per-vehicle actual distances (does not generate an invoice)", "POST", "First Mile"],
"POST /api/first-mile/:id/invoice": ["Generate the first-mile delivery-fee invoice", "POST", "First Mile"],
"POST /api/first-mile/:id/vehicles": ["Set the vehicles assigned to a first-mile pickup (multi-truck)", "POST", "First Mile"],
"POST /api/first-mile/accept/:reference": ["Accept a paid booking and create a first-mile leg", "POST", "First Mile"],
// Fuel
"POST /api/fuel/purchases": ["Record fuel purchase", "POST", "Fuel"],
// GPS Tracking
"POST /api/gps/devices": ["Register a GPS tracker", "POST", "GPS Tracking"],
"PATCH /api/gps/devices/:id": ["Update a GPS tracker (name / assigned vehicle)", "PATCH", "GPS Tracking"],
"DELETE /api/gps/devices/:id": ["Delete a GPS tracker", "DELETE", "GPS Tracking"],
// Import Operation
"POST /api/import-operations/customs/:bookingId/declaration": ["Batch 12: record declaration serial number", "POST", "Import Operation"],
"POST /api/import-operations/customs/:bookingId/documents": ["Batch 12: upload IM4/IM5/T1/permit/payment-slip documents", "POST", "Import Operation"],
"POST /api/import-operations/customs/:bookingId/duties-taxes-paid": ["Batch 12: mark duties and taxes paid", "POST", "Import Operation"],
"POST /api/import-operations/customs/:bookingId/notify-duties-taxes": ["Batch 12: notify duties and taxes", "POST", "Import Operation"],
"POST /api/import-operations/customs/:bookingId/release-permitted": ["Batch 12: mark import release permitted", "POST", "Import Operation"],
"POST /api/import-operations/customs/:bookingId/risk": ["Batch 12: assign customs risk", "POST", "Import Operation"],
"POST /api/import-operations/djibouti-incidents": ["Batch 8: report a Djibouti import incident / exception", "POST", "Import Operation"],
"POST /api/import-operations/empty-container-returns": ["Batch 16: create an empty container return record", "POST", "Import Operation"],
"POST /api/import-operations/empty-container-returns/:id/status": ["Batch 16: advance empty container return workflow", "POST", "Import Operation"],
// Incident
"POST /api/incidents": ["Report an incident", "POST", "Incident"],
"PATCH /api/incidents/:id": ["Update an incident", "PATCH", "Incident"],
"DELETE /api/incidents/:id": ["Delete an incident", "DELETE", "Incident"],
// Interchange Document
"PATCH /api/interchange-documents/:id/acknowledge": ["Acknowledge an interchange document", "PATCH", "Interchange Document"],
"PATCH /api/interchange-documents/:id/dispute": ["Dispute an interchange document", "PATCH", "Interchange Document"],
"POST /api/interchange-documents/generate-from-schedule": ["Generate interchange document from a train schedule handover", "POST", "Interchange Document"],
// Last Mile
"POST /api/last-mile": ["Create a last-mile leg", "POST", "Last Mile"],
"PATCH /api/last-mile/:id": ["Update a last-mile leg", "PATCH", "Last Mile"],
"DELETE /api/last-mile/:id": ["Soft-delete a last-mile leg", "DELETE", "Last Mile"],
"POST /api/last-mile/:id/detention-times": ["Set each truck\\'s own detention window (arrived at destination / returned)", "POST", "Last Mile"],
"POST /api/last-mile/:id/distances": ["Set per-vehicle actual distances (does not generate an invoice)", "POST", "Last Mile"],
"POST /api/last-mile/:id/invoice": ["Generate the delivery-fee invoice for a last-mile leg", "POST", "Last Mile"],
"POST /api/last-mile/:id/proof-of-delivery": ["Record proof of delivery (signature + photos) and complete the leg", "POST", "Last Mile"],
"POST /api/last-mile/:id/vehicles": ["Set the vehicles assigned to a last-mile delivery (multi-truck)", "POST", "Last Mile"],
"POST /api/last-mile/:id/warehouse-gate-times": ["Set each truck\\'s warehouse gate arrival/departure times", "POST", "Last Mile"],
"POST /api/last-mile/accept/:reference": ["Accept a paid booking and create a last-mile leg", "POST", "Last Mile"],
// Last Mile Request
"POST /api/last-mile-requests/:id/approve": ["Truck & Machinery chief approves the request — the advance defaults to the live last-mile rate; LM contract becomes signable and the advance invoice follows the customer signature", "POST", "Last Mile Request"],
"POST /api/last-mile-requests/:id/contract/sign": ["Customer agrees and signs the LM contract — then the advance invoice is issued", "POST", "Last Mile Request"],
"POST /api/last-mile-requests/:id/reject": ["Truck & Machinery chief rejects the request with a reason", "POST", "Last Mile Request"],
"POST /api/last-mile-requests/:id/submit": ["Customer confirms which containers go via EDR last-mile", "POST", "Last Mile Request"],
// Locomotive
"POST /api/locomotives": ["Create a locomotive", "POST", "Locomotive"],
"PATCH /api/locomotives/:id": ["Update a locomotive", "PATCH", "Locomotive"],
"POST /api/locomotives/:id/decommission": ["Decommission a locomotive", "POST", "Locomotive"],
"DELETE /api/locomotives/:id/permanent": ["Permanently delete a locomotive (irreversible; refused if any train references it)", "DELETE", "Locomotive"],
// Maintenance
"POST /api/maintenance/costs": ["Record maintenance cost", "POST", "Maintenance"],
"POST /api/maintenance/intervals": ["Define/adjust a service interval (e.g. oil change every 10,000 km)", "POST", "Maintenance"],
"DELETE /api/maintenance/intervals/:id": ["Deactivate a service interval (stops auto-scheduling)", "DELETE", "Maintenance"],
"POST /api/maintenance/parts": ["Create part", "POST", "Maintenance"],
"PATCH /api/maintenance/parts/:id": ["Update part", "PATCH", "Maintenance"],
"DELETE /api/maintenance/parts/:id": ["Delete part", "DELETE", "Maintenance"],
"POST /api/maintenance/schedules": ["Schedule maintenance", "POST", "Maintenance"],
"PATCH /api/maintenance/schedules/:id": ["Update maintenance schedule", "PATCH", "Maintenance"],
"POST /api/maintenance/warranties": ["Create warranty", "POST", "Maintenance"],
"DELETE /api/maintenance/warranties/:id": ["Delete warranty", "DELETE", "Maintenance"],
"POST /api/maintenance/work-orders": ["Create work order", "POST", "Maintenance"],
"PATCH /api/maintenance/work-orders/:id": ["Update work order", "PATCH", "Maintenance"],
"DELETE /api/maintenance/work-orders/:id": ["Delete work order", "DELETE", "Maintenance"],
// Notification Inbox
"PATCH /api/notifications/:id/read": ["Mark one of my notifications as read", "PATCH", "Notification Inbox"],
"POST /api/notifications/read-all": ["Mark all my notifications as read", "POST", "Notification Inbox"],
// Organization User
"PUT /api/backoffice/organizations/:orgId/employee-users/:userId/roles": ["Replace org-scoped roles assigned to an employee user", "PUT", "Organization User"],
"POST /api/backoffice/organizations/:orgId/users": ["Create an organization user without assigning positions", "POST", "Organization User"],
// OTP
"POST /api/otp/send": ["Send OTP", "POST", "OTP"],
"POST /api/otp/verify": ["Verify OTP", "POST", "OTP"],
// Password Reset
"POST /api/auth/forgot-password/request": ["Send a password-reset code to the account's email AND phone", "POST", "Password Reset"],
"POST /api/auth/forgot-password/resolve-link": ["Validate a staff-issued reset link and return its set-password ticket", "POST", "Password Reset"],
"POST /api/auth/forgot-password/verify": ["Exchange a valid reset code for a single-use set-password ticket", "POST", "Password Reset"],
"POST /api/backoffice/customers/:companyId/reset-password": ["Send a password-reset link to a customer's primary contact", "POST", "Password Reset"],
// Payment
"POST /api/billing/invoices/:id/confirm-offline": ["Finance confirms a USD invoice paid by bank transfer — slip file required, settles the full balance", "POST", "Payment"],
"POST /api/billing/my-invoices/:id/confirm": ["Confirm an OTP-debit payment (CAC Bank) for one of the customer's invoices", "POST", "Payment"],
"POST /api/billing/my-invoices/:id/pay": ["Initiate payment for one of the customer's invoices", "POST", "Payment"],
"POST /api/internal/payments/bill-query": ["Live still-payable check + payer name for a CBE bill (called while CBE is on the line)", "POST", "Payment"],
"POST /api/internal/payments/mark-paid": ["Apply a payment.succeeded / payment.failed event from the payment service (idempotent)", "POST", "Payment"],
"POST /api/payments/initiate": ["Initiate payment for an invoice", "POST", "Payment"],
"POST /api/payments/redirect-success/:bookingId": ["Success-redirect ack: mark payment processing + invoice PAYMENT_PROCESSING (webhook remains source of truth)", "POST", "Payment"],
// Priority Config
"POST /api/priority-configs": ["Create a priority config", "POST", "Priority Config"],
"PATCH /api/priority-configs/:id": ["Update a priority config", "PATCH", "Priority Config"],
"DELETE /api/priority-configs/:id": ["Soft-delete a priority config", "DELETE", "Priority Config"],
"POST /api/priority-configs/:id/move-order": ["Move a priority config up or down in display order", "POST", "Priority Config"],
"POST /api/priority-configs/reorder": ["Bulk reorder priority configs by ID list", "POST", "Priority Config"],
// Priority Rule Change Request
"POST /api/priority-rule-change-requests": ["Submit a priority-rule change for approval", "POST", "Priority Rule Change Request"],
"POST /api/priority-rule-change-requests/:id/approve": ["Approve and apply a pending change", "POST", "Priority Rule Change Request"],
"POST /api/priority-rule-change-requests/:id/reject": ["Reject a pending change", "POST", "Priority Rule Change Request"],
// Procurement
"POST /api/procurement/acquisitions": ["Create an asset acquisition", "POST", "Procurement"],
"PATCH /api/procurement/acquisitions/:id": ["Update an asset acquisition", "PATCH", "Procurement"],
"DELETE /api/procurement/acquisitions/:id": ["Delete an asset acquisition", "DELETE", "Procurement"],
"POST /api/procurement/disposals": ["Create an asset disposal", "POST", "Procurement"],
"DELETE /api/procurement/disposals/:id": ["Delete an asset disposal", "DELETE", "Procurement"],
"POST /api/procurement/vendors": ["Create a vendor", "POST", "Procurement"],
"PATCH /api/procurement/vendors/:id": ["Update a vendor", "PATCH", "Procurement"],
"DELETE /api/procurement/vendors/:id": ["Delete a vendor", "DELETE", "Procurement"],
// Rate
"POST /api/rates": ["Create a rate (DRAFT)", "POST", "Rate"],
"PATCH /api/rates/:id": ["Update a DRAFT rate", "PATCH", "Rate"],
"DELETE /api/rates/:id": ["Soft-delete a rate", "DELETE", "Rate"],
"POST /api/rates/:id/approve": ["CEO approves a rate", "POST", "Rate"],
"POST /api/rates/:id/submit": ["Submit rate for CEO approval", "POST", "Rate"],
// Rate Change Request
"POST /api/rate-change-requests": ["Propose a change to a LIVE rate", "POST", "Rate Change Request"],
"POST /api/rate-change-requests/:id/approve": ["Approve a rate change and put it into effect", "POST", "Rate Change Request"],
"POST /api/rate-change-requests/:id/reject": ["Reject a rate change — the rate keeps its current value", "POST", "Rate Change Request"],
// Route
"POST /api/routes": ["Create route", "POST", "Route"],
"PATCH /api/routes/:id": ["Update route", "PATCH", "Route"],
"DELETE /api/routes/:id": ["Deactivate route", "DELETE", "Route"],
"DELETE /api/routes/:id/permanent": ["Permanently delete a route (irreversible; refused while any train schedule references it)", "DELETE", "Route"],
// Schedule
// NOTE: duplicate route — also declared in modules/scheduling-reschedule/scheduling-reschedule.controller.ts:52.
// Two controllers register this same path; Nest serves whichever module loads first.
"POST /api/train-scheduling/schedules/:id/maintenance": ["Reschedule train for maintenance (new departure + rebalance)", "POST", "Schedule"],
"POST /api/train-scheduling/schedules/:id/reschedule/execute": ["Execute a confirmed reschedule plan", "POST", "Schedule"],
"POST /api/train-scheduling/schedules/:id/reschedule/preview": ["Preview reschedule / government preempt plan", "POST", "Schedule"],
// Service Type
"POST /api/service-types": ["Create a service type", "POST", "Service Type"],
"PATCH /api/service-types/:id": ["Update a service type", "PATCH", "Service Type"],
"DELETE /api/service-types/:id": ["Soft-delete a service type", "DELETE", "Service Type"],
"POST /api/service-types/:id/move-order": ["Move a service type up or down in display order", "POST", "Service Type"],
"POST /api/service-types/reorder": ["Bulk reorder service types by ID list", "POST", "Service Type"],
// Shipping Line
"POST /api/shipping-lines": ["Create a shipping line", "POST", "Shipping Line"],
"PATCH /api/shipping-lines/:id": ["Update a shipping line", "PATCH", "Shipping Line"],
"DELETE /api/shipping-lines/:id": ["Soft-delete a shipping line", "DELETE", "Shipping Line"],
// Signature
"PUT /api/me/signature": ["Create or update the reusable saved signature", "PUT", "Signature"],
// Support Chat
"POST /api/support/agent/conversations": ["Start chatting with a company (returns the thread if one exists)", "POST", "Support Chat"],
"POST /api/support/agent/conversations/:id/messages": ["Reply as an agent, optionally with attachments", "POST", "Support Chat"],
"POST /api/support/agent/conversations/:id/read": ["Mark a thread read (agent side)", "POST", "Support Chat"],
"POST /api/support/conversation/messages": ["Send a message as the customer (optionally with attachments), opening the thread if needed", "POST", "Support Chat"],
"POST /api/support/conversation/read": ["Mark my company's thread read (customer side)", "POST", "Support Chat"],
// Support Content
"PATCH /api/support-content/documents/:slug": ["Replace a document's payload, recording a new version", "PATCH", "Support Content"],
"POST /api/support-content/documents/:slug/versions/:version/restore": ["Restore a version — re-saves it as a new version, never destructive", "POST", "Support Content"],
"POST /api/support-content/media": ["Upload an image or video for a help section", "POST", "Support Content"],
// Train
"POST /api/trains": ["Register a new train", "POST", "Train"],
"PATCH /api/trains/:id": ["Update a train", "PATCH", "Train"],
"DELETE /api/trains/:id": ["Delete a train", "DELETE", "Train"],
// Train Build
"POST /api/train-builder": ["Build a train: code + yard + 2+ locomotives (+ optional wagons)", "POST", "Train Build"],
"DELETE /api/train-builder/:id": ["Disband the train (release wagons and locomotives)", "DELETE", "Train Build"],
"POST /api/train-builder/:id/activate": ["Reactivate a deactivated train back to AVAILABLE", "POST", "Train Build"],
"POST /api/train-builder/:id/deactivate": ["Deactivate the train (park it) — only allowed with no active schedule", "POST", "Train Build"],
"PATCH /api/train-builder/:id/details": ["Edit the train's name and fixed import/export run numbers", "PATCH", "Train Build"],
"PUT /api/train-builder/:id/locomotives": ["Replace the locomotive set (minimum 1, same yard)", "PUT", "Train Build"],
"POST /api/train-builder/:id/reorder-wagons": ["Persist a drag-reorder of the full consist", "POST", "Train Build"],
"POST /api/train-builder/:id/wagons": ["Append AVAILABLE wagons from the train's yard to the consist", "POST", "Train Build"],
"DELETE /api/train-builder/:id/wagons/:wagonId": ["Detach one wagon from the consist", "DELETE", "Train Build"],
"POST /api/train-builder/:id/wagons/:wagonId/maintenance": ["Detach one wagon and move it to MAINTENANCE status", "POST", "Train Build"],
"PATCH /api/train-builder/:id/yard": ["Relocate the train — its locomotives and wagons move to the new yard with it", "PATCH", "Train Build"],
// Train Schedule
"POST /api/train-scheduling/bookings/:bookingId/allocate": ["Staff: place a paid booking onto a fitting train (notifies customer on date change)", "POST", "Train Schedule"],
"POST /api/train-scheduling/bookings/:bookingId/expire": ["Staff: expire a reservation and free its capacity", "POST", "Train Schedule"],
"POST /api/train-scheduling/bookings/:bookingId/mark-paid": ["Staff: mark a reserved booking paid and allocate it now", "POST", "Train Schedule"],
"POST /api/train-scheduling/bookings/:bookingId/move-schedule": ["Re-point a booking to another OPEN same-route schedule", "POST", "Train Schedule"],
"POST /api/train-scheduling/bulk/preview": ["Preview a bulk train schedule", "POST", "Train Schedule"],
"POST /api/train-scheduling/bulk/schedules": ["Create a bulk train schedule", "POST", "Train Schedule"],
"POST /api/train-scheduling/bulk/schedules/:id/assign-bookings": ["Assign bulk bookings to a train schedule", "POST", "Train Schedule"],
"POST /api/train-scheduling/bulk/schedules/:id/cancel": ["Cancel bulk train schedule", "POST", "Train Schedule"],
"POST /api/train-scheduling/container/preview": ["Preview a container train schedule", "POST", "Train Schedule"],
"POST /api/train-scheduling/container/schedules": ["Create a container train schedule", "POST", "Train Schedule"],
"POST /api/train-scheduling/container/schedules/:id/assign-bookings": ["Assign container bookings to a train schedule", "POST", "Train Schedule"],
"POST /api/train-scheduling/container/schedules/:id/cancel": ["Cancel container train schedule", "POST", "Train Schedule"],
"PATCH /api/train-scheduling/global-rules": ["Update global train scheduling rules (singleton)", "PATCH", "Train Schedule"],
"POST /api/train-scheduling/preview": ["Preview a mixed-capable train schedule", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/adjust-consist": ["Permanently trim free wagons off / couple yard wagons onto the schedule's built train (weight & length limits incl. tolerance enforced, every change logged)", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/arrive": ["Mark a dispatched train arrived (move assets to destination yard, free assets)", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/assign-bookings": ["Assign bookings to a train schedule (mixed-capable)", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/assign-unassigned-booking": ["Assign one linked unallocated booking to wagons (preserves existing assignments)", "POST", "Train Schedule"],
"PATCH /api/train-scheduling/schedules/:id/booking-window": ["Open or close a schedule booking window", "PATCH", "Train Schedule"],
"DELETE /api/train-scheduling/schedules/:id/bookings/:bookingId": ["Unassign a booking from a train schedule", "DELETE", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/bookings/:bookingId/load": ["Confirm a booking's cargo loaded at its origin yard (any direction; train must be at that yard)", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/bookings/:bookingId/unload": ["Confirm a booking's cargo unloaded at its destination yard — per-booking arrival, may precede the train's final arrival", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/checkpoints": ["Log the train passing a station (final station triggers arrival)", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/confirm-loading": ["Confirm cargo loaded on the train (any direction; unblocks import-Djibouti dispatch)", "POST", "Train Schedule"],
"PATCH /api/train-scheduling/schedules/:id/container-items/:itemId": ["Update a container number on a wagon slot", "PATCH", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/dispatch": ["Dispatch a scheduled train", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/doc-review-complete": ["Staff finished document review early — run the batch/payment phase now (applies to the whole route-day group)", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/finalize": ["Finalize a draft train schedule", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/import-djibouti/depart": ["Depart loaded import train from Djibouti", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/import-djibouti/documents": ["Upload/check an import Djibouti-side document", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/import-djibouti/gatepass-granted": ["Mark import Djibouti gatepass permission granted", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/import-djibouti/load-list": ["Generate import load list / marshalling document summary", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/import-djibouti/loaded-on-train": ["Confirm import cargo loaded on train at Djibouti", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/import-djibouti/ready-for-loading": ["Mark import train ready for loading at Djibouti", "POST", "Train Schedule"],
"PATCH /api/train-scheduling/schedules/:id/import-loading-status": ["Mark import bookings loaded/unloaded on this schedule (tracking only, does not affect dispatch)", "PATCH", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/intercity/:bookingId/load": ["Confirm intercity cargo loaded (train must be at the booking's origin yard)", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/intercity/:bookingId/unload": ["Confirm intercity cargo unloaded at the booking's destination yard (completes the booking)", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/intercity/accept": ["Accept intercity bookings onto this train (opens their pay window; capacity re-checked per booking)", "POST", "Train Schedule"],
"PATCH /api/train-scheduling/schedules/:id/loading-status": ["Mark bookings loaded/unloaded on this schedule (any direction, pre-dispatch only)", "PATCH", "Train Schedule"],
// NOTE: duplicate route — also declared in modules/train-scheduling/controllers/train-scheduling.controller.ts:798.
// Two controllers register this same path; Nest serves whichever module loads first.
"POST /api/train-scheduling/schedules/:id/maintenance [modules/train-scheduling/controllers/train-scheduling.controller.ts]": ["Maintenance reschedule: move the train to a new departure with every allocated booking aboard — links, wagons and window settings unchanged", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/pin-wagons": ["Pin physical wagons to train set slots", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/run-allocation": ["Run wagon-level allocation for all eligible linked bookings", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/run-batch": ["Manually run the batch fill for a schedule", "POST", "Train Schedule"],
"PATCH /api/train-scheduling/schedules/:id/schedule-date": ["Reschedule a train's departure date — only before the booking window opens, and only if the new date still leaves room for the booking lead window", "PATCH", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/switch-government-booking": ["Switch out commercial bookings to allocate a government booking in their place", "POST", "Train Schedule"],
"DELETE /api/train-scheduling/schedules/:id/wagons/:trainSetWagonId": ["Remove an empty wagon slot from a train", "DELETE", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/wagons/:wagonId/move-load": ["Move a wagon's whole load to another wagon (empty → move/repin, loaded → swap loads)", "POST", "Train Schedule"],
"PATCH /api/train-scheduling/schedules/:id/window-rule": ["Override the booking-window rule for one schedule (open/close hour, duration, doc-review, payment, lead days) — only before the window opens", "PATCH", "Train Schedule"],
// Transit Agent
"POST /api/transit-agents": ["Create a transit agent", "POST", "Transit Agent"],
"PATCH /api/transit-agents/:id": ["Update a transit agent", "PATCH", "Transit Agent"],
"DELETE /api/transit-agents/:id": ["Soft-delete a transit agent", "DELETE", "Transit Agent"],
// Truck Type
"POST /api/truck-types": ["Create a truck type", "POST", "Truck Type"],
"PATCH /api/truck-types/:id": ["Update a truck type", "PATCH", "Truck Type"],
"DELETE /api/truck-types/:id": ["Soft-delete a truck type", "DELETE", "Truck Type"],
// User Trade Access
"PUT /api/user-trade-access/:userId": ["Set the trade directions a backoffice user may see", "PUT", "User Trade Access"],
// Vehicle
"POST /api/vehicles": ["Create a new vehicle", "POST", "Vehicle"],
"PATCH /api/vehicles/:id": ["Update a vehicle", "PATCH", "Vehicle"],
"DELETE /api/vehicles/:id": ["Delete a vehicle", "DELETE", "Vehicle"],
// Wagon
"POST /api/wagons": ["Create a new wagon", "POST", "Wagon"],
"PATCH /api/wagons/:id": ["Update a wagon", "PATCH", "Wagon"],
"DELETE /api/wagons/:id": ["Delete a wagon", "DELETE", "Wagon"],
"POST /api/wagons/:id/assign-train": ["Assign wagon to a train", "POST", "Wagon"],
"DELETE /api/wagons/:id/permanent": ["Permanently delete a wagon (irreversible; refused if it has movements, containers or train-set slots)", "DELETE", "Wagon"],
"POST /api/wagons/:id/unassign-train": ["Unassign wagon from train", "POST", "Wagon"],
"POST /api/wagons/bulk-status": ["Set the status of multiple wagons (audited in wagon_status_logs)", "POST", "Wagon"],
"POST /api/wagons/bulk-transfer": ["Transfer multiple wagons to a destination yard", "POST", "Wagon"],
// Wagon Transfer Request
"POST /api/wagon-transfer-requests": ["File a count-only wagon-transfer request", "POST", "Wagon Transfer Request"],
"POST /api/wagon-transfer-requests/:id/cancel": ["Withdraw a request that has not moved any wagon yet (use close-short once wagons have moved)", "POST", "Wagon Transfer Request"],
"POST /api/wagon-transfer-requests/:id/close-short": ["OCC: end the request with fewer wagons than asked for — what moved stays, the requester is told the shortfall", "POST", "Wagon Transfer Request"],
"POST /api/wagon-transfer-requests/:id/fulfill": ["OCC: pick wagons and execute the transfer", "POST", "Wagon Transfer Request"],
"POST /api/wagon-transfer-requests/bulk-fulfill": ["OCC: accept-and-execute a subset of pending requests (auto-picks available wagons; the rest stay PENDING)", "POST", "Wagon Transfer Request"],
// Wagon Type
"POST /api/wagon-types": ["Create a wagon type", "POST", "Wagon Type"],
"PATCH /api/wagon-types/:id": ["Update a wagon type", "PATCH", "Wagon Type"],
"DELETE /api/wagon-types/:id": ["Soft-delete a wagon type", "DELETE", "Wagon Type"],
// Warehouse
"POST /api/warehouse-allocation-rules": ["Create a warehouse allocation rule", "POST", "Warehouse"],
"PATCH /api/warehouse-allocation-rules/:id": ["Update a warehouse allocation rule", "PATCH", "Warehouse"],
"DELETE /api/warehouse-allocation-rules/:id": ["Delete a warehouse allocation rule", "DELETE", "Warehouse"],
"POST /api/warehouse-allocation/preview": ["Preview the yard/warehouse/zone a booking would be allocated to", "POST", "Warehouse"],
"POST /api/warehouse-fee-rules": ["Create a storage / demurrage fee rule", "POST", "Warehouse"],
"PATCH /api/warehouse-fee-rules/:id": ["Update a fee rule", "PATCH", "Warehouse"],
"DELETE /api/warehouse-fee-rules/:id": ["Delete a fee rule", "DELETE", "Warehouse"],
"POST /api/warehouse-fees/accrual/:inventoryId/acknowledge": ["Acknowledge / snooze an item fee-accrual alert", "POST", "Warehouse"],
"DELETE /api/warehouse-fees/accrual/:inventoryId/acknowledge": ["Remove an accrual acknowledgement (re-surface for alerts)", "DELETE", "Warehouse"],
"POST /api/warehouses": ["Create warehouse", "POST", "Warehouse"],
"PATCH /api/warehouses/:id": ["Update warehouse", "PATCH", "Warehouse"],
"POST /api/warehouses/:warehouseId/yards": ["Create a yard within a warehouse", "POST", "Warehouse"],
// Warehouse Fee Invoice
"POST /api/last-mile/:id/generate-truck-detention-invoice": ["Generate a truck-detention invoice for a last-mile leg (per truck per day)", "POST", "Warehouse Fee Invoice"],
"PATCH /api/warehouse-fee-invoices/:id/cancel": ["Cancel a warehouse fee invoice", "PATCH", "Warehouse Fee Invoice"],
"POST /api/warehouse-fee-invoices/:id/pay": ["Record a payment against a warehouse fee invoice", "POST", "Warehouse Fee Invoice"],
"POST /api/warehouse-fee-invoices/:id/pay-online": ["Initiate Telebirr/Waafi payment for a warehouse fee invoice", "POST", "Warehouse Fee Invoice"],
"POST /api/warehouse-inventory/:id/generate-fee-invoice": ["Generate a warehouse fee invoice from Batch 5 fee calculation", "POST", "Warehouse Fee Invoice"],
// Warehouse Inspection Report
"PATCH /api/warehouse-inspection-reports/:id": ["Update an inspection report", "PATCH", "Warehouse Inspection Report"],
"POST /api/warehouse-inspection-reports/:id/attachments": ["Upload inspection images / documents", "POST", "Warehouse Inspection Report"],
"POST /api/warehouse-inventory/:inventoryId/inspection-reports": ["Create an inspection / damage report for an inventory item", "POST", "Warehouse Inspection Report"],
// Warehouse Inventory
"POST /api/warehouse-inventory/:id/deliver": ["Deliver import goods to the customer + capture proof of delivery", "POST", "Warehouse Inventory"],
"PATCH /api/warehouse-inventory/:id/dispatch": ["Mark loaded inventory DISPATCHED (left the terminal)", "PATCH", "Warehouse Inventory"],
"POST /api/warehouse-inventory/:id/gate-clearance": ["Final terminal release / gate clearance (blocked while fees unpaid)", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/:id/load": ["Load READY_FOR_LOADING inventory onto a wagon", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/:id/move": ["Move inventory to another warehouse/yard/zone", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/:id/ready-for-loading": ["Mark reserved inventory READY_FOR_LOADING", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/:id/ready-for-pickup": ["Mark inspected IMPORT inventory READY_FOR_PICKUP", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/:id/release": ["Issue a DO / release order for ready-for-pickup inventory", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/:id/store": ["Mark received inventory as STORED (optional explicit warehouse/yard/zone)", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/auto-load-ready": ["Auto-load READY_FOR_LOADING inventory with PAID bookings", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/auto-unload-arrived": ["Bulk auto-unload all arrived bookings into the warehouse", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/bookings/:bookingId/approve-delivery": ["Approve delivery — customer records their full name (signature optional)", "POST", "Warehouse Inventory"],
"PATCH /api/warehouse-inventory/bookings/:bookingId/double-handling": ["Record Yes/No double handling after unloading (Yes applies the double-handling fee rule)", "PATCH", "Warehouse Inventory"],
"POST /api/warehouse-inventory/bookings/:bookingId/request-handover-signature": ["Ask the customer to sign the handover (creates one if none, then notifies)", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/bookings/:bookingId/unload": ["Unload a single arrived booking into a location", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/bulk-dispatch-export": ["Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED)", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/bulk-mark-inspected": ["Bulk mark received inventory inspection PASSED (EXPORT → READY_FOR_LOADING)", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/export/auto-unload-at-djibouti": ["Unload all eligible export items assigned to an arrived Djibouti-side train", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/handovers/:handoverId/sign": ["Customer signs one handover (EDR last-mile: one signature per truck)", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/import/auto-unload-arrived-bookings": ["Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED)", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/receive": ["Receive inventory at a warehouse location", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/receive-bulk": ["Bulk-receive selected eligible PAID bookings into a location", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/reserve": ["Reserve stored inventory for a PAID booking", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/train/:scheduleId/load": ["Load selected inventory items onto their allocated wagons for a train", "POST", "Warehouse Inventory"],
// Warehouse Yard
"PATCH /api/warehouse-yards/:id": ["Update warehouse yard", "PATCH", "Warehouse Yard"],
"POST /api/warehouse-yards/:yardId/zones": ["Create a zone within a yard", "POST", "Warehouse Yard"],
// Warehouse Zone
"PATCH /api/warehouse-zones/:id": ["Update warehouse zone", "PATCH", "Warehouse Zone"],
// Weight Limit Rule
"POST /api/weight-limit-rules": ["Create a weight limit rule", "POST", "Weight Limit Rule"],
"PATCH /api/weight-limit-rules/:id": ["Update a weight limit rule", "PATCH", "Weight Limit Rule"],
"DELETE /api/weight-limit-rules/:id": ["Soft-delete a weight limit rule", "DELETE", "Weight Limit Rule"],
// Yard
"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"],
"POST /api/yards/:id/move-order": ["Move a yard up or down in display order", "POST", "Yard"],
"POST /api/yards/reorder": ["Bulk reorder yards by ID list", "POST", "Yard"],
// Yard Distance
"POST /api/yard-distances": ["Create a yard distance", "POST", "Yard Distance"],
"PATCH /api/yard-distances/:id": ["Update a yard distance", "PATCH", "Yard Distance"],
"DELETE /api/yard-distances/:id": ["Soft-delete a yard distance", "DELETE", "Yard Distance"],
};
module.exports = AUDIT_ENDPOINTS;

View File

@@ -0,0 +1,922 @@
# Freight API — Mutating Endpoints (Audit Surface)
Every state-changing route in `apps/edr-freight-api``POST`, `PUT`, `PATCH`, `DELETE`.
This is the candidate surface for audit logging: each row is an action a user can take
that changes persisted state and therefore needs a who / what / when trail.
All paths include the global prefix `api` (`app.setGlobalPrefix("api")` in `src/main.ts`).
Titles come from each route's `@ApiOperation({ summary })`; where a route has none,
the title is derived from its handler name.
> Generated by reading the `@Post` / `@Put` / `@Patch` / `@Delete` decorators in
> `src/**/*.controller.ts`. Re-generate after adding routes so this stays complete.
## Summary
| Method | Count |
| --- | ---: |
| `POST` | 351 |
| `PUT` | 8 |
| `PATCH` | 72 |
| `DELETE` | 61 |
| **Total** | **492** |
Across **66** entities.
## Entity index
| Entity | Endpoints |
| --- | ---: |
| [Account](#account) | 3 |
| [AI Assist](#ai-assist) | 1 |
| [Approval Rule](#approval-rule) | 5 |
| [Booking](#booking) | 57 |
| [Cargo](#cargo) | 6 |
| [Cargo Type](#cargo-type) | 5 |
| [Company](#company) | 30 |
| [Compliance](#compliance) | 3 |
| [Consignment](#consignment) | 1 |
| [Container](#container) | 5 |
| [Container Type](#container-type) | 5 |
| [Contract](#contract) | 68 |
| [Contract Template](#contract-template) | 8 |
| [Driver](#driver) | 5 |
| [Dropdown Setting](#dropdown-setting) | 7 |
| [EIMS Invoice](#eims-invoice) | 3 |
| [Exchange Setting](#exchange-setting) | 1 |
| [Facility](#facility) | 3 |
| [Fayda Verification](#fayda-verification) | 1 |
| [File Upload Setting](#file-upload-setting) | 7 |
| [First Mile](#first-mile) | 7 |
| [Fuel](#fuel) | 1 |
| [GPS Tracking](#gps-tracking) | 3 |
| [Import Operation](#import-operation) | 9 |
| [Incident](#incident) | 3 |
| [Interchange Document](#interchange-document) | 3 |
| [Last Mile](#last-mile) | 10 |
| [Last Mile Request](#last-mile-request) | 4 |
| [Locomotive](#locomotive) | 4 |
| [Maintenance](#maintenance) | 13 |
| [Notification Inbox](#notification-inbox) | 2 |
| [Organization User](#organization-user) | 2 |
| [OTP](#otp) | 2 |
| [Password Reset](#password-reset) | 4 |
| [Payment](#payment) | 7 |
| [Priority Config](#priority-config) | 5 |
| [Priority Rule Change Request](#priority-rule-change-request) | 3 |
| [Procurement](#procurement) | 8 |
| [Rate](#rate) | 5 |
| [Rate Change Request](#rate-change-request) | 3 |
| [Route](#route) | 4 |
| [Schedule](#schedule) | 3 |
| [Service Type](#service-type) | 5 |
| [Shipping Line](#shipping-line) | 3 |
| [Signature](#signature) | 1 |
| [Support Chat](#support-chat) | 5 |
| [Support Content](#support-content) | 3 |
| [Train](#train) | 3 |
| [Train Build](#train-build) | 11 |
| [Train Schedule](#train-schedule) | 48 |
| [Transit Agent](#transit-agent) | 3 |
| [Truck Type](#truck-type) | 3 |
| [User Trade Access](#user-trade-access) | 1 |
| [Vehicle](#vehicle) | 3 |
| [Wagon](#wagon) | 8 |
| [Wagon Transfer Request](#wagon-transfer-request) | 5 |
| [Wagon Type](#wagon-type) | 3 |
| [Warehouse](#warehouse) | 12 |
| [Warehouse Fee Invoice](#warehouse-fee-invoice) | 5 |
| [Warehouse Inspection Report](#warehouse-inspection-report) | 3 |
| [Warehouse Inventory](#warehouse-inventory) | 24 |
| [Warehouse Yard](#warehouse-yard) | 2 |
| [Warehouse Zone](#warehouse-zone) | 1 |
| [Weight Limit Rule](#weight-limit-rule) | 3 |
| [Yard](#yard) | 5 |
| [Yard Distance](#yard-distance) | 3 |
---
## Endpoints by entity
### Account
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Send a verification code to a new email/phone before changing it | `POST` | `/api/me/contact/otp` | `modules/auth/account.controller.ts:26` |
| Change the account's email or phone, gated by a verification code | `PATCH` | `/api/me/contact` | `modules/auth/account.controller.ts:40` |
| Change the account's display name | `PATCH` | `/api/me/name` | `modules/auth/account.controller.ts:54` |
### AI Assist
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Mock AI: extract structured booking fields from free-text request | `POST` | `/api/ai/booking/extract` | `modules/ai/ai.controller.ts:16` |
### Approval Rule
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Create an approval rule step | `POST` | `/api/approval-rules` | `modules/rule-engine/controllers/approval-rules.controller.ts:66` |
| Move an approval step up or down within its chain | `POST` | `/api/approval-rules/:id/move-order` | `modules/rule-engine/controllers/approval-rules.controller.ts:51` |
| Bulk reorder approval steps within a chain | `POST` | `/api/approval-rules/reorder` | `modules/rule-engine/controllers/approval-rules.controller.ts:43` |
| Update an approval rule | `PATCH` | `/api/approval-rules/:id` | `modules/rule-engine/controllers/approval-rules.controller.ts:73` |
| Soft-delete an approval rule | `DELETE` | `/api/approval-rules/:id` | `modules/rule-engine/controllers/approval-rules.controller.ts:80` |
### Booking
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Create a new freight booking (DRAFT) | `POST` | `/api/bookings` | `modules/bookings/bookings.controller.ts:170` |
| Allocate containers to vehicles | `POST` | `/api/bookings/:bookingId/allocate-containers` | `modules/bookings/booking-allocation.controller.ts:13` |
| Cancel booking | `POST` | `/api/bookings/:id/cancel` | `modules/bookings/bookings.controller.ts:1544` |
| Customer cancels an unpaid hold (SELECTED_FOR_BATCH → CANCELLED); | `POST` | `/api/bookings/:id/cancel-hold` | `modules/bookings/bookings.controller.ts:1568` |
| GL ET uploads customs declaration on booking (GENERAL customs) | `POST` | `/api/bookings/:id/clearance/declaration` | `modules/bookings/bookings.controller.ts:1126` |
| Upload Booking Delivery Order | `POST` | `/api/bookings/:id/clearance/delivery-order` | `modules/bookings/bookings.controller.ts:1268` |
| Customer uploads clearance documents (fieldname = document key) | `POST` | `/api/bookings/:id/clearance/documents` | `modules/bookings/bookings.controller.ts:959` |
| GL ET sends a draft customs declaration (multi-file) with an estimated price for the customer to review | `POST` | `/api/bookings/:id/clearance/draft-declaration` | `modules/bookings/bookings.controller.ts:1175` |
| Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia | `POST` | `/api/bookings/:id/clearance/draft-declaration/accept` | `modules/bookings/bookings.controller.ts:1200` |
| Customer requests a change to the draft customs declaration with a reason — GL Ethiopia sends a corrected draft (repeatable) | `POST` | `/api/bookings/:id/clearance/draft-declaration/change` | `modules/bookings/bookings.controller.ts:1211` |
| GL ET sets duty/tax on booking with notice attachment | `POST` | `/api/bookings/:id/clearance/duty` | `modules/bookings/bookings.controller.ts:1144` |
| Customer uploads duty/tax payment slip on booking | `POST` | `/api/bookings/:id/clearance/duty-slip` | `modules/bookings/bookings.controller.ts:1238` |
| Confirm Booking Export Release | `POST` | `/api/bookings/:id/clearance/export-release` | `modules/bookings/bookings.controller.ts:1326` |
| GL finalizes clearance (requires 100% approved) → CLEARANCE_READY | `POST` | `/api/bookings/:id/clearance/finalize` | `modules/bookings/bookings.controller.ts:1087` |
| GL ET finalizes import pre-clearance on booking | `POST` | `/api/bookings/:id/clearance/finalize-pre-clearance` | `modules/bookings/bookings.controller.ts:1230` |
| GL uploads customs output documents (IM4/EX3/…) | `POST` | `/api/bookings/:id/clearance/output-documents` | `modules/bookings/bookings.controller.ts:1071` |
| Customer requests operation with a schedule day | `POST` | `/api/bookings/:id/clearance/proceed` | `modules/bookings/bookings.controller.ts:979` |
| Upload Booking Release Order | `POST` | `/api/bookings/:id/clearance/release-order` | `modules/bookings/bookings.controller.ts:1288` |
| GL reviews a clearance document (Approve | Query) | `POST` | `/api/bookings/:id/clearance/review` | `modules/bookings/bookings.controller.ts:1051` |
| Request Booking RO Amendment | `POST` | `/api/bookings/:id/clearance/ro-amendment` | `modules/bookings/bookings.controller.ts:1311` |
| GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns | `POST` | `/api/bookings/:id/clearance/transit-assignee/assign` | `modules/bookings/bookings.controller.ts:1112` |
| GL ET asks GL Djibouti to name the transit officer — required before the import customs declaration | `POST` | `/api/bookings/:id/clearance/transit-assignee/request` | `modules/bookings/bookings.controller.ts:1098` |
| Upload Booking Transit Permit | `POST` | `/api/bookings/:id/clearance/transit-permit` | `modules/bookings/bookings.controller.ts:1251` |
| Confirm submit after price change | `POST` | `/api/bookings/:id/confirm-submit` | `modules/bookings/bookings.controller.ts:906` |
| Request freight consolidation | `POST` | `/api/bookings/:id/consolidation` | `modules/bookings/bookings.controller.ts:1583` |
| Generate contract PDF from template | `POST` | `/api/bookings/:id/contract/generate` | `modules/bookings/bookings.controller.ts:1406` |
| Apply digital signature (customer or staff) | `POST` | `/api/bookings/:id/contract/sign` | `modules/bookings/bookings.controller.ts:1452` |
| Customer cancels their own booking before payment — no cancellation fee | `POST` | `/api/bookings/:id/customer-cancel` | `modules/bookings/bookings.controller.ts:1555` |
| Customer assigns external truck and driver for terminal pickup | `POST` | `/api/bookings/:id/customer-truck-assignment` | `modules/bookings/bookings.controller.ts:460` |
| Add a customer self-haul truck carrying 12 of the booking containers | `POST` | `/api/bookings/:id/customer-trucks` | `modules/bookings/bookings.controller.ts:686` |
| Register an import truck leaving: containers loaded + weighed gross (staff) | `POST` | `/api/bookings/:id/customer-trucks/:assignmentId/depart` | `modules/bookings/bookings.controller.ts:788` |
| Truck_dispatch: load selected containers onto a truck (staff) | `POST` | `/api/bookings/:id/customer-trucks/:assignmentId/load` | `modules/bookings/bookings.controller.ts:773` |
| Bulk add customer trucks from array payload (Excel parsed) | `POST` | `/api/bookings/:id/customer-trucks/bulk` | `modules/bookings/bookings.controller.ts:701` |
| Customer digital signature (deprecated — use POST contract/sign) | `POST` | `/api/bookings/:id/customer/sign` | `modules/bookings/bookings.controller.ts:1487` |
| Upload documents for a booking (DRAFT only) | `POST` | `/api/bookings/:id/documents` | `modules/bookings/bookings.controller.ts:869` |
| Generate a GRN over the received containers (all received, or a subset) — one GRN per batch | `POST` | `/api/bookings/:id/generate-grn` | `modules/bookings/bookings.controller.ts:820` |
| Generate price preview (DRAFT or CHANGES_REQUESTED) | `POST` | `/api/bookings/:id/generate-price` | `modules/bookings/bookings.controller.ts:882` |
| Expedite government booking to PAID / ELIGIBLE for scheduling | `POST` | `/api/bookings/:id/government-expedite` | `modules/bookings/bookings.controller.ts:1390` |
| Staff contract signature and fully execute (use contract/sign STAFF preferred) | `POST` | `/api/bookings/:id/marketing/approve` | `modules/bookings/bookings.controller.ts:1505` |
| Operations reviews an operation request: ACCEPT (→ batch pool), | `POST` | `/api/bookings/:id/operation/review` | `modules/bookings/bookings.controller.ts:1030` |
| Mark completed | `POST` | `/api/bookings/:id/operations/complete` | `modules/bookings/bookings.controller.ts:1536` |
| Mark in transit | `POST` | `/api/bookings/:id/operations/start-transit` | `modules/bookings/bookings.controller.ts:1528` |
| Customer reject price estimate | `POST` | `/api/bookings/:id/reject` | `modules/bookings/bookings.controller.ts:918` |
| Staff accept intake → set contract validity window + start approval chain | `POST` | `/api/bookings/:id/staff/accept` | `modules/bookings/bookings.controller.ts:1355` |
| Staff final reject | `POST` | `/api/bookings/:id/staff/reject` | `modules/bookings/bookings.controller.ts:1374` |
| Staff return booking for customer updates | `POST` | `/api/bookings/:id/staff/request-changes` | `modules/bookings/bookings.controller.ts:1339` |
| Customer submit booking | `POST` | `/api/bookings/:id/submit` | `modules/bookings/bookings.controller.ts:894` |
| Request a partial wagon cancellation on a PAID booking — opens the cancellation-fee invoice; wagons are released only once the fee settles | `POST` | `/api/bookings/:id/wagon-cancellations` | `modules/bookings/bookings.controller.ts:558` |
| Preview the fee/credit of a partial wagon cancellation (no writes) | `POST` | `/api/bookings/:id/wagon-cancellations/preview` | `modules/bookings/bookings.controller.ts:544` |
| Rebook a wagon-cancellation credit: pick a shipment day only — the new booking is created under the contract and marked PAID (freight already paid; contract must still be valid) | `POST` | `/api/bookings/wagon-cancellations/:cancellationId/rebook` | `modules/bookings/bookings.controller.ts:638` |
| Withdraw a fee-pending wagon cancellation (owner, or staff with the void permission) | `POST` | `/api/bookings/wagon-cancellations/:cancellationId/withdraw` | `modules/bookings/bookings.controller.ts:624` |
| Update booking | `PATCH` | `/api/bookings/:id` | `modules/bookings/bookings.controller.ts:211` |
| Edit a not-yet-arrived customer truck (plate/driver/type + containers) | `PATCH` | `/api/bookings/:id/customer-trucks/:assignmentId` | `modules/bookings/bookings.controller.ts:716` |
| Export only: choose direct truck-to-train (no warehouse, no GRN) or warehouse first | `PATCH` | `/api/bookings/:id/export-handover-mode` | `modules/bookings/bookings.controller.ts:761` |
| Soft-delete DRAFT booking | `DELETE` | `/api/bookings/:id` | `modules/bookings/bookings.controller.ts:861` |
| Remove consolidation pairing | `DELETE` | `/api/bookings/:id/consolidation` | `modules/bookings/bookings.controller.ts:1590` |
| Remove a not-yet-arrived customer truck from a booking | `DELETE` | `/api/bookings/:id/customer-trucks/:assignmentId` | `modules/bookings/bookings.controller.ts:732` |
### Cargo
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Create a new cargo | `POST` | `/api/cargoes` | `modules/cargoes/cargoes.controller.ts:34` |
| Mark cargo as delivered | `POST` | `/api/cargoes/:id/deliver` | `modules/cargoes/cargoes.controller.ts:81` |
| Load cargo into a container | `POST` | `/api/cargoes/:id/load` | `modules/cargoes/cargoes.controller.ts:67` |
| Unload cargo from container | `POST` | `/api/cargoes/:id/unload` | `modules/cargoes/cargoes.controller.ts:74` |
| Update a cargo | `PATCH` | `/api/cargoes/:id` | `modules/cargoes/cargoes.controller.ts:53` |
| Delete a cargo | `DELETE` | `/api/cargoes/:id` | `modules/cargoes/cargoes.controller.ts:60` |
### Cargo Type
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Create a cargo type | `POST` | `/api/cargo-types` | `modules/rule-engine/controllers/cargo-types.controller.ts:51` |
| Move a cargo type up or down in display order | `POST` | `/api/cargo-types/:id/move-order` | `modules/rule-engine/controllers/cargo-types.controller.ts:36` |
| Bulk reorder cargo types by ID list | `POST` | `/api/cargo-types/reorder` | `modules/rule-engine/controllers/cargo-types.controller.ts:28` |
| Update a cargo type | `PATCH` | `/api/cargo-types/:id` | `modules/rule-engine/controllers/cargo-types.controller.ts:58` |
| Soft-delete a cargo type | `DELETE` | `/api/cargo-types/:id` | `modules/rule-engine/controllers/cargo-types.controller.ts:65` |
### Company
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter) | `POST` | `/api/companies` | `modules/companies/companies.controller.ts:565` |
| Upload documents for a company (onboarding) | `POST` | `/api/companies/:companyId/documents` | `modules/companies/companies.controller.ts:728` |
| Add a profile (employee) to a company | `POST` | `/api/companies/:companyId/profiles` | `modules/companies/companies.controller.ts:843` |
| Approve a pending profile change request (applies the changes) | `POST` | `/api/companies/change-requests/:id/approve` | `modules/companies/companies.controller.ts:790` |
| Reject a pending profile change request with a note | `POST` | `/api/companies/change-requests/:id/reject` | `modules/companies/companies.controller.ts:806` |
| Ask for specific changes on a pending request without rejecting it (row stays open, next edit appends to it) | `POST` | `/api/companies/change-requests/:id/request-changes` | `modules/companies/companies.controller.ts:824` |
| Create a single operational profile for the current user's company. The role starts pending and does not become the active mode | `POST` | `/api/companies/company-profile` | `modules/companies/companies.controller.ts:272` |
| Add operational profile(s) (importer/exporter/forwarder) to the current user's company | `POST` | `/api/companies/company-profiles` | `modules/companies/companies.controller.ts:229` |
| Add business-license document(s) to a profile. For an approved company | `POST` | `/api/companies/company-profiles/:profileId/license` | `modules/companies/companies.controller.ts:290` |
| Replace a business-license file with a newly uploaded one (staged for | `POST` | `/api/companies/company-profiles/:profileId/license/:fileId/replace` | `modules/companies/companies.controller.ts:311` |
| Resubmit a rejected operational role for approval (→ pending) | `POST` | `/api/companies/company-profiles/:profileId/reapply` | `modules/companies/companies.controller.ts:165` |
| Create a company with its associated external profile (onboarding) | `POST` | `/api/companies/create` | `modules/companies/companies.controller.ts:539` |
| Ask the customer to correct one uploaded document | `POST` | `/api/companies/documents/:fileId/request-change` | `modules/companies/companies.controller.ts:699` |
| Fetch company info from eTrade by TIN | `POST` | `/api/companies/fetch-etrade-info` | `modules/companies/companies.controller.ts:197` |
| Bind a completed Fayda verification to the company's owner or Power of Attorney | `POST` | `/api/companies/identity/fayda/complete` | `modules/companies/companies.controller.ts:414` |
| Declare the General Manager is the company's owner, copying the owner's verified identity across | `POST` | `/api/companies/identity/gm/same-as-owner` | `modules/companies/companies.controller.ts:432` |
| Declare the Power of Attorney is the company's owner, copying the owner's identity across | `POST` | `/api/companies/identity/poa/same-as-owner` | `modules/companies/companies.controller.ts:461` |
| Mark the current user's onboarding as complete | `POST` | `/api/companies/onboarding/complete` | `modules/companies/companies.controller.ts:527` |
| Begin onboarding: create a draft company + profile + role(s) so later steps can save incrementally | `POST` | `/api/companies/onboarding/start` | `modules/companies/companies.controller.ts:246` |
| Upload the Power of Attorney delegation letter, replacing any existing one | `POST` | `/api/companies/poa-delegation` | `modules/companies/companies.controller.ts:380` |
| Update a company | `PATCH` | `/api/companies/:id` | `modules/companies/companies.controller.ts:613` |
| Update a company profile's approval status | `PATCH` | `/api/companies/company-profiles/:profileId/status` | `modules/companies/companies.controller.ts:748` |
| Persist the user's current onboarding wizard step | `PATCH` | `/api/companies/onboarding-step` | `modules/companies/companies.controller.ts:504` |
| Update profile (flattened settings page) | `PATCH` | `/api/companies/profile` | `modules/companies/companies.controller.ts:219` |
| Soft-delete a company | `DELETE` | `/api/companies/:id` | `modules/companies/companies.controller.ts:634` |
| Remove a business-license file (staged for review on an approved company) | `DELETE` | `/api/companies/company-profiles/:profileId/license/:fileId` | `modules/companies/companies.controller.ts:338` |
| Remove the company's Power of Attorney — the verified identity, its details and the delegation paper together | `DELETE` | `/api/companies/identity/fayda/poa` | `modules/companies/companies.controller.ts:491` |
| Clear the General Manager's identity — the \"same as owner\" declaration or a verification, and the details either wrote | `DELETE` | `/api/companies/identity/gm` | `modules/companies/companies.controller.ts:448` |
| Undo the Power of Attorney \"same as owner\" declaration and the identity it copied, leaving the representative open to be verified in their own right | `DELETE` | `/api/companies/identity/poa/same-as-owner` | `modules/companies/companies.controller.ts:478` |
| Remove the Power of Attorney delegation letter (staged for review on an approved company) | `DELETE` | `/api/companies/poa-delegation/:fileId` | `modules/companies/companies.controller.ts:401` |
### Compliance
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Create a compliance record | `POST` | `/api/compliance` | `modules/compliance/compliance.controller.ts:23` |
| Update a compliance record | `PATCH` | `/api/compliance/:id` | `modules/compliance/compliance.controller.ts:51` |
| Soft-delete a compliance record | `DELETE` | `/api/compliance/:id` | `modules/compliance/compliance.controller.ts:58` |
### Consignment
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Create a new consignment | `POST` | `/api/consignments` | `modules/consignments/consignments.controller.ts:29` |
### Container
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Create a new container | `POST` | `/api/containers` | `modules/container-management/containers.controller.ts:33` |
| Assign container to a wagon | `POST` | `/api/containers/:id/assign-wagon` | `modules/container-management/containers.controller.ts:66` |
| Unassign container from wagon | `POST` | `/api/containers/:id/unassign-wagon` | `modules/container-management/containers.controller.ts:73` |
| Update a container | `PATCH` | `/api/containers/:id` | `modules/container-management/containers.controller.ts:52` |
| Delete a container | `DELETE` | `/api/containers/:id` | `modules/container-management/containers.controller.ts:59` |
### Container Type
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Create a container type | `POST` | `/api/container-types` | `modules/rule-engine/controllers/container-types.controller.ts:51` |
| Move a container type up or down in display order | `POST` | `/api/container-types/:id/move-order` | `modules/rule-engine/controllers/container-types.controller.ts:36` |
| Bulk reorder container types by ID list | `POST` | `/api/container-types/reorder` | `modules/rule-engine/controllers/container-types.controller.ts:28` |
| Update a container type | `PATCH` | `/api/container-types/:id` | `modules/rule-engine/controllers/container-types.controller.ts:58` |
| Soft-delete a container type | `DELETE` | `/api/container-types/:id` | `modules/rule-engine/controllers/container-types.controller.ts:65` |
### Contract
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Create a new contract (DRAFT) with routes + cargo scope | `POST` | `/api/contracts` | `modules/contracts/contracts.controller.ts:188` |
| Approve one approval step in sequence | `POST` | `/api/contracts/:id/approval-steps/:stepId/approve` | `modules/contracts/contracts.controller.ts:552` |
| Reject one approval step — to the customer (terminal → REJECTED) or, via returnToStepId, back to an earlier approver (chain re-runs from there) | `POST` | `/api/contracts/:id/approval-steps/:stepId/reject` | `modules/contracts/contracts.controller.ts:571` |
| Customer submits a shipment request on a GENERAL customs contract | `POST` | `/api/contracts/:id/booking-requests` | `modules/contracts/contracts.controller.ts:170` |
| Create a shipment booking under a contract — Path A (customer) or Path B (GL Ethiopia) | `POST` | `/api/contracts/:id/bookings` | `modules/contracts/contracts.controller.ts:1076` |
| Complete an initiated booking after Operations finalized its clearance — cargo + binding day, window and departure checks, pricing and invoicing | `POST` | `/api/contracts/:id/bookings/:bookingId/complete` | `modules/contracts/contracts.controller.ts:1133` |
| Initiate a bare booking instance under an import/export contract (ONE_TIME or GENERAL) — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS). ONE_TIME customs instances are opened by the customer (or GL); GENERAL customs comes from a shipment request | `POST` | `/api/contracts/:id/bookings/initiate` | `modules/contracts/contracts.controller.ts:1105` |
| Customer cancels their own contract (blocked while a booking is live) | `POST` | `/api/contracts/:id/cancel` | `modules/contracts/contracts.controller.ts:526` |
| GL ET uploads customs declaration documents (multi-file) | `POST` | `/api/contracts/:id/clearance/declaration` | `modules/contracts/contracts.controller.ts:795` |
| GL DJ uploads Delivery Order (import) with vessel arrival + DO collected dates | `POST` | `/api/contracts/:id/clearance/delivery-order` | `modules/contracts/contracts.controller.ts:957` |
| Customer uploads clearance documents (fieldname = document key) | `POST` | `/api/contracts/:id/clearance/documents` | `modules/contracts/contracts.controller.ts:734` |
| GL replaces a clearance document in place (reason required) — the previous version is kept in the file history and the new one needs approving | `POST` | `/api/contracts/:id/clearance/documents/:fileKey/replace` | `modules/contracts/contracts.controller.ts:894` |
| GL ET sets duty/tax requirement and advises amount with notice attachment | `POST` | `/api/contracts/:id/clearance/duty` | `modules/contracts/contracts.controller.ts:808` |
| Customer uploads duty/tax payment slip on contract | `POST` | `/api/contracts/:id/clearance/duty-slip` | `modules/contracts/contracts.controller.ts:932` |
| Customer disputes the advised duty/tax with a reason — reopens the step so GL Ethiopia can re-advise (repeatable) | `POST` | `/api/contracts/:id/clearance/duty/dispute` | `modules/contracts/contracts.controller.ts:918` |
| GL ET confirms export release after declaration | `POST` | `/api/contracts/:id/clearance/export-release` | `modules/contracts/contracts.controller.ts:1008` |
| GL ET finalizes clearance → CLEARANCE_READY_FOR_BOOKING (legacy) | `POST` | `/api/contracts/:id/clearance/finalize` | `modules/contracts/contracts.controller.ts:788` |
| GL ET finalizes export clearance after post-booking transit permit upload | `POST` | `/api/contracts/:id/clearance/finalize-export-clearance` | `modules/contracts/contracts.controller.ts:1018` |
| GL ET finalizes import pre-clearance — unlocks Djibouti DO upload | `POST` | `/api/contracts/:id/clearance/finalize-pre-clearance` | `modules/contracts/contracts.controller.ts:838` |
| Operations finalizes self-clearance → customer may create the booking | `POST` | `/api/contracts/:id/clearance/ops-finalize` | `modules/contracts/contracts.controller.ts:1051` |
| Operations reviews a customer self-clearance document (Approve | Query) | `POST` | `/api/contracts/:id/clearance/ops-review` | `modules/contracts/contracts.controller.ts:1032` |
| GL uploads customs output documents (IM4/EX3/…) pre-booking | `POST` | `/api/contracts/:id/clearance/output-documents` | `modules/contracts/contracts.controller.ts:776` |
| GL DJ uploads Release Order + vessel departure date (export) | `POST` | `/api/contracts/:id/clearance/release-order` | `modules/contracts/contracts.controller.ts:978` |
| GL ET reviews a clearance document (Approve | Query) | `POST` | `/api/contracts/:id/clearance/review` | `modules/contracts/contracts.controller.ts:756` |
| GL DJ requests port amendment when RO vessel window is too short | `POST` | `/api/contracts/:id/clearance/ro-amendment` | `modules/contracts/contracts.controller.ts:997` |
| GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns | `POST` | `/api/contracts/:id/clearance/transit-assignee/assign` | `modules/contracts/contracts.controller.ts:863` |
| GL ET asks GL Djibouti to name the transit officer — required before the customs declaration | `POST` | `/api/contracts/:id/clearance/transit-assignee/request` | `modules/contracts/contracts.controller.ts:845` |
| GL ET uploads import transit permit documents (multi-file) | `POST` | `/api/contracts/:id/clearance/transit-permit` | `modules/contracts/contracts.controller.ts:944` |
| Confirm submit after a price change | `POST` | `/api/contracts/:id/confirm-submit` | `modules/contracts/contracts.controller.ts:380` |
| Generate contract document → CONTRACT_READY | `POST` | `/api/contracts/:id/contract/generate` | `modules/contracts/contracts.controller.ts:596` |
| Send the sudo-mode signing OTP to the contract company's registered phone (server picks the number) | `POST` | `/api/contracts/:id/contract/send-signing-otp` | `modules/contracts/contracts.controller.ts:667` |
| Apply digital signature (customer or staff/director/ceo) | `POST` | `/api/contracts/:id/contract/sign` | `modules/contracts/contracts.controller.ts:680` |
| Upload intake documents for a contract (DRAFT only) | `POST` | `/api/contracts/:id/documents` | `modules/contracts/contracts.controller.ts:354` |
| Generate unit-rate breakdown (no totals at contract phase) | `POST` | `/api/contracts/:id/generate-price` | `modules/contracts/contracts.controller.ts:366` |
| GL marks a pre-booking (contract) milestone complete | `POST` | `/api/contracts/:id/milestones/:code/complete` | `modules/contracts/contracts.controller.ts:1222` |
| Create a renewal draft linked via renewalOfId | `POST` | `/api/contracts/:id/renew` | `modules/contracts/contracts.controller.ts:705` |
| Staff lift a suspension — contract returns to its prior status | `POST` | `/api/contracts/:id/resume` | `modules/contracts/contracts.controller.ts:510` |
| Staff accept → set validity window + start approval chain | `POST` | `/api/contracts/:id/staff/accept` | `modules/contracts/contracts.controller.ts:387` |
| Staff reject contract | `POST` | `/api/contracts/:id/staff/reject` | `modules/contracts/contracts.controller.ts:476` |
| Staff return contract for customer updates | `POST` | `/api/contracts/:id/staff/request-changes` | `modules/contracts/contracts.controller.ts:460` |
| Customer submit contract (freezes contract_rate_snapshots) | `POST` | `/api/contracts/:id/submit` | `modules/contracts/contracts.controller.ts:373` |
| Staff freeze a signed contract (reversible, any post-signature step) | `POST` | `/api/contracts/:id/suspend` | `modules/contracts/contracts.controller.ts:492` |
| Pre-create validation + authoritative price preview: full booking price breakdown (rail, first/last mile, surcharges), overweight lines and 20ft weight-pairing errors for a shipment payload (no booking created) | `POST` | `/api/contracts/:id/validate-shipment` | `modules/contracts/contracts.controller.ts:1162` |
| GL marks a shipment request accepted + links the created booking | `POST` | `/api/contracts/booking-requests/:reqId/accept` | `modules/contracts/contracts.controller.ts:134` |
| Customer cancels their own pending shipment request | `POST` | `/api/contracts/booking-requests/:reqId/cancel` | `modules/contracts/contracts.controller.ts:160` |
| GL rejects a shipment request | `POST` | `/api/contracts/booking-requests/:reqId/reject` | `modules/contracts/contracts.controller.ts:149` |
| GL uploads post-booking operational documents (DO/RO/T1/…) | `POST` | `/api/contracts/bookings/:bookingId/documents` | `modules/contracts/contracts.controller.ts:1441` |
| GL ET advises duty & tax amount + declaration serial | `POST` | `/api/contracts/bookings/:bookingId/duty` | `modules/contracts/contracts.controller.ts:1260` |
| Customer uploads the duty/tax payment slip | `POST` | `/api/contracts/bookings/:bookingId/duty-slip` | `modules/contracts/contracts.controller.ts:1455` |
| GL DJ raises the post-offload final invoice (amount + invoice document) | `POST` | `/api/contracts/bookings/:bookingId/final-invoice` | `modules/contracts/contracts.controller.ts:1332` |
| Customer attaches the payment slip for the final invoice | `POST` | `/api/contracts/bookings/:bookingId/final-invoice-slip` | `modules/contracts/contracts.controller.ts:1374` |
| Customer approves the drafted final invoice — unlocks the payment slip | `POST` | `/api/contracts/bookings/:bookingId/final-invoice/approve` | `modules/contracts/contracts.controller.ts:1359` |
| GL (ET or DJ) confirms the payment slip — settles the final invoice | `POST` | `/api/contracts/bookings/:bookingId/final-invoice/confirm` | `modules/contracts/contracts.controller.ts:1386` |
| GL DJ logs a cargo exception with photo evidence | `POST` | `/api/contracts/bookings/:bookingId/incidents` | `modules/contracts/contracts.controller.ts:1479` |
| GL / Ops / Terminal marks a post-booking milestone complete | `POST` | `/api/contracts/bookings/:bookingId/milestones/:code/complete` | `modules/contracts/contracts.controller.ts:1205` |
| GL ET assigns a customs risk level (GREEN/YELLOW/RED) | `POST` | `/api/contracts/bookings/:bookingId/risk` | `modules/contracts/contracts.controller.ts:1241` |
| GL ET advises (or skips) the post-arrival additional duty/tax round (import) | `POST` | `/api/contracts/bookings/:bookingId/second-duty` | `modules/contracts/contracts.controller.ts:1399` |
| Customer attaches the additional duty/tax payment slip | `POST` | `/api/contracts/bookings/:bookingId/second-duty-slip` | `modules/contracts/contracts.controller.ts:1429` |
| GL station manager routes the shipment + binds staff | `POST` | `/api/contracts/bookings/:bookingId/station-assign` | `modules/contracts/contracts.controller.ts:1276` |
| Close (accept) the T1 set — GL ET after arrival (import) / GL DJ after gate pass (export) | `POST` | `/api/contracts/bookings/:bookingId/t1-close` | `modules/contracts/contracts.controller.ts:1316` |
| GL Djibouti uploads T1 transit documents (multi-file) after wagon allocation; locked once the train departs | `POST` | `/api/contracts/bookings/:bookingId/t1-documents` | `modules/contracts/contracts.controller.ts:1301` |
| GL ET uploads export transit permit documents (multi-file) | `POST` | `/api/contracts/bookings/:bookingId/transport-document` | `modules/contracts/contracts.controller.ts:1289` |
| Share a document with the other GL desk | `POST` | `/api/gl-exchange/:entityId` | `modules/contracts/gl-exchange.controller.ts:59` |
| Edit this contract\'s document articles only (per-contract; never touches the six shared templates) | `PUT` | `/api/contracts/:id/document/articles` | `modules/contracts/contracts.controller.ts:441` |
| Update contract | `PATCH` | `/api/contracts/:id` | `modules/contracts/contracts.controller.ts:327` |
| Uploader edits a shared document (title, visibility, file) | `PATCH` | `/api/gl-exchange/documents/:documentId` | `modules/contracts/gl-exchange.controller.ts:79` |
| Soft-delete DRAFT contract | `DELETE` | `/api/contracts/:id` | `modules/contracts/contracts.controller.ts:346` |
| Uploader removes a shared document | `DELETE` | `/api/gl-exchange/documents/:documentId` | `modules/contracts/gl-exchange.controller.ts:105` |
### Contract Template
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Create a bulk contract template for a (cargo type, customs option) pair | `POST` | `/api/contract-templates` | `modules/contract-templates/contract-templates.controller.ts:56` |
| Add an article to the template | `POST` | `/api/contract-templates/:code/articles` | `modules/contract-templates/contract-templates.controller.ts:118` |
| Render an HTML preview of the template against mock contract data | `POST` | `/api/contract-templates/:code/preview` | `modules/contract-templates/contract-templates.controller.ts:97` |
| Replace the full ordered article list (used for reorder) | `PUT` | `/api/contract-templates/:code/articles` | `modules/contract-templates/contract-templates.controller.ts:111` |
| Update template metadata (name, title, recitals, active flag) | `PATCH` | `/api/contract-templates/:code` | `modules/contract-templates/contract-templates.controller.ts:77` |
| Update an article's title or body | `PATCH` | `/api/contract-templates/:code/articles/:articleId` | `modules/contract-templates/contract-templates.controller.ts:125` |
| Delete a staff-created bulk template (system templates refuse) | `DELETE` | `/api/contract-templates/:code` | `modules/contract-templates/contract-templates.controller.ts:84` |
| Remove an article from the template | `DELETE` | `/api/contract-templates/:code/articles/:articleId` | `modules/contract-templates/contract-templates.controller.ts:136` |
### Driver
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Create a new driver | `POST` | `/api/drivers` | `modules/drivers/drivers.controller.ts:41` |
| Upload driver documents (code driver_docs) | `POST` | `/api/drivers/:id/documents` | `modules/drivers/drivers.controller.ts:80` |
| Update a driver | `PATCH` | `/api/drivers/:id` | `modules/drivers/drivers.controller.ts:128` |
| Delete a driver | `DELETE` | `/api/drivers/:id` | `modules/drivers/drivers.controller.ts:138` |
| Delete a driver document | `DELETE` | `/api/drivers/:id/documents/:fileId` | `modules/drivers/drivers.controller.ts:121` |
### Dropdown Setting
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Create a new dropdown setting | `POST` | `/api/dropdown-settings` | `modules/dropdown-settings/dropdown-settings.controller.ts:61` |
| Append a single option to a setting | `POST` | `/api/dropdown-settings/:id/options` | `modules/dropdown-settings/dropdown-settings.controller.ts:98` |
| Replace the full option list for a setting | `PUT` | `/api/dropdown-settings/:id/options` | `modules/dropdown-settings/dropdown-settings.controller.ts:88` |
| Update a dropdown setting's metadata | `PATCH` | `/api/dropdown-settings/:id` | `modules/dropdown-settings/dropdown-settings.controller.ts:68` |
| Update a single option | `PATCH` | `/api/dropdown-settings/options/:optionId` | `modules/dropdown-settings/dropdown-settings.controller.ts:108` |
| Soft-delete a dropdown setting | `DELETE` | `/api/dropdown-settings/:id` | `modules/dropdown-settings/dropdown-settings.controller.ts:78` |
| Soft-delete a single option | `DELETE` | `/api/dropdown-settings/options/:optionId` | `modules/dropdown-settings/dropdown-settings.controller.ts:118` |
### EIMS Invoice
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Register the invoice with MoR EIMS. Idempotent — an invoice that already has an IRN is returned unchanged | `POST` | `/api/invoices/:id/eims/register` | `modules/eims/eims-invoice.controller.ts:32` |
| Resolve an unacknowledged submission: record the IRN confirmed with MoR, or discard it. Clears the system-wide block | `POST` | `/api/invoices/:id/eims/resolve` | `modules/eims/eims-invoice.controller.ts:49` |
| Verify the invoice's stored IRN against EIMS | `POST` | `/api/invoices/:id/eims/verify` | `modules/eims/eims-invoice.controller.ts:42` |
### Exchange Setting
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Set the USD→ETB fallback by hand (used only while CBE is unreachable) | `PATCH` | `/api/exchange-settings` | `modules/exchange-settings/exchange-settings.controller.ts:35` |
### Facility
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Create a new facility | `POST` | `/api/facilities` | `modules/facilities/facilities.controller.ts:22` |
| Update a facility | `PATCH` | `/api/facilities/:id` | `modules/facilities/facilities.controller.ts:41` |
| Delete a facility (soft delete) | `DELETE` | `/api/facilities/:id` | `modules/facilities/facilities.controller.ts:48` |
### Fayda Verification
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Start a VeriFayda 2.0 verification session | `POST` | `/api/fayda/verification/start` | `modules/verifayda/verifayda.controller.ts:44` |
### File Upload Setting
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Create a new file upload setting | `POST` | `/api/file-upload-settings` | `modules/file-upload-settings/file-upload-settings.controller.ts:56` |
| Append a single field to a setting | `POST` | `/api/file-upload-settings/:id/fields` | `modules/file-upload-settings/file-upload-settings.controller.ts:93` |
| Replace the full field list for a setting | `PUT` | `/api/file-upload-settings/:id/fields` | `modules/file-upload-settings/file-upload-settings.controller.ts:83` |
| Update a file upload setting's metadata | `PATCH` | `/api/file-upload-settings/:id` | `modules/file-upload-settings/file-upload-settings.controller.ts:63` |
| Update a single field | `PATCH` | `/api/file-upload-settings/fields/:fieldId` | `modules/file-upload-settings/file-upload-settings.controller.ts:103` |
| Soft-delete a file upload setting | `DELETE` | `/api/file-upload-settings/:id` | `modules/file-upload-settings/file-upload-settings.controller.ts:73` |
| Soft-delete a single field | `DELETE` | `/api/file-upload-settings/fields/:fieldId` | `modules/file-upload-settings/file-upload-settings.controller.ts:113` |
### First Mile
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Create a first-mile leg | `POST` | `/api/first-mile` | `modules/first-mile/first-mile.controller.ts:84` |
| Set per-vehicle actual distances (does not generate an invoice) | `POST` | `/api/first-mile/:id/distances` | `modules/first-mile/first-mile.controller.ts:124` |
| Generate the first-mile delivery-fee invoice | `POST` | `/api/first-mile/:id/invoice` | `modules/first-mile/first-mile.controller.ts:100` |
| Set the vehicles assigned to a first-mile pickup (multi-truck) | `POST` | `/api/first-mile/:id/vehicles` | `modules/first-mile/first-mile.controller.ts:114` |
| Accept a paid booking and create a first-mile leg | `POST` | `/api/first-mile/accept/:reference` | `modules/first-mile/first-mile.controller.ts:77` |
| Update a first-mile leg | `PATCH` | `/api/first-mile/:id` | `modules/first-mile/first-mile.controller.ts:91` |
| Soft-delete a first-mile leg | `DELETE` | `/api/first-mile/:id` | `modules/first-mile/first-mile.controller.ts:134` |
### Fuel
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Record fuel purchase | `POST` | `/api/fuel/purchases` | `modules/fuel/fuel.controller.ts:22` |
### GPS Tracking
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Register a GPS tracker | `POST` | `/api/gps/devices` | `modules/gps-tracking/gps-tracking.controller.ts:52` |
| Update a GPS tracker (name / assigned vehicle) | `PATCH` | `/api/gps/devices/:id` | `modules/gps-tracking/gps-tracking.controller.ts:59` |
| Delete a GPS tracker | `DELETE` | `/api/gps/devices/:id` | `modules/gps-tracking/gps-tracking.controller.ts:66` |
### Import Operation
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Batch 12: record declaration serial number | `POST` | `/api/import-operations/customs/:bookingId/declaration` | `modules/import-operations/import-operations.controller.ts:53` |
| Batch 12: upload IM4/IM5/T1/permit/payment-slip documents | `POST` | `/api/import-operations/customs/:bookingId/documents` | `modules/import-operations/import-operations.controller.ts:44` |
| Batch 12: mark duties and taxes paid | `POST` | `/api/import-operations/customs/:bookingId/duties-taxes-paid` | `modules/import-operations/import-operations.controller.ts:71` |
| Batch 12: notify duties and taxes | `POST` | `/api/import-operations/customs/:bookingId/notify-duties-taxes` | `modules/import-operations/import-operations.controller.ts:62` |
| Batch 12: mark import release permitted | `POST` | `/api/import-operations/customs/:bookingId/release-permitted` | `modules/import-operations/import-operations.controller.ts:86` |
| Batch 12: assign customs risk | `POST` | `/api/import-operations/customs/:bookingId/risk` | `modules/import-operations/import-operations.controller.ts:80` |
| Batch 8: report a Djibouti import incident / exception | `POST` | `/api/import-operations/djibouti-incidents` | `modules/import-operations/import-operations.controller.ts:32` |
| Batch 16: create an empty container return record | `POST` | `/api/import-operations/empty-container-returns` | `modules/import-operations/import-operations.controller.ts:101` |
| Batch 16: advance empty container return workflow | `POST` | `/api/import-operations/empty-container-returns/:id/status` | `modules/import-operations/import-operations.controller.ts:107` |
### Incident
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Report an incident | `POST` | `/api/incidents` | `modules/incidents/incidents.controller.ts:36` |
| Update an incident | `PATCH` | `/api/incidents/:id` | `modules/incidents/incidents.controller.ts:72` |
| Delete an incident | `DELETE` | `/api/incidents/:id` | `modules/incidents/incidents.controller.ts:79` |
### Interchange Document
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Generate interchange document from a train schedule handover | `POST` | `/api/interchange-documents/generate-from-schedule` | `modules/interchange-documents/interchange-documents.controller.ts:41` |
| Acknowledge an interchange document | `PATCH` | `/api/interchange-documents/:id/acknowledge` | `modules/interchange-documents/interchange-documents.controller.ts:48` |
| Dispute an interchange document | `PATCH` | `/api/interchange-documents/:id/dispute` | `modules/interchange-documents/interchange-documents.controller.ts:58` |
### Last Mile
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Create a last-mile leg | `POST` | `/api/last-mile` | `modules/last-mile/last-mile.controller.ts:97` |
| Set each truck\'s own detention window (arrived at destination / returned) | `POST` | `/api/last-mile/:id/detention-times` | `modules/last-mile/last-mile.controller.ts:142` |
| Set per-vehicle actual distances (does not generate an invoice) | `POST` | `/api/last-mile/:id/distances` | `modules/last-mile/last-mile.controller.ts:132` |
| Generate the delivery-fee invoice for a last-mile leg | `POST` | `/api/last-mile/:id/invoice` | `modules/last-mile/last-mile.controller.ts:179` |
| Record proof of delivery (signature + photos) and complete the leg | `POST` | `/api/last-mile/:id/proof-of-delivery` | `modules/last-mile/last-mile.controller.ts:166` |
| Set the vehicles assigned to a last-mile delivery (multi-truck) | `POST` | `/api/last-mile/:id/vehicles` | `modules/last-mile/last-mile.controller.ts:122` |
| Set each truck\'s warehouse gate arrival/departure times | `POST` | `/api/last-mile/:id/warehouse-gate-times` | `modules/last-mile/last-mile.controller.ts:154` |
| Accept a paid booking and create a last-mile leg | `POST` | `/api/last-mile/accept/:reference` | `modules/last-mile/last-mile.controller.ts:90` |
| Update a last-mile leg | `PATCH` | `/api/last-mile/:id` | `modules/last-mile/last-mile.controller.ts:104` |
| Soft-delete a last-mile leg | `DELETE` | `/api/last-mile/:id` | `modules/last-mile/last-mile.controller.ts:113` |
### Last Mile Request
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Truck & Machinery chief approves the request — the advance defaults to the live last-mile rate; LM contract becomes signable and the advance invoice follows the customer signature | `POST` | `/api/last-mile-requests/:id/approve` | `modules/last-mile-requests/last-mile-requests.controller.ts:119` |
| Customer agrees and signs the LM contract — then the advance invoice is issued | `POST` | `/api/last-mile-requests/:id/contract/sign` | `modules/last-mile-requests/last-mile-requests.controller.ts:83` |
| Truck & Machinery chief rejects the request with a reason | `POST` | `/api/last-mile-requests/:id/reject` | `modules/last-mile-requests/last-mile-requests.controller.ts:130` |
| Customer confirms which containers go via EDR last-mile | `POST` | `/api/last-mile-requests/:id/submit` | `modules/last-mile-requests/last-mile-requests.controller.ts:108` |
### Locomotive
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Create a locomotive | `POST` | `/api/locomotives` | `modules/locomotives/locomotives.controller.ts:58` |
| Decommission a locomotive | `POST` | `/api/locomotives/:id/decommission` | `modules/locomotives/locomotives.controller.ts:72` |
| Update a locomotive | `PATCH` | `/api/locomotives/:id` | `modules/locomotives/locomotives.controller.ts:65` |
| Permanently delete a locomotive (irreversible; refused if any train references it) | `DELETE` | `/api/locomotives/:id/permanent` | `modules/locomotives/locomotives.controller.ts:82` |
### Maintenance
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Record maintenance cost | `POST` | `/api/maintenance/costs` | `modules/maintenance/maintenance.controller.ts:38` |
| Define/adjust a service interval (e.g. oil change every 10,000 km) | `POST` | `/api/maintenance/intervals` | `modules/maintenance/maintenance.controller.ts:59` |
| Create part | `POST` | `/api/maintenance/parts` | `modules/maintenance/maintenance.controller.ts:150` |
| Schedule maintenance | `POST` | `/api/maintenance/schedules` | `modules/maintenance/maintenance.controller.ts:31` |
| Create warranty | `POST` | `/api/maintenance/warranties` | `modules/maintenance/maintenance.controller.ts:186` |
| Create work order | `POST` | `/api/maintenance/work-orders` | `modules/maintenance/maintenance.controller.ts:110` |
| Update part | `PATCH` | `/api/maintenance/parts/:id` | `modules/maintenance/maintenance.controller.ts:170` |
| Update maintenance schedule | `PATCH` | `/api/maintenance/schedules/:id` | `modules/maintenance/maintenance.controller.ts:45` |
| Update work order | `PATCH` | `/api/maintenance/work-orders/:id` | `modules/maintenance/maintenance.controller.ts:134` |
| Deactivate a service interval (stops auto-scheduling) | `DELETE` | `/api/maintenance/intervals/:id` | `modules/maintenance/maintenance.controller.ts:73` |
| Delete part | `DELETE` | `/api/maintenance/parts/:id` | `modules/maintenance/maintenance.controller.ts:177` |
| Delete warranty | `DELETE` | `/api/maintenance/warranties/:id` | `modules/maintenance/maintenance.controller.ts:200` |
| Delete work order | `DELETE` | `/api/maintenance/work-orders/:id` | `modules/maintenance/maintenance.controller.ts:141` |
### Notification Inbox
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Mark all my notifications as read | `POST` | `/api/notifications/read-all` | `modules/notification-inbox/notification-inbox.controller.ts:53` |
| Mark one of my notifications as read | `PATCH` | `/api/notifications/:id/read` | `modules/notification-inbox/notification-inbox.controller.ts:44` |
### Organization User
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Create an organization user without assigning positions | `POST` | `/api/backoffice/organizations/:orgId/users` | `modules/backoffice/backoffice.controller.ts:24` |
| Replace org-scoped roles assigned to an employee user | `PUT` | `/api/backoffice/organizations/:orgId/employee-users/:userId/roles` | `modules/backoffice/backoffice.controller.ts:58` |
### OTP
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Send OTP | `POST` | `/api/otp/send` | `modules/otp/otp.controller.ts:41` |
| Verify OTP | `POST` | `/api/otp/verify` | `modules/otp/otp.controller.ts:62` |
### Password Reset
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Send a password-reset code to the account's email AND phone | `POST` | `/api/auth/forgot-password/request` | `modules/auth/forgot-password.controller.ts:30` |
| Validate a staff-issued reset link and return its set-password ticket | `POST` | `/api/auth/forgot-password/resolve-link` | `modules/auth/forgot-password.controller.ts:73` |
| Exchange a valid reset code for a single-use set-password ticket | `POST` | `/api/auth/forgot-password/verify` | `modules/auth/forgot-password.controller.ts:62` |
| Send a password-reset link to a customer's primary contact | `POST` | `/api/backoffice/customers/:companyId/reset-password` | `modules/auth/customer-reset.controller.ts:49` |
### Payment
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Finance confirms a USD invoice paid by bank transfer — slip file required, settles the full balance | `POST` | `/api/billing/invoices/:id/confirm-offline` | `modules/billing/billing.controller.ts:81` |
| Confirm an OTP-debit payment (CAC Bank) for one of the customer's invoices | `POST` | `/api/billing/my-invoices/:id/confirm` | `modules/billing/portal-billing.controller.ts:102` |
| Initiate payment for one of the customer's invoices | `POST` | `/api/billing/my-invoices/:id/pay` | `modules/billing/portal-billing.controller.ts:86` |
| Live still-payable check + payer name for a CBE bill (called while CBE is on the line) | `POST` | `/api/internal/payments/bill-query` | `modules/payment/internal-payment.controller.ts:56` |
| Apply a payment.succeeded / payment.failed event from the payment service (idempotent) | `POST` | `/api/internal/payments/mark-paid` | `modules/payment/internal-payment.controller.ts:45` |
| Initiate payment for an invoice | `POST` | `/api/payments/initiate` | `modules/billing/payment.controller.ts:39` |
| Success-redirect ack: mark payment processing + invoice PAYMENT_PROCESSING (webhook remains source of truth) | `POST` | `/api/payments/redirect-success/:bookingId` | `modules/payment/payment.controller.ts:89` |
### Priority Config
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Create a priority config | `POST` | `/api/priority-configs` | `modules/rule-engine/controllers/priority-configs.controller.ts:51` |
| Move a priority config up or down in display order | `POST` | `/api/priority-configs/:id/move-order` | `modules/rule-engine/controllers/priority-configs.controller.ts:66` |
| Bulk reorder priority configs by ID list | `POST` | `/api/priority-configs/reorder` | `modules/rule-engine/controllers/priority-configs.controller.ts:58` |
| Update a priority config | `PATCH` | `/api/priority-configs/:id` | `modules/rule-engine/controllers/priority-configs.controller.ts:74` |
| Soft-delete a priority config | `DELETE` | `/api/priority-configs/:id` | `modules/rule-engine/controllers/priority-configs.controller.ts:81` |
### Priority Rule Change Request
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Submit a priority-rule change for approval | `POST` | `/api/priority-rule-change-requests` | `modules/rule-engine/controllers/priority-rule-change-requests.controller.ts:34` |
| Approve and apply a pending change | `POST` | `/api/priority-rule-change-requests/:id/approve` | `modules/rule-engine/controllers/priority-rule-change-requests.controller.ts:52` |
| Reject a pending change | `POST` | `/api/priority-rule-change-requests/:id/reject` | `modules/rule-engine/controllers/priority-rule-change-requests.controller.ts:65` |
### Procurement
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Create an asset acquisition | `POST` | `/api/procurement/acquisitions` | `modules/procurement/procurement.controller.ts:56` |
| Create an asset disposal | `POST` | `/api/procurement/disposals` | `modules/procurement/procurement.controller.ts:90` |
| Create a vendor | `POST` | `/api/procurement/vendors` | `modules/procurement/procurement.controller.ts:28` |
| Update an asset acquisition | `PATCH` | `/api/procurement/acquisitions/:id` | `modules/procurement/procurement.controller.ts:75` |
| Update a vendor | `PATCH` | `/api/procurement/vendors/:id` | `modules/procurement/procurement.controller.ts:41` |
| Delete an asset acquisition | `DELETE` | `/api/procurement/acquisitions/:id` | `modules/procurement/procurement.controller.ts:82` |
| Delete an asset disposal | `DELETE` | `/api/procurement/disposals/:id` | `modules/procurement/procurement.controller.ts:103` |
| Delete a vendor | `DELETE` | `/api/procurement/vendors/:id` | `modules/procurement/procurement.controller.ts:48` |
### Rate
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Create a rate (DRAFT) | `POST` | `/api/rates` | `modules/rule-engine/controllers/rates.controller.ts:46` |
| CEO approves a rate | `POST` | `/api/rates/:id/approve` | `modules/rule-engine/controllers/rates.controller.ts:70` |
| Submit rate for CEO approval | `POST` | `/api/rates/:id/submit` | `modules/rule-engine/controllers/rates.controller.ts:63` |
| Update a DRAFT rate | `PATCH` | `/api/rates/:id` | `modules/rule-engine/controllers/rates.controller.ts:56` |
| Soft-delete a rate | `DELETE` | `/api/rates/:id` | `modules/rule-engine/controllers/rates.controller.ts:82` |
### Rate Change Request
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Propose a change to a LIVE rate | `POST` | `/api/rate-change-requests` | `modules/rule-engine/controllers/rate-change-requests.controller.ts:23` |
| Approve a rate change and put it into effect | `POST` | `/api/rate-change-requests/:id/approve` | `modules/rule-engine/controllers/rate-change-requests.controller.ts:37` |
| Reject a rate change — the rate keeps its current value | `POST` | `/api/rate-change-requests/:id/reject` | `modules/rule-engine/controllers/rate-change-requests.controller.ts:48` |
### Route
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Create route | `POST` | `/api/routes` | `modules/routes/routes.controller.ts:61` |
| Update route | `PATCH` | `/api/routes/:id` | `modules/routes/routes.controller.ts:68` |
| Deactivate route | `DELETE` | `/api/routes/:id` | `modules/routes/routes.controller.ts:90` |
| Permanently delete a route (irreversible; refused while any train schedule references it) | `DELETE` | `/api/routes/:id/permanent` | `modules/routes/routes.controller.ts:79` |
### Schedule
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Reschedule train for maintenance (new departure + rebalance) | `POST` | `/api/train-scheduling/schedules/:id/maintenance` | `modules/scheduling-reschedule/scheduling-reschedule.controller.ts:52` |
| Execute a confirmed reschedule plan | `POST` | `/api/train-scheduling/schedules/:id/reschedule/execute` | `modules/scheduling-reschedule/scheduling-reschedule.controller.ts:30` |
| Preview reschedule / government preempt plan | `POST` | `/api/train-scheduling/schedules/:id/reschedule/preview` | `modules/scheduling-reschedule/scheduling-reschedule.controller.ts:20` |
### Service Type
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Create a service type | `POST` | `/api/service-types` | `modules/rule-engine/controllers/service-types.controller.ts:51` |
| Move a service type up or down in display order | `POST` | `/api/service-types/:id/move-order` | `modules/rule-engine/controllers/service-types.controller.ts:36` |
| Bulk reorder service types by ID list | `POST` | `/api/service-types/reorder` | `modules/rule-engine/controllers/service-types.controller.ts:28` |
| Update a service type | `PATCH` | `/api/service-types/:id` | `modules/rule-engine/controllers/service-types.controller.ts:58` |
| Soft-delete a service type | `DELETE` | `/api/service-types/:id` | `modules/rule-engine/controllers/service-types.controller.ts:65` |
### Shipping Line
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Create a shipping line | `POST` | `/api/shipping-lines` | `modules/rule-engine/controllers/shipping-lines.controller.ts:33` |
| Update a shipping line | `PATCH` | `/api/shipping-lines/:id` | `modules/rule-engine/controllers/shipping-lines.controller.ts:40` |
| Soft-delete a shipping line | `DELETE` | `/api/shipping-lines/:id` | `modules/rule-engine/controllers/shipping-lines.controller.ts:47` |
### Signature
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Create or update the reusable saved signature | `PUT` | `/api/me/signature` | `modules/signatures/signatures.controller.ts:23` |
### Support Chat
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Start chatting with a company (returns the thread if one exists) | `POST` | `/api/support/agent/conversations` | `modules/support-chat/support-chat-agent.controller.ts:49` |
| Reply as an agent, optionally with attachments | `POST` | `/api/support/agent/conversations/:id/messages` | `modules/support-chat/support-chat-agent.controller.ts:74` |
| Mark a thread read (agent side) | `POST` | `/api/support/agent/conversations/:id/read` | `modules/support-chat/support-chat-agent.controller.ts:114` |
| Send a message as the customer (optionally with attachments), opening the thread if needed | `POST` | `/api/support/conversation/messages` | `modules/support-chat/support-chat.controller.ts:65` |
| Mark my company's thread read (customer side) | `POST` | `/api/support/conversation/read` | `modules/support-chat/support-chat.controller.ts:102` |
### Support Content
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Restore a version — re-saves it as a new version, never destructive | `POST` | `/api/support-content/documents/:slug/versions/:version/restore` | `modules/support-content/support-content.controller.ts:123` |
| Upload an image or video for a help section | `POST` | `/api/support-content/media` | `modules/support-content/support-content.controller.ts:55` |
| Replace a document's payload, recording a new version | `PATCH` | `/api/support-content/documents/:slug` | `modules/support-content/support-content.controller.ts:93` |
### Train
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Register a new train | `POST` | `/api/trains` | `modules/trains/trains.controller.ts:33` |
| Update a train | `PATCH` | `/api/trains/:id` | `modules/trains/trains.controller.ts:52` |
| Delete a train | `DELETE` | `/api/trains/:id` | `modules/trains/trains.controller.ts:59` |
### Train Build
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Build a train: code + yard + 2+ locomotives (+ optional wagons) | `POST` | `/api/train-builder` | `modules/trains/train-builder.controller.ts:50` |
| Reactivate a deactivated train back to AVAILABLE | `POST` | `/api/train-builder/:id/activate` | `modules/trains/train-builder.controller.ts:158` |
| Deactivate the train (park it) — only allowed with no active schedule | `POST` | `/api/train-builder/:id/deactivate` | `modules/trains/train-builder.controller.ts:149` |
| Persist a drag-reorder of the full consist | `POST` | `/api/train-builder/:id/reorder-wagons` | `modules/trains/train-builder.controller.ts:142` |
| Append AVAILABLE wagons from the train's yard to the consist | `POST` | `/api/train-builder/:id/wagons` | `modules/trains/train-builder.controller.ts:109` |
| Detach one wagon and move it to MAINTENANCE status | `POST` | `/api/train-builder/:id/wagons/:wagonId/maintenance` | `modules/trains/train-builder.controller.ts:131` |
| Replace the locomotive set (minimum 1, same yard) | `PUT` | `/api/train-builder/:id/locomotives` | `modules/trains/train-builder.controller.ts:78` |
| Edit the train's name and fixed import/export run numbers | `PATCH` | `/api/train-builder/:id/details` | `modules/trains/train-builder.controller.ts:88` |
| Relocate the train — its locomotives and wagons move to the new yard with it | `PATCH` | `/api/train-builder/:id/yard` | `modules/trains/train-builder.controller.ts:100` |
| Disband the train (release wagons and locomotives) | `DELETE` | `/api/train-builder/:id` | `modules/trains/train-builder.controller.ts:165` |
| Detach one wagon from the consist | `DELETE` | `/api/train-builder/:id/wagons/:wagonId` | `modules/trains/train-builder.controller.ts:120` |
### Train Schedule
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Staff: place a paid booking onto a fitting train (notifies customer on date change) | `POST` | `/api/train-scheduling/bookings/:bookingId/allocate` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:880` |
| Staff: expire a reservation and free its capacity | `POST` | `/api/train-scheduling/bookings/:bookingId/expire` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:845` |
| Staff: mark a reserved booking paid and allocate it now | `POST` | `/api/train-scheduling/bookings/:bookingId/mark-paid` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:835` |
| Re-point a booking to another OPEN same-route schedule | `POST` | `/api/train-scheduling/bookings/:bookingId/move-schedule` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:855` |
| Preview a bulk train schedule | `POST` | `/api/train-scheduling/bulk/preview` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:302` |
| Create a bulk train schedule | `POST` | `/api/train-scheduling/bulk/schedules` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:316` |
| Assign bulk bookings to a train schedule | `POST` | `/api/train-scheduling/bulk/schedules/:id/assign-bookings` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:349` |
| Cancel bulk train schedule | `POST` | `/api/train-scheduling/bulk/schedules/:id/cancel` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:976` |
| Preview a container train schedule | `POST` | `/api/train-scheduling/container/preview` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:295` |
| Create a container train schedule | `POST` | `/api/train-scheduling/container/schedules` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:309` |
| Assign container bookings to a train schedule | `POST` | `/api/train-scheduling/container/schedules/:id/assign-bookings` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:335` |
| Cancel container train schedule | `POST` | `/api/train-scheduling/container/schedules/:id/cancel` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:969` |
| Preview a mixed-capable train schedule | `POST` | `/api/train-scheduling/preview` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:288` |
| Permanently trim free wagons off / couple yard wagons onto the schedule's built train (weight & length limits incl. tolerance enforced, every change logged) | `POST` | `/api/train-scheduling/schedules/:id/adjust-consist` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:197` |
| Mark a dispatched train arrived (move assets to destination yard, free assets) | `POST` | `/api/train-scheduling/schedules/:id/arrive` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:915` |
| Assign bookings to a train schedule (mixed-capable) | `POST` | `/api/train-scheduling/schedules/:id/assign-bookings` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:323` |
| Assign one linked unallocated booking to wagons (preserves existing assignments) | `POST` | `/api/train-scheduling/schedules/:id/assign-unassigned-booking` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:423` |
| Confirm a booking's cargo loaded at its origin yard (any direction; train must be at that yard) | `POST` | `/api/train-scheduling/schedules/:id/bookings/:bookingId/load` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:564` |
| Confirm a booking's cargo unloaded at its destination yard — per-booking arrival, may precede the train's final arrival | `POST` | `/api/train-scheduling/schedules/:id/bookings/:bookingId/unload` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:577` |
| Log the train passing a station (final station triggers arrival) | `POST` | `/api/train-scheduling/schedules/:id/checkpoints` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:903` |
| Confirm cargo loaded on the train (any direction; unblocks import-Djibouti dispatch) | `POST` | `/api/train-scheduling/schedules/:id/confirm-loading` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:662` |
| Dispatch a scheduled train | `POST` | `/api/train-scheduling/schedules/:id/dispatch` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:514` |
| Staff finished document review early — run the batch/payment phase now (applies to the whole route-day group) | `POST` | `/api/train-scheduling/schedules/:id/doc-review-complete` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:824` |
| Finalize a draft train schedule | `POST` | `/api/train-scheduling/schedules/:id/finalize` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:507` |
| Depart loaded import train from Djibouti | `POST` | `/api/train-scheduling/schedules/:id/import-djibouti/depart` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:675` |
| Upload/check an import Djibouti-side document | `POST` | `/api/train-scheduling/schedules/:id/import-djibouti/documents` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:622` |
| Mark import Djibouti gatepass permission granted | `POST` | `/api/train-scheduling/schedules/:id/import-djibouti/gatepass-granted` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:632` |
| Generate import load list / marshalling document summary | `POST` | `/api/train-scheduling/schedules/:id/import-djibouti/load-list` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:685` |
| Confirm import cargo loaded on train at Djibouti | `POST` | `/api/train-scheduling/schedules/:id/import-djibouti/loaded-on-train` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:652` |
| Mark import train ready for loading at Djibouti | `POST` | `/api/train-scheduling/schedules/:id/import-djibouti/ready-for-loading` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:642` |
| Confirm intercity cargo loaded (train must be at the booking's origin yard) | `POST` | `/api/train-scheduling/schedules/:id/intercity/:bookingId/load` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:590` |
| Confirm intercity cargo unloaded at the booking's destination yard (completes the booking) | `POST` | `/api/train-scheduling/schedules/:id/intercity/:bookingId/unload` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:602` |
| Accept intercity bookings onto this train (opens their pay window; capacity re-checked per booking) | `POST` | `/api/train-scheduling/schedules/:id/intercity/accept` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:541` |
| Maintenance reschedule: move the train to a new departure with every allocated booking aboard — links, wagons and window settings unchanged | `POST` | `/api/train-scheduling/schedules/:id/maintenance` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:798` |
| Pin physical wagons to train set slots | `POST` | `/api/train-scheduling/schedules/:id/pin-wagons` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:500` |
| Run wagon-level allocation for all eligible linked bookings | `POST` | `/api/train-scheduling/schedules/:id/run-allocation` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:747` |
| Manually run the batch fill for a schedule | `POST` | `/api/train-scheduling/schedules/:id/run-batch` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:739` |
| Switch out commercial bookings to allocate a government booking in their place | `POST` | `/api/train-scheduling/schedules/:id/switch-government-booking` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:439` |
| Move a wagon's whole load to another wagon (empty → move/repin, loaded → swap loads) | `POST` | `/api/train-scheduling/schedules/:id/wagons/:wagonId/move-load` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:402` |
| Update global train scheduling rules (singleton) | `PATCH` | `/api/train-scheduling/global-rules` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:124` |
| Open or close a schedule booking window | `PATCH` | `/api/train-scheduling/schedules/:id/booking-window` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:756` |
| Update a container number on a wagon slot | `PATCH` | `/api/train-scheduling/schedules/:id/container-items/:itemId` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:391` |
| Mark import bookings loaded/unloaded on this schedule (tracking only, does not affect dispatch) | `PATCH` | `/api/train-scheduling/schedules/:id/import-loading-status` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:474` |
| Mark bookings loaded/unloaded on this schedule (any direction, pre-dispatch only) | `PATCH` | `/api/train-scheduling/schedules/:id/loading-status` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:487` |
| Reschedule a train's departure date — only before the booking window opens, and only if the new date still leaves room for the booking lead window | `PATCH` | `/api/train-scheduling/schedules/:id/schedule-date` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:784` |
| Override the booking-window rule for one schedule (open/close hour, duration, doc-review, payment, lead days) — only before the window opens | `PATCH` | `/api/train-scheduling/schedules/:id/window-rule` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:770` |
| Unassign a booking from a train schedule | `DELETE` | `/api/train-scheduling/schedules/:id/bookings/:bookingId` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:363` |
| Remove an empty wagon slot from a train | `DELETE` | `/api/train-scheduling/schedules/:id/wagons/:trainSetWagonId` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:378` |
### Transit Agent
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Create a transit agent | `POST` | `/api/transit-agents` | `modules/transit-agents/transit-agents.controller.ts:66` |
| Update a transit agent | `PATCH` | `/api/transit-agents/:id` | `modules/transit-agents/transit-agents.controller.ts:73` |
| Soft-delete a transit agent | `DELETE` | `/api/transit-agents/:id` | `modules/transit-agents/transit-agents.controller.ts:80` |
### Truck Type
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Create a truck type | `POST` | `/api/truck-types` | `modules/truck-types/truck-types.controller.ts:58` |
| Update a truck type | `PATCH` | `/api/truck-types/:id` | `modules/truck-types/truck-types.controller.ts:65` |
| Soft-delete a truck type | `DELETE` | `/api/truck-types/:id` | `modules/truck-types/truck-types.controller.ts:72` |
### User Trade Access
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Set the trade directions a backoffice user may see | `PUT` | `/api/user-trade-access/:userId` | `modules/user-trade-access/user-trade-access.controller.ts:45` |
### Vehicle
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Create a new vehicle | `POST` | `/api/vehicles` | `modules/vehicles/vehicles.controller.ts:37` |
| Update a vehicle | `PATCH` | `/api/vehicles/:id` | `modules/vehicles/vehicles.controller.ts:78` |
| Delete a vehicle | `DELETE` | `/api/vehicles/:id` | `modules/vehicles/vehicles.controller.ts:88` |
### Wagon
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Create a new wagon | `POST` | `/api/wagons` | `modules/wagons/wagons.controller.ts:39` |
| Assign wagon to a train | `POST` | `/api/wagons/:id/assign-train` | `modules/wagons/wagons.controller.ts:100` |
| Unassign wagon from train | `POST` | `/api/wagons/:id/unassign-train` | `modules/wagons/wagons.controller.ts:107` |
| Set the status of multiple wagons (audited in wagon_status_logs) | `POST` | `/api/wagons/bulk-status` | `modules/wagons/wagons.controller.ts:121` |
| Transfer multiple wagons to a destination yard | `POST` | `/api/wagons/bulk-transfer` | `modules/wagons/wagons.controller.ts:114` |
| Update a wagon | `PATCH` | `/api/wagons/:id` | `modules/wagons/wagons.controller.ts:71` |
| Delete a wagon | `DELETE` | `/api/wagons/:id` | `modules/wagons/wagons.controller.ts:93` |
| Permanently delete a wagon (irreversible; refused if it has movements, containers or train-set slots) | `DELETE` | `/api/wagons/:id/permanent` | `modules/wagons/wagons.controller.ts:82` |
### Wagon Transfer Request
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| File a count-only wagon-transfer request | `POST` | `/api/wagon-transfer-requests` | `modules/wagons/wagon-transfer-requests.controller.ts:50` |
| Withdraw a request that has not moved any wagon yet (use close-short once wagons have moved) | `POST` | `/api/wagon-transfer-requests/:id/cancel` | `modules/wagons/wagon-transfer-requests.controller.ts:167` |
| OCC: end the request with fewer wagons than asked for — what moved stays, the requester is told the shortfall | `POST` | `/api/wagon-transfer-requests/:id/close-short` | `modules/wagons/wagon-transfer-requests.controller.ts:153` |
| OCC: pick wagons and execute the transfer | `POST` | `/api/wagon-transfer-requests/:id/fulfill` | `modules/wagons/wagon-transfer-requests.controller.ts:142` |
| OCC: accept-and-execute a subset of pending requests (auto-picks available wagons; the rest stay PENDING) | `POST` | `/api/wagon-transfer-requests/bulk-fulfill` | `modules/wagons/wagon-transfer-requests.controller.ts:73` |
### Wagon Type
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Create a wagon type | `POST` | `/api/wagon-types` | `modules/wagon-types/wagon-types.controller.ts:53` |
| Update a wagon type | `PATCH` | `/api/wagon-types/:id` | `modules/wagon-types/wagon-types.controller.ts:60` |
| Soft-delete a wagon type | `DELETE` | `/api/wagon-types/:id` | `modules/wagon-types/wagon-types.controller.ts:67` |
### Warehouse
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Create a warehouse allocation rule | `POST` | `/api/warehouse-allocation-rules` | `modules/warehouses/warehouse-rules.controller.ts:33` |
| Preview the yard/warehouse/zone a booking would be allocated to | `POST` | `/api/warehouse-allocation/preview` | `modules/warehouses/warehouse-rules.controller.ts:55` |
| Create a storage / demurrage fee rule | `POST` | `/api/warehouse-fee-rules` | `modules/warehouses/warehouse-rules.controller.ts:70` |
| Acknowledge / snooze an item fee-accrual alert | `POST` | `/api/warehouse-fees/accrual/:inventoryId/acknowledge` | `modules/warehouses/warehouse-rules.controller.ts:106` |
| Create warehouse | `POST` | `/api/warehouses` | `modules/warehouses/warehouses.controller.ts:51` |
| Create a yard within a warehouse | `POST` | `/api/warehouses/:warehouseId/yards` | `modules/warehouses/warehouses.controller.ts:78` |
| Update a warehouse allocation rule | `PATCH` | `/api/warehouse-allocation-rules/:id` | `modules/warehouses/warehouse-rules.controller.ts:40` |
| Update a fee rule | `PATCH` | `/api/warehouse-fee-rules/:id` | `modules/warehouses/warehouse-rules.controller.ts:77` |
| Update warehouse | `PATCH` | `/api/warehouses/:id` | `modules/warehouses/warehouses.controller.ts:64` |
| Delete a warehouse allocation rule | `DELETE` | `/api/warehouse-allocation-rules/:id` | `modules/warehouses/warehouse-rules.controller.ts:47` |
| Delete a fee rule | `DELETE` | `/api/warehouse-fee-rules/:id` | `modules/warehouses/warehouse-rules.controller.ts:84` |
| Remove an accrual acknowledgement (re-surface for alerts) | `DELETE` | `/api/warehouse-fees/accrual/:inventoryId/acknowledge` | `modules/warehouses/warehouse-rules.controller.ts:119` |
### Warehouse Fee Invoice
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Generate a truck-detention invoice for a last-mile leg (per truck per day) | `POST` | `/api/last-mile/:id/generate-truck-detention-invoice` | `modules/warehouses/warehouse-invoice.controller.ts:28` |
| Record a payment against a warehouse fee invoice | `POST` | `/api/warehouse-fee-invoices/:id/pay` | `modules/warehouses/warehouse-invoice.controller.ts:109` |
| Initiate Telebirr/Waafi payment for a warehouse fee invoice | `POST` | `/api/warehouse-fee-invoices/:id/pay-online` | `modules/warehouses/warehouse-invoice.controller.ts:116` |
| Generate a warehouse fee invoice from Batch 5 fee calculation | `POST` | `/api/warehouse-inventory/:id/generate-fee-invoice` | `modules/warehouses/warehouse-invoice.controller.ts:20` |
| Cancel a warehouse fee invoice | `PATCH` | `/api/warehouse-fee-invoices/:id/cancel` | `modules/warehouses/warehouse-invoice.controller.ts:102` |
### Warehouse Inspection Report
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Upload inspection images / documents | `POST` | `/api/warehouse-inspection-reports/:id/attachments` | `modules/warehouses/warehouse-inspection.controller.ts:68` |
| Create an inspection / damage report for an inventory item | `POST` | `/api/warehouse-inventory/:inventoryId/inspection-reports` | `modules/warehouses/warehouse-inspection.controller.ts:37` |
| Update an inspection report | `PATCH` | `/api/warehouse-inspection-reports/:id` | `modules/warehouses/warehouse-inspection.controller.ts:61` |
### Warehouse Inventory
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Deliver import goods to the customer + capture proof of delivery | `POST` | `/api/warehouse-inventory/:id/deliver` | `modules/warehouses/warehouse-inventory.controller.ts:594` |
| Final terminal release / gate clearance (blocked while fees unpaid) | `POST` | `/api/warehouse-inventory/:id/gate-clearance` | `modules/warehouses/warehouse-inventory.controller.ts:219` |
| Load READY_FOR_LOADING inventory onto a wagon | `POST` | `/api/warehouse-inventory/:id/load` | `modules/warehouses/warehouse-inventory.controller.ts:384` |
| Move inventory to another warehouse/yard/zone | `POST` | `/api/warehouse-inventory/:id/move` | `modules/warehouses/warehouse-inventory.controller.ts:359` |
| Mark reserved inventory READY_FOR_LOADING | `POST` | `/api/warehouse-inventory/:id/ready-for-loading` | `modules/warehouses/warehouse-inventory.controller.ts:373` |
| Mark inspected IMPORT inventory READY_FOR_PICKUP | `POST` | `/api/warehouse-inventory/:id/ready-for-pickup` | `modules/warehouses/warehouse-inventory.controller.ts:391` |
| Issue a DO / release order for ready-for-pickup inventory | `POST` | `/api/warehouse-inventory/:id/release` | `modules/warehouses/warehouse-inventory.controller.ts:402` |
| Mark received inventory as STORED (optional explicit warehouse/yard/zone) | `POST` | `/api/warehouse-inventory/:id/store` | `modules/warehouses/warehouse-inventory.controller.ts:366` |
| Auto-load READY_FOR_LOADING inventory with PAID bookings | `POST` | `/api/warehouse-inventory/auto-load-ready` | `modules/warehouses/warehouse-inventory.controller.ts:125` |
| Bulk auto-unload all arrived bookings into the warehouse | `POST` | `/api/warehouse-inventory/auto-unload-arrived` | `modules/warehouses/warehouse-inventory.controller.ts:118` |
| Approve delivery — customer records their full name (signature optional) | `POST` | `/api/warehouse-inventory/bookings/:bookingId/approve-delivery` | `modules/warehouses/warehouse-inventory.controller.ts:470` |
| Ask the customer to sign the handover (creates one if none, then notifies) | `POST` | `/api/warehouse-inventory/bookings/:bookingId/request-handover-signature` | `modules/warehouses/warehouse-inventory.controller.ts:509` |
| Unload a single arrived booking into a location | `POST` | `/api/warehouse-inventory/bookings/:bookingId/unload` | `modules/warehouses/warehouse-inventory.controller.ts:209` |
| Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED) | `POST` | `/api/warehouse-inventory/bulk-dispatch-export` | `modules/warehouses/warehouse-inventory.controller.ts:195` |
| Bulk mark received inventory inspection PASSED (EXPORT → READY_FOR_LOADING) | `POST` | `/api/warehouse-inventory/bulk-mark-inspected` | `modules/warehouses/warehouse-inventory.controller.ts:202` |
| Unload all eligible export items assigned to an arrived Djibouti-side train | `POST` | `/api/warehouse-inventory/export/auto-unload-at-djibouti` | `modules/warehouses/warehouse-inventory.controller.ts:294` |
| Customer signs one handover (EDR last-mile: one signature per truck) | `POST` | `/api/warehouse-inventory/handovers/:handoverId/sign` | `modules/warehouses/warehouse-inventory.controller.ts:493` |
| Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED) | `POST` | `/api/warehouse-inventory/import/auto-unload-arrived-bookings` | `modules/warehouses/warehouse-inventory.controller.ts:244` |
| Receive inventory at a warehouse location | `POST` | `/api/warehouse-inventory/receive` | `modules/warehouses/warehouse-inventory.controller.ts:322` |
| Bulk-receive selected eligible PAID bookings into a location | `POST` | `/api/warehouse-inventory/receive-bulk` | `modules/warehouses/warehouse-inventory.controller.ts:140` |
| Reserve stored inventory for a PAID booking | `POST` | `/api/warehouse-inventory/reserve` | `modules/warehouses/warehouse-inventory.controller.ts:330` |
| Load selected inventory items onto their allocated wagons for a train | `POST` | `/api/warehouse-inventory/train/:scheduleId/load` | `modules/warehouses/warehouse-inventory.controller.ts:184` |
| Mark loaded inventory DISPATCHED (left the terminal) | `PATCH` | `/api/warehouse-inventory/:id/dispatch` | `modules/warehouses/warehouse-inventory.controller.ts:602` |
| Record Yes/No double handling after unloading (Yes applies the double-handling fee rule) | `PATCH` | `/api/warehouse-inventory/bookings/:bookingId/double-handling` | `modules/warehouses/warehouse-inventory.controller.ts:556` |
### Warehouse Yard
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Create a zone within a yard | `POST` | `/api/warehouse-yards/:yardId/zones` | `modules/warehouses/warehouse-yards.controller.ts:50` |
| Update warehouse yard | `PATCH` | `/api/warehouse-yards/:id` | `modules/warehouses/warehouse-yards.controller.ts:36` |
### Warehouse Zone
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Update warehouse zone | `PATCH` | `/api/warehouse-zones/:id` | `modules/warehouses/warehouse-zones.controller.ts:37` |
### Weight Limit Rule
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Create a weight limit rule | `POST` | `/api/weight-limit-rules` | `modules/rule-engine/controllers/weight-limit-rules.controller.ts:32` |
| Update a weight limit rule | `PATCH` | `/api/weight-limit-rules/:id` | `modules/rule-engine/controllers/weight-limit-rules.controller.ts:39` |
| Soft-delete a weight limit rule | `DELETE` | `/api/weight-limit-rules/:id` | `modules/rule-engine/controllers/weight-limit-rules.controller.ts:46` |
### Yard
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Create a yard | `POST` | `/api/yards` | `modules/rule-engine/controllers/yards.controller.ts:53` |
| Move a yard up or down in display order | `POST` | `/api/yards/:id/move-order` | `modules/rule-engine/controllers/yards.controller.ts:38` |
| Bulk reorder yards by ID list | `POST` | `/api/yards/reorder` | `modules/rule-engine/controllers/yards.controller.ts:30` |
| Update a yard | `PATCH` | `/api/yards/:id` | `modules/rule-engine/controllers/yards.controller.ts:60` |
| Soft-delete a yard | `DELETE` | `/api/yards/:id` | `modules/rule-engine/controllers/yards.controller.ts:67` |
### Yard Distance
| Title | Method | Endpoint | Source |
| --- | --- | --- | --- |
| Create a yard distance | `POST` | `/api/yard-distances` | `modules/rule-engine/controllers/yard-distances.controller.ts:42` |
| Update a yard distance | `PATCH` | `/api/yard-distances/:id` | `modules/rule-engine/controllers/yard-distances.controller.ts:49` |
| Soft-delete a yard distance | `DELETE` | `/api/yard-distances/:id` | `modules/rule-engine/controllers/yard-distances.controller.ts:56` |

View File

@@ -37,7 +37,8 @@
"iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert",
"iam:migration:show": "pnpm run iam:typeorm:cli migration:show",
"migration:run": "nest build && node dist/scripts/migrate.js",
"script": "ts-node -r tsconfig-paths/register src/scripts/main.ts"
"script": "ts-node -r tsconfig-paths/register src/scripts/main.ts",
"eims:login": "ts-node -r tsconfig-paths/register src/scripts/eims-login.ts"
},
"dependencies": {
"@edr/api-common": "workspace:*",
@@ -59,7 +60,6 @@
"@nestjs/typeorm": "^11.0.1",
"@nestjs/websockets": "^11.1.27",
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.6.0.tgz",
"@tria-plc/auditlog": "file:../../local-packages/tria-plc-auditlog-1.1.2.tgz",
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-1.0.0.tgz",
"amqp-connection-manager": "^5.0.0",
"amqplib": "^2.0.1",
@@ -69,6 +69,7 @@
"cross-env": "^10.1.0",
"dotenv": "^17.4.2",
"dotenv-cli": "^11.0.0",
"exceljs": "^4.4.0",
"handlebars": "^4.7.9",
"jose": "^5.10.0",
"libphonenumber-js": "^1.13.6",

View File

@@ -16,13 +16,14 @@ import {
import { IamBaselineSeeder, IamSeedModule } from "@edr/iam-seed";
import { IamModule } from "@tria-plc/iamapi-common";
import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module";
import { MezgebModule } from "@tria-plc/auditlog";
import appConfig from "./config/app.config";
import databaseConfig from "./config/database.config";
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";
@@ -40,6 +41,8 @@ import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.
import { TrainSchedulingModule } from "./modules/train-scheduling/train-scheduling.module";
import { SchedulingRescheduleModule } from "./modules/scheduling-reschedule/scheduling-reschedule.module";
import { CompaniesModule } from "./modules/companies/companies.module";
import { ShippingLineBookingCompletionModule } from "./modules/shipping-lines/shipping-line-booking-completion.module";
import { ShippingLineCompaniesModule } from "./modules/shipping-lines/shipping-line-companies.module";
import { TrackingModule } from "./modules/tracking/tracking.module";
import { BillingModule } from "./modules/billing/billing.module";
import { NotificationsModule } from "./modules/notifications/notifications.module";
@@ -48,7 +51,11 @@ 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";
import { SupportContentModule } from "./modules/support-content/support-content.module";
import { OtpModule } from "./modules/otp/otp.module";
import { HealthModule } from "./modules/health/health.module";
import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module";
@@ -66,6 +73,7 @@ import { FreightPositionsSeeder } from "./seed/freight-positions.seeder";
import { PaymentModule } from "./modules/payment/payment.module";
// import { PricingDataSeeder } from "./seed/pricing-data.seeder";
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
import { SupportContentSeeder } from "./seed/support-content.seeder";
// import { YardFacilitiesSeeder } from "./seed/yard-facilities.seeder";
// import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder";
// import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder";
@@ -76,13 +84,15 @@ import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
// import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder";
// import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder";
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
import { FreightNotificationPermissionsSeeder } from "./seed/freight-notification-permissions.seeder";
// import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
// import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-demo.seeder";
// import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
// import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-demo.seeder";
//New Trains, Wagons, Container and Cargo management modules
import { TrainsModule } from "./modules/trains/trains.module";
import { VerifaydaModule } from "./modules/verifayda/verifayda.module";
import { EimsModule } from "./modules/eims/eims.module";
import { FleetHistoryModule } from "./modules/fleet-history/fleet-history.module";
import { WagonsModule } from "./modules/wagons/wagons.module";
import { ContainersModule } from "./modules/container-management/containers.module";
@@ -108,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";
import { LoggerMiddleware } from "./logger.middleware";
// 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";
@@ -126,6 +139,8 @@ if (!process.env.APPLICATION_NAME) {
telebirrConfig,
rabbitmqConfig,
faydaConfig,
eimsConfig,
chatConfig,
],
}),
ScheduleModule.forRoot(),
@@ -163,19 +178,6 @@ if (!process.env.APPLICATION_NAME) {
return dataSource;
},
}),
// Request + entity-level audit logging over RabbitMQ (@tria-plc/auditlog).
// Must come after TypeOrmModule above so it picks up this app's DataSource.
// rmqUrl falls back the same way notifications.module.ts's RABBITMQ_URL
// does: the dev broker only provisions the `edr` user on the `payment`
// vhost (docker-compose's RABBITMQ_DEFAULT_USER/VHOST), so an unset
// RABBITMQ_URL must land there too, not on guest@'/' (403 ACCESS_REFUSED).
MezgebModule.forRoot({
applicationName: "freight-api",
rmqUrl:
process.env.RABBITMQ_URL ??
process.env.PAYMENT_RABBITMQ_URL ??
"amqp://localhost:5672",
}),
SharedAuthModule,
IamModule.forRoot({
applications: [EDR_FREIGHT_APPLICATION],
@@ -207,6 +209,8 @@ if (!process.env.APPLICATION_NAME) {
TrainSchedulingModule,
SchedulingRescheduleModule,
CompaniesModule,
ShippingLineCompaniesModule,
ShippingLineBookingCompletionModule,
TrackingModule,
BillingModule,
NotificationsModule,
@@ -215,7 +219,11 @@ if (!process.env.APPLICATION_NAME) {
FileUploadSettingsModule,
DropdownSettingsModule,
ExchangeSettingsModule,
PaymentSettingsModule,
StampSettingsModule,
LogoSettingsModule,
ContractTemplatesModule,
SupportContentModule,
OtpModule,
HealthModule,
RuleEngineModule,
@@ -247,16 +255,20 @@ if (!process.env.APPLICATION_NAME) {
InterchangeDocumentsModule,
ImportOperationsModule,
VerifaydaModule,
EimsModule,
FleetHistoryModule,
AiModule,
AuditModule,
ChatModule,
],
providers: [
EdrOrgSeeder,
FreightPositionsSeeder,
FileUploadSettingsSeeder,
SupportContentSeeder,
// YardFacilitiesSeeder,
FreightPermissionKeyMigrationSeeder,
FreightNotificationPermissionsSeeder,
// Disabled seeds — providers commented out (imports/injection/run too):
// DemoUsersSeeder,
// FreightStaffUsersSeeder,
@@ -271,8 +283,8 @@ if (!process.env.APPLICATION_NAME) {
// WarehouseDemoSeeder,
// ExportDjiboutiInterchangeDemoSeeder,
// MarshallingDemoTrainsSeeder,
ApprovedFirstLastMileDemoBookingsSeeder,
PaidImportExportMileDemoSeeder,
// ApprovedFirstLastMileDemoBookingsSeeder,
// PaidImportExportMileDemoSeeder,
LoginAudienceMiddleware,
// Feeds position-TYPE grants to the synchronous permission checks — without
// it, staff whose permissions live on their position type resolve to none.
@@ -285,8 +297,10 @@ export class AppModule implements OnApplicationBootstrap {
private readonly edrOrgSeeder: EdrOrgSeeder,
private readonly freightPositionsSeeder: FreightPositionsSeeder,
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
private readonly supportContentSeeder: SupportContentSeeder,
// private readonly yardFacilitiesSeeder: YardFacilitiesSeeder,
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
private readonly freightNotificationPermissionsSeeder: FreightNotificationPermissionsSeeder,
// Disabled seeds — injections commented out (imports/provider/run too):
// private readonly demoUsersSeeder: DemoUsersSeeder,
// private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
@@ -328,10 +342,24 @@ export class AppModule implements OnApplicationBootstrap {
await this.edrOrgSeeder.run();
await this.iamBaselineSeeder.run();
await this.freightPositionsSeeder.run();
// freightNotificationPermissions → seeds the <module>:get_notification
// keys and backfills them onto whoever
// already holds each desk's anchor
// permission. Runs LAST in this block so
// it sees a freshly-seeded catalog and
// freshly-seeded positions. Unlike the
// seeders above it is NOT gated behind
// SEED_EDR_ORG — without it every staff
// notification resolves to no one.
await this.freightNotificationPermissionsSeeder.run();
// File upload settings — keep enabled.
await this.fileUploadSettingsSeeder.run();
// Portal help/FAQ/legal copy — keep enabled. Idempotent by emptiness, so
// it fills an empty table once and never touches admin edits afterwards.
await this.supportContentSeeder.run();
// Flags which yards can load/unload cargo (Indode, Sebeta, Modjo, Adama,
// Dire Dawa). Idempotent; creates no yards.
// await this.yardFacilitiesSeeder.run();
@@ -364,7 +392,9 @@ export class AppModule implements OnApplicationBootstrap {
}
configure(consumer: MiddlewareConsumer) {
consumer.apply(LoggerMiddleware).forRoutes("*");
// FIRST: opens the request log context every later middleware/guard/service
// writes into via logCtx(). Anything applied above it logs into the void.
consumer.apply(RequestLogMiddleware).forRoutes("*");
consumer
.apply(LoginAudienceMiddleware)
.forRoutes(

View File

@@ -49,6 +49,8 @@ export const MixedAudience = (permission: string | string[]) =>
export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view);
export const ChatSync = () => BookingStaff(FREIGHT_PERMS.chat.sync);
/**
* The document-review countdown in the backoffice header. Its own permission so
* it can be granted to exactly the position types that decide operation
@@ -90,6 +92,10 @@ export const TrainSchedulingReschedule = () =>
export const TrainSchedulingRulesManage = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.rulesManage);
/** Edit a schedule's operational run numbers (train + voyage) before dispatch. */
export const TrainSchedulingEditTrainNumber = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.editTrainNumber);
/**
* Fleet guards take an optional granular per-resource key (locomotives:create,
* wagons:delete, …). The legacy coarse fleet:view / fleet:manage keys remain

View File

@@ -0,0 +1,13 @@
/**
* Dev/staging bypass gate for OTP, payment and Fayda verification.
*
* Gated purely on `ENV` (dev|staging) — never on NODE_ENV, so it can't be
* mistaken for a prod-vs-non-prod switch. `ENV` is simply left unset in
* production, so this is always false there.
*/
export function isBypassEnv(): boolean {
return ["dev", "staging"].includes(process.env.ENV ?? "");
}
/** Fixed code accepted in addition to the real one when isBypassEnv(). */
export const DEV_BYPASS_OTP = "000000";

View File

@@ -0,0 +1,30 @@
import { MulterOptions } from "@nestjs/platform-express/multer/interfaces/multer-options.interface";
/**
* Ceiling for a single uploaded document, in bytes.
*
* Mirrors the 50MB `max_size_mb` the file-upload settings hand the portal, so
* the client-side gate and the server-side cap agree. Raising this alone is not
* enough to accept a 50MB upload: the reverse proxy in front of the API applies
* its own `client_max_body_size`, and nginx's 1MB default rejects the request
* with a 413 before it ever reaches Nest (see docs/uploads.md).
*/
export const DOCUMENT_UPLOAD_MAX_BYTES = 50 * 1024 * 1024;
/** Upper bound on parts in one multipart document post. */
export const DOCUMENT_UPLOAD_MAX_FILES = 20;
/**
* Multer caps for the document upload routes.
*
* Without an explicit `fileSize`, multer's default is unlimited and every byte
* is buffered in memory, so an oversized post is absorbed in full before
* anything can reject it. With the limit set, multer stops reading the socket
* at the ceiling instead.
*/
export const documentUploadMulterOptions: MulterOptions = {
limits: {
fileSize: DOCUMENT_UPLOAD_MAX_BYTES,
files: DOCUMENT_UPLOAD_MAX_FILES,
},
};

View File

@@ -41,4 +41,16 @@ export class PaginationQueryDto {
@Transform(({ value }) => String(value).toUpperCase())
@IsIn(['ASC', 'DESC'])
sortOrder?: 'ASC' | 'DESC';
/**
* Column to sort by, as a public field name (not a raw SQL column). The
* actual whitelist lives in `applySort`'s `sortable` map at each call site,
* not here — a per-DTO `@IsIn` is opt-in and has been forgotten before.
* An unrecognized value falls back silently rather than 400ing, so a stale
* bookmark or shared link never breaks.
*/
@ApiPropertyOptional({ description: 'Public field name; unknown values fall back to the endpoint default.' })
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' && value.trim() ? value.trim() : undefined))
sortBy?: string;
}

View File

@@ -36,4 +36,27 @@ describe('assertExportReceivedWithGrn', () => {
assertExportReceivedWithGrn(db([]), { id: 'b-1', tradeDirection: 'DOMESTIC' }),
).resolves.toBeUndefined();
});
it('never blocks direct truck-to-train export — that cargo has no GRN by design', async () => {
const source = db([]);
await expect(
assertExportReceivedWithGrn(source, {
id: 'b-1',
tradeDirection: 'EXPORT',
exportHandoverMode: 'DIRECT_TO_TRAIN',
}),
).resolves.toBeUndefined();
// Direct short-circuits before querying — there is no inventory to look for.
expect(source.query as jest.Mock).not.toHaveBeenCalled();
});
it('still gates a warehouse export booking', async () => {
await expect(
assertExportReceivedWithGrn(db([]), {
id: 'b-1',
tradeDirection: 'EXPORT',
exportHandoverMode: 'WAREHOUSE',
}),
).rejects.toBeInstanceOf(BadRequestException);
});
});

View File

@@ -5,8 +5,15 @@ import type { DataSource, EntityManager } from 'typeorm';
export interface ExportLoadGateBooking {
id: string;
tradeDirection?: string | null;
/** 'DIRECT_TO_TRAIN' skips the gate entirely; null/'WAREHOUSE' keeps it. */
exportHandoverMode?: string | null;
}
/** Direct truck-to-train: the cargo never sees a warehouse, so it never has a GRN. */
export const DIRECT_TO_TRAIN = 'DIRECT_TO_TRAIN';
/** Warehouse-then-train: the existing flow. Also what a null mode means. */
export const WAREHOUSE = 'WAREHOUSE';
/**
* Export cargo may not be loaded onto its train until it has physically reached
* the warehouse and been issued a GRN — whether it got there by first-mile or by
@@ -21,12 +28,18 @@ export interface ExportLoadGateBooking {
* "Received with a GRN" = an inventory row that has reached the warehouse
* (RECEIVED or any later stage) and carries a GRN, in the column or the notes
* fallback older rows use.
*
* Export has a second, warehouse-free shape: the customer's truck loads straight
* onto the wagon. That cargo is never received and never GRN'd, so a booking
* marked DIRECT_TO_TRAIN is outside this gate by definition — its custody is
* attested by the carriage acceptance sheet instead.
*/
export async function assertExportReceivedWithGrn(
db: DataSource | EntityManager,
booking: ExportLoadGateBooking,
): Promise<void> {
if (booking.tradeDirection !== 'EXPORT') return;
if (booking.exportHandoverMode === DIRECT_TO_TRAIN) return;
const [row] = await db.query(
`SELECT 1

View File

@@ -0,0 +1,199 @@
import { Logger } from "@nestjs/common";
import type { Repository } from "typeorm";
import {
BaseRepository,
RequestLogMiddleware,
getLogContext,
logCtx,
runWithLogContext,
} from "@edr/api-common";
describe("logCtx", () => {
it("is a no-op outside a request", () => {
expect(() => logCtx({ bookingId: "b1" })).not.toThrow();
expect(getLogContext()).toBeUndefined();
});
it("collects data points across the request and isolates concurrent ones", async () => {
const collect = async (id: string) =>
runWithLogContext({ requestId: id }, async () => {
logCtx({ bookingId: id });
await Promise.resolve();
logCtx({ from: "DRAFT", to: "SUBMITTED" }, { path: "booking.status" });
logCtx({ wagonId: "w1" }, { path: "wagons", mode: "push" });
logCtx({ wagonId: "w2" }, { path: "wagons", mode: "push" });
logCtx(1, { path: "smsSent", mode: "count" });
logCtx(1, { path: "smsSent", mode: "count" });
logCtx("PAID", { path: "payment.state", mode: "set" });
logCtx({ ignored: true }, (ctx) => {
ctx.custom = "yes";
});
return getLogContext();
});
const [a, b] = await Promise.all([collect("r1"), collect("r2")]);
expect(a).toEqual({
requestId: "r1",
bookingId: "r1",
booking: { status: { from: "DRAFT", to: "SUBMITTED" } },
wagons: [{ wagonId: "w1" }, { wagonId: "w2" }],
smsSent: 2,
payment: { state: "PAID" },
custom: "yes",
});
expect(b?.requestId).toBe("r2");
expect(b?.bookingId).toBe("r2");
});
});
describe("RequestLogMiddleware", () => {
it("emits one canonical JSON line carrying the collected context", () => {
// Raw stdout, not the Nest logger — the line must be parsable JSON with no
// "[Nest] … LOG [request]" prefix in front of it.
const lines: string[] = [];
jest.spyOn(process.stdout, "write").mockImplementation((chunk) => {
lines.push(String(chunk));
return true;
});
jest.spyOn(Logger.prototype, "log").mockImplementation(() => undefined);
const listeners: Record<string, () => void> = {};
const req = {
method: "POST",
url: "/api/bookings/1/submit",
originalUrl: "/api/bookings/1/submit?dry=1",
baseUrl: "/api/bookings",
route: { path: "/:id/submit" },
headers: {
"user-agent": "jest",
"x-request-id": "req-42",
authorization: "Bearer tok",
"x-client-app": "freight-backoffice",
"current-project-id": "proj-3",
},
ip: "10.0.0.1",
query: { dry: "1" },
user: {
id: "u-7",
sessionId: "sess-9",
userType: "STAFF",
status: "ACTIVE",
username: "nati",
email: "nati@example.com",
phoneNumber: "0911000000",
name: { en: "Nati" },
roles: [{ key: "freight_operations" }],
permissions: [{ key: "a" }, { key: "b" }],
employee: {
id: "emp-1",
organizationId: "org-1",
unitId: "unit-2",
position: {
id: "pos-5",
key: "ops_officer",
employeePositionId: "ep-6",
isDelegate: true,
delegatorId: "pos-1",
positionType: { key: "operations" },
},
},
},
};
const res = {
statusCode: 409,
writableEnded: true,
setHeader: jest.fn(),
on: (event: string, fn: () => void) => {
listeners[event] = fn;
},
};
new RequestLogMiddleware().use(req, res, () => {
logCtx({ bookingId: "b-1" });
logCtx("REJECTED", { path: "booking.outcome", mode: "set" });
});
listeners.finish();
listeners.close(); // aborts/close after finish must not double-log
expect(lines).toHaveLength(1);
expect(lines[0].endsWith("\n")).toBe(true);
expect(lines[0].startsWith("{")).toBe(true);
expect(JSON.parse(lines[0])).toMatchObject({
level: "warn",
logger: "request",
type: "http_request",
requestId: "req-42",
method: "POST",
route: "/api/bookings/:id/submit",
url: "/api/bookings/1/submit?dry=1",
status: 409,
userId: "u-7",
ip: "10.0.0.1",
userAgent: "jest",
query: { dry: "1" },
bookingId: "b-1",
booking: { outcome: "REJECTED" },
});
expect(JSON.parse(lines[0]).auth).toEqual({
authenticated: true,
hasBearer: true,
clientApp: "freight-backoffice",
userId: "u-7",
sessionId: "sess-9",
userType: "STAFF",
userStatus: "ACTIVE",
roles: ["freight_operations"],
permissionCount: 2,
employeeId: "emp-1",
organizationId: "org-1",
unitId: "unit-2",
positionId: "pos-5",
positionKey: "ops_officer",
positionType: "operations",
employeePositionId: "ep-6",
isDelegate: true,
delegatorId: "pos-1",
projectId: "proj-3",
});
// No personal data reaches the line, whatever the token carried.
expect(lines[0]).not.toMatch(/nati|example\.com|0911000000/);
expect(res.setHeader).toHaveBeenCalledWith("x-request-id", "req-42");
jest.restoreAllMocks();
});
});
describe("BaseRepository write trail", () => {
class TestRepo extends BaseRepository<{ id: string; status?: string }> {
constructor(repo: Repository<{ id: string; status?: string }>) {
super(repo);
}
}
const typeormRepo = {
metadata: { tableName: "booking" },
create: (data: unknown) => data,
save: async (data: unknown) => data,
update: async () => undefined,
findOne: async () => ({ id: "b-1", status: "SUBMITTED" }),
softDelete: async () => undefined,
delete: async () => undefined,
} as unknown as Repository<{ id: string; status?: string }>;
it("records creates, status changes and deletes without any service opting in", async () => {
const ctx = await runWithLogContext({}, async () => {
const repo = new TestRepo(typeormRepo);
await repo.create({ id: "b-1" });
await repo.update("b-1", { status: "SUBMITTED" });
await repo.update("b-1", { id: "b-1" }); // no status → no transition entry
await repo.softDelete("b-1");
return getLogContext();
});
expect(ctx).toEqual({
db: { created: { booking: 1 }, updated: { booking: 2 } },
statusChanges: [{ entity: "booking", id: "b-1", status: "SUBMITTED" }],
deleted: [{ entity: "booking", id: "b-1" }],
});
});
});

View File

@@ -0,0 +1,48 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
export interface FacetBucket {
value: string;
count: number;
}
/**
* One `GROUP BY` query per faceted column, each with every OTHER active
* filter applied but its OWN predicate omitted. That omission is the point:
* with `status=SUBMITTED` selected, the status facet still reports
* `APPROVED: 8` so the user can switch, while the freightType facet reflects
* only the SUBMITTED-scoped set. Omit `search` from nothing — it's a scope,
* not a pill, and stays applied in every facet.
*
* Capped at 50 buckets per column — FK-id facets (warehouseId, yardId) can
* have real cardinality; beyond 50 the frontend should fall back to a
* typeahead instead of a checkbox list. Never facet a column whose popover
* would need its own search box (references, plate numbers, free text).
*
* @param base builds a FRESH query builder (soft-delete guard only,
* no filters) — called once per facet column.
* @param applyFilters applies every filter to `qb`, using `omit` to skip
* one column's own predicate.
* @param columns facet key -> "alias.column" SQL reference.
*/
export async function computeFacets<T extends ObjectLiteral>(
base: () => SelectQueryBuilder<T>,
applyFilters: (qb: SelectQueryBuilder<T>, omit?: string) => void,
columns: Record<string, string>,
): Promise<Record<string, FacetBucket[]>> {
const entries = await Promise.all(
Object.entries(columns).map(async ([key, column]) => {
const qb = base();
applyFilters(qb, key);
const rows = await qb
.select(column, 'value')
.addSelect('COUNT(*)::int', 'count')
.andWhere(`${column} IS NOT NULL`)
.groupBy(column)
.orderBy('count', 'DESC')
.limit(50)
.getRawMany<{ value: string; count: number }>();
return [key, rows.map((r) => ({ value: String(r.value), count: Number(r.count) }))] as const;
}),
);
return Object.fromEntries(entries);
}

View File

@@ -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, string> | 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<Map<string, string>> {
const resolved = new Map<string, string>();
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<IamUserRow & { id: string }>;
for (const row of rows) {
const name = pickUserName(row);
if (name) resolved.set(row.id, name);
}
return resolved;
}

View File

@@ -0,0 +1,76 @@
import { SelectQueryBuilder } from 'typeorm';
import { applySort, buildPaginationMeta, normalizePagination } from './pagination.util';
/** Minimal fake — just enough of the SelectQueryBuilder chain applySort touches. */
function fakeQb() {
const calls: Array<{ method: string; args: unknown[] }> = [];
const qb = {
alias: 'contract',
orderBy(...args: unknown[]) {
calls.push({ method: 'orderBy', args });
return qb;
},
addOrderBy(...args: unknown[]) {
calls.push({ method: 'addOrderBy', args });
return qb;
},
};
return { qb: qb as unknown as SelectQueryBuilder<any>, calls };
}
const SORTABLE = {
createdAt: 'contract.createdAt',
contractValidUntil: 'contract.contractValidUntil',
};
describe('applySort', () => {
it('resolves a whitelisted sortBy to its column', () => {
const { qb, calls } = fakeQb();
applySort(qb, { sortBy: 'contractValidUntil', sortOrder: 'ASC' }, SORTABLE, 'createdAt');
expect(calls[0]).toEqual({
method: 'orderBy',
args: ['contract.contractValidUntil', 'ASC'],
});
});
it('falls back to the default column for an unknown sortBy instead of throwing', () => {
const { qb, calls } = fakeQb();
// A stale bookmark or shared link naming a removed/renamed column must
// never 400 — it should silently behave as if sortBy were absent.
expect(() =>
applySort(qb, { sortBy: "id; DROP TABLE contracts; --" }, SORTABLE, 'createdAt'),
).not.toThrow();
expect(calls[0]).toEqual({ method: 'orderBy', args: ['contract.createdAt', 'DESC'] });
});
it('defaults sortOrder to DESC when absent or not ASC', () => {
const { qb, calls } = fakeQb();
applySort(qb, {}, SORTABLE, 'createdAt');
expect(calls[0]).toEqual({ method: 'orderBy', args: ['contract.createdAt', 'DESC'] });
});
it('always appends an id ASC tiebreaker', () => {
const { qb, calls } = fakeQb();
applySort(qb, { sortBy: 'createdAt' }, SORTABLE, 'createdAt');
expect(calls[1]).toEqual({ method: 'addOrderBy', args: ['contract.id', 'ASC'] });
});
});
describe('normalizePagination / buildPaginationMeta', () => {
it('clamps page to >= 1 and pageSize to the configured max', () => {
const p = normalizePagination({ page: 0, pageSize: 999 }, { maxPageSize: 100 });
expect(p).toEqual({ page: 1, pageSize: 100, skip: 0, take: 100 });
});
it('computes hasNextPage/hasPreviousPage from total', () => {
const meta = buildPaginationMeta(45, 2, 20);
expect(meta).toEqual({
page: 2,
pageSize: 20,
total: 45,
totalPages: 3,
hasNextPage: true,
hasPreviousPage: true,
});
});
});

View File

@@ -83,3 +83,32 @@ export function paginateArray<T>(
meta: buildPaginationMeta(rows.length, page, pageSize),
};
}
/**
* Apply `ORDER BY` from a query DTO's `sortBy`/`sortOrder`, resolved against a
* whitelist — never interpolate `sortBy` into a query builder directly, it is
* unvalidated user input and an unwhitelisted `orderBy(\`alias.${sortBy}\`)`
* is a SQL-injection primitive (see the deleted `findAllWithFilters` methods
* on drivers/vehicles repositories, which had exactly that bug).
*
* An unknown `sortBy` falls back to `fallback` instead of throwing — a stale
* bookmark or shared link should never 400.
*
* Always appends `id ASC` as a tiebreaker: sorting by a non-unique column
* (status, createdAt on bulk-imported rows) without one can drop or
* duplicate rows across pages once LIMIT/OFFSET is involved.
*
* @param sortable public sort key -> "alias.column" SQL reference. Also
* doubles as the Swagger enum / frontend's sortable-column list.
* @param fallback a key that must exist in `sortable`.
*/
export function applySort<T extends ObjectLiteral>(
qb: SelectQueryBuilder<T>,
query: { sortBy?: string; sortOrder?: 'ASC' | 'DESC' },
sortable: Record<string, string>,
fallback: string,
): SelectQueryBuilder<T> {
const column = (query.sortBy && sortable[query.sortBy]) || sortable[fallback];
qb.orderBy(column, query.sortOrder === 'ASC' ? 'ASC' : 'DESC');
return qb.addOrderBy(`${qb.alias}.id`, 'ASC');
}

View File

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

View File

@@ -56,11 +56,6 @@ import { EmployeePositionActivePeriod } from "@tria-plc/iamapi-common/entities/i
import { UnitConfiguration } from "@tria-plc/iamapi-common/entities/iam/organization-structure/unit-configuration.entity";
import { Site } from "@tria-plc/iamapi-common/entities/iam/site/site.entity";
import { SiteSetting } from "@tria-plc/iamapi-common/entities/iam/site/site-setting.entity";
import { AuditLog, AuditLogCommand } from "@tria-plc/auditlog";
// @tria-plc/auditlog's entities live in node_modules, same as the iam ones —
// the glob below only matches this app's own src/**/*.entity.ts.
const auditEntities = [AuditLog, AuditLogCommand];
const iamEntities = [
UnitSetting,
@@ -185,7 +180,6 @@ export function buildDataSourceOptions(): DataSourceOptions {
entities: [
__dirname + "/../**/*.entity.{ts,js}",
...iamEntities,
...auditEntities,
],
migrations: [],
};
@@ -215,8 +209,11 @@ export function buildFreightMigrationDataSourceOptions(): DataSourceOptions {
};
}
export default registerAs("database", (): TypeOrmModuleOptions => ({
export default registerAs(
"database",
(): TypeOrmModuleOptions => ({
...buildDataSourceOptions(),
autoLoadEntities: true,
migrationsRun: false,
}));
}),
);

View File

@@ -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<string, string | undefined>, fn: () => void) => {
const prior: Record<string, string | undefined> = {};
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");
},
);
});
});

View File

@@ -0,0 +1,291 @@
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.
*
* Disabled by default: with `EIMS_ENABLED=false` the config resolves to a stub and every EIMS
* service throws a clear error on use, so a deployment without credentials still boots.
*
* Secrets (client secret, API key) and the credential file paths live only here and are never
* logged — validation reports missing variable *names*, never their values.
*/
export interface EimsConfig {
enabled: boolean;
baseUrl: string;
clientId: string;
clientSecret: string;
apiKey: string;
tin: string;
/**
* Optional *expectations* for the source-system identity, not inputs.
*
* The access token MoR issues carries `systemNumber` and `systemType` claims for the credentials
* that authenticated, and those are what registration uses. When these are set they are compared
* against the token and a mismatch fails fast — neither side silently wins. Leave them empty to
* take whatever the gateway says.
*/
systemNumber: string;
systemType: string;
/** Filesystem path to the INSA-issued RSA private key (PEM). Never leaves the server. */
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;
/**
* Automatic submission of issued invoices, off by default.
*
* Invoices are produced by the workflow, so the production path is a sweep rather than a human
* action — but enabling it starts filing real documents with the tax authority, which is
* irreversible from our side. It therefore needs its own deliberate switch, separate from
* `EIMS_ENABLED`, so that authentication can be live long before filing is.
*/
autoSubmit: boolean;
autoSubmitCron: string;
/** MoR rejects a document whose date is more than 3 days old; the sweep will not attempt those. */
autoSubmitMaxAgeDays: number;
/**
* Seller identity and tax/business treatment for the invoice document.
*
* None of this is derivable from the database: EDR's own legal identity exists nowhere in the
* codebase, and the app models no tax at all. Values are required at registration time and are
* validated there rather than at boot, so a deployment can run with EIMS enabled for
* authentication before finance has signed off on the tax treatment.
*/
invoice: EimsInvoiceConfig;
}
export interface EimsInvoiceConfig {
sellerLegalName: string;
sellerVatNumber: string;
sellerPhone: string;
sellerEmail: string;
/** MoR *codes*, not names (e.g. "13" for Addis Ababa, "574"). */
sellerRegion: string;
sellerWereda: string;
sellerCity: string | null;
sellerSubCity: string | null;
sellerHouseNumber: string | null;
sellerLocality: string | null;
/** REQUIRES_BUSINESS_CONFIRMATION — no tax model exists in this application. */
taxCode: string;
taxRatePercent: number | null;
exciseTaxValue: number | null;
incomeWithholdValue: number | null;
transactionWithholdValue: number | null;
/** B2B / B2C — a tax classification, so it is configured, not inferred. */
transactionType: string;
natureOfSupplies: string;
paymentMode: string;
paymentTerm: string;
unitDefault: string;
/**
* Domestic fallback only — used when the buyer's `Company.country` is empty or "Ethiopia" (the
* column's own default) and not already listed in `buyerCountryCodes`. A genuinely foreign
* buyer must be in `buyerCountryCodes` by name or the mapping fails locally; this value is never
* applied to them, so an unconfigured foreign country can't silently be filed as Ethiopia.
*/
buyerCountryCode: string | null;
/**
* Country name → MoR code, from `EIMS_BUYER_COUNTRY_CODES` ("Ethiopia=231,Djibouti=071"). Format
* unconfirmed (unlike Region/Wereda, MoR has never named a Country regex), so — unlike them —
* this is not validated against a fixed digit pattern, only looked up by name.
*/
buyerCountryCodes: Record<string, string>;
/**
* Buyer region name → MoR numeric code, from `EIMS_BUYER_REGION_CODES`
* ("Addis Ababa=13,Oromia=4"). A buyer whose region is neither a code nor in this map fails
* locally rather than being filed with a guessed one.
*/
buyerRegionCodes: Record<string, string>;
/** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */
buyerWeredaCodes: Record<string, string>;
/**
* Buyer *zone* name → MoR City code, from `EIMS_BUYER_CITY_CODES` ("Kirkos=101"). `Company` has
* no dedicated city column — Zone is the closest match in EDR's own data. Optional, unlike
* Region/Wereda: MoR has never required City on a live buyer (confirmed — filing already
* succeeds with it null), so an unmapped zone falls back to null rather than failing the
* mapping.
*/
buyerCityCodes: Record<string, string>;
/**
* Per-`chargeType` tax treatment, e.g. `EIMS_TAX_CODE_BY_CHARGE_TYPE=RAIL_FREIGHT=VAT0` +
* `EIMS_TAX_RATE_BY_CHARGE_TYPE=RAIL_FREIGHT=0`. A charge type not listed here falls back to
* `taxCode`/`taxRatePercent`. Needed for an invoice whose lines carry different MoR tax
* treatment (e.g. zero-rated freight next to a taxed accessorial) — the flat `taxCode` above
* cannot express that. Values are raw strings; the context builder parses/validates them.
*/
taxCodeByChargeType: Record<string, string>;
taxRateByChargeType: Record<string, string>;
/** Same mechanism, for `EIMS_EXCISE_BY_CHARGE_TYPE` / `EIMS_DISCOUNT_BY_CHARGE_TYPE`. Charge
* types not listed fall back to `exciseTaxValue` / 0 respectively. */
exciseByChargeType: Record<string, string>;
discountByChargeType: Record<string, string>;
cashierName: string | null;
salesPersonName: string | null;
/**
* TEMPORARY / experimental — `EIMS_BUYER_ID_TYPE` + `EIMS_BUYER_ID_NUMBER`, applied to every
* buyer regardless of who they are. Only exists to test whether rule 7004 ("Id types should be
* one of NID, KID, SID, WID, PST, DLS, MRS") is satisfied by *any* IdType/IdNumber pair, ahead
* of MoR's answer on whether it's required for a TIN-only corporate buyer and which value fits.
* Wrong for a real, non-self buyer — remove once MoR answers and a real per-buyer field exists.
*/
buyerIdType: string | null;
buyerIdNumber: string | null;
}
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;
const value = Number.parseInt(raw, 10);
if (Number.isNaN(value) || value <= 0) {
throw new Error(`${name} must be a positive integer`);
}
return value;
};
/** "Addis Ababa=13,Oromia=4" → { "Addis Ababa": "13", Oromia: "4" }. */
const parseCodeMap = (raw: string | undefined): Record<string, string> => {
const map: Record<string, string> = {};
for (const pair of (raw ?? "").split(",")) {
const [name, code] = pair.split("=");
if (name?.trim() && code?.trim()) map[name.trim()] = code.trim();
}
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;
const value = Number(raw);
if (!Number.isFinite(value)) throw new Error(`${name} must be a number`);
return value;
};
export default registerAs("eims", (): EimsConfig => {
const enabled = (process.env.EIMS_ENABLED ?? "false").toLowerCase() === "true";
const baseUrl = (process.env.EIMS_BASE_URL ?? "https://core.mor.gov.et").replace(/\/+$/, "");
const httpTimeoutMs = positiveInt(process.env.EIMS_HTTP_TIMEOUT_MS, 30_000, "EIMS_HTTP_TIMEOUT_MS");
const tokenSkewMs =
positiveInt(process.env.EIMS_TOKEN_SKEW_SECONDS, 45, "EIMS_TOKEN_SKEW_SECONDS") * 1000;
const base: EimsConfig = {
enabled,
baseUrl,
clientId: process.env.EIMS_CLIENT_ID ?? "",
clientSecret: process.env.EIMS_CLIENT_SECRET ?? "",
apiKey: process.env.EIMS_API_KEY ?? "",
tin: process.env.EIMS_TIN ?? "",
systemNumber: process.env.EIMS_SYSTEM_NUMBER ?? "",
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",
// Every 5 minutes by default: filing is not latency-sensitive, and a slow cadence keeps a
// misconfiguration from filing a burst of bad documents before anyone notices.
autoSubmitCron: process.env.EIMS_AUTO_SUBMIT_CRON || "0 */5 * * * *",
autoSubmitMaxAgeDays: positiveInt(
process.env.EIMS_AUTO_SUBMIT_MAX_AGE_DAYS,
3,
"EIMS_AUTO_SUBMIT_MAX_AGE_DAYS",
),
invoice: {
sellerLegalName: process.env.EIMS_SELLER_LEGAL_NAME ?? "",
sellerVatNumber: process.env.EIMS_SELLER_VAT_NUMBER ?? "",
sellerPhone: process.env.EIMS_SELLER_PHONE ?? "",
sellerEmail: process.env.EIMS_SELLER_EMAIL ?? "",
sellerRegion: process.env.EIMS_SELLER_REGION ?? "",
sellerWereda: process.env.EIMS_SELLER_WEREDA ?? "",
sellerCity: process.env.EIMS_SELLER_CITY || null,
sellerSubCity: process.env.EIMS_SELLER_SUBCITY || null,
sellerHouseNumber: process.env.EIMS_SELLER_HOUSE_NUMBER || null,
sellerLocality: process.env.EIMS_SELLER_LOCALITY || null,
taxCode: process.env.EIMS_TAX_CODE ?? "",
taxRatePercent: optionalNumber(process.env.EIMS_TAX_RATE_PERCENT, "EIMS_TAX_RATE_PERCENT"),
exciseTaxValue: optionalNumber(process.env.EIMS_EXCISE_TAX_VALUE, "EIMS_EXCISE_TAX_VALUE"),
incomeWithholdValue: optionalNumber(
process.env.EIMS_INCOME_WITHHOLD_VALUE,
"EIMS_INCOME_WITHHOLD_VALUE",
),
transactionWithholdValue: optionalNumber(
process.env.EIMS_TRANSACTION_WITHHOLD_VALUE,
"EIMS_TRANSACTION_WITHHOLD_VALUE",
),
transactionType: process.env.EIMS_TRANSACTION_TYPE ?? "",
natureOfSupplies: process.env.EIMS_NATURE_OF_SUPPLIES ?? "",
paymentMode: process.env.EIMS_PAYMENT_MODE ?? "",
paymentTerm: process.env.EIMS_PAYMENT_TERM ?? "",
unitDefault: process.env.EIMS_UNIT_DEFAULT ?? "",
buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null,
buyerCountryCodes: parseCodeMap(process.env.EIMS_BUYER_COUNTRY_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),
discountByChargeType: parseCodeMap(process.env.EIMS_DISCOUNT_BY_CHARGE_TYPE),
cashierName: process.env.EIMS_CASHIER_NAME || null,
salesPersonName: process.env.EIMS_SALESPERSON_NAME || null,
buyerIdType: process.env.EIMS_BUYER_ID_TYPE || null,
buyerIdNumber: process.env.EIMS_BUYER_ID_NUMBER || null,
},
};
if (!enabled) return base;
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(", ")}`,
);
}
return base;
});

View File

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

View File

@@ -120,6 +120,8 @@ export class ContractDocumentViewModelBuilder {
contract.tradeDirection,
contract.freightType,
contract.customsClearingEnabled,
// Bulk templates are keyed by the contract's cargo type.
(contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId,
);
dynamicTemplate = dynamicSource
? {

View File

@@ -0,0 +1,99 @@
import { ContractViewModelBuilder, ContractSignatureView } from "./contract-view-model.builder";
/**
* The EDR side of a contract is sealed with the ONE global company stamp, read
* live at render time; the client side keeps whatever stamp the customer
* uploaded. These specs pin that asymmetry — the standing rule is that
* centralizing the EDR seal must not touch customer stamps.
*/
describe("ContractViewModelBuilder.attachProviderStamp", () => {
const STAMP = "data:image/png;base64,RURS";
const build = (stampImageUrl: string | null = STAMP) => {
const getStampImageUrl = jest.fn().mockResolvedValue(stampImageUrl);
const builder = Object.create(
ContractViewModelBuilder.prototype,
) as ContractViewModelBuilder;
Object.assign(builder, { stampSettings: { getStampImageUrl } });
return { builder, getStampImageUrl };
};
const sig = (role: "STAFF" | "CUSTOMER", extra: Partial<ContractSignatureView> = {}) =>
({
role,
signerDisplayName: `${role} signer`,
signedAt: "1 January 2026",
signatureImageUrl: "https://minio.local/sig.png",
...extra,
}) as ContractSignatureView;
it("stamps the EDR side with the global stamp", async () => {
const { builder } = build();
const signatures = [sig("STAFF")];
await builder.attachProviderStamp(signatures);
expect(signatures[0]!.stampImageUrl).toBe(STAMP);
});
it("leaves the customer side untouched", async () => {
const { builder } = build();
const customerStamp = "data:image/png;base64,Q1VTVA==";
const signatures = [
sig("CUSTOMER", { stampImageUrl: customerStamp }),
sig("STAFF"),
];
await builder.attachProviderStamp(signatures);
expect(signatures[0]!.stampImageUrl).toBe(customerStamp);
expect(signatures[1]!.stampImageUrl).toBe(STAMP);
});
it("does not read the stamp at all when EDR has not signed yet", async () => {
const { builder, getStampImageUrl } = build();
const signatures = [sig("CUSTOMER")];
await builder.attachProviderStamp(signatures);
expect(getStampImageUrl).not.toHaveBeenCalled();
expect(signatures[0]!.stampImageUrl).toBeUndefined();
});
it("renders unstamped rather than failing when no stamp is configured", async () => {
const { builder } = build(null);
const signatures = [sig("STAFF")];
await expect(builder.attachProviderStamp(signatures)).resolves.toBeUndefined();
expect(signatures[0]!.stampImageUrl).toBeNull();
});
it("reads the stamp once for every EDR signature row", async () => {
const { builder, getStampImageUrl } = build();
const signatures = [sig("STAFF"), sig("STAFF")];
await builder.attachProviderStamp(signatures);
expect(getStampImageUrl).toHaveBeenCalledTimes(1);
expect(signatures.map((s) => s.stampImageUrl)).toEqual([STAMP, STAMP]);
});
it("is applied by loadSignatures, so the HTML view and the PDF agree", async () => {
const { builder } = build();
Object.assign(builder, {
bookingsRepository: {
findContractSignatures: jest.fn().mockResolvedValue([
{ signerRole: "STAFF", signerDisplayName: "EDR", signedAt: new Date() },
]),
},
});
const views = await (
builder as unknown as {
loadSignatures(id: string): Promise<ContractSignatureView[]>;
}
).loadSignatures("b-1");
expect(views[0]!.stampImageUrl).toBe(STAMP);
});
});

View File

@@ -44,6 +44,7 @@ const UNIT_LABELS: Record<string, string> = {
PER_CONTAINER: 'per container',
PER_KM: 'per km',
PER_TON_KM: 'per ton per km',
PER_LITER: 'per liter',
PER_INVOICE: 'per invoice',
FLAT: 'flat',
};
@@ -66,6 +67,7 @@ const TRIGGER_ROUTE_LABELS: Partial<Record<Rate['trigger'], string>> = {
DEMURRAGE: 'Demurrage / wagon detention',
PIL_EXTRA_FEE: 'PIL shipping line extra fee',
CUSTOMS_CLEARANCE: 'Customs clearance service',
FUEL: 'Fuel surcharge',
};
@Injectable()
@@ -101,6 +103,15 @@ export class ContractRateScheduleBuilder {
continue;
}
// Fuel is sold per lane + commodity — only lanes matching the contract's
// direction belong on its schedule, labeled with their leg.
if (rate.trigger === 'FUEL') {
if (this.fuelDirectionMatches(rate, direction)) {
surcharges.push(this.fuelRow(rate));
}
continue;
}
// Everything left is a trigger-based charge (surcharge / demurrage / customs).
surcharges.push(this.surchargeRow(rate));
}
@@ -176,6 +187,35 @@ export class ContractRateScheduleBuilder {
};
}
private fuelDirectionMatches(rate: Rate, direction: ContractDirection): boolean {
const want =
direction === 'IMP' ? 'IMPORT' : direction === 'EXP' ? 'EXPORT' : 'DOMESTIC';
return rate.tradeDirection === want;
}
/**
* Fuel row — the lane matters, so it rides along in the charge label.
* Per-liter collapses to one flat total (base liters × rate value); the
* customer only ever sees the final price.
*/
private fuelRow(rate: Rate): RateScheduleRow {
const origin = rate.originYard?.label ?? rate.originYard?.code ?? '—';
const destination =
rate.destinationYard?.label ?? rate.destinationYard?.code ?? '—';
const perLiter = rate.rateUnit === 'PER_LITER';
return {
route: `Fuel surcharge (${origin}${destination})`,
cargo: this.cargoLabel(rate),
currency: rate.currency,
amount: this.formatAmount(
perLiter
? Number(rate.baseLiters ?? 0) * Number(rate.rateValue)
: rate.rateValue,
),
unit: perLiter ? 'flat' : this.unitLabel(rate.rateUnit),
};
}
private surchargeRow(rate: Rate): RateScheduleRow {
return {
route: TRIGGER_ROUTE_LABELS[rate.trigger] ?? this.titleCase(rate.trigger),

View File

@@ -9,6 +9,8 @@ import {
import { ContractPricingScheduleBuilder, PricingSchedule } from './contract-pricing-schedule.builder';
import { ContractRateScheduleBuilder, RateSchedule } from './contract-rate-schedule.builder';
import { ContractTemplateResolver } from './contract-template.resolver';
import { StampSettingsService } from '../modules/stamp-settings/stamp-settings.service';
import { LogoSettingsService } from '../modules/logo-settings/logo-settings.service';
import { ContractTemplateMeta, getTemplateMeta } from './contract-template.registry';
export interface ContractSignatureView {
@@ -16,6 +18,11 @@ export interface ContractSignatureView {
signerDisplayName: string;
signedAt: string;
signatureImageUrl?: string | null;
/**
* Round company seal shown beside the signature. Populated for the EDR
* (STAFF) side only, from the single global stamp — see attachProviderStamp.
*/
stampImageUrl?: string | null;
}
/**
@@ -105,6 +112,8 @@ export interface ContractViewModel {
hasCustomerSignature: boolean;
hasStaffSignature: boolean;
dynamicTemplate?: ContractDynamicTemplateView;
/** Company logo for the cover-page header (LogoSettingsService); null renders the "EDR" mark. */
logoImageUrl?: string | null;
}
@Injectable()
@@ -114,6 +123,8 @@ export class ContractViewModelBuilder {
private readonly templateResolver: ContractTemplateResolver,
private readonly pricingBuilder: ContractPricingScheduleBuilder,
private readonly rateScheduleBuilder: ContractRateScheduleBuilder,
private readonly stampSettings: StampSettingsService,
private readonly logoSettings: LogoSettingsService,
) {}
async build(bookingId: string): Promise<{ booking: Booking; view: ContractViewModel }> {
@@ -131,6 +142,7 @@ export class ContractViewModelBuilder {
template.freight,
);
const signatures = await this.loadSignatures(bookingId);
const logoImageUrl = await this.logoSettings.getLogoImageUrl();
const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER');
const hasStaff = signatures.some((s) => s.role === 'STAFF');
@@ -187,6 +199,7 @@ export class ContractViewModelBuilder {
hasContractDocument: hasContractFile,
hasCustomerSignature: hasCustomer,
hasStaffSignature: hasStaff,
logoImageUrl,
};
return { booking, view };
@@ -194,7 +207,30 @@ export class ContractViewModelBuilder {
private async loadSignatures(bookingId: string): Promise<ContractSignatureView[]> {
const rows = await this.bookingsRepository.findContractSignatures(bookingId);
return rows.map((s) => this.toSignatureView(s));
const views = rows.map((s) => this.toSignatureView(s));
await this.attachProviderStamp(views);
return views;
}
/**
* Stamp the EDR side of the contract with the ONE global company stamp
* (StampSettingsService) — staff never upload or pick a stamp, so nothing is
* stored per signature and the seal is read live at render time. The client
* side is left alone: a customer's own stamp is their business.
*
* Read live and deliberately not snapshotted, so replacing the company stamp
* re-seals contracts on their next render. `getStampImageUrl()` never throws
* and returns a data URL, which `signatures_block.hbs` renders as-is and the
* signature inliner skips.
*/
async attachProviderStamp(signatures: ContractSignatureView[]): Promise<void> {
const staff = signatures.filter((s) => s.role === 'STAFF');
if (staff.length === 0) return;
const stampImageUrl = await this.stampSettings.getStampImageUrl();
for (const sig of staff) {
sig.stampImageUrl = stampImageUrl;
}
}
toSignatureView(row: BookingContractSignature): ContractSignatureView {

View File

@@ -77,6 +77,12 @@
letter-spacing: 0.08em;
width: 72px;
}
.logo-mark img {
display: block;
max-height: 100%;
max-width: 100%;
object-fit: contain;
}
.kicker {
color: #0e5b45;
font-family: Arial, sans-serif;

View File

@@ -11,7 +11,7 @@
{{!-- ─────────────────────────── Cover page ─────────────────────────── --}}
<section class="cover page-section">
<div class="brand-row">
<div class="logo-mark">EDR</div>
<div class="logo-mark">{{#if logoImageUrl}}<img src="{{logoImageUrl}}" alt="Company logo" />{{else}}EDR{{/if}}</div>
<div>
<p class="kicker">Ethio-Djibouti Standard Gauge Railway Share Company</p>
<p class="muted">Freight Transport Services</p>

View File

@@ -9,7 +9,7 @@
<main class="contract">
<section class="cover page-section">
<div class="brand-row">
<div class="logo-mark">EDR</div>
<div class="logo-mark">{{#if logoImageUrl}}<img src="{{logoImageUrl}}" alt="Company logo" />{{else}}EDR{{/if}}</div>
<div>
<p class="kicker">Ethio-Djibouti Standard Gauge Railway Share Company</p>
<p class="muted">Freight Transport Contract</p>

View File

@@ -9,6 +9,7 @@
main { padding: 32px 40px; }
.brand-row { display: flex; align-items: center; gap: 14px; border-bottom: 3px solid #1a5632; padding-bottom: 14px; }
.logo-mark { background: #1a5632; color: #fff; font-weight: 700; font-size: 18px; padding: 10px 14px; border-radius: 6px; }
.logo-mark img { display: block; max-height: 32px; max-width: 100px; object-fit: contain; }
.kicker { margin: 0; font-weight: 700; }
.muted { margin: 0; color: #666; }
h1 { font-size: 20px; margin: 24px 0 4px; }
@@ -32,7 +33,7 @@
<body>
<main>
<div class="brand-row">
<div class="logo-mark">EDR</div>
<div class="logo-mark">{{#if logoImageUrl}}<img src="{{logoImageUrl}}" alt="Company logo" />{{else}}EDR{{/if}}</div>
<div>
<p class="kicker">Ethio-Djibouti Standard Gauge Railway Share Company</p>
<p class="muted">Last-Mile Delivery Contract</p>

View File

@@ -1,21 +0,0 @@
import { Injectable, NestMiddleware, Logger } from "@nestjs/common";
import { Request, Response, NextFunction } from "express";
@Injectable()
export class LoggerMiddleware implements NestMiddleware {
private readonly logger = new Logger("HTTP");
use(req: Request, res: Response, next: NextFunction) {
const start = Date.now();
res.on("finish", () => {
const duration = Date.now() - start;
this.logger.log(
`${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`,
);
});
next();
}
}

View File

@@ -10,17 +10,25 @@ import {
ResponseTransformInterceptor,
createValidationPipe,
} from "@edr/api-common";
import { getAuditLoggerConfig } from "@tria-plc/auditlog";
import { AppModule } from "./app.module";
/**
* JSON body ceiling. Signing posts the signature AND the company stamp as
* base64 in one JSON body, and base64 inflates bytes by ~4/3 — a 10MB stamp is
* ~13.4MB on the wire. Express defaults to 100kb, which rejected any real stamp
* image with a 413 "request entity too large".
* JSON body ceiling. Customer signing posts the signature AND the customer's
* own company stamp as base64 in one JSON body, and base64 inflates bytes by
* ~4/3 — a 50MB asset is ~67MB on the wire. Express defaults to 100kb, which
* rejected any real stamp image with a 413 "request entity too large".
* (Staff signing posts only a signature: EDR's seal is the one global stamp,
* read server-side. Uploading that stamp under Settings goes through this same
* ceiling, so the headroom is still needed on both counts.)
*
* Sized to clear the 50MB per-document ceiling
* (`DOCUMENT_UPLOAD_MAX_BYTES`) after base64 inflation, with room for the
* surrounding JSON. Note that the reverse proxy applies its own
* `client_max_body_size` and rejects oversized bodies before Nest sees them —
* raising this alone does not lift the limit end to end (see docs/uploads.md).
*/
const JSON_BODY_LIMIT = "20mb";
const JSON_BODY_LIMIT = "100mb";
/**
* Static /etc/hosts-style overrides from `DNS_HOST_OVERRIDES`, formatted as
@@ -161,11 +169,6 @@ export async function createFreightApp(): Promise<NestExpressApplication> {
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new ResponseTransformInterceptor());
// Audit listener: consumes the RMQ events MezgebModule's client interceptor
// (app.module.ts) emits and persists them via the AuditLogController /
// AuditLogCommandController @EventPattern handlers. Same queue config the
// client side uses, reused from the package so the two never drift apart.
app.connectMicroservice(getAuditLoggerConfig());
await app.startAllMicroservices();
const config = new DocumentBuilder()

View File

@@ -0,0 +1,100 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
const CONTAINER_CODES = [
'IMPORT_CONTAINER_CUSTOMS',
'IMPORT_CONTAINER_NO_CUSTOMS',
'EXPORT_CONTAINER_CUSTOMS',
'EXPORT_CONTAINER_NO_CUSTOMS',
'INTERCITY_CONTAINER',
];
const BULK_CODES = [
'IMPORT_BULK_CUSTOMS',
'IMPORT_BULK_NO_CUSTOMS',
'EXPORT_BULK_CUSTOMS',
'EXPORT_BULK_NO_CUSTOMS',
'INTERCITY_BULK',
];
/**
* Bulk contract templates become staff-created, keyed by (cargo type, customs
* clearing) instead of the fixed direction codes. The five container templates
* stay seeded and become undeletable system rows; the five seeded bulk rows are
* retired (soft-deleted). cargo_types gains has_contract_template, marking
* which bulk commodities may carry their own template.
*/
export class BulkContractTemplates3320000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.cargo_types
ADD COLUMN IF NOT EXISTS has_contract_template boolean NOT NULL DEFAULT false
`);
await queryRunner.query(`
ALTER TABLE freight.contract_templates
ADD COLUMN IF NOT EXISTS cargo_type_id uuid REFERENCES freight.cargo_types(id),
ADD COLUMN IF NOT EXISTS with_customs boolean,
ADD COLUMN IF NOT EXISTS is_system boolean NOT NULL DEFAULT false
`);
// Generated bulk codes (BULK_<cargo code>_NO_CUSTOMS) outgrow varchar(40).
await queryRunner.query(`
ALTER TABLE freight.contract_templates
ALTER COLUMN code TYPE varchar(80)
`);
await queryRunner.query(
`UPDATE freight.contract_templates SET is_system = true WHERE code = ANY($1)`,
[CONTAINER_CODES],
);
// Retire the fixed bulk templates; staff recreate them per cargo type.
await queryRunner.query(
`UPDATE freight.contract_templates SET deleted_at = now()
WHERE code = ANY($1) AND deleted_at IS NULL`,
[BULK_CODES],
);
// Code stays unique among live rows only, so a deleted combo can be
// recreated under the same generated code.
await queryRunner.query(
`ALTER TABLE freight.contract_templates DROP CONSTRAINT IF EXISTS uq_contract_templates_code`,
);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_templates_code
ON freight.contract_templates (code) WHERE deleted_at IS NULL
`);
// One template per (bulk cargo type, customs option) — the "same
// combination" rule, enforced even under concurrent creates.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_templates_cargo_customs
ON freight.contract_templates (cargo_type_id, with_customs)
WHERE deleted_at IS NULL AND cargo_type_id IS NOT NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX IF EXISTS freight.uq_contract_templates_cargo_customs`,
);
await queryRunner.query(`DROP INDEX IF EXISTS freight.uq_contract_templates_code`);
await queryRunner.query(`
ALTER TABLE freight.contract_templates
ADD CONSTRAINT uq_contract_templates_code UNIQUE (code)
`);
await queryRunner.query(
`UPDATE freight.contract_templates SET deleted_at = NULL WHERE code = ANY($1)`,
[BULK_CODES],
);
await queryRunner.query(`
ALTER TABLE freight.contract_templates
DROP COLUMN IF EXISTS cargo_type_id,
DROP COLUMN IF EXISTS with_customs,
DROP COLUMN IF EXISTS is_system
`);
await queryRunner.query(`
ALTER TABLE freight.cargo_types DROP COLUMN IF EXISTS has_contract_template
`);
}
}

View File

@@ -0,0 +1,73 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* EIMS registration state.
*
* `freight.invoices` gains the per-invoice registration outcome: which EIMS counter the invoice
* consumed, the returned IRN, and the last failure. The partial unique index on `eims_irn` is the
* database-level guarantee that one IRN can never be recorded against two invoices, independent of
* application logic.
*
* `freight.eims_system_state` is a single row per MoR system number holding the sequence the
* gateway expects: the next `SourceSystem.InvoiceCounter` and the `ReferenceDetails.PreviousIrn`
* of the last successful registration. Registration takes `FOR UPDATE` on this row, so the counter
* and the IRN chain stay consistent under concurrent submissions.
*
* The `in_flight_*` columns make a submission a *durable reservation*: the counter is consumed and
* the holder recorded in a committed transaction before the HTTP call, so a crash mid-flight leaves
* evidence instead of silently freeing the slot for a blind resubmission. `blocked_reason` is set
* when a submission ends ambiguously (timeout, network, 5xx) — the IRN is unknown, so every later
* document for this system number would chain to a stale `PreviousIrn` and registration stops until
* a human resolves it.
*
* `eims_ack_date` is varchar, not timestamptz: EIMS returns a Java ZonedDateTime string
* ("2025-03-21T08:33:32.707753413Z[Etc/UTC]") that no JS date parser accepts. It is stored
* verbatim so a compliance value is never mangled by a parse.
*/
export class EimsInvoiceRegistration3330000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.invoices
ADD COLUMN IF NOT EXISTS eims_status varchar(20) NOT NULL DEFAULT 'NOT_SUBMITTED',
ADD COLUMN IF NOT EXISTS eims_irn varchar(64),
ADD COLUMN IF NOT EXISTS eims_invoice_counter bigint,
ADD COLUMN IF NOT EXISTS eims_submitted_at timestamptz,
ADD COLUMN IF NOT EXISTS eims_ack_date varchar(64),
ADD COLUMN IF NOT EXISTS eims_last_error jsonb
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS ux_invoices_eims_irn
ON freight.invoices (eims_irn) WHERE eims_irn IS NOT NULL
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.eims_system_state (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
system_number varchar(32) NOT NULL UNIQUE,
next_invoice_counter bigint NOT NULL DEFAULT 1,
previous_irn varchar(64),
in_flight_invoice_id uuid,
in_flight_counter bigint,
blocked_reason text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.eims_system_state`);
await queryRunner.query(`DROP INDEX IF EXISTS freight.ux_invoices_eims_irn`);
await queryRunner.query(`
ALTER TABLE freight.invoices
DROP COLUMN IF EXISTS eims_status,
DROP COLUMN IF EXISTS eims_irn,
DROP COLUMN IF EXISTS eims_invoice_counter,
DROP COLUMN IF EXISTS eims_submitted_at,
DROP COLUMN IF EXISTS eims_ack_date,
DROP COLUMN IF EXISTS eims_last_error
`);
}
}

View File

@@ -0,0 +1,34 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* EIMS document numbering.
*
* MoR validates `DocumentDetails.DocumentNumber` against `^(0|[1-9][0-9]{0,8})$` — a plain integer
* of at most nine digits. Our own `INV-YYYYMMDD-NNNNN` can therefore never be sent, so EIMS needs
* its own sequence, allocated from the same locked state row as the invoice counter and recorded
* on the invoice so a filed document can be traced back to it.
*/
export class EimsDocumentNumberSequence3340000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.eims_system_state
ADD COLUMN IF NOT EXISTS next_document_number bigint NOT NULL DEFAULT 1,
ADD COLUMN IF NOT EXISTS in_flight_document_number bigint
`);
await queryRunner.query(`
ALTER TABLE freight.invoices
ADD COLUMN IF NOT EXISTS eims_document_number varchar(16)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.invoices DROP COLUMN IF EXISTS eims_document_number
`);
await queryRunner.query(`
ALTER TABLE freight.eims_system_state
DROP COLUMN IF EXISTS next_document_number,
DROP COLUMN IF EXISTS in_flight_document_number
`);
}
}

View File

@@ -0,0 +1,73 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Editable customer-facing copy for the portal's public pages (/help, /faq,
* /terms, /privacy) plus the shared support-contact block, with an append-only
* version log behind it.
*
* `payload` is opaque jsonb: the five documents have genuinely different shapes
* and the help page's blocks change with the copy, so typed columns would mean
* a migration per wording tweak. The shape is enforced by per-slug DTOs on
* write instead.
*
* No rows are inserted here — `SupportContentSeeder` fills the table on first
* boot and skips whenever it is non-empty, so a redeploy never overwrites
* admin edits the way a migration-embedded INSERT eventually would.
*/
export class SupportContent3350000000000 implements MigrationInterface {
name = "SupportContent3350000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.support_documents (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
slug varchar(32) NOT NULL,
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
version integer NOT NULL DEFAULT 1,
updated_by_id uuid,
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 uq_support_documents_slug
ON freight.support_documents (slug);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.support_document_versions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
document_id uuid NOT NULL
REFERENCES freight.support_documents(id) ON DELETE CASCADE,
version integer NOT NULL,
payload jsonb NOT NULL,
actor_id uuid,
note varchar(255),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
`);
// Closes the concurrent-save race: two editors saving at once cannot both
// claim the same version number.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_support_doc_version
ON freight.support_document_versions (document_id, version);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_support_doc_versions_document
ON freight.support_document_versions (document_id);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP TABLE IF EXISTS freight.support_document_versions;`,
);
await queryRunner.query(`DROP TABLE IF EXISTS freight.support_documents;`);
}
}

View File

@@ -0,0 +1,112 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Converts the HELP document from its original fixed-block shape
* (`video` / `chat` / `channels` / `topics` / `checklist`) to the free-form
* `sections[]` builder, where every block is a heading plus markdown plus
* attached media.
*
* Only rows still in the old shape are touched — detected by the presence of a
* `channels` key — so this is a no-op on any environment seeded after the
* change, and re-running it does nothing.
*
* The payload literal is inlined rather than imported from
* `SUPPORT_CONTENT_DEFAULTS`: a migration must keep doing the same thing
* forever, and that constant will keep moving.
*
* The rewrite also bumps `version` and writes a matching history row. The live
* row's version always having a matching entry in
* `support_document_versions` is the invariant the history list and rollback
* both depend on, and a silent payload swap would break it.
*/
const HELP_SECTIONS = [
{
id: "help-walkthrough",
heading: "Portal walkthrough",
body: "A guided tour of the portal — registering your company, raising a booking against a contract, and settling an invoice.",
media: [
{
id: "help-walkthrough-video",
kind: "video",
src: "/assets/edr-portal-guide.webm",
caption: null,
},
],
},
{
id: "help-chat",
heading: "Chat with our team",
body: "Signed-in customers can open a support conversation from the headset button at the bottom right of every portal page. You can send screenshots and documents in the chat, and replies appear there and as a notification.\n\n[Open the portal](/portal)",
media: [],
},
{
id: "help-contact",
heading: "Contact us",
body: "- **Email** — [{{supportEmail}}](mailto:{{supportEmail}}). Best for document issues and anything needing an attachment.\n- **Phone** — [{{supportPhone}}](tel:{{supportPhoneTel}}). Best for urgent problems with cargo already in transit.\n- **Head office** — {{supportOffice}}. Walk-in support during working hours.\n- **Support hours** — {{supportHours}}. Outside these hours, email us and we reply the next working day.",
media: [],
},
{
id: "help-topics",
heading: "Common topics",
body: "- **[Account & onboarding](/faq)** — registering your company, uploading your trade licence and TIN, and getting an operational profile approved.\n- **[Contracts](/faq)** — requesting a freight contract, reviewing its terms and signing it with your saved signature and stamp.\n- **[Bookings & tracking](/faq)** — raising a booking against a contract, adding last-mile transport and following the consignment along the corridor.\n- **[Invoices & payments](/faq)** — finding invoices, paying through the bank channels and confirming a payment that has not yet settled.",
media: [],
},
{
id: "help-checklist",
heading: "What to include when you contact us",
body: "- Your company name and the email you sign in with.\n- The reference of the contract, booking or invoice involved.\n- What you expected to happen and what happened instead.\n- A screenshot of any error message the portal showed.",
media: [],
},
];
export class SupportHelpSections3360000000000 implements MigrationInterface {
name = "SupportHelpSections3360000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
const rows: { id: string; version: number; payload: Record<string, unknown> }[] =
await queryRunner.query(`
SELECT id, version, payload
FROM freight.support_documents
WHERE slug = 'HELP' AND payload ? 'channels'
`);
for (const row of rows) {
const payload = {
title: row.payload.title ?? "Help & Support",
subtitle:
row.payload.subtitle ??
"Get answers fast — watch the walkthrough, browse the common topics, check the FAQ, or reach our team directly.",
sections: HELP_SECTIONS,
};
const version = row.version + 1;
await queryRunner.query(
`UPDATE freight.support_documents
SET payload = $1::jsonb, version = $2, updated_at = now()
WHERE id = $3`,
[JSON.stringify(payload), version, row.id],
);
await queryRunner.query(
`INSERT INTO freight.support_document_versions
(document_id, version, payload, actor_id, note)
VALUES ($1, $2, $3::jsonb, NULL, $4)`,
[
row.id,
version,
JSON.stringify(payload),
"Converted help page to free-form sections",
],
);
}
}
/**
* Not reversible: the old fixed blocks cannot be recovered from markdown
* sections an editor may since have rewritten. The version history holds the
* pre-conversion payload if it is ever genuinely needed.
*/
public async down(): Promise<void> {
// no-op
}
}

View File

@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Export cargo reaches a train two ways, and until now only one was modelled.
*
* DIRECT_TO_TRAIN — the customer's truck pulls alongside and the cargo goes
* straight onto the wagon. It never enters a warehouse, so no GRN is ever
* raised; the Carriage Acceptance Sheet is the only document handed over.
*
* WAREHOUSE — cargo is received into the warehouse, GRN'd, then loaded. This is
* the existing flow and stays gated on the GRN.
*
* NULL means WAREHOUSE, so existing rows keep today's behaviour with no backfill.
*/
export class BookingExportHandoverMode3370000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS export_handover_mode varchar(20)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings DROP COLUMN IF EXISTS export_handover_mode
`);
}
}

View File

@@ -0,0 +1,91 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Folds each help section's `media[]` array into its markdown body.
*
* Attachments used to hang off the section as a separate list, rendered after
* the text — which meant an author could not put a picture next to the sentence
* it illustrates, and had two different places to manage media. They are now
* embedded with markdown's image syntax, and the renderer picks `<img>` or
* `<video>` from the file extension.
*
* Existing entries are converted rather than dropped: an uploaded object
* becomes `![caption](minio:<key>)`, and a shipped asset path or external URL
* is kept verbatim. Sections are left alone once they have no `media` key, so
* re-running does nothing.
*/
interface LegacyMedia {
src: string;
caption?: string | null;
}
interface LegacySection {
id: string;
heading: string;
body: string;
media?: LegacyMedia[];
}
/** Uploaded objects are stored as keys; the `minio:` ref is signed on read. */
function toMarkdown(item: LegacyMedia): string {
const isStoredObject = !/^(https?:\/\/|\/)/.test(item.src);
const target = isStoredObject ? `minio:${item.src}` : item.src;
return `![${item.caption ?? ""}](${target})`;
}
export class SupportHelpInlineMedia3370000000000 implements MigrationInterface {
name = "SupportHelpInlineMedia3370000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
const rows: {
id: string;
version: number;
payload: { sections?: LegacySection[] } & Record<string, unknown>;
}[] = await queryRunner.query(`
SELECT id, version, payload
FROM freight.support_documents
WHERE slug = 'HELP'
`);
for (const row of rows) {
const sections = row.payload.sections ?? [];
if (!sections.some((section) => section.media !== undefined)) continue;
const payload = {
...row.payload,
sections: sections.map(({ media, ...section }) => {
const embeds = (media ?? []).map(toMarkdown);
return {
...section,
body: [section.body, ...embeds].filter(Boolean).join("\n\n"),
};
}),
};
const version = row.version + 1;
await queryRunner.query(
`UPDATE freight.support_documents
SET payload = $1::jsonb, version = $2, updated_at = now()
WHERE id = $3`,
[JSON.stringify(payload), version, row.id],
);
await queryRunner.query(
`INSERT INTO freight.support_document_versions
(document_id, version, payload, actor_id, note)
VALUES ($1, $2, $3::jsonb, NULL, $4)`,
[
row.id,
version,
JSON.stringify(payload),
"Moved attachments inline into the section text",
],
);
}
}
/** Not reversible — see the note on SupportHelpSections3360000000000. */
public async down(): Promise<void> {
// no-op
}
}

View File

@@ -0,0 +1,44 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Raises the per-field document ceiling from 10MB to 50MB.
*
* `max_size_mb` is what the portal enforces client-side (SmartFileInput blocks
* the file and shows "File size exceeds the limit of NMB"), so the seeded 10
* was the visible limit for every existing form even after the server-side caps
* were lifted. The seeder only writes these rows on first insert, so deployed
* environments keep their old value until this runs.
*
* Only rows still sitting at the old default are touched — a field an admin has
* deliberately tuned to something else keeps that value.
*/
export class RaiseDocumentUploadSizeLimit3380000000000
implements MigrationInterface
{
name = "RaiseDocumentUploadSizeLimit3380000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.file_upload_fields
ALTER COLUMN max_size_mb SET DEFAULT 50
`);
await queryRunner.query(`
UPDATE freight.file_upload_fields
SET max_size_mb = 50
WHERE max_size_mb = 10
`);
}
/**
* Restores the column default only. The old per-row values are not
* recoverable (10 and an admin-chosen 10 are indistinguishable after `up`),
* and shrinking a customer's limit back down would reject documents they have
* already uploaded, so the rows are deliberately left at 50.
*/
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.file_upload_fields
ALTER COLUMN max_size_mb SET DEFAULT 10
`);
}
}

View File

@@ -0,0 +1,81 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Backoffice audit trail for every state-changing freight endpoint.
*
* No `updated_at` / `deleted_at` columns, unlike every other table here: audit
* rows are insert-only evidence. A soft-delete column would let an actor erase
* their own trail and TypeORM would then hide those rows from default queries
* silently — see the entity comment.
*
* `user_id` intentionally carries NO foreign key to the `iam` schema.
* Cross-schema FKs are forbidden platform-wide, and one here would let user
* deletion cascade away the record of what that user did.
*
* DDL is idempotent (`IF NOT EXISTS`) because watch-mode API instances race
* `migrationsRun` against each other on the shared dev database.
*/
export class CreateAuditLogs3390000000000 implements MigrationInterface {
name = "CreateAuditLogs3390000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.audit_logs (
id uuid NOT NULL DEFAULT gen_random_uuid(),
title varchar(255) NOT NULL,
method varchar(10) NOT NULL,
url text NOT NULL,
route_path varchar(255),
type varchar(50) NOT NULL,
is_success boolean NOT NULL,
user_id uuid,
resource_id varchar(64),
request jsonb,
status_code smallint,
error_message text,
user_name varchar(150),
user_role varchar(100),
ip_address inet,
user_agent text,
request_id varchar(64),
duration_ms integer,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT "PK_audit_logs" PRIMARY KEY (id)
)
`);
// Every audit query is time-bounded, so created_at leads each index.
// DESC matches the "newest first" read path the controller exposes.
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_audit_logs_created_at"
ON freight.audit_logs (created_at DESC)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_audit_logs_user_id_created_at"
ON freight.audit_logs (user_id, created_at DESC)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_audit_logs_type_created_at"
ON freight.audit_logs (type, created_at DESC)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_audit_logs_type_resource_id"
ON freight.audit_logs (type, resource_id)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_audit_logs_route_path_created_at"
ON freight.audit_logs (route_path, created_at DESC)
`);
// Failures are a small slice of the table but carry the security signal
// (403s especially), so they get their own partial index.
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_audit_logs_failures"
ON freight.audit_logs (created_at DESC)
WHERE is_success = false
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.audit_logs`);
}
}

View File

@@ -0,0 +1,210 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Onboarding revamp: one company, one verified identity.
*
* The general manager is removed outright — it named who to talk to and gated
* nothing — and the company's people become its **owner** (whoever the eTrade
* licence names as the business's manager) and its **Power of Attorney**.
* Exactly one of them is identity-verified, chosen by the company's own answer
* to "does anyone hold power of attorney for you?", stored as
* `attributes.poaDeclared`.
*
* The order below matters — each step depends on data a later step destroys:
*
* 1. Rescue notification addresses. `companyNotifyEmailExpr` used to fall
* through to `attributes->>'generalManagerEmail'`, and `companies.email` was
* only ever written for a Fayda-verified owner — so every foreign company
* had none and was reached solely through that fallback. Promote it to the
* column before the key is stripped, or those companies stop receiving mail
* in silence.
* 2. Backfill the owner. `ownerName`/`ownerEmail`/`ownerPhone` are now required
* onboarding fields; without this every already-onboarded company would
* report three missing fields the moment it opened its settings page.
* 3. Resolve `poaSameAsOwner`. That flag waived the DARS delegation paper. It
* is gone, so the companies holding it must be re-expressed:
* - NOT a freight forwarder → "no PoA" (the owner represents themselves,
* nothing to delegate). Their PoA details are cleared.
* - A freight forwarder → "yes" and details KEPT. A forwarder signs on
* other companies' behalf, so a representative is non-negotiable and the
* waiver no longer exists. These companies will be asked for a
* delegation paper they were previously excused — an intentional,
* visible consequence, not an oversight. Count them before deploying.
* 4. Derive the declaration for everyone else, from whether PoA details exist.
* 5. Move drafts off the deleted "personnel" wizard step.
* 6. Only now drop the columns and strip the retired attribute keys.
*
* Irreversible by design: `down()` restores the columns' shape but cannot
* recover the values, and re-deriving `poaSameAsOwner` from `poaDeclared` would
* be a guess.
*/
export class RemoveGeneralManager3390000000000 implements MigrationInterface {
name = "RemoveGeneralManager3390000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
// 1. Rescue the notification address before the key it lives under is gone.
await queryRunner.query(`
UPDATE freight.companies
SET email = COALESCE(
NULLIF(email, ''),
NULLIF(attributes->>'ownerEmail', ''),
NULLIF(attributes->>'generalManagerEmail', ''),
NULLIF(attributes->>'contactPersonEmail', '')
)
WHERE COALESCE(email, '') = ''
`);
// 2. Backfill the owner from the best source each company actually has:
// its Fayda-verified owner claims (already under owner*), then the general
// manager it named, then its contact person. A company with none of these
// never finished onboarding, and will be asked on its next visit.
await queryRunner.query(`
UPDATE freight.companies
SET attributes = attributes
|| jsonb_strip_nulls(jsonb_build_object(
'ownerName', COALESCE(
NULLIF(attributes->>'ownerName', ''),
NULLIF(attributes->>'generalManagerName', ''),
NULLIF(attributes->>'contactPersonName', '')
),
'ownerEmail', COALESCE(
NULLIF(attributes->>'ownerEmail', ''),
NULLIF(attributes->>'generalManagerEmail', ''),
NULLIF(attributes->>'contactPersonEmail', ''),
NULLIF(email, '')
),
'ownerPhone', COALESCE(
NULLIF(attributes->>'ownerPhone', ''),
NULLIF(attributes->>'generalManagerPhone', ''),
NULLIF(attributes->>'contactPersonPhone', ''),
NULLIF(phone, '')
)
))
WHERE attributes IS NOT NULL
`);
// 2b. Capture eTrade's manager for the owner-vs-licence check the
// backoffice now makes. Nothing stored it before, so the best we have is
// the owner name itself — which makes existing companies read as "matches"
// rather than as a false mismatch on data nobody ever compared. The value
// is refreshed for real on the company's next eTrade lookup.
await queryRunner.query(`
UPDATE freight.companies
SET attributes = jsonb_set(
attributes, '{etradeManagerName}', to_jsonb(attributes->>'ownerName')
)
WHERE COALESCE(attributes->>'ownerName', '') <> ''
AND attributes->>'etradeManagerName' IS NULL
AND COALESCE(licence_number, '') <> ''
`);
// 3a. Owner-represents-themselves, and NOT a forwarder → "no PoA".
await queryRunner.query(`
UPDATE freight.companies c
SET attributes = (c.attributes - 'poaName' - 'poaPhone' - 'poaEmail'
- 'poaLocation' - 'poaAddress' - 'poaFaydaSub'
- 'poaFaydaVerifiedAt' - 'poaBirthdate' - 'poaGender')
|| jsonb_build_object('poaDeclared', 'no')
WHERE (c.attributes->>'poaSameAsOwner')::boolean IS TRUE
AND NOT EXISTS (
SELECT 1 FROM freight.company_profiles cp
WHERE cp.company_id = c.id
AND cp.type = 'freight_forwarder'
AND cp.deleted_at IS NULL
)
`);
// 3b. Forwarders keep their representative and lose the waiver.
await queryRunner.query(`
UPDATE freight.companies c
SET attributes = c.attributes || jsonb_build_object('poaDeclared', 'yes')
WHERE (c.attributes->>'poaSameAsOwner')::boolean IS TRUE
AND EXISTS (
SELECT 1 FROM freight.company_profiles cp
WHERE cp.company_id = c.id
AND cp.type = 'freight_forwarder'
AND cp.deleted_at IS NULL
)
`);
// 4. Everyone else: "yes" if a representative was named or the company is a
// forwarder, "no" if it finished onboarding without one. A company still
// mid-onboarding is left unanswered — it will be asked, which is the point.
await queryRunner.query(`
UPDATE freight.companies c
SET attributes = COALESCE(c.attributes, '{}'::jsonb)
|| jsonb_build_object('poaDeclared', 'yes')
WHERE c.attributes->>'poaDeclared' IS NULL
AND (
COALESCE(c.attributes->>'poaName', '') <> ''
OR COALESCE(c.attributes->>'poaPhone', '') <> ''
OR COALESCE(c.attributes->>'poaEmail', '') <> ''
OR COALESCE(c.attributes->>'poaLocation', '') <> ''
OR COALESCE(c.attributes->>'poaAddress', '') <> ''
OR EXISTS (
SELECT 1 FROM freight.company_profiles cp
WHERE cp.company_id = c.id
AND cp.type = 'freight_forwarder'
AND cp.deleted_at IS NULL
)
)
`);
await queryRunner.query(`
UPDATE freight.companies c
SET attributes = COALESCE(c.attributes, '{}'::jsonb)
|| jsonb_build_object('poaDeclared', 'no')
WHERE c.attributes->>'poaDeclared' IS NULL
AND EXISTS (
SELECT 1 FROM freight.external_profiles ep
WHERE ep.company_id = c.id
AND ep.onboarding_completed = true
AND ep.deleted_at IS NULL
)
`);
// 5. The "personnel" (general manager) wizard step no longer exists; a
// draft resting on it would fall back to the very first step and make the
// customer walk the whole wizard again.
await queryRunner.query(`
UPDATE freight.external_profiles
SET onboarding_step = 'owner'
WHERE onboarding_step = 'personnel'
`);
// 6. Retire the general manager and the flag it shared the model with.
await queryRunner.query(`
UPDATE freight.companies
SET attributes = attributes - 'generalManagerName' - 'generalManagerEmail'
- 'generalManagerPhone' - 'gmSameAsOwner' - 'gmFaydaSub'
- 'gmFaydaVerifiedAt' - 'gmName' - 'gmEmail' - 'gmPhone'
- 'gmAddress' - 'gmBirthdate' - 'gmGender'
- 'poaSameAsOwner'
WHERE attributes IS NOT NULL
`);
await queryRunner.query(`
ALTER TABLE freight.companies
DROP COLUMN IF EXISTS general_manager_name,
DROP COLUMN IF EXISTS general_manager_email,
DROP COLUMN IF EXISTS general_manager_phone
`);
}
/**
* Restores the columns' shape only. The values, the `gm*` attributes and the
* `poaSameAsOwner` flag are not recoverable — this migration folded them into
* `ownerEmail` / `poaDeclared`, and there is no way back that isn't a guess.
*/
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.companies
ADD COLUMN IF NOT EXISTS general_manager_name varchar(100),
ADD COLUMN IF NOT EXISTS general_manager_email varchar(150),
ADD COLUMN IF NOT EXISTS general_manager_phone varchar(20)
`);
await queryRunner.query(`
UPDATE freight.external_profiles
SET onboarding_step = 'personnel'
WHERE onboarding_step = 'owner'
`);
}
}

View File

@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Single-row table holding the one company stamp/seal image stamped onto
* generated invoice/receipt PDFs (see StampSettingsService /
* InvoiceDocumentService). Same single-row shape as exchange_settings; the
* app never inserts more than one row.
*/
export class StampSettings3400000000000 implements MigrationInterface {
name = "StampSettings3400000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.stamp_settings (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
stamp_file_id uuid REFERENCES freight.files(id),
updated_by_id uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.stamp_settings;`);
}
}

View File

@@ -0,0 +1,29 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Wagons the requester specifically asked for. A transfer request stays
* count-driven (`quantity` is what must be delivered), but a requester who
* picked wagons off the yard desk now records WHICH ones — OCC sees the numbers
* on the queue and the fulfil picker pre-selects them.
*
* Stored as a uuid[] column rather than a join table: the list is read and
* written whole, never queried by wagon, and a preference carries no lifecycle
* of its own (no FK — a purged wagon simply drops out of the display).
*/
export class TransferRequestPreferredWagons3400000000000
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.wagon_transfer_requests
ADD COLUMN IF NOT EXISTS preferred_wagon_ids uuid[]
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.wagon_transfer_requests
DROP COLUMN IF EXISTS preferred_wagon_ids
`);
}
}

View File

@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Voyage number for one departure.
*
* `train_number` already exists on a schedule (the run number), but operations
* also quote a VOYAGE number — the sailing/run identifier yards and customs use
* for a specific departure. It belongs on the schedule, not the built train: one
* train serves many departures and each carries its own voyage.
*
* Nullable and un-indexed: it is display/reference data typed by staff, not a
* lookup key, and older schedules simply have none.
*/
export class ScheduleVoyageNumber3410000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules
ADD COLUMN IF NOT EXISTS voyage_number varchar(20)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules
DROP COLUMN IF EXISTS voyage_number
`);
}
}

View File

@@ -0,0 +1,69 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Bulk contract templates gain trade direction, so the unique key becomes
* (cargo type, direction, customs option) instead of (cargo type, customs).
*
* Intercity is domestic and crosses no border, so it has no customs variant at
* all: with_customs stays NULL there, enforced by ck_bulk_intercity_no_customs.
* The unique index coalesces that NULL so two intercity templates for the same
* cargo type still collide (plain NULLs never do).
*
* No backfill: staff-created bulk templates are keyed by cargo_type_id and no
* such row exists yet — the seeded direction-keyed bulk rows were retired by
* 3320000000000 and carry a NULL cargo_type_id. The five system container
* templates are untouched: cargo_type_id IS NULL keeps them out of both the
* index and the check.
*/
export class BulkTemplateTradeDirection3420000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.contract_templates
ADD COLUMN IF NOT EXISTS trade_direction varchar(20)
`);
await queryRunner.query(
`DROP INDEX IF EXISTS freight.uq_contract_templates_cargo_customs`,
);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_templates_cargo_dir_customs
ON freight.contract_templates
(cargo_type_id, trade_direction, COALESCE(with_customs, false))
WHERE deleted_at IS NULL AND cargo_type_id IS NOT NULL
`);
await queryRunner.query(`
ALTER TABLE freight.contract_templates
DROP CONSTRAINT IF EXISTS ck_bulk_intercity_no_customs
`);
await queryRunner.query(`
ALTER TABLE freight.contract_templates
ADD CONSTRAINT ck_bulk_intercity_no_customs CHECK (
cargo_type_id IS NULL
OR (
trade_direction IN ('IMPORT', 'EXPORT', 'INTERCITY')
AND (trade_direction = 'INTERCITY') = (with_customs IS NULL)
)
)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.contract_templates
DROP CONSTRAINT IF EXISTS ck_bulk_intercity_no_customs
`);
await queryRunner.query(
`DROP INDEX IF EXISTS freight.uq_contract_templates_cargo_dir_customs`,
);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_templates_cargo_customs
ON freight.contract_templates (cargo_type_id, with_customs)
WHERE deleted_at IS NULL AND cargo_type_id IS NOT NULL
`);
await queryRunner.query(`
ALTER TABLE freight.contract_templates DROP COLUMN IF EXISTS trade_direction
`);
}
}

View File

@@ -0,0 +1,62 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Fuel surcharge, sold per lane + commodity:
*
* - cargo_types.has_fuel marks the commodities that incur it (same shape as
* has_lashing — the booking's cargo type flag is what fires the charge).
* - rates.base_liters carries the liters a PER_LITER fuel rate bills
* (price = base_liters × rate_value, once per booking). NULL on every other
* rate shape, including PER_WAGON fuel rates (wagons × rate_value).
* - CK_rates_yard_scope gains FUEL in its yard-carrying branch: fuel is priced
* per origin → destination leg like customs clearance and container return.
*/
export class FuelSurcharge3430000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.cargo_types
ADD COLUMN IF NOT EXISTS has_fuel boolean NOT NULL DEFAULT false
`);
await queryRunner.query(`
ALTER TABLE freight.rates
ADD COLUMN IF NOT EXISTS base_liters numeric(14,4)
`);
await queryRunner.query(
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`,
);
await queryRunner.query(`
ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR
CASE
WHEN (trigger = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY'))
OR trigger IN ('CUSTOMS_CLEARANCE', 'WITH_RETURN', 'FUEL')
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
END
)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`,
);
await queryRunner.query(`
ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR
CASE
WHEN (trigger = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY'))
OR trigger IN ('CUSTOMS_CLEARANCE', 'WITH_RETURN')
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
END
)
`);
await queryRunner.query(`ALTER TABLE freight.rates DROP COLUMN IF EXISTS base_liters`);
await queryRunner.query(
`ALTER TABLE freight.cargo_types DROP COLUMN IF EXISTS has_fuel`,
);
}
}

View File

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

View File

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

View File

@@ -0,0 +1,33 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* `eims_irn varchar(64)` was sized for a guess; the real value MoR returns is longer. Confirmed
* live 2026-08-12 — a genuine `POST /v1/register` acceptance came back as
* `test-aeedcbf496f0b63bb035ba3ca1dc674e3c81c980cafeb1c5038421999a23a215` (69 chars: a `test-`
* prefix + a 64-hex-char body), which overflowed the column and threw *after* MoR had already
* accepted the document — `settleSuccess` never committed, leaving the invoice stuck `SUBMITTING`
* and the system-wide reservation stuck in-flight with no block/alert (see
* `EimsInvoiceRegistrationService` for the accompanying code fix).
*
* Widened to `text` rather than a new fixed length: MoR has never documented an IRN format or
* length, and a `test-` prefix on a *production* endpoint suggests this may not even be MoR's
* real production shape — guessing another fixed bound risks the exact same failure again.
*/
export class WidenEimsIrn3450000000000 implements MigrationInterface {
name = "WidenEimsIrn3450000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.invoices
ALTER COLUMN eims_irn TYPE text
`);
}
/** Only safe if nothing stored so far exceeds 64 chars — true only until this migration ran. */
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.invoices
ALTER COLUMN eims_irn TYPE varchar(64)
`);
}
}

View File

@@ -0,0 +1,20 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/** `DocumentDetails`/register-response field `signedQR`, persisted alongside `eims_irn`. */
export class AddEimsSignedQr3460000000000 implements MigrationInterface {
name = "AddEimsSignedQr3460000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.invoices
ADD COLUMN IF NOT EXISTS eims_signed_qr text
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.invoices
DROP COLUMN IF EXISTS eims_signed_qr
`);
}
}

View File

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

View File

@@ -0,0 +1,26 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/** Columns for `POST /v1/cancel` — see `EimsCancellationService`. */
export class EimsCancellation3470000000000 implements MigrationInterface {
name = "EimsCancellation3470000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.invoices
ADD COLUMN IF NOT EXISTS eims_cancelled_at timestamptz,
ADD COLUMN IF NOT EXISTS eims_cancellation_date varchar(64),
ADD COLUMN IF NOT EXISTS eims_cancellation_reason_code varchar(8),
ADD COLUMN IF NOT EXISTS eims_cancellation_remark text
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.invoices
DROP COLUMN IF EXISTS eims_cancelled_at,
DROP COLUMN IF EXISTS eims_cancellation_date,
DROP COLUMN IF EXISTS eims_cancellation_reason_code,
DROP COLUMN IF EXISTS eims_cancellation_remark
`);
}
}

View File

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

View File

@@ -0,0 +1,24 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Same bug as `3450000000000-WidenEimsIrn`, same fix: `previous_irn` stores a real MoR IRN too
* (fed into the next registration's `ReferenceDetails.PreviousIrn`) and would overflow the same
* varchar(64) on the next successful registration.
*/
export class WidenPreviousIrn3480000000000 implements MigrationInterface {
name = "WidenPreviousIrn3480000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.eims_system_state
ALTER COLUMN previous_irn TYPE text
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.eims_system_state
ALTER COLUMN previous_irn TYPE varchar(64)
`);
}
}

View File

@@ -0,0 +1,34 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/** `freight.eims_receipts` — see `EimsReceipt` entity. */
export class EimsReceipts3490000000000 implements MigrationInterface {
name = "EimsReceipts3490000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.eims_receipts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
invoice_id uuid NOT NULL REFERENCES freight.invoices(id),
kind varchar(16) NOT NULL,
status varchar(20) NOT NULL DEFAULT 'NOT_SUBMITTED',
receipt_number varchar(64) NOT NULL,
rrn text,
qr text,
ack_status varchar(8),
submitted_at timestamptz,
last_error jsonb,
request jsonb,
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_eims_receipts_invoice_id ON freight.eims_receipts (invoice_id)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.eims_receipts`);
}
}

View File

@@ -0,0 +1,27 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Single-row table holding the one company logo image stamped onto every
* generated document (see LogoSettingsService). Same single-row shape as
* stamp_settings; the app never inserts more than one row.
*/
export class LogoSettings3500000000000 implements MigrationInterface {
name = "LogoSettings3500000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.logo_settings (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
logo_file_id uuid REFERENCES freight.files(id),
updated_by_id uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.logo_settings;`);
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,35 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Empty containers ride an export departure back to Djibouti, so a return now
* records which train schedule carries it and on which wagon slot. Size is
* captured too: the wagon rule is one 40ft OR two 20ft per wagon, which cannot
* be enforced without knowing the box size.
*/
export class EmptyReturnTrainLoad3540000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.empty_container_returns
ADD COLUMN IF NOT EXISTS container_size character varying(10),
ADD COLUMN IF NOT EXISTS train_schedule_id uuid,
ADD COLUMN IF NOT EXISTS wagon_sequence_no integer
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_empty_container_returns_train_schedule_id
ON freight.empty_container_returns (train_schedule_id)
WHERE train_schedule_id IS NOT NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DROP INDEX IF EXISTS freight.idx_empty_container_returns_train_schedule_id
`);
await queryRunner.query(`
ALTER TABLE freight.empty_container_returns
DROP COLUMN IF EXISTS container_size,
DROP COLUMN IF EXISTS train_schedule_id,
DROP COLUMN IF EXISTS wagon_sequence_no
`);
}
}

View File

@@ -0,0 +1,56 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Supports the Stripe-style pill filter bar: every list it lands on filters
* and sorts server-side now. `@Index` decorators alone do nothing —
* `synchronize: false` means an index exists only if a migration created it
* (see the `RepairSynchronizeDrift`-style gaps this closes).
*
* `idx_warehouse_inventory_status` already exists (FreightBaseline). Bookings
* and wagons have no plain `status` index — `idx_bookings_route_day` and
* `idx_wagons_readiness` only cover `status` as a trailing/partial column,
* not a standalone `WHERE status = $1`, and `status` is the single
* most-filtered column on both lists (bookings: 37 values).
*
* `(created_at DESC, id ASC)` partials match the default sort + id
* tiebreaker `applySort` now appends everywhere, and none of these tables
* had a created_at index at all.
*/
export class FilterableListIndexes3540000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_bookings_status
ON freight.bookings USING btree (status)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_wagons_status
ON freight.wagons USING btree (status)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_contracts_created_at_id
ON freight.contracts (created_at DESC, id ASC) WHERE deleted_at IS NULL
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_bookings_created_at_id
ON freight.bookings (created_at DESC, id ASC) WHERE deleted_at IS NULL
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_created_at_id
ON freight.warehouse_inventory (created_at DESC, id ASC) WHERE deleted_at IS NULL
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_wagons_created_at_id
ON freight.wagons (created_at DESC, id ASC) WHERE deleted_at IS NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_wagons_created_at_id`);
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_warehouse_inventory_created_at_id`);
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_bookings_created_at_id`);
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_contracts_created_at_id`);
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_wagons_status`);
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_bookings_status`);
}
}

View File

@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Debit/credit note filing — confirmed directly by MoR support: same `/v1/register` endpoint,
* distinguished by `DocumentDetails.Type` ("DEB"/"CRE") + a `Reason`, linked to the original
* invoice via `ReferenceDetails.RelatedDocument`. See `Invoice.eimsDocumentType`.
*/
export class EimsDebitCreditNotes3550000000000 implements MigrationInterface {
name = "EimsDebitCreditNotes3550000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.invoices
ADD COLUMN IF NOT EXISTS eims_document_type varchar(8) NOT NULL DEFAULT 'INV',
ADD COLUMN IF NOT EXISTS eims_reason text,
ADD COLUMN IF NOT EXISTS related_invoice_id uuid REFERENCES freight.invoices(id)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.invoices
DROP COLUMN IF EXISTS eims_document_type,
DROP COLUMN IF EXISTS eims_reason,
DROP COLUMN IF EXISTS related_invoice_id
`);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,84 @@
/**
* Who acted, and does this API audit them?
*
* The staff/customer split reuses the exact discriminator the permission guards
* already apply (`freight-permission.guard.ts`): `userType === 'employee'` is
* backoffice, `individual` / `external_organization` are customers. Restating
* the rule instead of importing it would let the two drift apart silently.
*/
const EMPLOYEE_USER_TYPE = 'employee';
const SUPER_ADMIN_ROLE = 'super_admin';
/** The subset of the JWT payload this module reads. */
export interface AuditActorSource {
id?: string;
sub?: string;
userType?: string;
username?: string;
name?: string | { en?: string; am?: string };
firstName?: string;
lastName?: string;
email?: string;
roles?: { key?: string; name?: string }[];
employee?: unknown;
}
export interface AuditActor {
userId: string | null;
userName: string | null;
userRole: string | null;
}
function isSuperAdmin(user: AuditActorSource): boolean {
return Boolean(user.roles?.some((role) => role.key === SUPER_ADMIN_ROLE));
}
/**
* Is this caller a backoffice user whose actions are audited?
*
* Only employees qualify. Customers are excluded by request, and unauthenticated
* callers are excluded too — which means failed logins, OTP sends and password
* resets produce no audit rows. That was a deliberate call: those endpoints are
* not backoffice actions. Note the trade-off, since failed-auth attempts are
* often what an incident review looks for first.
*/
export function isAuditableActor(user: AuditActorSource | null | undefined): boolean {
if (!user) return false;
// Super admins may not carry an `employee` userType on every token, but are
// unambiguously staff — the permission guards treat them the same way.
return user.userType === EMPLOYEE_USER_TYPE || isSuperAdmin(user);
}
/** Best-effort display name, tolerating the several shapes tokens use. */
function resolveUserName(user: AuditActorSource): string | null {
if (typeof user.name === 'string' && user.name.trim()) return user.name.trim();
if (user.name && typeof user.name === 'object') {
const localized = user.name.en ?? user.name.am;
if (localized?.trim()) return localized.trim();
}
const composed = [user.firstName, user.lastName].filter(Boolean).join(' ').trim();
if (composed) return composed;
return user.username?.trim() || user.email?.trim() || null;
}
/**
* Snapshot the actor at the moment of the action.
*
* Name and role are copied, never referenced: resolving them from IAM at read
* time would rewrite history whenever someone is renamed, changes role or is
* deleted. An audit row from last year must still say who acted and with what
* authority *then*.
*/
export function resolveAuditActor(user: AuditActorSource): AuditActor {
const roleKey = user.roles?.[0]?.key ?? user.roles?.[0]?.name ?? null;
return {
userId: user.id ?? user.sub ?? null,
userName: resolveUserName(user),
userRole: roleKey,
};
}

View File

@@ -0,0 +1,140 @@
import { AUDIT_ENDPOINTS, type AuditEndpointMeta } from './audit-endpoints';
/** What a matched request resolved to. */
export interface MatchedAuditEndpoint {
/** Human-readable action, e.g. "Approve contract". */
title: string;
/** Primary entity, e.g. "Contract". */
type: string;
/** The route template, e.g. `/api/contracts/:id/cancel`. */
routePath: string;
/** First path parameter of the template, when the route has one. */
resourceId: string | null;
}
interface CompiledRoute {
regex: RegExp;
/** Param names in capture-group order, e.g. ['id', 'stepId']. */
paramNames: string[];
routePath: string;
meta: AuditEndpointMeta;
/** Literal (non-parameter) segment count — used to rank specificity. */
staticSegments: number;
}
const ESCAPE_REGEX = /[.*+?^${}()|[\]\\]/g;
/**
* Two keys in AUDIT_ENDPOINTS point at the same path: one route is declared by
* two different controllers, so the generator suffixed the second with
* ` [modules/...controller.ts]` to keep both entries. Only the path itself is
* matchable, so the suffix is stripped here.
*/
function stripSourceSuffix(key: string): string {
const bracket = key.indexOf(' [');
return bracket === -1 ? key : key.slice(0, bracket);
}
/**
* Compile one `"/api/contracts/:id/cancel"` template into an anchored regex.
*
* A parameter matches a single path segment only (`[^/]+`), so
* `/api/contracts/:id` cannot swallow `/api/contracts/:id/cancel`.
*/
function compileTemplate(path: string): { regex: RegExp; paramNames: string[] } {
const paramNames: string[] = [];
const pattern = path
.split('/')
.map((segment) => {
if (!segment.startsWith(':')) {
return segment.replace(ESCAPE_REGEX, '\\$&');
}
paramNames.push(segment.slice(1));
return '([^/]+)';
})
.join('/');
return { regex: new RegExp(`^${pattern}$`), paramNames };
}
/**
* Method-bucketed lookup table for the audited routes.
*
* A direct `AUDIT_ENDPOINTS[url]` lookup cannot work: the keys are templates
* with `:params` while a live request carries real ids and a query string, so
* every parameterized route — most of the 488 — would miss. Templates are
* compiled to regexes once at module load and matched per request.
*
* Within a method, routes are ordered by literal-segment count descending, so
* a specific route always wins over a parameterized one that could also match
* (`/api/routes/:id/permanent` before `/api/routes/:id`).
*/
class AuditEndpointMatcher {
private readonly byMethod = new Map<string, CompiledRoute[]>();
constructor() {
for (const [key, meta] of Object.entries(AUDIT_ENDPOINTS)) {
const [method, rawPath] = stripSourceSuffix(key).split(' ');
if (!method || !rawPath) continue;
const { regex, paramNames } = compileTemplate(rawPath);
const bucket = this.byMethod.get(method) ?? [];
bucket.push({
regex,
paramNames,
routePath: rawPath,
meta,
staticSegments: rawPath
.split('/')
.filter((s) => s && !s.startsWith(':')).length,
});
this.byMethod.set(method, bucket);
}
for (const bucket of this.byMethod.values()) {
bucket.sort((a, b) => b.staticSegments - a.staticSegments);
}
}
/**
* Resolve a live request to its audit metadata, or null when the route is
* not audited (every GET, and anything absent from AUDIT_ENDPOINTS).
*
* `url` may include a query string; it is ignored for matching.
*/
match(method: string, url: string): MatchedAuditEndpoint | null {
const bucket = this.byMethod.get(method.toUpperCase());
if (!bucket) return null;
const path = stripQuery(url);
for (const route of bucket) {
const result = route.regex.exec(path);
if (!result) continue;
const [title, , type] = route.meta;
return {
title,
type,
routePath: route.routePath,
// The first path parameter is the affected record in this API's
// conventions (`/api/contracts/:id/...`). Routes with no parameter
// (a create) legitimately have no resource id yet.
resourceId: route.paramNames.length > 0 ? result[1] : null,
};
}
return null;
}
}
/** Strip query string and hash from a URL, leaving the path. */
export function stripQuery(url: string): string {
const queryIndex = url.indexOf('?');
const path = queryIndex === -1 ? url : url.slice(0, queryIndex);
const hashIndex = path.indexOf('#');
return hashIndex === -1 ? path : path.slice(0, hashIndex);
}
/** Compiled once at module load and shared by the interceptor. */
export const auditEndpointMatcher = new AuditEndpointMatcher();

View File

@@ -0,0 +1,689 @@
/**
* Freight API — every state-changing endpoint (POST / PUT / PATCH / DELETE).
*
* Shape: "<METHOD> <path>": [title, method, entity]
*
* Keyed by method + path rather than path alone: 50 paths serve more than one
* method (PATCH and DELETE on /api/contracts/:id, for example), so a path-only
* key would collide and drop those endpoints.
*
* Paths include the global prefix `api` (see app.setGlobalPrefix in src/main.ts).
* Titles come from each route's @ApiOperation summary, falling back to a
* humanized handler name where a route has none.
*
* Excludes the AI Assist and Account entities.
* Generated from the controllers under src/ — 517 endpoints.
*/
/** [title, method, entity] for one auditable route. */
export type AuditEndpointMeta = readonly [title: string, method: string, entity: string];
export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
// Approval Rule
"POST /api/approval-rules": ["Create an approval rule step", "POST", "Approval Rule"],
"PATCH /api/approval-rules/:id": ["Update an approval rule", "PATCH", "Approval Rule"],
"DELETE /api/approval-rules/:id": ["Soft-delete an approval rule", "DELETE", "Approval Rule"],
"POST /api/approval-rules/:id/move-order": ["Move an approval step up or down within its chain", "POST", "Approval Rule"],
"POST /api/approval-rules/reorder": ["Bulk reorder approval steps within a chain", "POST", "Approval Rule"],
// Booking
"POST /api/bookings": ["Create a new freight booking (DRAFT)", "POST", "Booking"],
"POST /api/bookings/:bookingId/allocate-containers": ["Allocate containers to vehicles", "POST", "Booking"],
"PATCH /api/bookings/:id": ["Update booking", "PATCH", "Booking"],
"DELETE /api/bookings/:id": ["Soft-delete DRAFT booking", "DELETE", "Booking"],
"POST /api/bookings/:id/cancel": ["Cancel booking", "POST", "Booking"],
"POST /api/bookings/:id/cancel-hold": ["Customer cancels an unpaid hold (SELECTED_FOR_BATCH → CANCELLED);", "POST", "Booking"],
"POST /api/bookings/:id/clearance/declaration": ["GL ET uploads customs declaration on booking (GENERAL customs)", "POST", "Booking"],
"POST /api/bookings/:id/clearance/delivery-order": ["Upload Booking Delivery Order", "POST", "Booking"],
"POST /api/bookings/:id/clearance/documents": ["Customer uploads clearance documents (fieldname = document key)", "POST", "Booking"],
"POST /api/bookings/:id/clearance/draft-declaration": ["GL ET sends a draft customs declaration (multi-file) with an estimated price for the customer to review", "POST", "Booking"],
"POST /api/bookings/:id/clearance/draft-declaration/accept": ["Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia", "POST", "Booking"],
"POST /api/bookings/:id/clearance/draft-declaration/change": ["Customer requests a change to the draft customs declaration with a reason — GL Ethiopia sends a corrected draft (repeatable)", "POST", "Booking"],
"POST /api/bookings/:id/clearance/duty": ["GL ET sets duty/tax on booking with notice attachment", "POST", "Booking"],
"POST /api/bookings/:id/clearance/duty-slip": ["Customer uploads duty/tax payment slip on booking", "POST", "Booking"],
"POST /api/bookings/:id/clearance/export-release": ["Confirm Booking Export Release", "POST", "Booking"],
"POST /api/bookings/:id/clearance/finalize": ["GL finalizes clearance (requires 100% approved) → CLEARANCE_READY", "POST", "Booking"],
"POST /api/bookings/:id/clearance/finalize-pre-clearance": ["GL ET finalizes import pre-clearance on booking", "POST", "Booking"],
"POST /api/bookings/:id/clearance/output-documents": ["GL uploads customs output documents (IM4/EX3/…)", "POST", "Booking"],
"POST /api/bookings/:id/clearance/proceed": ["Customer requests operation with a schedule day", "POST", "Booking"],
"POST /api/bookings/:id/clearance/release-order": ["Upload Booking Release Order", "POST", "Booking"],
"POST /api/bookings/:id/clearance/review": ["GL reviews a clearance document (Approve | Query)", "POST", "Booking"],
"POST /api/bookings/:id/clearance/charges/port-document": ["GL Djibouti uploads the port-charges document", "POST", "Booking"],
"PATCH /api/bookings/:id/clearance/charges/:chargeId/bill": ["GL Ethiopia sets or revises a clearance charge's amount + currency", "PATCH", "Booking"],
"POST /api/bookings/:id/clearance/charges/:chargeId/send": ["GL Ethiopia issues the clearance charge invoice to the customer", "POST", "Booking"],
"POST /api/bookings/:id/clearance/charges/miscellaneous": ["GL Ethiopia creates the miscellaneous clearance charge", "POST", "Booking"],
"POST /api/bookings/:id/clearance/ro-amendment": ["Request Booking RO Amendment", "POST", "Booking"],
"POST /api/bookings/:id/clearance/transit-assignee/assign": ["GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns", "POST", "Booking"],
"POST /api/bookings/:id/clearance/transit-assignee/request": ["GL ET asks GL Djibouti to name the transit officer — required before the import customs declaration", "POST", "Booking"],
"POST /api/bookings/:id/clearance/transit-permit": ["Upload Booking Transit Permit", "POST", "Booking"],
"POST /api/bookings/:id/confirm-submit": ["Confirm submit after price change", "POST", "Booking"],
"POST /api/bookings/:id/consolidation": ["Request freight consolidation", "POST", "Booking"],
"DELETE /api/bookings/:id/consolidation": ["Remove consolidation pairing", "DELETE", "Booking"],
"POST /api/bookings/:id/contract/generate": ["Generate contract PDF from template", "POST", "Booking"],
"POST /api/bookings/:id/contract/sign": ["Apply digital signature (customer or staff)", "POST", "Booking"],
"POST /api/bookings/:id/customer-cancel": ["Customer cancels their own booking before payment — no cancellation fee", "POST", "Booking"],
"POST /api/bookings/:id/customer-truck-assignment": ["Customer assigns external truck and driver for terminal pickup", "POST", "Booking"],
"POST /api/bookings/:id/customer-trucks": ["Add a customer self-haul truck carrying 12 of the booking containers", "POST", "Booking"],
"PATCH /api/bookings/:id/customer-trucks/:assignmentId": ["Edit a not-yet-arrived customer truck (plate/driver/type + containers)", "PATCH", "Booking"],
"DELETE /api/bookings/:id/customer-trucks/:assignmentId": ["Remove a not-yet-arrived customer truck from a booking", "DELETE", "Booking"],
"POST /api/bookings/:id/customer-trucks/:assignmentId/depart": ["Register an import truck leaving: containers loaded + weighed gross (staff)", "POST", "Booking"],
"POST /api/bookings/:id/customer-trucks/:assignmentId/load": ["Truck_dispatch: load selected containers onto a truck (staff)", "POST", "Booking"],
"POST /api/bookings/:id/customer-trucks/bulk": ["Bulk add customer trucks from array payload (Excel parsed)", "POST", "Booking"],
"POST /api/bookings/:id/customer/sign": ["Customer digital signature (deprecated — use POST contract/sign)", "POST", "Booking"],
"POST /api/bookings/:id/documents": ["Upload documents for a booking (DRAFT only)", "POST", "Booking"],
"PATCH /api/bookings/:id/export-handover-mode": ["Export only: choose direct truck-to-train (no warehouse, no GRN) or warehouse first", "PATCH", "Booking"],
"POST /api/bookings/:id/generate-grn": ["Generate a GRN over the received containers (all received, or a subset) — one GRN per batch", "POST", "Booking"],
"POST /api/bookings/:id/generate-price": ["Generate price preview (DRAFT or CHANGES_REQUESTED)", "POST", "Booking"],
"POST /api/bookings/:id/government-expedite": ["Expedite government booking to PAID / ELIGIBLE for scheduling", "POST", "Booking"],
"POST /api/bookings/:id/marketing/approve": ["Staff contract signature and fully execute (use contract/sign STAFF preferred)", "POST", "Booking"],
"POST /api/bookings/:id/operation/review": ["Operations reviews an operation request: ACCEPT (→ batch pool),", "POST", "Booking"],
"POST /api/bookings/:id/operations/complete": ["Mark completed", "POST", "Booking"],
"POST /api/bookings/:id/operations/start-transit": ["Mark in transit", "POST", "Booking"],
"POST /api/bookings/:id/reject": ["Customer reject price estimate", "POST", "Booking"],
"POST /api/bookings/:id/staff/accept": ["Staff accept intake → set contract validity window + start approval chain", "POST", "Booking"],
"POST /api/bookings/:id/staff/reject": ["Staff final reject", "POST", "Booking"],
"POST /api/bookings/:id/staff/request-changes": ["Staff return booking for customer updates", "POST", "Booking"],
"POST /api/bookings/:id/submit": ["Customer submit booking", "POST", "Booking"],
"POST /api/bookings/:id/wagon-cancellations": ["Request a partial wagon cancellation on a PAID booking — opens the cancellation-fee invoice; wagons are released only once the fee settles", "POST", "Booking"],
"POST /api/bookings/:id/wagon-cancellations/preview": ["Preview the fee/credit of a partial wagon cancellation (no writes)", "POST", "Booking"],
"POST /api/bookings/wagon-cancellations/:cancellationId/rebook": ["Rebook a wagon-cancellation credit: pick a shipment day only — the new booking is created under the contract and marked PAID (freight already paid; contract must still be valid)", "POST", "Booking"],
"POST /api/bookings/wagon-cancellations/:cancellationId/withdraw": ["Withdraw a fee-pending wagon cancellation (owner, or staff with the void permission)", "POST", "Booking"],
"POST /api/bookings/consolidation-approvals/:approvalId/approve": ["Approve a shared wagon: both bookings leave the gate and continue to Operations together.", "POST", "Booking"],
"POST /api/bookings/consolidation-approvals/:approvalId/reject": ["Reject a shared wagon: both bookings go back to GL for changes with the reason.", "POST", "Booking"],
"POST /api/bookings/:id/paired-decision": ["Apply a staff decision (accept / cancel / operationAccept / requestChanges) to BOTH halves of a consolidated pair, all-or-nothing.", "POST", "Booking"],
// Cargo
"POST /api/cargoes": ["Create a new cargo", "POST", "Cargo"],
"PATCH /api/cargoes/:id": ["Update a cargo", "PATCH", "Cargo"],
"DELETE /api/cargoes/:id": ["Delete a cargo", "DELETE", "Cargo"],
"POST /api/cargoes/:id/deliver": ["Mark cargo as delivered", "POST", "Cargo"],
"POST /api/cargoes/:id/load": ["Load cargo into a container", "POST", "Cargo"],
"POST /api/cargoes/:id/unload": ["Unload cargo from container", "POST", "Cargo"],
// Cargo Type
"POST /api/cargo-types": ["Create a cargo type", "POST", "Cargo Type"],
"PATCH /api/cargo-types/:id": ["Update a cargo type", "PATCH", "Cargo Type"],
"DELETE /api/cargo-types/:id": ["Soft-delete a cargo type", "DELETE", "Cargo Type"],
"POST /api/cargo-types/:id/move-order": ["Move a cargo type up or down in display order", "POST", "Cargo Type"],
"POST /api/cargo-types/reorder": ["Bulk reorder cargo types by ID list", "POST", "Cargo Type"],
// Chat
"POST /api/chat/sync": ["Re-run the chat room/membership reconcile immediately (normally nightly)", "POST", "Chat"],
// Company
"POST /api/companies": ["Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter)", "POST", "Company"],
"POST /api/companies/:companyId/documents": ["Upload documents for a company (onboarding)", "POST", "Company"],
"POST /api/companies/:companyId/profiles": ["Add a profile (employee) to a company", "POST", "Company"],
"PATCH /api/companies/:id": ["Update a company", "PATCH", "Company"],
"DELETE /api/companies/:id": ["Soft-delete a company", "DELETE", "Company"],
"POST /api/companies/change-requests/:id/approve": ["Approve a pending profile change request (applies the changes)", "POST", "Company"],
"POST /api/companies/change-requests/:id/reject": ["Reject a pending profile change request with a note", "POST", "Company"],
"POST /api/companies/change-requests/:id/request-changes": ["Ask for specific changes on a pending request without rejecting it (row stays open, next edit appends to it)", "POST", "Company"],
"POST /api/companies/company-profile": ["Create a single operational profile for the current user's company. The role starts pending and does not become the active mode", "POST", "Company"],
"POST /api/companies/company-profiles": ["Add operational profile(s) (importer/exporter/forwarder) to the current user's company", "POST", "Company"],
"POST /api/companies/company-profiles/:profileId/license": ["Add business-license document(s) to a profile. For an approved company", "POST", "Company"],
"DELETE /api/companies/company-profiles/:profileId/license/:fileId": ["Remove a business-license file (staged for review on an approved company)", "DELETE", "Company"],
"POST /api/companies/company-profiles/:profileId/license/:fileId/replace": ["Replace a business-license file with a newly uploaded one (staged for", "POST", "Company"],
"POST /api/companies/company-profiles/:profileId/reapply": ["Resubmit a rejected operational role for approval (→ pending)", "POST", "Company"],
"PATCH /api/companies/company-profiles/:profileId/status": ["Update a company profile's approval status", "PATCH", "Company"],
"POST /api/companies/create": ["Create a company with its associated external profile (onboarding)", "POST", "Company"],
"POST /api/companies/documents/:fileId/request-change": ["Ask the customer to correct one uploaded document", "POST", "Company"],
"POST /api/companies/fetch-etrade-info": ["Fetch company info from eTrade by TIN", "POST", "Company"],
"POST /api/companies/identity/fayda/complete": ["Bind a completed Fayda verification to the company's owner or Power of Attorney", "POST", "Company"],
"PATCH /api/companies/onboarding-step": ["Persist the user's current onboarding wizard step", "PATCH", "Company"],
"POST /api/companies/onboarding/complete": ["Mark the current user's onboarding as complete", "POST", "Company"],
"POST /api/companies/onboarding/start": ["Begin onboarding: create a draft company + profile + role(s) so later steps can save incrementally", "POST", "Company"],
"POST /api/companies/poa-delegation": ["Upload the Power of Attorney delegation letter, replacing any existing one", "POST", "Company"],
"DELETE /api/companies/poa-delegation/:fileId": ["Remove the Power of Attorney delegation letter (staged for review on an approved company)", "DELETE", "Company"],
"PATCH /api/companies/profile": ["Update profile (flattened settings page)", "PATCH", "Company"],
"PATCH /api/companies/identity/poa-declared": ["Answer whether anyone holds power of attorney for this company — the question that decides whose identity is verified.", "PATCH", "Company"],
"POST /api/companies/onboarding/revert-to-etrade": ["Drop the manual-registration route (co-operative or foreign investment licence): clear the typed registration and reopen onboarding so the TIN is verified against eTrade", "POST", "Company"],
// Compliance
"POST /api/compliance": ["Create a compliance record", "POST", "Compliance"],
"PATCH /api/compliance/:id": ["Update a compliance record", "PATCH", "Compliance"],
"DELETE /api/compliance/:id": ["Soft-delete a compliance record", "DELETE", "Compliance"],
// Consignment
"POST /api/consignments": ["Create a new consignment", "POST", "Consignment"],
// Container
"POST /api/containers": ["Create a new container", "POST", "Container"],
"PATCH /api/containers/:id": ["Update a container", "PATCH", "Container"],
"DELETE /api/containers/:id": ["Delete a container", "DELETE", "Container"],
"POST /api/containers/:id/assign-wagon": ["Assign container to a wagon", "POST", "Container"],
"POST /api/containers/:id/unassign-wagon": ["Unassign container from wagon", "POST", "Container"],
// Container Type
"POST /api/container-types": ["Create a container type", "POST", "Container Type"],
"PATCH /api/container-types/:id": ["Update a container type", "PATCH", "Container Type"],
"DELETE /api/container-types/:id": ["Soft-delete a container type", "DELETE", "Container Type"],
"POST /api/container-types/:id/move-order": ["Move a container type up or down in display order", "POST", "Container Type"],
"POST /api/container-types/reorder": ["Bulk reorder container types by ID list", "POST", "Container Type"],
// Contract
"POST /api/contracts": ["Create a new contract (DRAFT) with routes + cargo scope", "POST", "Contract"],
"PATCH /api/contracts/:id": ["Update contract", "PATCH", "Contract"],
"DELETE /api/contracts/:id": ["Soft-delete DRAFT contract", "DELETE", "Contract"],
"POST /api/contracts/:id/approval-steps/:stepId/approve": ["Approve one approval step in sequence", "POST", "Contract"],
"POST /api/contracts/:id/approval-steps/:stepId/reject": ["Reject one approval step — to the customer (terminal → REJECTED) or, via returnToStepId, back to an earlier approver (chain re-runs from there)", "POST", "Contract"],
"POST /api/contracts/:id/booking-requests": ["Customer submits a shipment request on a GENERAL customs contract", "POST", "Contract"],
"POST /api/contracts/:id/bookings": ["Create a shipment booking under a contract — Path A (customer) or Path B (GL Ethiopia)", "POST", "Contract"],
"POST /api/contracts/:id/bookings/:bookingId/complete": ["Complete an initiated booking after Operations finalized its clearance — cargo + binding day, window and departure checks, pricing and invoicing", "POST", "Contract"],
"POST /api/contracts/:id/bookings/initiate": ["Initiate a bare booking instance under an import/export contract (ONE_TIME or GENERAL) — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS). ONE_TIME customs instances are opened by the customer (or GL); GENERAL customs comes from a shipment request", "POST", "Contract"],
"POST /api/contracts/:id/cancel": ["Customer cancels their own contract (blocked while a booking is live)", "POST", "Contract"],
"POST /api/contracts/:id/clearance/declaration": ["GL ET uploads customs declaration documents (multi-file)", "POST", "Contract"],
"POST /api/contracts/:id/clearance/delivery-order": ["GL DJ uploads Delivery Order (import) with vessel arrival + DO collected dates", "POST", "Contract"],
"POST /api/contracts/:id/clearance/documents": ["Customer uploads clearance documents (fieldname = document key)", "POST", "Contract"],
"POST /api/contracts/:id/clearance/documents/:fileKey/replace": ["GL replaces a clearance document in place (reason required) — the previous version is kept in the file history and the new one needs approving", "POST", "Contract"],
"POST /api/contracts/:id/clearance/duty": ["GL ET sets duty/tax requirement and advises amount with notice attachment", "POST", "Contract"],
"POST /api/contracts/:id/clearance/duty-slip": ["Customer uploads duty/tax payment slip on contract", "POST", "Contract"],
"POST /api/contracts/:id/clearance/duty/dispute": ["Customer disputes the advised duty/tax with a reason — reopens the step so GL Ethiopia can re-advise (repeatable)", "POST", "Contract"],
"POST /api/contracts/:id/clearance/export-release": ["GL ET confirms export release after declaration", "POST", "Contract"],
"POST /api/contracts/:id/clearance/finalize": ["GL ET finalizes clearance → CLEARANCE_READY_FOR_BOOKING (legacy)", "POST", "Contract"],
"POST /api/contracts/:id/clearance/finalize-export-clearance": ["GL ET finalizes export clearance after post-booking transit permit upload", "POST", "Contract"],
"POST /api/contracts/:id/clearance/finalize-pre-clearance": ["GL ET finalizes import pre-clearance — unlocks Djibouti DO upload", "POST", "Contract"],
"POST /api/contracts/:id/clearance/ops-finalize": ["Operations finalizes self-clearance → customer may create the booking", "POST", "Contract"],
"POST /api/contracts/:id/clearance/ops-review": ["Operations reviews a customer self-clearance document (Approve | Query)", "POST", "Contract"],
"POST /api/contracts/:id/clearance/output-documents": ["GL uploads customs output documents (IM4/EX3/…) pre-booking", "POST", "Contract"],
"POST /api/contracts/:id/clearance/release-order": ["GL DJ uploads Release Order + vessel departure date (export)", "POST", "Contract"],
"POST /api/contracts/:id/clearance/review": ["GL ET reviews a clearance document (Approve | Query)", "POST", "Contract"],
"POST /api/contracts/:id/clearance/ro-amendment": ["GL DJ requests port amendment when RO vessel window is too short", "POST", "Contract"],
"POST /api/contracts/:id/clearance/transit-assignee/assign": ["GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns", "POST", "Contract"],
"POST /api/contracts/:id/clearance/transit-assignee/request": ["GL ET asks GL Djibouti to name the transit officer — required before the customs declaration", "POST", "Contract"],
"POST /api/contracts/:id/clearance/transit-permit": ["GL ET uploads import transit permit documents (multi-file)", "POST", "Contract"],
"POST /api/contracts/:id/confirm-submit": ["Confirm submit after a price change", "POST", "Contract"],
"POST /api/contracts/:id/contract/generate": ["Generate contract document → CONTRACT_READY", "POST", "Contract"],
"POST /api/contracts/:id/contract/send-signing-otp": ["Send the sudo-mode signing OTP to the contract company's registered phone (server picks the number)", "POST", "Contract"],
"POST /api/contracts/:id/contract/sign": ["Apply digital signature (customer or staff/director/ceo)", "POST", "Contract"],
"PUT /api/contracts/:id/document/articles": ["Edit this contract\\'s document articles only (per-contract; never touches the six shared templates)", "PUT", "Contract"],
"POST /api/contracts/:id/documents": ["Upload intake documents for a contract (DRAFT only)", "POST", "Contract"],
"POST /api/contracts/:id/generate-price": ["Generate unit-rate breakdown (no totals at contract phase)", "POST", "Contract"],
"POST /api/contracts/:id/milestones/:code/complete": ["GL marks a pre-booking (contract) milestone complete", "POST", "Contract"],
"POST /api/contracts/:id/renew": ["Create a renewal draft linked via renewalOfId", "POST", "Contract"],
"POST /api/contracts/:id/resume": ["Staff lift a suspension — contract returns to its prior status", "POST", "Contract"],
"POST /api/contracts/:id/staff/accept": ["Staff accept → set validity window + start approval chain", "POST", "Contract"],
"POST /api/contracts/:id/staff/reject": ["Staff reject contract", "POST", "Contract"],
"POST /api/contracts/:id/staff/request-changes": ["Staff return contract for customer updates", "POST", "Contract"],
"POST /api/contracts/:id/submit": ["Customer submit contract (freezes contract_rate_snapshots)", "POST", "Contract"],
"POST /api/contracts/:id/suspend": ["Staff freeze a signed contract (reversible, any post-signature step)", "POST", "Contract"],
"POST /api/contracts/:id/validate-shipment": ["Pre-create validation + authoritative price preview: full booking price breakdown (rail, first/last mile, surcharges), overweight lines and 20ft weight-pairing errors for a shipment payload (no booking created)", "POST", "Contract"],
"POST /api/contracts/booking-requests/:reqId/accept": ["GL marks a shipment request accepted + links the created booking", "POST", "Contract"],
"POST /api/contracts/booking-requests/:reqId/cancel": ["Customer cancels their own pending shipment request", "POST", "Contract"],
"POST /api/contracts/booking-requests/:reqId/reject": ["GL rejects a shipment request", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/documents": ["GL uploads post-booking operational documents (DO/RO/T1/…)", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/duty": ["GL ET advises duty & tax amount + declaration serial", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/duty-slip": ["Customer uploads the duty/tax payment slip", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/final-invoice": ["GL DJ raises the post-offload final invoice (amount + invoice document)", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/final-invoice-slip": ["Customer attaches the payment slip for the final invoice", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/final-invoice/approve": ["Customer approves the drafted final invoice — unlocks the payment slip", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/final-invoice/confirm": ["GL (ET or DJ) confirms the payment slip — settles the final invoice", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/incidents": ["GL DJ logs a cargo exception with photo evidence", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/milestones/:code/complete": ["GL / Ops / Terminal marks a post-booking milestone complete", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/risk": ["GL ET assigns a customs risk level (GREEN/YELLOW/RED)", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/second-duty": ["GL ET advises (or skips) the post-arrival additional duty/tax round (import)", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/second-duty-slip": ["Customer attaches the additional duty/tax payment slip", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/station-assign": ["GL station manager routes the shipment + binds staff", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/t1-close": ["Close (accept) the T1 set — GL ET after arrival (import) / GL DJ after gate pass (export)", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/t1-documents": ["GL Djibouti uploads T1 transit documents (multi-file) after wagon allocation; locked once the train departs", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/transport-document": ["GL ET uploads export transit permit documents (multi-file)", "POST", "Contract"],
"POST /api/gl-exchange/:entityId": ["Share a document with the other GL desk", "POST", "Contract"],
"PATCH /api/gl-exchange/documents/:documentId": ["Uploader edits a shared document (title, visibility, file)", "PATCH", "Contract"],
"DELETE /api/gl-exchange/documents/:documentId": ["Uploader removes a shared document", "DELETE", "Contract"],
"POST /api/contracts/:id/bookings/:bookingId/complete-consolidated": ["Complete this booking and its chosen shared-wagon partner together (all-or-nothing). Each booking is priced and invoiced separately — only the wagon is shared.", "POST", "Contract"],
// Contract Template
"POST /api/contract-templates": ["Create a bulk contract template for a (cargo type, customs option) pair", "POST", "Contract Template"],
"PATCH /api/contract-templates/:code": ["Update template metadata (name, title, recitals, active flag)", "PATCH", "Contract Template"],
"DELETE /api/contract-templates/:code": ["Delete a staff-created bulk template (system templates refuse)", "DELETE", "Contract Template"],
"POST /api/contract-templates/:code/articles": ["Add an article to the template", "POST", "Contract Template"],
"PUT /api/contract-templates/:code/articles": ["Replace the full ordered article list (used for reorder)", "PUT", "Contract Template"],
"PATCH /api/contract-templates/:code/articles/:articleId": ["Update an article's title or body", "PATCH", "Contract Template"],
"DELETE /api/contract-templates/:code/articles/:articleId": ["Remove an article from the template", "DELETE", "Contract Template"],
"POST /api/contract-templates/:code/preview": ["Render an HTML preview of the template against mock contract data", "POST", "Contract Template"],
// Driver
"POST /api/drivers": ["Create a new driver", "POST", "Driver"],
"PATCH /api/drivers/:id": ["Update a driver", "PATCH", "Driver"],
"DELETE /api/drivers/:id": ["Delete a driver", "DELETE", "Driver"],
"POST /api/drivers/:id/documents": ["Upload driver documents (code driver_docs)", "POST", "Driver"],
"DELETE /api/drivers/:id/documents/:fileId": ["Delete a driver document", "DELETE", "Driver"],
// Dropdown Setting
"POST /api/dropdown-settings": ["Create a new dropdown setting", "POST", "Dropdown Setting"],
"PATCH /api/dropdown-settings/:id": ["Update a dropdown setting's metadata", "PATCH", "Dropdown Setting"],
"DELETE /api/dropdown-settings/:id": ["Soft-delete a dropdown setting", "DELETE", "Dropdown Setting"],
"POST /api/dropdown-settings/:id/options": ["Append a single option to a setting", "POST", "Dropdown Setting"],
"PUT /api/dropdown-settings/:id/options": ["Replace the full option list for a setting", "PUT", "Dropdown Setting"],
"PATCH /api/dropdown-settings/options/:optionId": ["Update a single option", "PATCH", "Dropdown Setting"],
"DELETE /api/dropdown-settings/options/:optionId": ["Soft-delete a single option", "DELETE", "Dropdown Setting"],
// EIMS Invoice
"POST /api/invoices/:id/eims/register": ["Register the invoice with MoR EIMS. Idempotent — an invoice that already has an IRN is returned unchanged", "POST", "EIMS Invoice"],
"POST /api/invoices/:id/eims/resolve": ["Resolve an unacknowledged submission: record the IRN confirmed with MoR, or discard it. Clears the system-wide block", "POST", "EIMS Invoice"],
"POST /api/invoices/:id/eims/verify": ["Verify the invoice's stored IRN against EIMS", "POST", "EIMS Invoice"],
"POST /api/invoices/:id/eims/cancel": ["Cancel the invoice", "POST", "EIMS Invoice"],
"POST /api/invoices/:id/eims/receipt/sales": ["Register a sales receipt with MoR EIMS against a registered invoice", "POST", "EIMS Invoice"],
"POST /api/invoices/:id/eims/receipt/withholding": ["Register a withholding receipt with MoR EIMS against a registered invoice", "POST", "EIMS Invoice"],
"POST /api/invoices/eims/bulk-cancel": ["Cancel multiple invoices", "POST", "EIMS Invoice"],
// Exchange Setting
"PATCH /api/exchange-settings": ["Set the USD→ETB fallback by hand (used only while CBE is unreachable)", "PATCH", "Exchange Setting"],
// Facility
"POST /api/facilities": ["Create a new facility", "POST", "Facility"],
"PATCH /api/facilities/:id": ["Update a facility", "PATCH", "Facility"],
"DELETE /api/facilities/:id": ["Delete a facility (soft delete)", "DELETE", "Facility"],
// Fayda Verification
"POST /api/fayda/verification/start": ["Start a VeriFayda 2.0 verification session", "POST", "Fayda Verification"],
// File Upload Setting
"POST /api/file-upload-settings": ["Create a new file upload setting", "POST", "File Upload Setting"],
"PATCH /api/file-upload-settings/:id": ["Update a file upload setting's metadata", "PATCH", "File Upload Setting"],
"DELETE /api/file-upload-settings/:id": ["Soft-delete a file upload setting", "DELETE", "File Upload Setting"],
"POST /api/file-upload-settings/:id/fields": ["Append a single field to a setting", "POST", "File Upload Setting"],
"PUT /api/file-upload-settings/:id/fields": ["Replace the full field list for a setting", "PUT", "File Upload Setting"],
"PATCH /api/file-upload-settings/fields/:fieldId": ["Update a single field", "PATCH", "File Upload Setting"],
"DELETE /api/file-upload-settings/fields/:fieldId": ["Soft-delete a single field", "DELETE", "File Upload Setting"],
// First Mile
"POST /api/first-mile": ["Create a first-mile leg", "POST", "First Mile"],
"PATCH /api/first-mile/:id": ["Update a first-mile leg", "PATCH", "First Mile"],
"DELETE /api/first-mile/:id": ["Soft-delete a first-mile leg", "DELETE", "First Mile"],
"POST /api/first-mile/:id/distances": ["Set per-vehicle actual distances (does not generate an invoice)", "POST", "First Mile"],
"POST /api/first-mile/:id/invoice": ["Generate the first-mile delivery-fee invoice", "POST", "First Mile"],
"POST /api/first-mile/:id/vehicles": ["Set the vehicles assigned to a first-mile pickup (multi-truck)", "POST", "First Mile"],
"POST /api/first-mile/accept/:reference": ["Accept a paid booking and create a first-mile leg", "POST", "First Mile"],
// Fuel
"POST /api/fuel/purchases": ["Record fuel purchase", "POST", "Fuel"],
// GPS Tracking
"POST /api/gps/devices": ["Register a GPS tracker", "POST", "GPS Tracking"],
"PATCH /api/gps/devices/:id": ["Update a GPS tracker (name / assigned vehicle)", "PATCH", "GPS Tracking"],
"DELETE /api/gps/devices/:id": ["Delete a GPS tracker", "DELETE", "GPS Tracking"],
// Import Operation
"POST /api/import-operations/customs/:bookingId/declaration": ["Batch 12: record declaration serial number", "POST", "Import Operation"],
"POST /api/import-operations/customs/:bookingId/documents": ["Batch 12: upload IM4/IM5/T1/permit/payment-slip documents", "POST", "Import Operation"],
"POST /api/import-operations/customs/:bookingId/duties-taxes-paid": ["Batch 12: mark duties and taxes paid", "POST", "Import Operation"],
"POST /api/import-operations/customs/:bookingId/notify-duties-taxes": ["Batch 12: notify duties and taxes", "POST", "Import Operation"],
"POST /api/import-operations/customs/:bookingId/release-permitted": ["Batch 12: mark import release permitted", "POST", "Import Operation"],
"POST /api/import-operations/customs/:bookingId/risk": ["Batch 12: assign customs risk", "POST", "Import Operation"],
"POST /api/import-operations/djibouti-incidents": ["Batch 8: report a Djibouti import incident / exception", "POST", "Import Operation"],
"POST /api/import-operations/empty-container-returns": ["Batch 16: create an empty container return record", "POST", "Import Operation"],
"POST /api/import-operations/empty-container-returns/:id/status": ["Batch 16: advance empty container return workflow", "POST", "Import Operation"],
"POST /api/import-operations/empty-container-returns/load-on-train": ["Load returned empties onto an export train (1×40ft or 2×20ft per wagon)", "POST", "Import Operation"],
// Incident
"POST /api/incidents": ["Report an incident", "POST", "Incident"],
"PATCH /api/incidents/:id": ["Update an incident", "PATCH", "Incident"],
"DELETE /api/incidents/:id": ["Delete an incident", "DELETE", "Incident"],
// Interchange Document
"PATCH /api/interchange-documents/:id/acknowledge": ["Acknowledge an interchange document", "PATCH", "Interchange Document"],
"PATCH /api/interchange-documents/:id/dispute": ["Dispute an interchange document", "PATCH", "Interchange Document"],
"POST /api/interchange-documents/generate-from-schedule": ["Generate interchange document from a train schedule handover", "POST", "Interchange Document"],
// Last Mile
"POST /api/last-mile": ["Create a last-mile leg", "POST", "Last Mile"],
"PATCH /api/last-mile/:id": ["Update a last-mile leg", "PATCH", "Last Mile"],
"DELETE /api/last-mile/:id": ["Soft-delete a last-mile leg", "DELETE", "Last Mile"],
"POST /api/last-mile/:id/detention-times": ["Set each truck\\'s own detention window (arrived at destination / returned)", "POST", "Last Mile"],
"POST /api/last-mile/:id/distances": ["Set per-vehicle actual distances (does not generate an invoice)", "POST", "Last Mile"],
"POST /api/last-mile/:id/invoice": ["Generate the delivery-fee invoice for a last-mile leg", "POST", "Last Mile"],
"POST /api/last-mile/:id/proof-of-delivery": ["Record proof of delivery (signature + photos) and complete the leg", "POST", "Last Mile"],
"POST /api/last-mile/:id/vehicles": ["Set the vehicles assigned to a last-mile delivery (multi-truck)", "POST", "Last Mile"],
"POST /api/last-mile/:id/warehouse-gate-times": ["Set each truck\\'s warehouse gate arrival/departure times", "POST", "Last Mile"],
"POST /api/last-mile/accept/:reference": ["Accept a paid booking and create a last-mile leg", "POST", "Last Mile"],
// Last Mile Request
"POST /api/last-mile-requests/:id/approve": ["Truck & Machinery chief approves the request — the advance defaults to the live last-mile rate; LM contract becomes signable and the advance invoice follows the customer signature", "POST", "Last Mile Request"],
"POST /api/last-mile-requests/:id/contract/sign": ["Customer agrees and signs the LM contract — then the advance invoice is issued", "POST", "Last Mile Request"],
"POST /api/last-mile-requests/:id/reject": ["Truck & Machinery chief rejects the request with a reason", "POST", "Last Mile Request"],
"POST /api/last-mile-requests/:id/submit": ["Customer confirms which containers go via EDR last-mile", "POST", "Last Mile Request"],
// Locomotive
"POST /api/locomotives": ["Create a locomotive", "POST", "Locomotive"],
"PATCH /api/locomotives/:id": ["Update a locomotive", "PATCH", "Locomotive"],
"POST /api/locomotives/:id/decommission": ["Decommission a locomotive", "POST", "Locomotive"],
"DELETE /api/locomotives/:id/permanent": ["Permanently delete a locomotive (irreversible; refused if any train references it)", "DELETE", "Locomotive"],
// Logo Setting
"PUT /api/logo-settings": ["Replace the company logo", "PUT", "Logo Setting"],
"DELETE /api/logo-settings": ["Clear the company logo (documents fall back to their text mark)", "DELETE", "Logo Setting"],
// Maintenance
"POST /api/maintenance/costs": ["Record maintenance cost", "POST", "Maintenance"],
"POST /api/maintenance/intervals": ["Define/adjust a service interval (e.g. oil change every 10,000 km)", "POST", "Maintenance"],
"DELETE /api/maintenance/intervals/:id": ["Deactivate a service interval (stops auto-scheduling)", "DELETE", "Maintenance"],
"POST /api/maintenance/parts": ["Create part", "POST", "Maintenance"],
"PATCH /api/maintenance/parts/:id": ["Update part", "PATCH", "Maintenance"],
"DELETE /api/maintenance/parts/:id": ["Delete part", "DELETE", "Maintenance"],
"POST /api/maintenance/schedules": ["Schedule maintenance", "POST", "Maintenance"],
"PATCH /api/maintenance/schedules/:id": ["Update maintenance schedule", "PATCH", "Maintenance"],
"POST /api/maintenance/warranties": ["Create warranty", "POST", "Maintenance"],
"DELETE /api/maintenance/warranties/:id": ["Delete warranty", "DELETE", "Maintenance"],
"POST /api/maintenance/work-orders": ["Create work order", "POST", "Maintenance"],
"PATCH /api/maintenance/work-orders/:id": ["Update work order", "PATCH", "Maintenance"],
"DELETE /api/maintenance/work-orders/:id": ["Delete work order", "DELETE", "Maintenance"],
// Notification Inbox
"PATCH /api/notifications/:id/read": ["Mark one of my notifications as read", "PATCH", "Notification Inbox"],
"POST /api/notifications/read-all": ["Mark all my notifications as read", "POST", "Notification Inbox"],
// Organization User
"PUT /api/backoffice/organizations/:orgId/employee-users/:userId/roles": ["Replace org-scoped roles assigned to an employee user", "PUT", "Organization User"],
"POST /api/backoffice/organizations/:orgId/users": ["Create an organization user without assigning positions", "POST", "Organization User"],
// OTP
"POST /api/otp/send": ["Send OTP", "POST", "OTP"],
"POST /api/otp/verify": ["Verify OTP", "POST", "OTP"],
// Password Reset
"POST /api/auth/forgot-password/request": ["Send a password-reset code to the account's email AND phone", "POST", "Password Reset"],
"POST /api/auth/forgot-password/resolve-link": ["Validate a staff-issued reset link and return its set-password ticket", "POST", "Password Reset"],
"POST /api/auth/forgot-password/verify": ["Exchange a valid reset code for a single-use set-password ticket", "POST", "Password Reset"],
"POST /api/backoffice/customers/:companyId/reset-password": ["Send a password-reset link to a customer's primary contact", "POST", "Password Reset"],
// Payment
"POST /api/billing/invoices/:id/confirm-offline": ["Finance confirms a USD invoice paid by bank transfer — slip file required, settles the full balance", "POST", "Payment"],
"POST /api/billing/my-invoices/:id/confirm": ["Confirm an OTP-debit payment (CAC Bank) for one of the customer's invoices", "POST", "Payment"],
"POST /api/billing/my-invoices/:id/pay": ["Initiate payment for one of the customer's invoices", "POST", "Payment"],
"POST /api/internal/payments/bill-query": ["Live still-payable check + payer name for a CBE bill (called while CBE is on the line)", "POST", "Payment"],
"POST /api/internal/payments/mark-paid": ["Apply a payment.succeeded / payment.failed event from the payment service (idempotent)", "POST", "Payment"],
"POST /api/payments/initiate": ["Initiate payment for an invoice", "POST", "Payment"],
"POST /api/payments/redirect-success/:bookingId": ["Success-redirect ack: mark payment processing + invoice PAYMENT_PROCESSING (webhook remains source of truth)", "POST", "Payment"],
"POST /api/billing/invoices/:id/memo": ["Issue a credit or debit memo against a registered invoice (MoR DEB/CRE). Filing-equivalent — the auto-submit sweep picks it up like any other issued invoice.", "POST", "Payment"],
// Payment Setting
"PATCH /api/payment-settings/manual": ["Enable or disable manual invoice settlement for ETB and/or USD", "PATCH", "Payment Setting"],
// Priority Config
"POST /api/priority-configs": ["Create a priority config", "POST", "Priority Config"],
"PATCH /api/priority-configs/:id": ["Update a priority config", "PATCH", "Priority Config"],
"DELETE /api/priority-configs/:id": ["Soft-delete a priority config", "DELETE", "Priority Config"],
"POST /api/priority-configs/:id/move-order": ["Move a priority config up or down in display order", "POST", "Priority Config"],
"POST /api/priority-configs/reorder": ["Bulk reorder priority configs by ID list", "POST", "Priority Config"],
// Priority Rule Change Request
"POST /api/priority-rule-change-requests": ["Submit a priority-rule change for approval", "POST", "Priority Rule Change Request"],
"POST /api/priority-rule-change-requests/:id/approve": ["Approve and apply a pending change", "POST", "Priority Rule Change Request"],
"POST /api/priority-rule-change-requests/:id/reject": ["Reject a pending change", "POST", "Priority Rule Change Request"],
// Procurement
"POST /api/procurement/acquisitions": ["Create an asset acquisition", "POST", "Procurement"],
"PATCH /api/procurement/acquisitions/:id": ["Update an asset acquisition", "PATCH", "Procurement"],
"DELETE /api/procurement/acquisitions/:id": ["Delete an asset acquisition", "DELETE", "Procurement"],
"POST /api/procurement/disposals": ["Create an asset disposal", "POST", "Procurement"],
"DELETE /api/procurement/disposals/:id": ["Delete an asset disposal", "DELETE", "Procurement"],
"POST /api/procurement/vendors": ["Create a vendor", "POST", "Procurement"],
"PATCH /api/procurement/vendors/:id": ["Update a vendor", "PATCH", "Procurement"],
"DELETE /api/procurement/vendors/:id": ["Delete a vendor", "DELETE", "Procurement"],
// Rate
"POST /api/rates": ["Create a rate (DRAFT)", "POST", "Rate"],
"PATCH /api/rates/:id": ["Update a DRAFT rate", "PATCH", "Rate"],
"DELETE /api/rates/:id": ["Soft-delete a rate", "DELETE", "Rate"],
"POST /api/rates/:id/approve": ["CEO approves a rate", "POST", "Rate"],
"POST /api/rates/:id/submit": ["Submit rate for CEO approval", "POST", "Rate"],
// Rate Change Request
"POST /api/rate-change-requests": ["Propose a change to a LIVE rate", "POST", "Rate Change Request"],
"POST /api/rate-change-requests/:id/approve": ["Approve a rate change and put it into effect", "POST", "Rate Change Request"],
"POST /api/rate-change-requests/:id/reject": ["Reject a rate change — the rate keeps its current value", "POST", "Rate Change Request"],
// Route
"POST /api/routes": ["Create route", "POST", "Route"],
"PATCH /api/routes/:id": ["Update route", "PATCH", "Route"],
"DELETE /api/routes/:id": ["Deactivate route", "DELETE", "Route"],
"DELETE /api/routes/:id/permanent": ["Permanently delete a route (irreversible; refused while any train schedule references it)", "DELETE", "Route"],
// Schedule
// NOTE: duplicate route — also declared in modules/scheduling-reschedule/scheduling-reschedule.controller.ts:52.
// Two controllers register this same path; Nest serves whichever module loads first.
"POST /api/train-scheduling/schedules/:id/maintenance": ["Reschedule train for maintenance (new departure + rebalance)", "POST", "Schedule"],
"POST /api/train-scheduling/schedules/:id/reschedule/execute": ["Execute a confirmed reschedule plan", "POST", "Schedule"],
"POST /api/train-scheduling/schedules/:id/reschedule/preview": ["Preview reschedule / government preempt plan", "POST", "Schedule"],
// Service Type
"POST /api/service-types": ["Create a service type", "POST", "Service Type"],
"PATCH /api/service-types/:id": ["Update a service type", "PATCH", "Service Type"],
"DELETE /api/service-types/:id": ["Soft-delete a service type", "DELETE", "Service Type"],
"POST /api/service-types/:id/move-order": ["Move a service type up or down in display order", "POST", "Service Type"],
"POST /api/service-types/reorder": ["Bulk reorder service types by ID list", "POST", "Service Type"],
// Shipping Line (rule-engine lookup list — a code/label bookings reference,
// not an account)
"POST /api/shipping-lines": ["Create a shipping line", "POST", "Shipping Line"],
"PATCH /api/shipping-lines/:id": ["Update a shipping line", "PATCH", "Shipping Line"],
"DELETE /api/shipping-lines/:id": ["Soft-delete a shipping line", "DELETE", "Shipping Line"],
// Shipping Line Booking
"POST /api/shipping-line-bookings/initiate": ["Initiate a bare booking (no contract). Starts at AWAITING_DOCUMENTS so the shipping line can upload its documents for Operations to approve.", "POST", "Shipping Line Booking"],
"POST /api/shipping-line-bookings/:id/cancel": ["Cancel one of the signed-in shipping line's own bookings. Allowed only before the booking is priced.", "POST", "Shipping Line Booking"],
"POST /api/shipping-line-bookings/:id/price-preview": ["Authoritative price quote for the completion payload — same compute as /complete, saved as the booking's breakdown + rate snapshots (refreshed on every re-preview). Persists nothing else.", "POST", "Shipping Line Booking"],
"POST /api/shipping-line-bookings/:id/complete": ["Complete an approved (CLEARANCE_READY) booking: cargo + binding shipment day.", "POST", "Shipping Line Booking"],
// Shipping Line Credit
"POST /api/shipping-line-credits/invoice": ["Bill a batch of unbilled credits as one invoice. All credits must belong to the same shipping line.", "POST", "Shipping Line Credit"],
"POST /api/shipping-line-credits/:creditId/cancel": ["Write off an unbilled credit. Once billed, cancel the invoice instead.", "POST", "Shipping Line Credit"],
"POST /api/shipping-line-credits/invoices/:invoiceId/mark-paid-request": ["Request recording a full offline payment against a credit invoice (awaits chief approval).", "POST", "Shipping Line Credit"],
"POST /api/shipping-line-credits/invoices/:invoiceId/cancel-request": ["Request voiding a credit invoice — its credits return to the unbilled pool (awaits chief approval).", "POST", "Shipping Line Credit"],
"POST /api/shipping-line-credits/invoice-actions/:approvalId/approve": ["Approve a pending invoice request — executes the offline settlement or the cancellation.", "POST", "Shipping Line Credit"],
"POST /api/shipping-line-credits/invoice-actions/:approvalId/reject": ["Reject a pending invoice request — nothing is changed.", "POST", "Shipping Line Credit"],
// Shipping Line Company (carrier with a portal login, registered by staff)
"POST /api/shipping-line-companies": ["Register a shipping line company and send its activation link", "POST", "Shipping Line Company"],
"POST /api/shipping-line-companies/:id/resend-activation": ["Resend a shipping line company's activation link", "POST", "Shipping Line Company"],
// Signature
"PUT /api/me/signature": ["Create or update the reusable saved signature", "PUT", "Signature"],
// Stamp Setting
"PUT /api/stamp-settings": ["Replace the company stamp", "PUT", "Stamp Setting"],
"DELETE /api/stamp-settings": ["Clear the company stamp (invoices fall back to the plain seal)", "DELETE", "Stamp Setting"],
// Support Chat
"POST /api/support/agent/conversations": ["Start chatting with a company (returns the thread if one exists)", "POST", "Support Chat"],
"POST /api/support/agent/conversations/:id/messages": ["Reply as an agent, optionally with attachments", "POST", "Support Chat"],
"POST /api/support/agent/conversations/:id/read": ["Mark a thread read (agent side)", "POST", "Support Chat"],
"POST /api/support/conversation/messages": ["Send a message as the customer (optionally with attachments), opening the thread if needed", "POST", "Support Chat"],
"POST /api/support/conversation/read": ["Mark my company's thread read (customer side)", "POST", "Support Chat"],
// Support Content
"PATCH /api/support-content/documents/:slug": ["Replace a document's payload, recording a new version", "PATCH", "Support Content"],
"POST /api/support-content/documents/:slug/versions/:version/restore": ["Restore a version — re-saves it as a new version, never destructive", "POST", "Support Content"],
"POST /api/support-content/media": ["Upload an image or video for a help section", "POST", "Support Content"],
// Train
"POST /api/trains": ["Register a new train", "POST", "Train"],
"PATCH /api/trains/:id": ["Update a train", "PATCH", "Train"],
"DELETE /api/trains/:id": ["Delete a train", "DELETE", "Train"],
// Train Build
"POST /api/train-builder": ["Build a train: code + yard + 2+ locomotives (+ optional wagons)", "POST", "Train Build"],
"DELETE /api/train-builder/:id": ["Disband the train (release wagons and locomotives)", "DELETE", "Train Build"],
"POST /api/train-builder/:id/activate": ["Reactivate a deactivated train back to AVAILABLE", "POST", "Train Build"],
"POST /api/train-builder/:id/deactivate": ["Deactivate the train (park it) — only allowed with no active schedule", "POST", "Train Build"],
"PATCH /api/train-builder/:id/details": ["Edit the train's name and fixed import/export run numbers", "PATCH", "Train Build"],
"PUT /api/train-builder/:id/locomotives": ["Replace the locomotive set (minimum 1, same yard)", "PUT", "Train Build"],
"POST /api/train-builder/:id/reorder-wagons": ["Persist a drag-reorder of the full consist", "POST", "Train Build"],
"POST /api/train-builder/:id/wagons": ["Append AVAILABLE wagons from the train's yard to the consist", "POST", "Train Build"],
"DELETE /api/train-builder/:id/wagons/:wagonId": ["Detach one wagon from the consist", "DELETE", "Train Build"],
"POST /api/train-builder/:id/wagons/:wagonId/maintenance": ["Detach one wagon and move it to MAINTENANCE status", "POST", "Train Build"],
"PATCH /api/train-builder/:id/yard": ["Relocate the train — its locomotives and wagons move to the new yard with it", "PATCH", "Train Build"],
// Train Schedule
"POST /api/train-scheduling/bookings/:bookingId/allocate": ["Staff: place a paid booking onto a fitting train (notifies customer on date change)", "POST", "Train Schedule"],
"POST /api/train-scheduling/bookings/:bookingId/expire": ["Staff: expire a reservation and free its capacity", "POST", "Train Schedule"],
"POST /api/train-scheduling/bookings/:bookingId/mark-paid": ["Staff: mark a reserved booking paid and allocate it now", "POST", "Train Schedule"],
"POST /api/train-scheduling/bookings/:bookingId/move-schedule": ["Re-point a booking to another OPEN same-route schedule", "POST", "Train Schedule"],
"POST /api/train-scheduling/bulk/preview": ["Preview a bulk train schedule", "POST", "Train Schedule"],
"POST /api/train-scheduling/bulk/schedules": ["Create a bulk train schedule", "POST", "Train Schedule"],
"POST /api/train-scheduling/bulk/schedules/:id/assign-bookings": ["Assign bulk bookings to a train schedule", "POST", "Train Schedule"],
"POST /api/train-scheduling/bulk/schedules/:id/cancel": ["Cancel bulk train schedule", "POST", "Train Schedule"],
"POST /api/train-scheduling/container/preview": ["Preview a container train schedule", "POST", "Train Schedule"],
"POST /api/train-scheduling/container/schedules": ["Create a container train schedule", "POST", "Train Schedule"],
"POST /api/train-scheduling/container/schedules/:id/assign-bookings": ["Assign container bookings to a train schedule", "POST", "Train Schedule"],
"POST /api/train-scheduling/container/schedules/:id/cancel": ["Cancel container train schedule", "POST", "Train Schedule"],
"PATCH /api/train-scheduling/global-rules": ["Update global train scheduling rules (singleton)", "PATCH", "Train Schedule"],
"POST /api/train-scheduling/preview": ["Preview a mixed-capable train schedule", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/adjust-consist": ["Permanently trim free wagons off / couple yard wagons onto the schedule's built train (weight & length limits incl. tolerance enforced, every change logged)", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/arrive": ["Mark a dispatched train arrived (move assets to destination yard, free assets)", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/assign-bookings": ["Assign bookings to a train schedule (mixed-capable)", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/assign-unassigned-booking": ["Assign one linked unallocated booking to wagons (preserves existing assignments)", "POST", "Train Schedule"],
"PATCH /api/train-scheduling/schedules/:id/booking-window": ["Open or close a schedule booking window", "PATCH", "Train Schedule"],
"DELETE /api/train-scheduling/schedules/:id/bookings/:bookingId": ["Unassign a booking from a train schedule", "DELETE", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/bookings/:bookingId/load": ["Confirm a booking's cargo loaded at its origin yard (any direction; train must be at that yard)", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/bookings/:bookingId/unload": ["Confirm a booking's cargo unloaded at its destination yard — per-booking arrival, may precede the train's final arrival", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/checkpoints": ["Log the train passing a station (final station triggers arrival)", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/confirm-loading": ["Confirm cargo loaded on the train (any direction; unblocks import-Djibouti dispatch)", "POST", "Train Schedule"],
"PATCH /api/train-scheduling/schedules/:id/container-items/:itemId": ["Update a container number on a wagon slot", "PATCH", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/dispatch": ["Dispatch a scheduled train", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/doc-review-complete": ["Staff finished document review early — run the batch/payment phase now (applies to the whole route-day group)", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/finalize": ["Finalize a draft train schedule", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/import-djibouti/depart": ["Depart loaded import train from Djibouti", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/import-djibouti/documents": ["Upload/check an import Djibouti-side document", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/import-djibouti/gatepass-granted": ["Mark import Djibouti gatepass permission granted", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/import-djibouti/load-list": ["Generate import load list / marshalling document summary", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/import-djibouti/loaded-on-train": ["Confirm import cargo loaded on train at Djibouti", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/import-djibouti/ready-for-loading": ["Mark import train ready for loading at Djibouti", "POST", "Train Schedule"],
"PATCH /api/train-scheduling/schedules/:id/import-loading-status": ["Mark import bookings loaded/unloaded on this schedule (tracking only, does not affect dispatch)", "PATCH", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/intercity/:bookingId/load": ["Confirm intercity cargo loaded (train must be at the booking's origin yard)", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/intercity/:bookingId/unload": ["Confirm intercity cargo unloaded at the booking's destination yard (completes the booking)", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/intercity/accept": ["Accept intercity bookings onto this train (opens their pay window; capacity re-checked per booking)", "POST", "Train Schedule"],
"PATCH /api/train-scheduling/schedules/:id/loading-status": ["Mark bookings loaded/unloaded on this schedule (any direction, pre-dispatch only)", "PATCH", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/pin-wagons": ["Pin physical wagons to train set slots", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/run-allocation": ["Run wagon-level allocation for all eligible linked bookings", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/run-batch": ["Manually run the batch fill for a schedule", "POST", "Train Schedule"],
"PATCH /api/train-scheduling/schedules/:id/schedule-date": ["Reschedule a train's departure date — only before the booking window opens, and only if the new date still leaves room for the booking lead window", "PATCH", "Train Schedule"],
"PATCH /api/train-scheduling/schedules/:id/train-number": ["Edit a departure's train number and voyage number — allowed only until the train is dispatched", "PATCH", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/switch-government-booking": ["Switch out commercial bookings to allocate a government booking in their place", "POST", "Train Schedule"],
"DELETE /api/train-scheduling/schedules/:id/wagons/:trainSetWagonId": ["Remove an empty wagon slot from a train", "DELETE", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/wagons/:wagonId/move-load": ["Move a wagon's whole load to another wagon (empty → move/repin, loaded → swap loads)", "POST", "Train Schedule"],
"PATCH /api/train-scheduling/schedules/:id/window-rule": ["Override the booking-window rule for one schedule (open/close hour, duration, doc-review, payment, lead days) — only before the window opens", "PATCH", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/merge": ["Merge another train into this schedule: its wagons join this consist, a same-day schedule on it is absorbed, and the emptied train is deactivated", "POST", "Train Schedule"],
"PATCH /api/train-scheduling/schedules/:id/checkpoints/:sequenceNo": ["Edit a logged leg", "PATCH", "Train Schedule"],
// Transit Agent
"POST /api/transit-agents": ["Create a transit agent", "POST", "Transit Agent"],
"PATCH /api/transit-agents/:id": ["Update a transit agent", "PATCH", "Transit Agent"],
"DELETE /api/transit-agents/:id": ["Soft-delete a transit agent", "DELETE", "Transit Agent"],
// Truck Type
"POST /api/truck-types": ["Create a truck type", "POST", "Truck Type"],
"PATCH /api/truck-types/:id": ["Update a truck type", "PATCH", "Truck Type"],
"DELETE /api/truck-types/:id": ["Soft-delete a truck type", "DELETE", "Truck Type"],
// User Trade Access
"PUT /api/user-trade-access/:userId": ["Set the trade directions a backoffice user may see", "PUT", "User Trade Access"],
// Vehicle
"POST /api/vehicles": ["Create a new vehicle", "POST", "Vehicle"],
"PATCH /api/vehicles/:id": ["Update a vehicle", "PATCH", "Vehicle"],
"DELETE /api/vehicles/:id": ["Delete a vehicle", "DELETE", "Vehicle"],
// Wagon
"POST /api/wagons": ["Create a new wagon", "POST", "Wagon"],
"PATCH /api/wagons/:id": ["Update a wagon", "PATCH", "Wagon"],
"DELETE /api/wagons/:id": ["Delete a wagon", "DELETE", "Wagon"],
"POST /api/wagons/:id/assign-train": ["Assign wagon to a train", "POST", "Wagon"],
"DELETE /api/wagons/:id/permanent": ["Permanently delete a wagon (irreversible; refused if it has movements, containers or train-set slots)", "DELETE", "Wagon"],
"POST /api/wagons/:id/unassign-train": ["Unassign wagon from train", "POST", "Wagon"],
"POST /api/wagons/bulk-status": ["Set the status of multiple wagons (audited in wagon_status_logs)", "POST", "Wagon"],
"POST /api/wagons/bulk-transfer": ["Transfer multiple wagons to a destination yard", "POST", "Wagon"],
// Wagon Transfer Request
"POST /api/wagon-transfer-requests": ["File a count-only wagon-transfer request", "POST", "Wagon Transfer Request"],
"POST /api/wagon-transfer-requests/:id/cancel": ["Withdraw a request that has not moved any wagon yet (use close-short once wagons have moved)", "POST", "Wagon Transfer Request"],
"POST /api/wagon-transfer-requests/:id/close-short": ["OCC: end the request with fewer wagons than asked for — what moved stays, the requester is told the shortfall", "POST", "Wagon Transfer Request"],
"POST /api/wagon-transfer-requests/:id/fulfill": ["OCC: pick wagons and execute the transfer", "POST", "Wagon Transfer Request"],
"POST /api/wagon-transfer-requests/bulk-fulfill": ["OCC: accept-and-execute a subset of pending requests (auto-picks available wagons; the rest stay PENDING)", "POST", "Wagon Transfer Request"],
// Wagon Type
"POST /api/wagon-types": ["Create a wagon type", "POST", "Wagon Type"],
"PATCH /api/wagon-types/:id": ["Update a wagon type", "PATCH", "Wagon Type"],
"DELETE /api/wagon-types/:id": ["Soft-delete a wagon type", "DELETE", "Wagon Type"],
// Warehouse
"POST /api/warehouse-allocation-rules": ["Create a warehouse allocation rule", "POST", "Warehouse"],
"PATCH /api/warehouse-allocation-rules/:id": ["Update a warehouse allocation rule", "PATCH", "Warehouse"],
"DELETE /api/warehouse-allocation-rules/:id": ["Delete a warehouse allocation rule", "DELETE", "Warehouse"],
"POST /api/warehouse-allocation/preview": ["Preview the yard/warehouse/zone a booking would be allocated to", "POST", "Warehouse"],
"POST /api/warehouse-fee-rules": ["Create a storage / demurrage fee rule", "POST", "Warehouse"],
"PATCH /api/warehouse-fee-rules/:id": ["Update a fee rule", "PATCH", "Warehouse"],
"DELETE /api/warehouse-fee-rules/:id": ["Delete a fee rule", "DELETE", "Warehouse"],
"POST /api/warehouse-fees/accrual/:inventoryId/acknowledge": ["Acknowledge / snooze an item fee-accrual alert", "POST", "Warehouse"],
"DELETE /api/warehouse-fees/accrual/:inventoryId/acknowledge": ["Remove an accrual acknowledgement (re-surface for alerts)", "DELETE", "Warehouse"],
"POST /api/warehouses": ["Create warehouse", "POST", "Warehouse"],
"PATCH /api/warehouses/:id": ["Update warehouse", "PATCH", "Warehouse"],
"POST /api/warehouses/:warehouseId/yards": ["Create a yard within a warehouse", "POST", "Warehouse"],
// Warehouse Fee Invoice
"POST /api/last-mile/:id/generate-truck-detention-invoice": ["Generate a truck-detention invoice for a last-mile leg (per truck per day)", "POST", "Warehouse Fee Invoice"],
"PATCH /api/warehouse-fee-invoices/:id/cancel": ["Cancel a warehouse fee invoice", "PATCH", "Warehouse Fee Invoice"],
"POST /api/warehouse-fee-invoices/:id/pay": ["Record a payment against a warehouse fee invoice", "POST", "Warehouse Fee Invoice"],
"POST /api/warehouse-fee-invoices/:id/pay-online": ["Initiate Telebirr/Waafi payment for a warehouse fee invoice", "POST", "Warehouse Fee Invoice"],
"POST /api/warehouse-inventory/:id/generate-fee-invoice": ["Generate a warehouse fee invoice from Batch 5 fee calculation", "POST", "Warehouse Fee Invoice"],
// Warehouse Inspection Report
"PATCH /api/warehouse-inspection-reports/:id": ["Update an inspection report", "PATCH", "Warehouse Inspection Report"],
"POST /api/warehouse-inspection-reports/:id/attachments": ["Upload inspection images / documents", "POST", "Warehouse Inspection Report"],
"POST /api/warehouse-inventory/:inventoryId/inspection-reports": ["Create an inspection / damage report for an inventory item", "POST", "Warehouse Inspection Report"],
// Warehouse Inventory
"POST /api/warehouse-inventory/:id/deliver": ["Deliver import goods to the customer + capture proof of delivery", "POST", "Warehouse Inventory"],
"PATCH /api/warehouse-inventory/:id/dispatch": ["Mark loaded inventory DISPATCHED (left the terminal)", "PATCH", "Warehouse Inventory"],
"POST /api/warehouse-inventory/:id/gate-clearance": ["Final terminal release / gate clearance (blocked while fees unpaid)", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/:id/load": ["Load READY_FOR_LOADING inventory onto a wagon", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/:id/move": ["Move inventory to another warehouse/yard/zone", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/:id/ready-for-loading": ["Mark reserved inventory READY_FOR_LOADING", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/:id/ready-for-pickup": ["Mark inspected IMPORT inventory READY_FOR_PICKUP", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/:id/release": ["Issue a DO / release order for ready-for-pickup inventory", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/:id/store": ["Mark received inventory as STORED (optional explicit warehouse/yard/zone)", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/auto-load-ready": ["Auto-load READY_FOR_LOADING inventory with PAID bookings", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/auto-unload-arrived": ["Bulk auto-unload all arrived bookings into the warehouse", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/bookings/:bookingId/approve-delivery": ["Approve delivery — customer records their full name (signature optional)", "POST", "Warehouse Inventory"],
"PATCH /api/warehouse-inventory/bookings/:bookingId/double-handling": ["Record Yes/No double handling after unloading (Yes applies the double-handling fee rule)", "PATCH", "Warehouse Inventory"],
"POST /api/warehouse-inventory/bookings/:bookingId/request-handover-signature": ["Ask the customer to sign the handover (creates one if none, then notifies)", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/bookings/:bookingId/unload": ["Unload a single arrived booking into a location", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/bulk-dispatch-export": ["Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED)", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/bulk-mark-inspected": ["Bulk mark received inventory inspection PASSED (EXPORT → READY_FOR_LOADING)", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/export/auto-unload-at-djibouti": ["Unload all eligible export items assigned to an arrived Djibouti-side train", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/handovers/:handoverId/sign": ["Customer signs one handover (EDR last-mile: one signature per truck)", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/import/auto-unload-arrived-bookings": ["Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED)", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/receive": ["Receive inventory at a warehouse location", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/receive-bulk": ["Bulk-receive selected eligible PAID bookings into a location", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/reserve": ["Reserve stored inventory for a PAID booking", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/train/:scheduleId/load": ["Load selected inventory items onto their allocated wagons for a train", "POST", "Warehouse Inventory"],
// Warehouse Yard
"PATCH /api/warehouse-yards/:id": ["Update warehouse yard", "PATCH", "Warehouse Yard"],
"POST /api/warehouse-yards/:yardId/zones": ["Create a zone within a yard", "POST", "Warehouse Yard"],
// Warehouse Zone
"PATCH /api/warehouse-zones/:id": ["Update warehouse zone", "PATCH", "Warehouse Zone"],
// Weight Limit Rule
"POST /api/weight-limit-rules": ["Create a weight limit rule", "POST", "Weight Limit Rule"],
"PATCH /api/weight-limit-rules/:id": ["Update a weight limit rule", "PATCH", "Weight Limit Rule"],
"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"],
"POST /api/yards/:id/move-order": ["Move a yard up or down in display order", "POST", "Yard"],
"POST /api/yards/reorder": ["Bulk reorder yards by ID list", "POST", "Yard"],
// Yard Distance
"POST /api/yard-distances": ["Create a yard distance", "POST", "Yard Distance"],
"PATCH /api/yard-distances/:id": ["Update a yard distance", "PATCH", "Yard Distance"],
"DELETE /api/yard-distances/:id": ["Soft-delete a yard distance", "DELETE", "Yard Distance"],
};

View File

@@ -0,0 +1,79 @@
import { Injectable } from '@nestjs/common';
import { BaseRepository } from '@edr/api-common';
import { InjectRepository } from '@nestjs/typeorm';
import { Between, FindOptionsWhere, LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm';
import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { AuditLog } from './entities/audit-log.entity';
export interface AuditLogQuery {
type?: string;
userId?: string;
method?: string;
isSuccess?: boolean;
resourceId?: string;
from?: Date;
to?: Date;
skip: number;
take: number;
}
@Injectable()
export class AuditLogRepository extends BaseRepository<AuditLog> {
constructor(
@InjectRepository(AuditLog)
private readonly auditLogRepository: Repository<AuditLog>,
) {
super(auditLogRepository);
}
/**
* Insert one audit row.
*
* `insert` rather than `save`: save would issue a SELECT first to decide
* between insert and update, which is wasted work for a table that is only
* ever appended to.
*/
async record(entry: Partial<AuditLog>): Promise<void> {
await this.auditLogRepository.insert(
entry as QueryDeepPartialEntity<AuditLog>,
);
}
/**
* Paginated, filtered read. Newest first — every index on this table is
* ordered `created_at DESC` to match.
*/
async search(query: AuditLogQuery): Promise<[AuditLog[], number]> {
const where: FindOptionsWhere<AuditLog> = {};
if (query.type) where.type = query.type;
if (query.userId) where.userId = query.userId;
if (query.method) where.method = query.method;
if (query.resourceId) where.resourceId = query.resourceId;
if (query.isSuccess !== undefined) where.isSuccess = query.isSuccess;
// Date range: either bound may be supplied alone.
if (query.from && query.to) where.createdAt = Between(query.from, query.to);
else if (query.from) where.createdAt = MoreThanOrEqual(query.from);
else if (query.to) where.createdAt = LessThanOrEqual(query.to);
return this.auditLogRepository.findAndCount({
where,
order: { createdAt: 'DESC' },
skip: query.skip,
take: query.take,
});
}
/** Distinct entity types present, for populating a filter dropdown. */
async distinctTypes(): Promise<string[]> {
const rows = await this.auditLogRepository
.createQueryBuilder('audit_log')
.select('DISTINCT audit_log.type', 'type')
.orderBy('audit_log.type', 'ASC')
.getRawMany<{ type: string }>();
return rows.map((row) => row.type);
}
}

View File

@@ -1,32 +1,46 @@
import { Controller, Get, Query } from "@nestjs/common";
import { ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger";
import { Controller, Get, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { PaginatedResponse } from '@edr/types';
import { BookingStaff } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { AuditService } from "./audit.service";
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { AuditService } from './audit.service';
import { AuditLog } from './entities/audit-log.entity';
import { AuditLogQueryDto } from './dto/audit-log-query.dto';
@ApiTags("audit")
@Controller("audit")
@BookingStaff(FREIGHT_PERMS.audit.view)
/**
* Read-only view over the audit trail.
*
* Gated on `edr_freight_app:audit_log:view` — a dedicated view key rather than
* the broad `admin` key, so reading the trail can be granted without also
* granting write access to everything else.
*
* There is deliberately no write, update or delete endpoint here — rows are
* created only by `AuditInterceptor`, and an audit trail that can be edited
* through the API is not an audit trail.
*/
@ApiTags('audit')
@ApiBearerAuth()
@Controller('audit')
export class AuditController {
constructor(private readonly auditService: AuditService) {}
@Get("logs")
@ApiOperation({ summary: "List freight-api audit log commands" })
@ApiQuery({ name: "skip", type: Number, required: false })
@ApiQuery({ name: "take", type: Number, required: false })
list(@Query("skip") skip?: string, @Query("take") take?: string) {
// Same fallback chain @tria-plc/auditlog's client interceptor uses to
// stamp AuditLog.application (mezgeb/client/client-audit.interceptor.js)
// — reading it here instead of a hardcoded literal means this can't
// silently drift out of sync with whatever APPLICATION_NAME/APP_NAME
// actually is at runtime.
const application =
process.env.APPLICATION_NAME ?? process.env.APP_NAME ?? "DEFAULT";
return this.auditService.list(
application,
skip !== undefined ? parseInt(skip, 10) : undefined,
take !== undefined ? parseInt(take, 10) : undefined,
);
@Get('logs')
@BookingStaff(FREIGHT_PERMS.auditLog.view)
@ApiOperation({
summary:
'List backoffice audit logs — filter by entity type, user, method, outcome and date range',
})
list(@Query() query: AuditLogQueryDto): Promise<PaginatedResponse<AuditLog>> {
return this.auditService.search(query);
}
@Get('types')
@BookingStaff(FREIGHT_PERMS.auditLog.view)
@ApiOperation({
summary: 'Distinct entity types present in the audit log (filter dropdown)',
})
types(): Promise<string[]> {
return this.auditService.listTypes();
}
}

View File

@@ -0,0 +1,189 @@
import {
CallHandler,
ExecutionContext,
HttpException,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable, tap } from 'rxjs';
import type { Request, Response } from 'express';
import { AuditService } from './audit.service';
import {
auditEndpointMatcher,
type MatchedAuditEndpoint,
} from './audit-endpoint-matcher';
import {
isAuditableActor,
resolveAuditActor,
type AuditActorSource,
} from './audit-actor';
import { redactUrlQuery, sanitizeRequestPayload } from './audit.sanitizer';
/** Methods that can change state. Everything else is never audited. */
const AUDITED_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
/** `error_message` ceiling — stack traces do not belong in this column. */
const MAX_ERROR_LENGTH = 2_000;
type RequestWithUser = Request & {
user?: AuditActorSource;
files?: unknown;
file?: unknown;
id?: string;
};
/**
* Writes one `audit_logs` row per state-changing backoffice request.
*
* An interceptor rather than the two middlewares originally sketched, for one
* decisive reason: Express middleware runs BEFORE guards, so `req.user` is not
* populated yet. Both the backoffice-only rule and `user_id` would be
* unavailable there. Interceptors run after guards and wrap the handler's
* result, so a single class covers both halves — request context on the way in,
* outcome on the way out — sharing one timer for `duration_ms`.
*
* Registered globally (see `audit.module.ts`), so new routes are covered
* automatically as long as they appear in `AUDIT_ENDPOINTS`.
*/
@Injectable()
export class AuditInterceptor implements NestInterceptor {
constructor(private readonly auditService: AuditService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
// Non-HTTP contexts (the RabbitMQ microservice transport) have no request.
if (context.getType() !== 'http') return next.handle();
const httpContext = context.switchToHttp();
const request = httpContext.getRequest<RequestWithUser>();
if (!AUDITED_METHODS.has(request.method)) return next.handle();
// Backoffice only. Customers and unauthenticated callers are skipped
// outright — decided in `audit-actor.ts`, which reuses the same
// `userType` discriminator as the permission guards.
if (!isAuditableActor(request.user)) return next.handle();
const matched = auditEndpointMatcher.match(request.method, request.originalUrl);
// Not in AUDIT_ENDPOINTS means the route is not a known auditable action;
// recording it would produce rows with no title or entity.
if (!matched) return next.handle();
const startedAt = Date.now();
// The body is captured up front: handlers are free to mutate the DTO they
// are given, so reading it after the fact can record post-mutation values.
const requestPayload = sanitizeRequestPayload(
request.body,
request.files ?? request.file,
);
return next.handle().pipe(
tap({
next: () => {
const response = httpContext.getResponse<Response>();
void this.write(request, matched, requestPayload, startedAt, {
isSuccess: true,
// Nest has not applied the handler's @HttpCode yet at this point
// for some routes; statusCode on the response object is the value
// actually being sent.
statusCode: response.statusCode,
errorMessage: null,
});
},
error: (error: unknown) => {
void this.write(request, matched, requestPayload, startedAt, {
isSuccess: false,
statusCode: resolveErrorStatus(error),
errorMessage: resolveErrorMessage(error),
});
},
}),
);
}
/**
* Build and persist the row.
*
* Deliberately not awaited by `intercept`: the audit write must not add
* latency to the request, and `AuditService.record` already swallows its own
* failures so a rejected promise cannot surface as an unhandled rejection.
*/
private async write(
request: RequestWithUser,
matched: MatchedAuditEndpoint,
requestPayload: Record<string, unknown> | null,
startedAt: number,
outcome: {
isSuccess: boolean;
statusCode: number | null;
errorMessage: string | null;
},
): Promise<void> {
const actor = resolveAuditActor(request.user as AuditActorSource);
await this.auditService.record({
title: matched.title,
method: request.method,
// Full URL including query string, with sensitive query values redacted.
url: redactUrlQuery(request.originalUrl),
routePath: matched.routePath,
type: matched.type,
isSuccess: outcome.isSuccess,
statusCode: outcome.statusCode,
errorMessage: outcome.errorMessage,
userId: actor.userId,
userName: actor.userName,
userRole: actor.userRole,
resourceId: matched.resourceId,
request: requestPayload,
ipAddress: resolveIp(request),
userAgent: request.headers['user-agent'] ?? null,
requestId: resolveRequestId(request),
durationMs: Date.now() - startedAt,
});
}
}
/** HTTP status for the failure, falling back to 500 for non-HTTP errors. */
function resolveErrorStatus(error: unknown): number {
return error instanceof HttpException ? error.getStatus() : 500;
}
/** Message only — stack traces belong in application logs, not this column. */
function resolveErrorMessage(error: unknown): string | null {
if (error instanceof HttpException) {
const response = error.getResponse();
const message =
typeof response === 'string'
? response
: ((response as { message?: unknown })?.message ?? error.message);
const text = Array.isArray(message) ? message.join('; ') : String(message);
return text.slice(0, MAX_ERROR_LENGTH);
}
if (error instanceof Error) return error.message.slice(0, MAX_ERROR_LENGTH);
return error ? String(error).slice(0, MAX_ERROR_LENGTH) : null;
}
/**
* Client IP. The API sits behind a reverse proxy, so `req.ip` is the proxy
* unless `trust proxy` is set; the forwarded header is preferred and its first
* entry (the original client) taken.
*/
function resolveIp(request: Request): string | null {
const forwarded = request.headers['x-forwarded-for'];
const raw = Array.isArray(forwarded) ? forwarded[0] : forwarded;
const candidate = raw?.split(',')[0]?.trim() || request.ip;
if (!candidate) return null;
// Normalize IPv4-mapped IPv6 (`::ffff:10.0.0.1`), which the `inet` column
// accepts but which reads badly and breaks grouping by address.
return candidate.startsWith('::ffff:') ? candidate.slice(7) : candidate;
}
/** Correlation id from the proxy/tracing layer, when present. */
function resolveRequestId(request: RequestWithUser): string | null {
const header = request.headers['x-request-id'] ?? request.headers['x-correlation-id'];
const value = Array.isArray(header) ? header[0] : header;
return (value ?? request.id ?? null)?.toString().slice(0, 64) ?? null;
}

View File

@@ -1,13 +1,34 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { AuditLogCommand } from "@tria-plc/auditlog";
import { Global, Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditController } from "./audit.controller";
import { AuditService } from "./audit.service";
import { AuditController } from './audit.controller';
import { AuditInterceptor } from './audit.interceptor';
import { AuditLog } from './entities/audit-log.entity';
import { AuditLogRepository } from './audit-log.repository';
import { AuditService } from './audit.service';
/**
* Backoffice audit trail.
*
* `AuditInterceptor` is bound through `APP_INTERCEPTOR`, so it applies to every
* route in the application without touching the 488 mutating handlers
* individually. Coverage therefore follows `AUDIT_ENDPOINTS`: a new route is
* audited as soon as it appears in that map, and unknown routes are skipped
* rather than recorded with an empty title.
*
* Global so other modules can inject `AuditService` to record domain events
* that do not map cleanly onto an HTTP request.
*/
@Global()
@Module({
imports: [TypeOrmModule.forFeature([AuditLogCommand])],
imports: [TypeOrmModule.forFeature([AuditLog])],
controllers: [AuditController],
providers: [AuditService],
providers: [
AuditLogRepository,
AuditService,
{ provide: APP_INTERCEPTOR, useClass: AuditInterceptor },
],
exports: [AuditService, AuditLogRepository],
})
export class AuditModule {}

View File

@@ -0,0 +1,195 @@
/**
* Redaction and shrinking for anything copied into `audit_logs.request`.
*
* This matters more here than in a typical audit log. `main.ts` raises the JSON
* body ceiling to 100MB so contract signing can post a signature AND a company
* stamp as base64 in one request. Copying a body like that verbatim would put
* both a credential-grade artefact and a 100MB blob into the audit table, on
* the write path of every audited endpoint.
*/
const REDACTED = '[REDACTED]';
/**
* Substring-matched against lower-cased key names, so `newPassword`,
* `otpCode` and `x-authorization` are all caught without enumerating variants.
*
* `signature` and `stamp` are here because contract signing posts both as
* base64 — they are simultaneously the largest and the most sensitive fields
* this API accepts.
*/
const SENSITIVE_KEY_PATTERNS = [
'password',
'otp',
'token',
'secret',
'pin',
'authorization',
'signature',
'stamp',
'apikey',
'api_key',
'credential',
'ssn',
];
/** Serialized `request` ceiling. Beyond this the payload is dropped for a marker. */
const MAX_REQUEST_BYTES = 64 * 1024;
/** Depth guard: deep nesting is never worth the recursion cost here. */
const MAX_DEPTH = 6;
/** Long strings (base64 blobs) are truncated rather than stored whole. */
const MAX_STRING_LENGTH = 2_000;
function isSensitiveKey(key: string): boolean {
const lower = key.toLowerCase();
return SENSITIVE_KEY_PATTERNS.some((pattern) => lower.includes(pattern));
}
/**
* Multer file shape, reduced to a descriptor. The buffer is never stored —
* Postgres is the wrong home for file bytes, and `audit_logs` doubly so.
*/
function isMulterFile(value: unknown): boolean {
if (typeof value !== 'object' || value === null) return false;
const candidate = value as Record<string, unknown>;
return (
typeof candidate.originalname === 'string' &&
(typeof candidate.mimetype === 'string' || typeof candidate.size === 'number')
);
}
function describeFile(value: Record<string, unknown>): Record<string, unknown> {
return {
__file: true,
originalName: value.originalname ?? null,
mimeType: value.mimetype ?? null,
size: typeof value.size === 'number' ? value.size : null,
fieldName: value.fieldname ?? null,
};
}
function sanitizeValue(value: unknown, depth: number): unknown {
if (value === null || value === undefined) return value ?? null;
if (typeof value === 'string') {
return value.length > MAX_STRING_LENGTH
? `${value.slice(0, MAX_STRING_LENGTH)}…[truncated ${value.length} chars]`
: value;
}
if (typeof value === 'number' || typeof value === 'boolean') return value;
if (value instanceof Date) return value.toISOString();
// Buffers are file bytes by definition — never persisted, only described.
if (Buffer.isBuffer(value)) return { __buffer: true, size: value.length };
if (depth >= MAX_DEPTH) return '[MAX_DEPTH]';
if (Array.isArray(value)) {
// Cap array length: bulk endpoints post large collections.
const capped = value.slice(0, 50).map((item) => sanitizeValue(item, depth + 1));
if (value.length > 50) capped.push(`…[${value.length - 50} more items]`);
return capped;
}
if (typeof value === 'object') {
if (isMulterFile(value)) return describeFile(value as Record<string, unknown>);
const out: Record<string, unknown> = {};
for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
out[key] = isSensitiveKey(key) ? REDACTED : sanitizeValue(nested, depth + 1);
}
return out;
}
// Functions, symbols and anything else are not audit data.
return null;
}
/**
* Sanitize a request body (or query object) for storage.
*
* Returns null when there is nothing worth keeping, so empty bodies do not
* occupy jsonb rows.
*/
export function sanitizeRequestPayload(
body: unknown,
files?: unknown,
): Record<string, unknown> | null {
const payload: Record<string, unknown> = {};
if (body && typeof body === 'object' && Object.keys(body).length > 0) {
const sanitizedBody = sanitizeValue(body, 0);
if (sanitizedBody && typeof sanitizedBody === 'object') {
Object.assign(payload, sanitizedBody as Record<string, unknown>);
}
}
// Multer puts uploads on `req.files`, outside `req.body`, so they are folded
// in explicitly — otherwise a pure-upload request records an empty payload.
if (files) {
const sanitizedFiles = sanitizeValue(files, 0);
if (
sanitizedFiles &&
(Array.isArray(sanitizedFiles) || typeof sanitizedFiles === 'object')
) {
const hasEntries = Array.isArray(sanitizedFiles)
? sanitizedFiles.length > 0
: Object.keys(sanitizedFiles as object).length > 0;
if (hasEntries) payload.__uploads = sanitizedFiles;
}
}
if (Object.keys(payload).length === 0) return null;
// Final size guard. A body can stay under every per-field cap and still be
// enormous in aggregate, so the serialized form is measured before storing.
const serialized = JSON.stringify(payload);
if (serialized && Buffer.byteLength(serialized, 'utf8') > MAX_REQUEST_BYTES) {
return {
__truncated: true,
reason: 'Payload exceeded the audit size limit',
bytes: Buffer.byteLength(serialized, 'utf8'),
keys: Object.keys(payload).slice(0, 50),
};
}
return payload;
}
/**
* Rebuild a URL with sensitive query values redacted.
*
* `url` is stored with its full query string, and query strings are a common
* place for one-time tokens and signed links, so the same deny-list that
* protects the body is applied to the query.
*/
export function redactUrlQuery(url: string): string {
const queryIndex = url.indexOf('?');
if (queryIndex === -1) return url;
const path = url.slice(0, queryIndex);
const query = url.slice(queryIndex + 1);
if (!query) return path;
const redacted = query
.split('&')
.map((pair) => {
const eq = pair.indexOf('=');
if (eq === -1) return pair;
const key = pair.slice(0, eq);
// Keys arrive percent-encoded; decode before matching so `api%2Dkey`
// is not treated as harmless.
let decodedKey = key;
try {
decodedKey = decodeURIComponent(key);
} catch {
/* malformed encoding — fall back to the raw key */
}
return isSensitiveKey(decodedKey) ? `${key}=${REDACTED}` : pair;
})
.join('&');
return `${path}?${redacted}`;
}

View File

@@ -1,70 +1,71 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { AuditLogCommand } from "@tria-plc/auditlog";
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { PaginatedResponse } from '@edr/types';
import { CLIENT_APP_HEADER } from "../auth/login-audience.middleware";
import { AuditLog } from './entities/audit-log.entity';
import { AuditLogRepository } from './audit-log.repository';
import { AuditLogQueryDto } from './dto/audit-log-query.dto';
import {
buildPaginationMeta,
normalizePagination,
} from '../../common/utils/pagination.util';
export interface AuditLogListResult {
count: number;
items: AuditLogCommand[];
}
/**
* Own read path onto @tria-plc/auditlog's tables, gated by AuditController's
* @BookingStaff — the package's own AuditLogCommandController (mounted at
* /api/audit-log-commands) ships with no guards at all, so it can't be used
* directly for a permission-gated UI. Query mirrors the package's
* AuditLogCommandService.buildAuditLogQuery/getAllAuditLogs exactly.
*/
@Injectable()
export class AuditService {
constructor(
@InjectRepository(AuditLogCommand)
private readonly auditLogCommandRepository: Repository<AuditLogCommand>,
) {}
private readonly logger = new Logger(AuditService.name);
async list(
application: string,
skip = 0,
take = 10,
): Promise<AuditLogListResult> {
const [items, count] = await this.auditLogCommandRepository
.createQueryBuilder("audit_log_commands")
.leftJoinAndSelect("audit_log_commands.auditLog", "auditLog")
.andWhere(
"(audit_log_commands.auditLogId IS NULL OR auditLog.application = :application)",
{ application },
)
.andWhere(
"(audit_log_commands.auditLogId IS NULL OR auditLog.status = :status)",
{ status: "Commit" },
)
// Backoffice-only view: portal (customer-facing) writes carry the same
// request-header set by every axios call from that app — see
// login-audience.middleware.ts. Rows with no linked auditLog (child/
// event commands with no request context) stay visible; they aren't
// attributable to any frontend, so they're not portal noise either.
.andWhere(
"(audit_log_commands.auditLogId IS NULL OR auditLog.requestHeader ->> :clientAppHeader = :clientApp)",
{ clientAppHeader: CLIENT_APP_HEADER, clientApp: "backoffice" },
)
.select([
"audit_log_commands.id",
"audit_log_commands.createdAt",
"audit_log_commands.deletedAt",
"audit_log_commands.entityName",
"audit_log_commands.queryMethod",
"audit_log_commands.changes",
"audit_log_commands.payload",
"auditLog.id",
"auditLog.user",
])
.addOrderBy("audit_log_commands.createdAt", "DESC")
.skip(skip)
.take(take)
.getManyAndCount();
constructor(private readonly auditLogRepository: AuditLogRepository) {}
return { count, items };
/**
* Persist one audit row, swallowing any failure.
*
* An audit write must never turn a successful business action into an error
* for the user: if this table is full, misconfigured or mid-migration,
* contract approvals still need to work. Failures are logged so the gap is
* visible in application logs rather than silent.
*/
async record(entry: Partial<AuditLog>): Promise<void> {
try {
await this.auditLogRepository.record(entry);
} catch (error) {
this.logger.error(
`Failed to write audit log for ${entry.method} ${entry.routePath}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
/** Paginated, filtered audit history, newest first. */
async search(query: AuditLogQueryDto): Promise<PaginatedResponse<AuditLog>> {
const { page, pageSize, skip, take } = normalizePagination(query);
const from = query.from ? new Date(query.from) : undefined;
const to = query.to ? new Date(query.to) : undefined;
// A reversed range silently returns zero rows, which reads as "nothing
// happened" rather than "your filter is wrong" — reject it explicitly.
if (from && to && from > to) {
throw new BadRequestException('`from` must be earlier than `to`');
}
const [items, total] = await this.auditLogRepository.search({
type: query.type,
userId: query.userId,
method: query.method,
resourceId: query.resourceId,
isSuccess:
query.isSuccess === undefined ? undefined : query.isSuccess === 'true',
from,
to,
skip,
take,
});
return { items, meta: buildPaginationMeta(total, page, pageSize) };
}
/** Distinct entity types, for the filter dropdown on the audit screen. */
async listTypes(): Promise<string[]> {
return this.auditLogRepository.distinctTypes();
}
}

View File

@@ -0,0 +1,64 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsBooleanString, IsIn, IsISO8601, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
const AUDITED_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE'] as const;
/**
* Filters for the audit log read endpoint.
*
* Extends the shared pagination DTO so page/pageSize behave (and are capped)
* exactly as they do on every other list endpoint.
*/
export class AuditLogQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({
description: 'Entity type, e.g. "Contract", "Booking", "Locomotive".',
example: 'Contract',
})
@IsOptional()
@IsString()
@MaxLength(50)
type?: string;
@ApiPropertyOptional({ description: 'IAM id of the acting backoffice user.' })
@IsOptional()
@IsUUID()
userId?: string;
@ApiPropertyOptional({ enum: AUDITED_METHODS })
@IsOptional()
@Transform(({ value }) => String(value).toUpperCase())
@IsIn([...AUDITED_METHODS])
method?: string;
@ApiPropertyOptional({ description: 'Id of the affected record.' })
@IsOptional()
@IsString()
@MaxLength(64)
resourceId?: string;
@ApiPropertyOptional({
description: 'Filter by outcome: true = succeeded, false = failed.',
})
@IsOptional()
@IsBooleanString()
isSuccess?: string;
@ApiPropertyOptional({
description: 'Inclusive start of the range (ISO 8601).',
example: '2026-01-01T00:00:00.000Z',
})
@IsOptional()
@IsISO8601()
from?: string;
@ApiPropertyOptional({
description: 'Inclusive end of the range (ISO 8601).',
example: '2026-01-31T23:59:59.999Z',
})
@IsOptional()
@IsISO8601()
to?: string;
}

View File

@@ -0,0 +1,132 @@
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
/**
* One backoffice action against a state-changing endpoint.
*
* Deliberately does NOT extend `BaseEntity`, which is the repo standard
* everywhere else. `BaseEntity` carries `updatedAt` and `deletedAt`, and both
* are wrong here:
*
* - `updatedAt` implies an audit row can be edited. A record that can be
* rewritten after the fact is not evidence.
* - `deletedAt` (soft delete) would let anyone who can delete erase their own
* trail, and TypeORM would then hide those rows from every default query —
* the failure would be silent, which is the worst property an audit log can
* have.
*
* Rows are insert-only: nothing in this module updates or deletes them.
*
* `userId` is a bare uuid with NO foreign key into the `iam` schema. Two
* reasons: cross-schema FKs are forbidden platform-wide, and a FK would let
* deleting a user cascade away the record of what that user did — exactly
* backwards. `userName` / `userRole` are point-in-time snapshots for the same
* reason: resolving them at read time would rewrite history whenever somebody
* is renamed or changes role.
*/
@Entity({ schema: 'freight', name: 'audit_logs' })
// Every audit query is time-bounded, so created_at leads most indexes.
@Index('IDX_audit_logs_created_at', ['createdAt'])
@Index('IDX_audit_logs_user_id_created_at', ['userId', 'createdAt'])
@Index('IDX_audit_logs_type_created_at', ['type', 'createdAt'])
@Index('IDX_audit_logs_type_resource_id', ['type', 'resourceId'])
@Index('IDX_audit_logs_route_path_created_at', ['routePath', 'createdAt'])
export class AuditLog {
@PrimaryGeneratedColumn('uuid')
id!: string;
/**
* Human-readable action, e.g. "Approve contract" — taken from the matched
* entry in `AUDIT_ENDPOINTS`, which sources it from each route's
* `@ApiOperation` summary.
*/
@Column({ name: 'title', type: 'varchar', length: 255 })
title!: string;
@Column({ name: 'method', type: 'varchar', length: 10 })
method!: string;
/**
* The URL as actually called, real ids and query string included
* (`/api/contracts/abc-123/cancel?force=true`). Query values run through the
* same redaction pass as the body, so a `?token=` never lands here.
*/
@Column({ name: 'url', type: 'text' })
url!: string;
/**
* The route template (`/api/contracts/:id/cancel`).
*
* `url` alone cannot be grouped — every contract cancel is a distinct string.
* This column is the join key back to `AUDIT_ENDPOINTS` and makes
* "every contract cancellation" one indexed query instead of a regex scan.
*/
@Column({ name: 'route_path', type: 'varchar', length: 255, nullable: true })
routePath?: string | null;
/** Primary entity the action touched: `Contract`, `Booking`, `Locomotive`. */
@Column({ name: 'type', type: 'varchar', length: 50 })
type!: string;
@Column({ name: 'is_success', type: 'boolean' })
isSuccess!: boolean;
/** IAM user id. Nullable by design — see the class comment. */
@Column({ name: 'user_id', type: 'uuid', nullable: true })
userId?: string | null;
/**
* Id of the affected record, recovered from the first path parameter of the
* matched template.
*
* `varchar`, not `uuid`: not every identifier is a uuid
* (`/api/contract-templates/:code`), and a create has no id at all until it
* succeeds. A `uuid NOT NULL` column would throw during the write and lose
* the audit row rather than the id.
*/
@Column({ name: 'resource_id', type: 'varchar', length: 64, nullable: true })
resourceId?: string | null;
/**
* Sanitized request body. Secrets are replaced with `[REDACTED]` and uploads
* are reduced to `{ __file, originalName, mimeType, size }` descriptors —
* never raw bytes. See `audit.sanitizer.ts`.
*/
@Column({ name: 'request', type: 'jsonb', nullable: true })
request?: Record<string, unknown> | null;
/**
* `isSuccess` alone cannot separate 403 (denied — the security signal worth
* alerting on) from 500 (broke). Both are simply `false`.
*/
@Column({ name: 'status_code', type: 'smallint', nullable: true })
statusCode?: number | null;
@Column({ name: 'error_message', type: 'text', nullable: true })
errorMessage?: string | null;
/** Snapshot of the actor's display name at the time of the action. */
@Column({ name: 'user_name', type: 'varchar', length: 150, nullable: true })
userName?: string | null;
/** Snapshot of the actor's role at the time of the action. */
@Column({ name: 'user_role', type: 'varchar', length: 100, nullable: true })
userRole?: string | null;
/** Non-repudiation: the first thing asked in any incident review. */
@Column({ name: 'ip_address', type: 'inet', nullable: true })
ipAddress?: string | null;
/** Helps separate a real browser session from a script using a stolen token. */
@Column({ name: 'user_agent', type: 'text', nullable: true })
userAgent?: string | null;
/** Correlates this row with application logs/traces for the same request. */
@Column({ name: 'request_id', type: 'varchar', length: 64, nullable: true })
requestId?: string | null;
@Column({ name: 'duration_ms', type: 'integer', nullable: true })
durationMs?: number | null;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
}

View File

@@ -10,9 +10,10 @@ import { ResetChannel } from "./dto/forgot-password.dto";
import {
ForgotPasswordService,
RESET_LINK_TTL_MS,
type ResetTicket,
} from "./forgot-password.service";
import { maskOtpTarget } from "./mask-target.util";
import { isDomesticPhone } from "../otp/otp.service";
import { isDomesticPhone, type OtpTarget } from "../otp/otp.service";
/** The account a staff-triggered reset would land on. */
export interface CustomerResetTarget {
@@ -86,28 +87,113 @@ export class CustomerResetService {
const resolved = await this.resolvePrimaryContactUser(companyId);
if (!resolved) return null;
const { user, userId } = resolved;
const target = this.forgotPasswordService.targetFor(user, channel);
if (!target) return null;
return this.sendResetLinkToUser(resolved.userId, channel, {
scope: `company ${companyId}`,
});
}
// A foreign number is unreachable by the domestic-only SMS gateway — treat
// it like a missing phone rather than reporting "link sent" for a message
// that will never arrive. The backoffice disables the channel up front via
// `phoneIsDomestic`; this guards direct API calls.
/**
* Mint and deliver a reset link to a specific IAM account.
*
* The delivery half of {@link sendResetLinkToCustomer}, split out so callers
* that resolve their target differently can reuse it: a customer is found via
* the company's primary contact, while a shipping line has no contact row at
* all and resolves straight off its own record. Everything below the lookup —
* active-account gating, the domestic-SMS rule, mint-before-send, the
* undelivered-link diagnostic — is identical for both and must stay that way.
*
* `scope` only labels the log line with whatever the caller resolved from.
*
* `allowWithoutCredential` relaxes the lookup for first-time activation:
* the default gate requires an existing active credential (so a reset cannot
* revive a suspended account), but an account that has never set a password
* has no credential row yet and would be excluded from its own activation
* link. Callers pass it only when the account is expected to be
* password-less — see ShippingLineCompaniesService.
*/
async sendResetLinkToUser(
userId: string,
channel: ResetChannel,
options?: { scope?: string; allowWithoutCredential?: boolean },
): Promise<SentResetLink | null> {
const sent = await this.sendResetLinkToUserOnChannels(userId, [channel], options);
return sent[0] ?? null;
}
/**
* One ticket, several channels. Minting retires every earlier ticket for the
* user (`mintResetTicket`), so sending email and SMS as two separate mints
* makes the first link dead on arrival — the same link must go to both.
* Returns one entry per channel that was actually sent (unreachable channels
* are skipped, not errors).
*/
async sendResetLinkToUserOnChannels(
userId: string,
channels: ResetChannel[],
options?: { scope?: string; allowWithoutCredential?: boolean },
): Promise<SentResetLink[]> {
const user = options?.allowWithoutCredential
? await this.forgotPasswordService.resolveActivatableUserById(userId)
: await this.forgotPasswordService.resolveActiveUserById(userId);
if (!user?.id) {
this.logger.warn(
`User ${userId} is not an active account${
options?.allowWithoutCredential
? ""
: " (or has no active credential — pass allowWithoutCredential for first-time activation)"
}`,
);
return [];
}
// Mint once, before any send: a failed send leaves an unused ticket that
// simply expires, whereas sending a link before the ticket exists would
// hand the customer a URL that is dead on arrival.
let ticket: ResetTicket | null = null;
const sent: SentResetLink[] = [];
for (const channel of channels) {
const target = this.forgotPasswordService.targetFor(user, channel);
if (!target) continue;
// The gateway silently drops foreign numbers — treat like a missing phone
// rather than reporting "link sent" for a message that will never arrive.
// The backoffice disables the channel up front via `phoneIsDomestic`; this
// guards direct API calls.
if (channel === "phone" && target.phone && !isDomesticPhone(target.phone)) {
this.logger.warn(
`Staff reset via SMS refused for user ${userId} — non-domestic phone`,
);
return null;
continue;
}
// Mint first, send second: a failed send leaves an unused ticket that simply
// expires, whereas sending a link before the ticket exists would hand the
// customer a URL that is dead on arrival.
const ticket = await this.forgotPasswordService.mintResetTicket(
userId,
ticket ??= await this.forgotPasswordService.mintResetTicket(
user.id,
RESET_LINK_TTL_MS,
);
const result = await this.deliverResetLink(
target,
user.id,
channel,
ticket,
options?.scope,
);
if (result) sent.push(result);
}
return sent;
}
/**
* Shared tail: send the already-minted ticket to a resolved target → report.
*/
private async deliverResetLink(
target: OtpTarget,
userId: string,
channel: ResetChannel,
ticket: ResetTicket,
scope?: string,
): Promise<SentResetLink | null> {
// A foreign number is unreachable by the domestic-only SMS gateway — treat
// it like a missing phone rather than reporting "link sent" for a message
const link = this.buildResetLink(ticket.userId, ticket.verificationCode);
const expiresAt = new Date(Date.now() + RESET_LINK_TTL_MS);
@@ -127,7 +213,22 @@ export class CustomerResetService {
});
this.logger.log(
`Staff-triggered ${channel} reset link sent to user ${userId} (company ${companyId}) queued=${queued}`,
`Staff-triggered shipping line ${channel} reset link sent to user ${userId}${
scope ? ` (${scope})` : ""
} queued=${queued}`,
);
// SECURITY: logs a live password-reset credential in cleartext. Anyone with
// read access to the log stream can set the password for the account named
// on the same line — including on sends that succeeded, not just failures.
// Kept deliberately: log aggregation is the debugging path for flaky
// email/SMS here, the same tradeoff otp.service.ts makes for OTP codes. If
// that is ever revisited, gate this on an env flag rather than deleting it,
// so dev keeps its workflow.
this.logger.warn(
`reset-link.cleartext channel=${channel} user=${userId}${
scope ? ` (${scope})` : ""
} link=${link}`,
);
if (!queued) {

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { PaginatedResponse } from '@edr/types';
import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
@@ -19,6 +20,7 @@ import { paginateQuery } from '../../common/utils/pagination.util';
export class ListUsersService {
constructor(
@InjectRepository(User) private readonly users: Repository<User>,
private readonly config: ConfigService,
) {}
findAll(query: ListUsersQueryDto): Promise<PaginatedResponse<User>> {
@@ -40,6 +42,29 @@ export class ListUsersService {
])
.orderBy(`user.${sortBy}`, query.sortOrder ?? 'ASC');
// Restrict to one IAM organization when configured. The org key differs per
// environment (dev seeds `edr_freight`, production uses the registered
// company key), so this is config rather than a constant. An unset key
// means no restriction; a key matching no organization matches no user —
// failing closed rather than silently widening to every org.
const orgKey = this.config.get<string>('FREIGHT_ORG_KEY');
if (orgKey) {
// EXISTS, not a join: a user with several employee rows would otherwise
// be returned once per row, duplicating them in the list and inflating
// `getManyAndCount`'s total.
qb.andWhere(
`EXISTS (
SELECT 1
FROM iam.employees emp
JOIN iam.organizations org ON org.id = emp.organization_id
WHERE emp.user_id = "user".id
AND org.key = :orgKey
AND org.deleted_at IS NULL
)`,
{ orgKey },
);
}
if (query.userType) {
qb.andWhere('user.userType = :userType', { userType: query.userType });
}

View File

@@ -46,7 +46,8 @@ export class BackofficeService {
/**
* IAM user ids of every current employee across all organizations — used by
* the notification recipients resolver's `allBackoffice` selector.
* support chat for staff room membership. Notifications deliberately do NOT
* use this: they target a desk via `getEmployeeUserIdsByPermission`.
*/
async getAllCurrentEmployeeUserIds(): Promise<string[]> {
const employees = await this.employeeRepository.find({
@@ -63,24 +64,67 @@ export class BackofficeService {
* IAM user ids of current employees (any org) holding ANY of the given
* permission keys — used by the notification recipients resolver's
* `permissionKeys` selector for department/role-scoped targeting.
*
* This MUST agree with the request-time guard (`hasFreightPermission`,
* common/freight-permission.util.ts), which counts four grant carriers plus
* the super_admin bypass. Counting fewer silently drops legitimate
* recipients: an earlier version joined only direct position permissions, on
* which `bookings:view` resolved to 2 users — against 21 through position
* TYPES, which is where admin-created positions actually keep their grants.
*
* Raw SQL rather than QueryBuilder because `Position.positionTypePermissions`
* declares its inverse side against PositionType, so a relation join emits
* `ptp.position_type_id = position.id` and silently matches nothing. Same
* approach as FreightMeService's position-type lookups.
*/
async getEmployeeUserIdsByPermission(
permissionKeys: string[],
): Promise<string[]> {
if (!permissionKeys.length) return [];
const rows: { userId: string | null }[] = await this.employeeRepository
.createQueryBuilder("employee")
.innerJoin("employee.employeePositions", "employeePosition")
.innerJoin("employeePosition.position", "position")
.innerJoin("position.positionPermission", "positionPermission")
.innerJoin("positionPermission.permission", "permission")
.where("employee.isCurrent = :isCurrent", { isCurrent: true })
.andWhere("permission.key IN (:...permissionKeys)", { permissionKeys })
.select("DISTINCT employee.user_id", "userId")
.getRawMany();
return rows
.map((r) => r.userId)
.filter((id): id is string => Boolean(id));
const rows: { userId: string }[] = await this.dataSource.query(
`WITH target AS (SELECT id FROM iam.permissions WHERE key = ANY($1))
-- 1. IAM role grants (user_roles -> role_permissions).
SELECT e.user_id AS "userId"
FROM iam.employees e
JOIN iam.user_roles ur ON ur.user_id = e.user_id
JOIN iam.role_permissions rp ON rp.role_id = ur.role_id
WHERE e.is_current AND e.user_id IS NOT NULL
AND rp.permission_id IN (SELECT id FROM target)
UNION
-- 2. Direct position grants. A delegate keeps their own position AND
-- gains the one they stand in for, so both columns count.
SELECT e.user_id
FROM iam.employees e
JOIN iam.employee_positions ep
ON ep.employee_id = e.id AND ep.is_current
JOIN iam.position_permissions pp
ON pp.position_id IN (ep.position_id, ep.delegatee_position_id)
WHERE e.is_current AND e.user_id IS NOT NULL
AND pp.permission_id IN (SELECT id FROM target)
UNION
-- 3. Position TYPE grants — where admin-created positions keep theirs.
SELECT e.user_id
FROM iam.employees e
JOIN iam.employee_positions ep
ON ep.employee_id = e.id AND ep.is_current
JOIN iam.positions p
ON p.id IN (ep.position_id, ep.delegatee_position_id)
JOIN iam.position_type_permissions ptp
ON ptp.position_type_id = p.position_type_id
WHERE e.is_current AND e.user_id IS NOT NULL
AND ptp.permission_id IN (SELECT id FROM target)
UNION
-- 4. super_admin passes every freight permission check, so mirror that
-- here or admins go blind on desks nobody else has been granted yet.
SELECT e.user_id
FROM iam.employees e
JOIN iam.user_roles ur ON ur.user_id = e.user_id
JOIN iam.roles r ON r.id = ur.role_id
WHERE e.is_current AND e.user_id IS NOT NULL
AND r.key = 'super_admin'`,
[permissionKeys],
);
return rows.map((r) => r.userId);
}
async createOrganizationUser(

View File

@@ -1,22 +1,36 @@
import {
BadRequestException,
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
Query,
Res,
UploadedFile,
UseInterceptors,
} from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { FileInterceptor } from "@nestjs/platform-express";
import {
ApiBearerAuth,
ApiConsumes,
ApiOperation,
ApiTags,
} from "@nestjs/swagger";
import type { Response } from "express";
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 { resolveAuthUserId } from "../../common/resolve-auth-user-id";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { actorLabel } from "../warehouses/current-actor.util";
import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service";
import { BillingService } from "./billing.service";
import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
import { IssueMemoDto } from "./dto/issue-memo.dto";
@ApiTags("billing")
@Controller("billing")
@@ -25,6 +39,8 @@ import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
@BookingStaff([
FREIGHT_PERMS.invoices.view,
FREIGHT_PERMS.invoices.export,
FREIGHT_PERMS.invoices.confirmOffline,
FREIGHT_PERMS.invoices.memoIssue,
])
@ApiBearerAuth()
export class BillingController {
@@ -50,17 +66,84 @@ export class BillingController {
});
}
@Get("invoices/summary")
@ApiOperation({
summary:
"Total collected (paidAmount) across every filtered invoice, grouped by currency",
})
async collectedSummary(
@Query() query: FilterInvoiceDto,
@CurrentUser() user: TCurrentUser,
) {
const allowed =
await this.userTradeAccessService.resolveAllowedDirections(user);
return this.billingService.collectedSummary({
...query,
tradeDirections: allowed ?? undefined,
});
}
@Get("invoices/:id")
@ApiOperation({ summary: "Get an invoice with its line items" })
findById(@Param("id", ParseUUIDPipe) id: string) {
return this.billingService.findById(id);
}
@Get("offline-usd")
@ApiOperation({
summary:
"Finance worklist: USD and ETB invoices settled manually (bank transfer / counter), with booking pay-window context",
})
findOfflineUsd(@Query() query: FilterInvoiceDto) {
return this.billingService.findOfflineUsdPaginated(query);
}
@Post("invoices/:id/confirm-offline")
@BookingStaff(FREIGHT_PERMS.invoices.confirmOffline)
@UseInterceptors(FileInterceptor("file"))
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary:
"Finance confirms an invoice (USD or ETB) paid manually — slip file required, settles the full balance",
})
confirmOffline(
@Param("id", ParseUUIDPipe) id: string,
@UploadedFile() file: Express.Multer.File | undefined,
@Body("reference") reference: string | undefined,
@CurrentUser() user: TCurrentUser,
) {
return this.billingService.confirmOfflinePayment(id, file, {
reference: reference?.trim() || null,
userId: resolveAuthUserId(user),
userName: actorLabel(user) ?? null,
});
}
@Post("invoices/:id/memo")
@BookingStaff(FREIGHT_PERMS.invoices.memoIssue)
@ApiOperation({
summary:
"Issue a credit or debit memo against a registered invoice (MoR DEB/CRE). Filing-equivalent — the auto-submit sweep picks it up like any other issued invoice.",
})
issueMemo(@Param("id", ParseUUIDPipe) id: string, @Body() dto: IssueMemoDto) {
return this.billingService.issueMemo(id, dto);
}
@Get("invoices/:id/document")
@BookingStaff(FREIGHT_PERMS.invoices.export)
@ApiOperation({ summary: "Download the sealed invoice PDF" })
async document(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.billingService.document(id);
@ApiOperation({
summary:
'Download the sealed invoice PDF. ?format=a4 (default) or ?format=thermal for the 80mm thermal layout (ADD-P001).',
})
async document(
@Param("id", ParseUUIDPipe) id: string,
@Query("format") format: string | undefined,
@Res() res: Response,
) {
if (format !== undefined && format !== "a4" && format !== "thermal") {
throw new BadRequestException(`Unsupported format "${format}" — use "a4" or "thermal".`);
}
const { filename, buffer } = await this.billingService.document(id, format === "thermal" ? "thermal" : "a4");
sendPdf(res, filename, buffer);
}

View File

@@ -13,17 +13,21 @@ import { InvoiceRepository } from "./invoice.repository";
import { InvoiceLineRepository } from "./invoice-line.repository";
import { PaymentModule } from "../payment/payment.module";
import { CompaniesModule } from "../companies/companies.module";
import { FilesModule } from "../files/files.module";
@Module({
imports: [
TypeOrmModule.forFeature([Invoice, InvoiceLine]),
forwardRef(() => PaymentModule),
CompaniesModule,
// Cycles back via ShippingLineCompaniesModule, which imports this module.
forwardRef(() => CompaniesModule),
DocumentsModule,
UserTradeAccessModule,
FilesModule,
],
controllers: [BillingController, PortalBillingController, PaymentController],
providers: [BillingService, InvoiceRepository, InvoiceLineRepository],
exports: [BillingService],
})
export class BillingModule {}

View File

@@ -79,6 +79,9 @@ describe("BillingService.generateInvoice", () => {
{} as never, // payment
{} as never, // companies
{} as never, // invoiceDocuments
{} as never, // files
{ get: () => undefined } as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
);
});
@@ -117,6 +120,160 @@ describe("BillingService.generateInvoice", () => {
});
});
describe("BillingService.issueMemo", () => {
const ORIGINAL_ID = "original-invoice-1";
function originalInvoice(overrides: Record<string, unknown> = {}) {
return {
id: ORIGINAL_ID,
invoiceNumber: "INV-20260807-00042",
eimsIrn: "irn-value",
eimsDocumentType: "INV",
eimsStatus: "REGISTERED",
source: Freight.InvoiceSource.Booking,
sourceId: "booking-1",
companyId: "company-1",
companyProfileId: "profile-1",
shippingLineCompanyId: null,
currency: "ETB",
totalAmount: 1500,
lines: [
{ chargeType: "RAIL_FREIGHT", description: "Rail freight", quantity: 2, unitRate: 500, amount: 1000, currency: "ETB", metadata: null },
{ chargeType: "HAZARD_SURCHARGE", description: "Hazard surcharge", quantity: 2, unitRate: 250, amount: 500, currency: "ETB", metadata: null },
],
...overrides,
};
}
function build(original: ReturnType<typeof originalInvoice>) {
const savedLines: unknown[] = [];
const manager = makeManager(savedLines);
const dataSource = {
transaction: jest.fn().mockImplementation((cb: (mg: unknown) => unknown) => cb(manager)),
manager,
};
const invoices = { findById: jest.fn().mockResolvedValue(original) };
const invoiceLines = { findAll: jest.fn().mockResolvedValue(original.lines) };
const service = new BillingService(
dataSource as never,
invoices as never,
invoiceLines as never,
makeEvents() as never,
{} as never,
{} as never,
{} as never,
{} as never,
{ get: () => undefined } as never,
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
);
return { service, manager, savedLines };
}
it("creates a settled credit memo copying the original's lines, linked via relatedInvoiceId", async () => {
const { service, savedLines } = build(originalInvoice());
const memo = await service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: "Overbilled freight charge" });
expect(memo.invoiceNumber).toMatch(/^CRE-\d{8}-00001$/);
expect(memo.totalAmount).toBe(1500);
expect(memo.status).toBe(Freight.InvoiceStatus.Paid);
expect((memo as unknown as Record<string, unknown>).eimsDocumentType).toBe("CRE");
expect((memo as unknown as Record<string, unknown>).eimsReason).toBe("Overbilled freight charge");
expect((memo as unknown as Record<string, unknown>).relatedInvoiceId).toBe(ORIGINAL_ID);
expect((memo as unknown as Record<string, unknown>).paidAmount).toBe(1500);
expect((memo as unknown as Record<string, unknown>).balanceAmount).toBe(0);
expect(savedLines).toHaveLength(2);
});
it("creates an open, unpaid debit memo — a genuine new receivable, not force-settled", async () => {
const { service } = build(originalInvoice());
const memo = await service.issueMemo(ORIGINAL_ID, { type: "DEB", reason: "Additional handling fee" });
expect(memo.invoiceNumber).toMatch(/^DEB-\d{8}-00001$/);
expect(memo.status).toBe(Freight.InvoiceStatus.Pending);
expect(memo.balanceAmount).toBe(1500);
expect(memo.paidAmount).toBe(0);
});
it("keys the memo's sourceId to the original invoice's own id, not the original's sourceId", async () => {
const { service } = build(originalInvoice());
const memo = await service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: "test" });
expect(memo.sourceId).toBe(ORIGINAL_ID);
expect(memo.sourceId).not.toBe("booking-1");
});
it("allows a partial memo with explicit lines instead of copying the original", async () => {
const { service } = build(originalInvoice());
const memo = await service.issueMemo(ORIGINAL_ID, {
type: "CRE",
reason: "Partial credit",
lines: [{ chargeType: "RAIL_FREIGHT", quantity: 1, unitRate: 200, amount: 200 }],
});
expect(memo.totalAmount).toBe(200);
});
it("refuses a memo against an invoice never registered with EIMS", async () => {
const { service } = build(originalInvoice({ eimsIrn: null }));
await expect(service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: "x" })).rejects.toMatchObject({
response: expect.objectContaining({ code: "EIMS_RELATED_INVOICE_NOT_REGISTERED" }),
});
});
it("refuses a memo against a memo", async () => {
const { service } = build(originalInvoice({ eimsDocumentType: "CRE" }));
await expect(service.issueMemo(ORIGINAL_ID, { type: "DEB", reason: "x" })).rejects.toThrow(
"cannot issue a memo against a memo",
);
});
it("refuses a memo against an EIMS-cancelled invoice", async () => {
const { service } = build(originalInvoice({ eimsStatus: "CANCELLED" }));
await expect(service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: "x" })).rejects.toThrow(
"cancelled with EIMS",
);
});
it("refuses a credit memo whose total exceeds the original", async () => {
const { service } = build(originalInvoice({ totalAmount: 1500 }));
await expect(
service.issueMemo(ORIGINAL_ID, {
type: "CRE",
reason: "too much",
lines: [{ chargeType: "RAIL_FREIGHT", quantity: 1, unitRate: 2000, amount: 2000 }],
}),
).rejects.toThrow(/exceeds/);
});
it("does NOT bound a debit memo by the original's total — it is a new charge, not a refund", async () => {
const { service } = build(originalInvoice({ totalAmount: 1500 }));
const memo = await service.issueMemo(ORIGINAL_ID, {
type: "DEB",
reason: "additional charge",
lines: [{ chargeType: "RAIL_FREIGHT", quantity: 1, unitRate: 5000, amount: 5000 }],
});
expect(memo.totalAmount).toBe(5000);
});
it("refuses a blank reason", async () => {
const { service } = build(originalInvoice());
await expect(service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: " " })).rejects.toThrow(
"requires a reason",
);
});
});
describe("BillingService.markInvoiceAsPaid", () => {
it("marks the invoice PAID, stamps amounts/paidAt, links the payment, and emits ${source}.invoice.paid", async () => {
const open = {
@@ -140,6 +297,9 @@ describe("BillingService.markInvoiceAsPaid", () => {
{} as never, // payment
{} as never, // companies
{} 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);
@@ -193,6 +353,9 @@ describe("BillingService.markInvoiceAsPaid", () => {
{} as never, // payment
{} as never, // companies
{} 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);
@@ -236,6 +399,9 @@ describe("BillingService.settleByPaymentId", () => {
{} as never, // payment
{} as never, // companies
{} 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 };
}
@@ -347,6 +513,9 @@ describe("BillingService.recordPayment", () => {
{} as never, // payment
{} as never, // companies
{} 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 };
}
@@ -462,6 +631,9 @@ describe("BillingService.expirePayable — locked write runs in a transaction",
{} as never,
{} as never,
{} as never,
{} as never,
{} as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
);
return { service, defaultManager, txManager, transaction };
};
@@ -533,6 +705,9 @@ describe("BillingService.issuePayable", () => {
{} as never,
{} as never,
{} as never,
{} as never,
{} as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
);
return { service, manager };
};
@@ -622,6 +797,9 @@ describe("BillingService — CAC Bank (OTP debit)", () => {
payment as never,
{} as never,
{} as never,
{} as never,
{} as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
);
return { service, repo };
};
@@ -669,11 +847,11 @@ describe("BillingService — CAC Bank (OTP debit)", () => {
});
});
describe("BillingService — CBE bill amounts round UP to whole birr", () => {
// CBE bills whole birr. Ceil, never Math.round: a .40 balance rounded down
// settles 0.40 short while markInvoiceAsPaid still writes paidAmount =
// totalAmount — money missing from the bank with the books saying paid.
// payInvoice and billQuery must agree, or /cbe/payment sees a mismatch.
describe("BillingService — CBE bill amounts carry cents, never rounded", () => {
// CBE settles to the cent (/cbe/payment gates on amountsMatchToTheCent), so the
// bill must quote the exact balance. Rounding UP overcharged the payer by up to
// a birr; rounding DOWN underpaid while markInvoiceAsPaid still wrote paidAmount
// = totalAmount. payInvoice and billQuery must agree, or /cbe/payment mismatches.
const invoice = {
id: "inv-1",
status: Freight.InvoiceStatus.Pending,
@@ -682,9 +860,9 @@ describe("BillingService — CBE bill amounts round UP to whole birr", () => {
type: "PREPAID",
invoiceNumber: "INV-20260101-00001",
currency: "ETB",
// .40the case Math.round gets wrong (rounds down, underpays).
balanceAmount: 12345.4,
totalAmount: 12345.4,
// .43cents that must survive all the way to the bill.
balanceAmount: 12345.43,
totalAmount: 12345.43,
company: { name: "Acme PLC" },
paymentId: null,
dueAt: null,
@@ -703,11 +881,14 @@ describe("BillingService — CBE bill amounts round UP to whole birr", () => {
payment as never,
{} as never,
{} as never,
{} as never,
{} as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
);
return { service, repo };
};
it("opens the intent for the ceiled balance, never below it", async () => {
it("opens the intent for the exact balance, cents included", async () => {
const initiate = jest.fn().mockResolvedValue({
intentId: "intent-1",
immediateSuccess: false,
@@ -718,16 +899,137 @@ describe("BillingService — CBE bill amounts round UP to whole birr", () => {
await service.payInvoice("inv-1", { method: "CBE_BILL" });
expect(initiate).toHaveBeenCalledWith(
expect.objectContaining({ amountMinor: 12346 }),
expect.objectContaining({ amountMinor: 12345.43 }),
);
});
it("quotes the same ceiled amount on bill-query as payInvoice opened", async () => {
it("quotes the same exact amount on bill-query as payInvoice opened", async () => {
const { service } = build();
await expect(service.billQuery("booking-1")).resolves.toMatchObject({
stillPayable: true,
currentAmountMinor: 12346,
currentAmountMinor: 12345.43,
});
});
});
describe("BillingService.document", () => {
const invoiceRow = (over: Record<string, unknown> = {}) => ({
id: "inv-1",
invoiceNumber: "INV-20260812-00001",
source: "booking",
sourceId: "booking-1",
status: Freight.InvoiceStatus.Pending,
type: "freight",
currency: "ETB",
subtotalAmount: 100,
taxAmount: 0,
totalAmount: 100,
paidAmount: 0,
balanceAmount: 100,
issuedAt: new Date(2026, 7, 12),
dueAt: new Date(2026, 7, 19),
eimsIrn: null,
eimsSignedQr: null,
company: { name: "ABC Trading PLC", tin: "0999930000", vatNumber: "123475885858" },
...over,
});
const build = (invoice: Record<string, unknown>) => {
const render = jest.fn().mockResolvedValue({ filename: "x.pdf", buffer: Buffer.from("") });
const renderThermal = jest.fn().mockResolvedValue({ filename: "x-thermal.pdf", buffer: Buffer.from("") });
const service = new BillingService(
{} as never,
{ findById: jest.fn().mockResolvedValue(invoice) } as never,
{ findAll: jest.fn().mockResolvedValue([]) } as never,
{} as never,
{} as never,
{} as never,
{ render, renderThermal } as never,
{} as never,
{
get: (key: string) =>
key === "eims"
? { tin: "0053481357", invoice: { sellerVatNumber: "43256663343256663322" } }
: undefined,
} as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
);
return { service, render, renderThermal };
};
it("adds no EIMS IRN row and no QR for an unregistered invoice", async () => {
const { service, render } = build(invoiceRow());
await service.document("inv-1");
const model = render.mock.calls[0][0];
expect(model.summary.find((r: { label: string }) => r.label === "EIMS IRN")).toBeUndefined();
expect(model.qrImageUrl).toBeNull();
});
it("shows the buyer's name, TIN and VAT number on every invoice", async () => {
const { service, render } = build(invoiceRow());
await service.document("inv-1");
const model = render.mock.calls[0][0];
expect(model.summary).toContainEqual({ label: "Buyer", value: "ABC Trading PLC" });
expect(model.summary).toContainEqual({ label: "Buyer TIN", value: "0999930000" });
expect(model.summary).toContainEqual({ label: "Buyer VAT No.", value: "123475885858" });
});
it("omits the VAT row when the buyer company has none", async () => {
const { service, render } = build(invoiceRow({ company: { name: "Acme", tin: "0011223344" } }));
await service.document("inv-1");
const model = render.mock.calls[0][0];
expect(model.summary.find((r: { label: string }) => r.label === "Buyer VAT No.")).toBeUndefined();
});
it("shows EDR's own seller TIN and VAT number from EIMS config", async () => {
const { service, render } = build(invoiceRow());
await service.document("inv-1");
const model = render.mock.calls[0][0];
expect(model.summary).toContainEqual({ label: "Seller TIN", value: "0053481357" });
expect(model.summary).toContainEqual({
label: "Seller VAT No.",
value: "43256663343256663322",
});
});
it("adds the EIMS IRN to the summary and renders the QR for a registered invoice", async () => {
const { service, render } = build(
invoiceRow({ eimsIrn: "IRN-123", eimsSignedQr: "signed-payload" }),
);
await service.document("inv-1");
const model = render.mock.calls[0][0];
expect(model.summary).toContainEqual({ label: "EIMS IRN", value: "IRN-123" });
expect(model.qrImageUrl).toBe("data:image/png;base64,signed-payload");
});
it("calls render (not renderThermal) for the default format", async () => {
const { service, render, renderThermal } = build(invoiceRow());
jest.spyOn(service as never, "toDocumentModel").mockResolvedValue({} as never);
await service.document("inv-1");
expect(render).toHaveBeenCalledTimes(1);
expect(renderThermal).not.toHaveBeenCalled();
});
it("calls renderThermal (not render) for format 'thermal'", async () => {
const { service, render, renderThermal } = build(invoiceRow());
jest.spyOn(service as never, "toDocumentModel").mockResolvedValue({} as never);
await service.document("inv-1", "thermal");
expect(renderThermal).toHaveBeenCalledTimes(1);
expect(render).not.toHaveBeenCalled();
});
});

View File

@@ -1,4 +1,5 @@
import { Freight, PaymentReferenceType } from "@edr/types";
import { ConfigService } from "@nestjs/config";
import {
BadRequestException,
forwardRef,
@@ -8,16 +9,26 @@ import {
NotFoundException,
} from "@nestjs/common";
import { EventEmitter2 } from "@nestjs/event-emitter";
import { DataSource, EntityManager, In } from "typeorm";
import { logCtx } from "@edr/api-common";
import { DataSource, EntityManager, In, SelectQueryBuilder } from "typeorm";
import { Booking } from "../bookings/entities/booking.entity";
// Entity-only import (no module edge): portal reads resolve shipping-line
// payers straight off the table.
import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity";
import { 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";
import { FilesService } from "../files/files.service";
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
import { PaymentService } from "../payment/payment.service";
import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto";
import {
InvoiceDocumentModel,
InvoiceDocumentService,
pngDataUrl,
} from "./documents/invoice-document.service";
import { InvoiceLine } from "./entities/invoice-line.entity";
import { Invoice, InvoicePayment } from "./entities/invoice.entity";
@@ -35,6 +46,22 @@ export interface PayInvoiceOptions {
failureUrl?: string;
}
/** Booking context attached to a finance offline-USD invoice row. */
export interface OfflineUsdBookingInfo {
id: string;
reference: string;
tradeDirection: string | null;
paymentDeadline: Date | null;
paymentStatus: string;
}
/** Row shape of the manual-payments worklist. */
export type OfflineUsdInvoiceRow = Invoice & {
booking: OfflineUsdBookingInfo | null;
/** Shipping-line credit invoices span many bookings — one entry per credit. */
bookings: { id: string; reference: string; tradeDirection: string | null }[];
};
/** A single manual/offline settlement to record against an invoice. */
export interface RecordPaymentInput {
/** Amount settled by this payment; must be > 0. */
@@ -102,8 +129,16 @@ export interface GenerateInvoiceInput {
sourceId: string;
/** What the invoice is for (e.g. "prepaid", "credit"). */
type: string;
companyId: string;
companyProfileId: string;
/** The customer billed. Omit only when billing a shipping line instead. */
companyId?: string | null;
companyProfileId?: string | null;
/**
* The shipping line billed, for an invoice covering batched shipping-line
* credits. Mutually exclusive with `companyId` — the DB enforces this via
* `chk_invoices_single_payer`, and {@link createInvoice} rejects a payload
* setting both or neither before it ever reaches the constraint.
*/
shippingLineCompanyId?: string | null;
lines: InvoiceLineInput[];
currency?: string;
/** Explicit pre-tax subtotal; defaults to the sum of line amounts. */
@@ -122,6 +157,18 @@ export interface GenerateInvoiceInput {
status?: Freight.InvoiceStatus;
}
/** MoR `DocumentDetails.Type` for a memo — see `EIMS_DOCUMENT_TYPES` in `eims-invoice.mapper.ts`. */
export type MemoType = "CRE" | "DEB";
/** Everything needed to issue a credit or debit memo against an already-registered invoice. */
export interface IssueMemoInput {
type: MemoType;
/** Why the memo was issued — required by MoR as `DocumentDetails.Reason`. */
reason: string;
/** Omit to copy every line of the original verbatim (a full reversal/charge, the common case). */
lines?: InvoiceLineInput[];
}
/** Payload broadcast on `${source}.invoice.<event>`. */
export interface InvoiceEventPayload {
invoiceId: string;
@@ -129,8 +176,11 @@ export interface InvoiceEventPayload {
source: Freight.InvoiceSource;
sourceId: string;
type: string;
companyId: string;
companyProfileId: string;
/** Null when the payer is a shipping line rather than a customer company. */
companyId: string | null;
companyProfileId: string | null;
/** Set only on shipping-line invoices; mutually exclusive with `companyId`. */
shippingLineCompanyId?: string | null;
totalAmount: number;
currency: string;
status: Freight.InvoiceStatus;
@@ -150,6 +200,9 @@ export class BillingService {
private readonly payment: PaymentService,
private readonly companies: CompaniesService,
private readonly invoiceDocuments: InvoiceDocumentService,
private readonly files: FilesService,
private readonly config: ConfigService,
private readonly manualPaymentSettings: ManualPaymentSettingsService,
) { }
// ── Reads ──────────────────────────────────────────────────────────────────
@@ -164,6 +217,40 @@ export class BillingService {
* company (customer detail "Invoices" tab) and/or status/search (global
* invoices page).
*/
/** Same list filters `findAllPaginated` and `collectedSummary` both narrow by. */
private applyInvoiceFilters(
qb: SelectQueryBuilder<Invoice>,
filter: {
companyId?: string;
status?: Freight.InvoiceStatus;
search?: string;
tradeDirections?: string[];
},
) {
if (filter.companyId) {
qb.andWhere("invoice.companyId = :companyId", {
companyId: filter.companyId,
});
}
if (filter.status) {
qb.andWhere("invoice.status = :status", { status: filter.status });
}
if (filter.search) {
qb.andWhere(
"(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)",
{ search: `%${filter.search}%` },
);
}
if (filter.tradeDirections) {
applyBookingRefDirectionScope(
qb,
"invoice.source_id",
filter.tradeDirections,
);
}
return qb;
}
async findAllPaginated(
filter: {
companyId?: string;
@@ -187,13 +274,126 @@ export class BillingService {
.skip((page - 1) * pageSize)
.take(pageSize);
if (filter.companyId) {
qb.andWhere("invoice.companyId = :companyId", {
companyId: filter.companyId,
this.applyInvoiceFilters(qb, filter);
const [items, total] = await qb.getManyAndCount();
return { items: await this.attachShippingLineCompanies(items), total };
}
/**
* Batch-hydrate `shippingLineCompany` for any invoice billed to a shipping
* line (`companyId` null). No relation on `Invoice` to eager-load — see the
* entity's doc comment — so this is a second query keyed off the ids
* already loaded, same shape as `company`.
*/
private async attachShippingLineCompanies<T extends Invoice>(
invoices: T[],
): Promise<T[]> {
const ids = [
...new Set(
invoices
.map((i) => i.shippingLineCompanyId)
.filter((id): id is string => id != null),
),
];
if (!ids.length) return invoices;
const lines = await this.dataSource
.getRepository(ShippingLineCompany)
.find({ where: { id: In(ids) } });
const byId = new Map(lines.map((l) => [l.id, l]));
return invoices.map((invoice) => {
const line = invoice.shippingLineCompanyId
? byId.get(invoice.shippingLineCompanyId)
: undefined;
return line
? ({
...invoice,
shippingLineCompany: {
id: line.id,
name: line.name,
email: line.email,
phoneNumber: line.phoneNumber,
},
} as T)
: invoice;
});
}
/**
* Total collected (`paidAmount`) across every invoice matching the same
* filters as `findAllPaginated`, grouped by currency — unpaginated, so the
* invoices summary card reflects the whole filtered set, not just the
* visible page.
*/
async collectedSummary(
filter: {
companyId?: string;
status?: Freight.InvoiceStatus;
search?: string;
tradeDirections?: string[];
} = {},
): Promise<Record<string, number>> {
const qb = this.dataSource
.getRepository(Invoice)
.createQueryBuilder("invoice")
.select("invoice.currency", "currency")
.addSelect("SUM(invoice.paidAmount)", "collected")
.groupBy("invoice.currency");
this.applyInvoiceFilters(qb, filter);
const rows: { currency: string; collected: string }[] =
await qb.getRawMany();
return Object.fromEntries(
rows.map((row) => [row.currency, Number(row.collected) || 0]),
);
}
/**
* Finance's manual-settlement worklist: USD invoices (paid by bank transfer,
* never through the gateway) and ETB invoices Finance settles by hand (bank
* transfer / counter) instead of the customer paying online. Open ones by
* default or a single status when filtered; both currencies unless
* `currency` narrows it. Booking-sourced rows carry the booking's reference,
* trade direction and pay-window deadline so the UI can show the countdown
* and link to the booking.
*/
async findOfflineUsdPaginated(
filter: {
status?: Freight.InvoiceStatus;
search?: string;
currency?: "USD" | "ETB";
page?: number;
pageSize?: number;
} = {},
): Promise<{ items: OfflineUsdInvoiceRow[]; total: number }> {
const page = filter.page && filter.page > 0 ? filter.page : 1;
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 (:...currencies)", { currencies })
.orderBy("invoice.issuedAt", "DESC")
.skip((page - 1) * pageSize)
.take(pageSize);
if (filter.status) {
qb.andWhere("invoice.status = :status", { status: filter.status });
} else {
qb.andWhere("invoice.status IN (:...open)", { open: OPEN_STATUSES });
}
if (filter.search) {
qb.andWhere(
@@ -202,16 +402,141 @@ export class BillingService {
);
}
if (filter.tradeDirections) {
applyBookingRefDirectionScope(
qb,
"invoice.source_id",
filter.tradeDirections,
);
const [rawItems, total] = await qb.getManyAndCount();
const items = await this.attachShippingLineCompanies(rawItems);
const bookingIds = items
.filter((i) => i.source === "booking")
.map((i) => i.sourceId);
const bookings = bookingIds.length
? await this.dataSource.getRepository(Booking).find({
where: { id: In(bookingIds) },
select: [
"id",
"reference",
"tradeDirection",
"paymentDeadline",
"paymentStatus",
],
})
: [];
const byId = new Map(bookings.map((b) => [b.id, b]));
// Shipping-line credit invoices bill many bookings at once; each credit
// keeps its own booking link, so collect them per invoice.
const creditInvoiceIds = items
.filter((i) => i.source === Freight.InvoiceSource.ShippingLineCredit)
.map((i) => i.id);
const credits = creditInvoiceIds.length
? await this.dataSource.getRepository(ShippingLineCredit).find({
where: { invoiceId: In(creditInvoiceIds) },
relations: { booking: true },
})
: [];
const bookingsByInvoice = new Map<
string,
OfflineUsdInvoiceRow["bookings"]
>();
for (const c of credits) {
if (!c.invoiceId || !c.booking) continue;
const list = bookingsByInvoice.get(c.invoiceId) ?? [];
list.push({
id: c.booking.id,
reference: c.booking.reference,
tradeDirection: c.booking.tradeDirection ?? null,
});
bookingsByInvoice.set(c.invoiceId, list);
}
const [items, total] = await qb.getManyAndCount();
return { items, total };
return {
items: items.map((inv) => {
const b = byId.get(inv.sourceId);
return {
...inv,
booking: b
? {
id: b.id,
reference: b.reference,
tradeDirection: b.tradeDirection ?? null,
paymentDeadline: b.paymentDeadline ?? null,
paymentStatus: b.paymentStatus,
}
: null,
bookings: bookingsByInvoice.get(inv.id) ?? [],
} as OfflineUsdInvoiceRow;
}),
total,
};
}
/**
* Finance confirms an invoice (USD or ETB) as paid manually — bank transfer
* 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
* the booking advances exactly as if it had been paid through the gateway.
*
* Guarded by the booking's pay window: past the deadline the booking expires
* like any unpaid one, so confirmation is refused.
*/
async confirmOfflinePayment(
invoiceId: string,
file: Express.Multer.File | undefined,
input: {
reference?: string | null;
userId?: string | null;
userName?: string | null;
},
): Promise<Invoice> {
const invoice = await this.invoices.findById(invoiceId);
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
// The channel is a setting, not a role: even a permitted user cannot
// settle by hand in a currency whose channel is switched off.
if (!(await this.manualPaymentSettings.isEnabled(invoice.currency))) {
throw new BadRequestException(
`Manual payment is disabled for ${invoice.currency ?? "this"} invoices. Enable it in Configuration → Manual payments first.`,
);
}
if (!file) {
throw new BadRequestException("The bank payment slip file is required.");
}
if (invoice.source === "booking") {
const booking = await this.dataSource.getRepository(Booking).findOne({
where: { id: invoice.sourceId },
select: ["id", "paymentDeadline"],
});
const deadline = booking?.paymentDeadline;
if (deadline && new Date(deadline).getTime() < Date.now()) {
throw new BadRequestException(
"The payment window has closed — this booking can no longer be confirmed as paid.",
);
}
}
const slip = await this.files.upload({
resource: "invoice",
resourceId: invoice.id,
code: "OFFLINE_PAYMENT_SLIP",
file,
title: "Bank payment slip",
uploadedByUserId: input.userId ?? null,
uploadedByName: input.userName ?? null,
});
return this.recordPayment(invoiceId, {
amount: Number(invoice.balanceAmount),
method: "BANK_TRANSFER",
reference: input.reference || slip.name,
metadata: {
offline: true,
slipFileId: slip.id,
confirmedByUserId: input.userId ?? null,
confirmedByName: input.userName ?? null,
},
});
}
/** Invoice header plus its line items. */
@@ -220,21 +545,30 @@ export class BillingService {
relations: { company: true, companyProfile: true },
});
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
const [hydrated] = await this.attachShippingLineCompanies([invoice]);
const lines = await this.invoiceLines.findAll({
where: { invoiceId: id },
order: { createdAt: "ASC" },
});
return { ...invoice, lines } as Invoice & { lines: InvoiceLine[] };
return { ...hydrated, lines } as Invoice & { lines: InvoiceLine[] };
}
// ── Documents (central PDF) ──────────────────────────────────────────────────
/** Sealed PDF invoice for any source, rendered by the shared document service. */
async document(id: string): Promise<{ filename: string; buffer: Buffer }> {
/**
* Sealed PDF invoice for any source, rendered by the shared document service. `format`
* validation (rejecting anything but `"a4"`/`"thermal"`) is the controller's job — an input
* boundary check, not a business rule.
*/
async document(
id: string,
format: "a4" | "thermal" = "a4",
): Promise<{ filename: string; buffer: Buffer }> {
const invoice = await this.findById(id);
return this.invoiceDocuments.render(
this.toDocumentModel(invoice, "INVOICE"),
);
const model = await this.toDocumentModel(invoice, "INVOICE");
return format === "thermal"
? this.invoiceDocuments.renderThermal(model)
: this.invoiceDocuments.render(model);
}
/** Sealed PDF receipt; available once any payment has been recorded. */
@@ -246,15 +580,41 @@ export class BillingService {
);
}
return this.invoiceDocuments.render(
this.toDocumentModel(invoice, "RECEIPT"),
await this.toDocumentModel(invoice, "RECEIPT"),
);
}
/** Route + wagon count summary rows for a booking-sourced invoice; empty for every other source. */
private async bookingSummaryRows(
invoice: Invoice,
): Promise<InvoiceDocumentModel["summary"]> {
if (invoice.source !== Freight.InvoiceSource.Booking) return [];
const booking = await this.dataSource.getRepository(Booking).findOne({
where: { id: invoice.sourceId },
relations: { originYard: true, destinationYard: true },
});
if (!booking) return [];
return [
{
label: "Route",
value:
booking.originYard && booking.destinationYard
? `${booking.originYard.label}${booking.destinationYard.label}`
: null,
},
{
label: "Wagons",
value:
booking.wagonsRequired != null ? String(booking.wagonsRequired) : null,
},
];
}
/** Map a global invoice (+ lines) onto the source-agnostic document model. */
private toDocumentModel(
private async toDocumentModel(
invoice: Invoice & { lines: InvoiceLine[] },
kind: "INVOICE" | "RECEIPT",
): InvoiceDocumentModel {
): Promise<InvoiceDocumentModel> {
const title = invoice.source
? invoice.source.charAt(0).toUpperCase() + invoice.source.slice(1)
: "EDR";
@@ -272,17 +632,18 @@ export class BillingService {
totals.push({ label: "Paid", amount: Number(invoice.paidAmount) });
totals.push({ label: "Balance", amount: Number(invoice.balanceAmount) });
return {
kind,
title,
documentNumber: invoice.invoiceNumber,
issuedAt: invoice.issuedAt ?? invoice.createdAt,
status: invoice.status,
currency: invoice.currency,
summary: [
const summary: InvoiceDocumentModel["summary"] = [
// Buyer identity — was missing entirely; a MoR-registered invoice must show who it was
// filed against, not just the seller. VatNumber shown only when the company has one.
{ label: "Buyer", value: invoice.company?.name ?? null },
{ label: "Buyer TIN", value: invoice.company?.tin ?? null },
...(invoice.company?.vatNumber
? [{ label: "Buyer VAT No.", value: invoice.company.vatNumber }]
: []),
{ label: "Status", value: invoice.status },
{ label: "Type", value: invoice.type },
{ label: "Reference", value: invoice.sourceId },
...(await this.bookingSummaryRows(invoice)),
{ label: "Currency", value: invoice.currency },
{
label: "Issued",
@@ -296,7 +657,38 @@ export class BillingService {
? new Date(invoice.dueAt).toLocaleDateString("en-GB")
: null,
},
],
];
// Seller identity — EDR's own legal TIN/VAT live only in EIMS config (nowhere else in this
// codebase). Shown only when actually configured, same as the buyer VAT row.
const eimsCfg = this.config.get<EimsConfig>("eims");
if (eimsCfg?.tin) summary.push({ label: "Seller TIN", value: eimsCfg.tin });
if (eimsCfg?.invoice?.sellerVatNumber) {
summary.push({ label: "Seller VAT No.", value: eimsCfg.invoice.sellerVatNumber });
}
// MoR EIMS reference — only once actually registered, never a placeholder row.
if (invoice.eimsIrn) summary.push({ label: "EIMS IRN", value: invoice.eimsIrn });
// PNR — the CBE_BILL reference the customer pays against, written onto the booking at
// payment-initiation time (see initiatePayment()). Not a column on Invoice/Payment, so
// look it up by source id; only shown once a payment actually generated one.
if (invoice.source === Freight.InvoiceSource.Booking) {
const booking = await this.dataSource.getRepository(Booking).findOne({
where: { id: invoice.sourceId },
select: ["id", "pnrCode"],
});
if (booking?.pnrCode) summary.push({ label: "PNR", value: booking.pnrCode });
}
return {
kind,
title,
documentNumber: invoice.invoiceNumber,
issuedAt: invoice.issuedAt ?? invoice.createdAt,
status: invoice.status,
currency: invoice.currency,
summary,
categoryHeader: "Charge type",
lines: invoice.lines.map((l) => ({
description: l.description ?? l.chargeType,
@@ -307,6 +699,7 @@ export class BillingService {
currency: l.currency,
})),
totals,
qrImageUrl: invoice.eimsSignedQr ? pngDataUrl(invoice.eimsSignedQr) : null,
};
}
@@ -352,23 +745,61 @@ export class BillingService {
});
}
/** Invoices for the signed-in customer; empty when they have no company. */
/**
* Resolve a shipping-line company from the signed-in user (null for ordinary
* customers). Queried straight off the entity rather than through
* ShippingLineCompaniesService — that module already imports billing, so a
* service edge back would deepen the forwardRef cycle for one lookup.
*/
private async resolveShippingLineCompanyId(
userId: string,
): Promise<string | null> {
const line = await this.dataSource
.getRepository(ShippingLineCompany)
.findOne({ where: { userId } });
return line?.id ?? null;
}
/**
* Invoices for the signed-in portal user; empty when they have no company.
* A payer is either a customer company or a shipping line (enforced by the
* DB's single-payer check), so the two lookups cannot both match.
*/
async findForUser(
userId: string,
filter: { source?: string; sourceId?: string } = {},
): Promise<Invoice[]> {
const companyId = await this.resolveCompanyId(userId);
return companyId ? this.findByCompany(companyId, filter) : [];
if (companyId) return this.findByCompany(companyId, filter);
const shippingLineCompanyId =
await this.resolveShippingLineCompanyId(userId);
if (!shippingLineCompanyId) return [];
return this.invoices.findAll({
where: {
shippingLineCompanyId,
...(filter.source ? { source: filter.source } : {}),
...(filter.sourceId ? { sourceId: filter.sourceId } : {}),
},
order: { createdAt: "DESC" },
});
}
/** Company-scoped invoice detail (+ lines); 404 when not owned by the user. */
/** Payer-scoped invoice detail (+ lines); 404 when not owned by the user. */
async findByIdForUser(
id: string,
userId: string,
): Promise<Invoice & { lines: InvoiceLine[] }> {
const companyId = await this.resolveCompanyId(userId);
const invoice = await this.findById(id);
if (!companyId || invoice.companyId !== companyId) {
const ownedByCompany =
invoice.companyId != null &&
invoice.companyId === (await this.resolveCompanyId(userId));
const ownedByShippingLine =
!ownedByCompany &&
invoice.shippingLineCompanyId != null &&
invoice.shippingLineCompanyId ===
(await this.resolveShippingLineCompanyId(userId));
if (!ownedByCompany && !ownedByShippingLine) {
throw new NotFoundException(`Invoice ${id} not found`);
}
return invoice;
@@ -435,11 +866,16 @@ export class BillingService {
// ── Generation ───────────────────────────────────────────────────────────────
/** `<CODE>-YYYYMMDD-00001` — sequential per day & prefix, within the active transaction. */
private nextInvoiceNumber(mg: EntityManager): Promise<string> {
/**
* `<CODE>-YYYYMMDD-00001` — sequential per day & prefix, within the active transaction. `code`
* defaults to `INV`; a memo (`issueMemo`) uses `CRE`/`DEB` instead, which is its own independent
* daily sequence (different prefix hashes to a different advisory lock, see
* `nextDailyInvoiceNumber`) — not a collision risk with ordinary invoice numbers.
*/
private nextInvoiceNumber(mg: EntityManager, code = "INV"): Promise<string> {
return nextDailyInvoiceNumber(mg, {
table: "freight.invoices",
code: "INV",
code,
});
}
@@ -458,19 +894,147 @@ export class BillingService {
input: GenerateInvoiceInput,
manager?: EntityManager,
): Promise<Invoice & { lines: InvoiceLine[] }> {
console.log("oooooooooo", input);
const run = (mg: EntityManager) => this.createInvoice(input, mg);
return manager ? run(manager) : this.dataSource.transaction(run);
}
/**
* Issue a credit or debit memo against an already-registered invoice, per MoR's confirmed
* DEB/CRE filing mechanism (same `/v1/register` endpoint, `DocumentDetails.Type` + `Reason`,
* `ReferenceDetails.RelatedDocument` — see `eims-invoice.mapper.ts`). Reuses `createInvoice`
* unchanged: it has no side effects (no events, no notifications, no payment records — every
* event in this service fires from `runTransition` on a *transition*, not on create), so a memo
* is just an ordinary invoice with three extra columns set.
*
* `sourceId` is deliberately the *original invoice's own id*, not the original's `sourceId`
* (e.g. a booking id): `findPayable`, `expirePayable` and `billQuery` all resolve by
* `sourceId` with no `type` filter, so a memo sharing the booking's `sourceId` would be the
* newest matching row and could hijack a payer's balance at a CBE teller. An invoice's own
* `id` is never a value those lookups are ever queried with, so this isolates a memo from all
* of them regardless of its status — no `type`-based exclusion needed anywhere else.
*
* A credit note is created settled (PAID, balance 0) — nothing is ever collected against it, so
* leaving it payable would only add a phantom receivable that no payment flow will ever close.
* A debit note genuinely IS a new receivable and is created open/unpaid like any ordinary
* invoice (`createInvoice`'s own defaults: PENDING, `balanceAmount = totalAmount`) — it is
* findable and collectible through the normal invoice list/detail/payment tooling, safe from
* the CBE/booking-linked lookups above for the `sourceId` reason just given.
*/
async issueMemo(
originalId: string,
input: IssueMemoInput,
): Promise<Invoice & { lines: InvoiceLine[] }> {
const reason = input.reason?.trim();
if (!reason) {
throw new BadRequestException("A memo requires a reason.");
}
const original = await this.findById(originalId);
if (!original.eimsIrn) {
throw new BadRequestException({
code: "EIMS_RELATED_INVOICE_NOT_REGISTERED",
message: `Invoice ${original.invoiceNumber} was never registered with EIMS — nothing to reference.`,
});
}
if (original.eimsDocumentType && original.eimsDocumentType !== "INV") {
throw new BadRequestException(
`Invoice ${original.invoiceNumber} is itself a ${original.eimsDocumentType} — cannot issue a memo against a memo.`,
);
}
if (original.eimsStatus === EimsInvoiceStatus.Cancelled) {
throw new BadRequestException(
`Invoice ${original.invoiceNumber} was cancelled with EIMS — nothing to adjust.`,
);
}
const sourceLines = input.lines?.length ? input.lines : original.lines;
const lines: InvoiceLineInput[] = sourceLines.map((l) => ({
chargeType: l.chargeType,
description: l.description,
quantity: Number(l.quantity),
unitRate: Number(l.unitRate),
amount: Number(l.amount),
currency: l.currency,
metadata: l.metadata ?? null,
}));
const total = round2(lines.reduce((sum, l) => sum + Number(l.amount ?? 0), 0));
if (!(total > 0)) {
throw new BadRequestException("A memo must have a positive total.");
}
// Only a credit note is bounded by the original — it can only give back what was charged. A
// debit note is an additional charge, not a refund, so no such ceiling applies to it (do not
// assume the credit-note ceiling is correct for DEB).
if (input.type === "CRE" && total > Number(original.totalAmount)) {
throw new BadRequestException(
`Credit memo total (${total}) exceeds invoice ${original.invoiceNumber}'s total (${original.totalAmount}).`,
);
}
const code = input.type === "CRE" ? "CRE" : "DEB";
const settled = input.type === "CRE";
return this.dataSource.transaction(async (mg) => {
const memo = await this.createInvoice(
{
source: original.source as Freight.InvoiceSource,
sourceId: original.id,
type: input.type === "CRE" ? "credit_note" : "debit_note",
companyId: original.companyId,
companyProfileId: original.companyProfileId,
shippingLineCompanyId: original.shippingLineCompanyId,
lines,
currency: original.currency,
subtotalAmount: total,
taxAmount: 0,
totalAmount: total,
...(settled ? { status: Freight.InvoiceStatus.Paid, dueAt: new Date() } : {}),
},
mg,
code,
);
const patch: Record<string, unknown> = {
eimsDocumentType: input.type,
eimsReason: reason,
relatedInvoiceId: original.id,
...(settled
? { paidAmount: memo.totalAmount, balanceAmount: 0, paidAt: new Date() }
: {}),
};
await mg.update(Invoice, memo.id, patch);
this.logger.log(
`Issued ${input.type} memo ${memo.invoiceNumber} (${memo.id}) against invoice ${original.invoiceNumber}`,
);
return { ...memo, ...patch } as Invoice & { lines: InvoiceLine[] };
});
}
private async createInvoice(
input: GenerateInvoiceInput,
mg: EntityManager,
code = "INV",
): Promise<Invoice & { lines: InvoiceLine[] }> {
const currency = input.currency ?? "ETB";
const status = input.status ?? Freight.InvoiceStatus.Pending;
const issued = status !== Freight.InvoiceStatus.Draft;
// Exactly one payer, checked here so a bad payload fails with a clear
// message instead of a raw `chk_invoices_single_payer` violation.
const billsCompany = Boolean(input.companyId);
const billsShippingLine = Boolean(input.shippingLineCompanyId);
if (billsCompany === billsShippingLine) {
throw new BadRequestException(
"An invoice must be billed to exactly one payer: either companyId or shippingLineCompanyId.",
);
}
if (billsCompany && !input.companyProfileId) {
throw new BadRequestException(
"companyProfileId is required when billing a company.",
);
}
const lines = input.lines.map((l) => {
const quantity = l.quantity ?? 1;
const unitRate = l.unitRate ?? 0;
@@ -498,7 +1062,7 @@ export class BillingService {
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
);
const invoiceNumber = await this.nextInvoiceNumber(mg);
const invoiceNumber = await this.nextInvoiceNumber(mg, code);
const invoice = await mg.save(
mg.create(Invoice, {
@@ -506,8 +1070,9 @@ export class BillingService {
source: input.source,
sourceId: input.sourceId,
type: input.type,
companyId: input.companyId,
companyProfileId: input.companyProfileId,
companyId: input.companyId ?? null,
companyProfileId: input.companyProfileId ?? null,
shippingLineCompanyId: input.shippingLineCompanyId ?? null,
subtotalAmount: round2(subtotalAmount),
taxAmount: round2(taxAmount),
totalAmount: round2(totalAmount),
@@ -801,6 +1366,24 @@ export class BillingService {
await mg.update(Invoice, { id: invoice.id }, { status, ...extra });
// Every invoice status move in the app funnels through here — money
// changing state is the single most-asked question in support.
logCtx(
{
invoiceId: invoice.id,
invoiceNumber: invoice.invoiceNumber,
source: invoice.source,
sourceId: invoice.sourceId,
from: invoice.status,
to: status,
event,
amount: Number(invoice.totalAmount),
currency: invoice.currency,
paymentId: extra.paymentId ?? invoice.paymentId ?? undefined,
},
{ path: "invoiceTransitions", mode: "push" },
);
const updated = { ...invoice, ...extra, status } as Invoice;
return {
result: updated,
@@ -819,6 +1402,7 @@ export class BillingService {
type: invoice.type,
companyId: invoice.companyId,
companyProfileId: invoice.companyProfileId,
shippingLineCompanyId: invoice.shippingLineCompanyId ?? null,
totalAmount: invoice.totalAmount,
currency: invoice.currency,
status: invoice.status,
@@ -1170,6 +1754,21 @@ export class BillingService {
throw new BadRequestException("Invoice has no outstanding balance.");
}
logCtx(
{
invoiceId: invoice.id,
invoiceNumber: invoice.invoiceNumber,
source: invoice.source,
sourceId: invoice.sourceId,
companyId: invoice.companyId,
amountDue,
currency: invoice.currency,
method: opts.method ?? "TELEBIRR",
platform: opts.platform,
},
{ path: "payment.payInvoice" },
);
// CAC Bank is an OTP debit — the bank SMSes the code to this number, so it is
// required up front (the payment service rejects it otherwise, as a 502 here).
if (
@@ -1191,11 +1790,11 @@ export class BillingService {
// service branches on a domain-specific reference type.
referenceType: PaymentReferenceType.SHIPMENT,
orderRef: invoice.invoiceNumber.replace(/-/g, "_"),
// Whole birr, always UP. CBE bills this amount verbatim, so it must never
// land below the outstanding balance — Math.round would let a .40 balance
// settle 0.40 short. Ceil overcharges by <1 birr instead, and the same
// ceil in billQuery keeps the quoted and debited amounts identical.
amountMinor: Math.ceil(Number(invoice.balanceAmount)),
// Exact balance, cents included. CBE bills this verbatim and /cbe/payment
// matches the debited amount to the cent (amountsMatchToTheCent), so any
// rounding here would overcharge the payer and leave the invoice balance
// non-zero. billQuery quotes the same unrounded value.
amountMinor: round2(Number(invoice.balanceAmount)),
currency: invoice.currency,
reason: `Payment for invoice ${invoice.invoiceNumber}`,
method: opts.method ?? "TELEBIRR",
@@ -1293,7 +1892,24 @@ export class BillingService {
// first on DESC, which would hand back an unissued invoice.
order: { issuedAt: { direction: "DESC", nulls: "LAST" } },
});
if (!invoice) return null;
if (!invoice) {
logCtx(
{ paymentId, outcome: "no-invoice-for-payment" },
{ path: "payment.settleInvoice" },
);
return null;
}
logCtx(
{
paymentId,
providerTxnId,
invoiceId: invoice.id,
invoiceNumber: invoice.invoiceNumber,
invoiceStatus: invoice.status,
},
{ path: "payment.settleInvoice" },
);
const settleable: Freight.InvoiceStatus[] = [
...OPEN_STATUSES,
@@ -1303,6 +1919,12 @@ export class BillingService {
// Already PAID is the ordinary idempotent no-op (redelivery, or settled
// inline by payInvoice). Anything else means money was captured with
// nowhere to land — that needs a person, so say so loudly.
logCtx(
invoice.status === Freight.InvoiceStatus.Paid
? "already-paid"
: "captured-with-nowhere-to-land",
{ path: "payment.settleInvoice.outcome", mode: "set" },
);
if (invoice.status !== Freight.InvoiceStatus.Paid) {
this.logger.error(
`Payment ${paymentId} succeeded but invoice ${invoice.invoiceNumber} ` +
@@ -1340,9 +1962,10 @@ export class BillingService {
});
if (open) {
// Ceil, matching payInvoice — the amount CBE quotes at the counter has to
// be the amount the intent was opened for, or /cbe/payment sees a mismatch.
const balance = Math.ceil(Number(open.balanceAmount ?? open.totalAmount));
// Unrounded, matching payInvoice — the amount CBE quotes at the counter has
// to be the amount the intent was opened for, to the cent, or /cbe/payment
// sees a mismatch.
const balance = round2(Number(open.balanceAmount ?? open.totalAmount));
const expired = open.dueAt && open.dueAt.getTime() < Date.now();
return {
stillPayable: balance > 0 && !expired,
@@ -1377,7 +2000,7 @@ export class BillingService {
return {
stillPayable: false,
payerName: latest.company?.name ?? null,
currentAmountMinor: Math.ceil(Number(latest.totalAmount)),
currentAmountMinor: round2(Number(latest.totalAmount)),
currency: latest.currency,
paymentReason: `Freight invoice ${latest.invoiceNumber}`,
reason: closedInvoiceReason(latest.status),

View File

@@ -7,7 +7,9 @@ import { PdfRenderService } from "./pdf-render.service";
* Standalone document infrastructure — generic HTML→PDF plus the shared
* invoice/receipt renderer. Has no domain dependencies, so any module (billing,
* warehouses, …) can import it to print invoices without coupling to the
* billing payment graph.
* billing payment graph. StampSettingsService is @Global (see
* StampSettingsModule) so InvoiceDocumentService can inject it without this
* module declaring an explicit import.
*/
@Module({
providers: [PdfRenderService, InvoiceDocumentService],

View File

@@ -0,0 +1,89 @@
import { InvoiceDocumentModel, InvoiceDocumentService } from "./invoice-document.service";
const model = (over: Partial<InvoiceDocumentModel> = {}): InvoiceDocumentModel => ({
kind: "INVOICE",
title: "Freight",
documentNumber: "INV-20260812-00001",
issuedAt: new Date(2026, 7, 12),
status: "PENDING",
currency: "ETB",
summary: [{ label: "Status", value: "PENDING" }],
lines: [],
totals: [{ label: "Total", amount: 100, grand: true }],
...over,
});
describe("InvoiceDocumentService.buildHtml — EIMS QR", () => {
const service = new InvoiceDocumentService({} as never, {} as never, {} as never);
it("renders no QR block when qrImageUrl is unset", () => {
const html = service.buildHtml(model());
expect(html).not.toContain('class="qr"');
});
it("renders the QR image when qrImageUrl is set", () => {
const html = service.buildHtml(model({ qrImageUrl: "data:image/png;base64,QR" }));
expect(html).toContain('class="qr"');
expect(html).toContain('src="data:image/png;base64,QR"');
});
it("still shows the IRN text row via the ordinary summary grid", () => {
const html = service.buildHtml(
model({ summary: [{ label: "EIMS IRN", value: "IRN-123" }] }),
);
expect(html).toContain("EIMS IRN");
expect(html).toContain("IRN-123");
});
it("widens the summary's right margin only when a QR is present, to clear the QR block", () => {
// "summary-with-qr" also appears in the always-present <style> rule, so the check has to be
// the actual div's class attribute, not a bare substring match.
expect(service.buildHtml(model())).not.toContain('class="summary summary-with-qr"');
expect(service.buildHtml(model({ qrImageUrl: "data:image/png;base64,QR" }))).toContain(
'class="summary summary-with-qr"',
);
});
});
describe("InvoiceDocumentService.buildThermalHtml", () => {
const service = new InvoiceDocumentService({} as never, {} as never, {} as never);
it("renders no seal markup at all — dropped for thermal, not shrunk", () => {
const html = service.buildThermalHtml(model());
expect(html).not.toContain('class="seal"');
expect(html).not.toContain("seal-image");
});
it("renders the QR image when qrImageUrl is set, centered rather than absolutely positioned", () => {
const html = service.buildThermalHtml(model({ qrImageUrl: "data:image/png;base64,QR" }));
expect(html).toContain('class="qr"');
expect(html).toContain('src="data:image/png;base64,QR"');
expect(html).not.toContain("position: absolute");
});
it("wraps a long IRN summary value rather than truncating it", () => {
const irn = "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0";
const html = service.buildThermalHtml(model({ summary: [{ label: "EIMS IRN", value: irn }] }));
expect(html).toContain(irn);
expect(html).toContain("overflow-wrap: anywhere");
});
it("renders a line item as stacked description + qty x rate = amount, not a table row", () => {
const html = service.buildThermalHtml(
model({
lines: [{ description: "40ft container rail freight", quantity: 12, unitRate: 245683.95, amount: 2948207.4 }],
}),
);
expect(html).not.toContain("<table");
expect(html).not.toContain("<td");
expect(html).toContain("40ft container rail freight");
expect(html).toContain("12 x");
expect(html).toContain("2,948,207.4 Birr (ETB)");
});
it("uses fluid, full-width layout — no fixed-px A4 geometry", () => {
const html = service.buildThermalHtml(model());
expect(html).not.toContain("width: 330px");
expect(html).not.toContain("right: 160px");
});
});

View File

@@ -1,6 +1,10 @@
import { Injectable } from "@nestjs/common";
import { StampSettingsService } from "../../stamp-settings/stamp-settings.service";
import { LogoSettingsService } from "../../logo-settings/logo-settings.service";
import { PdfRenderService } from "./pdf-render.service";
import { sealClass, sealImageCss, sealMarkup } from "./seal-markup.util";
import { logoImageCss, logoMarkup } from "./logo-markup.util";
import {
PdfColor,
assembleSinglePagePdf,
@@ -14,6 +18,36 @@ import {
export type InvoiceDocumentKind = "INVOICE" | "RECEIPT";
/**
* MoR returns `signedQR`/`qr` as a base64 PNG already rendered server-side — confirmed against the
* Postman collection's `register` response (`signedQR` decodes to a PNG magic-byte header), not a
* payload we encode ourselves. Wrap, don't encode. Shared by `Invoice.eimsSignedQr`
* (`BillingService`) and `EimsReceipt.qr` (`eims-receipt-document.mapper.ts`) — same convention,
* same gateway.
*/
export const pngDataUrl = (base64: string): string => `data:image/png;base64,${base64}`;
// ── Shared HTML-builder helpers (buildHtml + buildThermalHtml) ──────────────────────────────────
// `buildFallbackPdf`'s own currency/money/date closures are a deliberately different, already-
// established convention (bare "ETB" vs "Birr (ETB)") for the vector renderer — not touched here.
function esc(value: unknown): string {
return String(value ?? "-")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function money(amount: unknown, currency: string): string {
return `${Number(amount ?? 0).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`;
}
function formatDate(value: unknown): string {
return value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-";
}
/** One billed line on the document (charge type / fee type agnostic). */
export interface InvoiceDocumentLine {
description: string | null;
@@ -53,6 +87,20 @@ export interface InvoiceDocumentModel {
totals: InvoiceDocumentTotal[];
/** Override the round seal text; defaults from kind/status. */
sealText?: string;
/**
* Company stamp image (data URL) to render instead of the plain text seal.
* Callers normally leave this unset — `InvoiceDocumentService.render()`
* fills it in from the single global stamp in StampSettingsService; set it
* explicitly only to override that default for one document.
*/
stampImageUrl?: string | null;
logoImageUrl?: string | null;
/**
* MoR EIMS verification QR (data URL, pre-rendered by the caller from `Invoice.eimsSignedQr` —
* see that column's comment). Set only once an invoice is actually registered; the IRN text
* itself goes through the ordinary `summary` rows, not a dedicated field.
*/
qrImageUrl?: string | null;
}
/**
@@ -63,12 +111,26 @@ export interface InvoiceDocumentModel {
*/
@Injectable()
export class InvoiceDocumentService {
constructor(private readonly pdf: PdfRenderService) {}
constructor(
private readonly pdf: PdfRenderService,
private readonly stampSettings: StampSettingsService,
private readonly logoSettings: LogoSettingsService,
) {}
async render(
model: InvoiceDocumentModel,
): Promise<{ filename: string; buffer: Buffer }> {
const html = this.buildHtml(model);
const stampImageUrl =
model.stampImageUrl !== undefined
? model.stampImageUrl
: await this.stampSettings.getStampImageUrl();
const logoImageUrl =
model.logoImageUrl !== undefined
? model.logoImageUrl
: await this.logoSettings.getLogoImageUrl();
const resolvedModel: InvoiceDocumentModel = { ...model, stampImageUrl, logoImageUrl };
const html = this.buildHtml(resolvedModel);
const kindLabel = model.kind === "RECEIPT" ? "receipt" : "invoice";
return {
filename: `${this.safeFilename(model.documentNumber)}-${kindLabel}.pdf`,
@@ -77,11 +139,128 @@ export class InvoiceDocumentService {
// Chromium-less fallback: draw a genuine styled invoice (header, seal,
// summary grid, line-item table, totals) from the model — not a flat
// plain-text dump — so it still reads as a proper invoice document.
fallback: () => this.buildFallbackPdf(model),
// ponytail: still draws the plain vector seal, not the uploaded stamp
// image, and omits the EIMS QR entirely — embedding a raster image
// needs a new PDF XObject primitive in styled-pdf.util.ts. Upgrade
// when the Chromium-less path needs to carry the real stamp/QR too;
// today it's a rare degraded fallback. The IRN text itself still
// comes through (buildFallbackPdf renders model.summary same as HTML).
fallback: () => this.buildFallbackPdf(resolvedModel),
}),
};
}
/**
* 80mm thermal invoice (ADD-P001) — physical page is the 80mm roll width; content stays within
* `THERMAL_MARGIN_MM` of each edge via `PdfRenderService`'s margin, not a narrower page, since
* thermal print mechanisms have a dead zone at the roll edge they can't reach either way.
*
* A genuinely different template from `buildHtml`, not a CSS variant of it: the A4 layout is
* absolutely-positioned and fixed-px (`.seal{right:28px}`, `.qr{right:160px}`,
* `.totals{width:330px}`), tuned for a 210mm page — none of it reflows at 72mm printable width.
* No seal here at all (a decorative wet-ink-style stamp is an A4/laser convention; no real POS
* thermal receipt carries one, and thermal heads render rotated circles badly) and line items
* are stacked (description, then `qty x rate = amount` below it) rather than a table — a real
* multi-column table leaves ~10-14 chars for description at this width, truncating almost every
* line, which stacking avoids entirely. No Chromium-less fallback — see `renderThermal`.
*/
async renderThermal(model: InvoiceDocumentModel): Promise<{ filename: string; buffer: Buffer }> {
const logoImageUrl =
model.logoImageUrl !== undefined ? model.logoImageUrl : await this.logoSettings.getLogoImageUrl();
// Seal deliberately dropped — never fetched, so no stampSettings call either.
const resolvedModel: InvoiceDocumentModel = { ...model, logoImageUrl, stampImageUrl: null };
const html = this.buildThermalHtml(resolvedModel);
return {
filename: `${this.safeFilename(model.documentNumber)}-thermal.pdf`,
buffer: await this.pdf.htmlToPdfBuffer(html, {
label: `${model.title} thermal invoice`,
thermal: true,
// A generic A4-shaped, QR-less fallback is not an acceptable stand-in for "the thermal
// printer output" — fail loudly instead; the caller has the A4 download to fall back to.
noFallback: true,
}),
};
}
buildThermalHtml(model: InvoiceDocumentModel): string {
const heading = `${model.title} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}`;
const logoInner = logoMarkup(model.logoImageUrl, "thermal-logo");
const summaryRows = model.summary
.map(
(row) =>
`<div class="row"><span class="label">${esc(row.label)}</span><span class="value">${esc(row.value)}</span></div>`,
)
.join("");
const itemBlocks = model.lines
.map((item) => {
const currency = item.currency ?? model.currency;
return `<div class="item">
<div class="item-desc">${esc(item.description)}</div>
<div class="item-calc">${esc(item.quantity ?? 0)} x ${esc(money(item.unitRate, currency))} = <strong>${esc(money(item.amount, currency))}</strong></div>
</div>`;
})
.join("");
const totalRows = model.totals
.map(
(total) =>
`<div class="total-row${total.grand ? " grand" : ""}"><span>${esc(total.label)}</span><strong>${esc(money(total.amount, model.currency))}</strong></div>`,
)
.join("");
const qrMarkup = model.qrImageUrl
? `<div class="qr"><img src="${esc(model.qrImageUrl)}" alt="EIMS verification QR" /><div class="qr-caption">Scan to verify (MoR EIMS)</div></div>`
: "";
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>${esc(heading)}</title>
<style>
body { font-family: Arial, sans-serif; font-size: 9px; color: #0f172a; margin: 0; }
.doc { width: 100%; box-sizing: border-box; }
.thermal-logo { display: block; max-height: 28px; max-width: 100%; object-fit: contain; margin: 0 auto 4px; }
.brand { text-align: center; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; }
.title { text-align: center; font-size: 13px; font-weight: 800; margin: 2px 0; }
.meta { text-align: center; font-size: 8px; color: #475569; margin-bottom: 4px; }
.rule { border-top: 1px dashed #334155; margin: 6px 0; }
.row { display: flex; justify-content: space-between; gap: 6px; font-family: monospace; font-size: 8.5px; padding: 1px 0; }
.row .label { color: #64748b; white-space: nowrap; }
.row .value { text-align: right; overflow-wrap: anywhere; }
.item { margin: 4px 0; }
.item-desc { font-size: 9px; overflow-wrap: anywhere; }
.item-calc { text-align: right; font-family: monospace; font-size: 8.5px; }
.total-row { display: flex; justify-content: space-between; font-size: 9px; padding: 2px 0; }
.total-row.grand { font-size: 11px; font-weight: 800; border-top: 1px solid #0f172a; margin-top: 3px; padding-top: 4px; }
.qr { text-align: center; margin: 8px 0; }
.qr img { width: 150px; height: 150px; }
.qr-caption { font-size: 7px; color: #64748b; margin-top: 2px; }
.footer { text-align: center; font-size: 7px; color: #94a3b8; margin-top: 8px; }
</style>
</head>
<body>
<div class="doc">
${logoInner}
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<div class="title">${esc(heading)}</div>
<div class="meta">${esc(model.documentNumber)} &middot; ${esc(formatDate(model.issuedAt))}</div>
<div class="rule"></div>
${summaryRows}
<div class="rule"></div>
${itemBlocks}
<div class="rule"></div>
${totalRows}
${qrMarkup}
<div class="footer">Thank you</div>
</div>
</body>
</html>`;
}
/**
* Vector-drawn styled invoice/receipt used when headless Chromium is
* unavailable. Mirrors the HTML layout closely enough to pass as the same
@@ -203,21 +382,17 @@ export class InvoiceDocumentService {
}
buildHtml(model: InvoiceDocumentModel): string {
const esc = (value: unknown) =>
String(value ?? "-")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
const money = (amount: unknown, currency = model.currency) =>
`${Number(amount ?? 0).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`;
const date = (value: unknown) =>
value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-";
const date = formatDate;
const showCategory = Boolean(model.categoryHeader);
const sealText =
model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR");
const sealInner = sealMarkup(model.stampImageUrl, sealText);
const sealCssClass = sealClass(model.stampImageUrl);
const logoInner = logoMarkup(model.logoImageUrl);
const qrMarkup = model.qrImageUrl
? `<div class="qr"><img src="${esc(model.qrImageUrl)}" alt="EIMS verification QR" /><span>Scan to verify (MoR EIMS)</span></div>`
: "";
const summaryRows = model.summary
.map((row) => `<div><span>${esc(row.label)}</span>${esc(row.value)}</div>`)
@@ -238,7 +413,7 @@ export class InvoiceDocumentService {
const totalRows = model.totals
.map(
(total) =>
`<div class="total-row${total.grand ? " grand" : ""}"><span>${esc(total.label)}</span><strong>${esc(money(total.amount))}</strong></div>`,
`<div class="total-row${total.grand ? " grand" : ""}"><span>${esc(total.label)}</span><strong>${esc(money(total.amount, model.currency))}</strong></div>`,
)
.join("");
@@ -256,8 +431,21 @@ export class InvoiceDocumentService {
.meta { text-align: right; font-size: 12px; color: #475569; }
.meta strong { display: block; color: #0f172a; font-size: 17px; margin-top: 5px; }
.seal { position: absolute; right: 28px; top: 118px; width: 116px; height: 116px; border: 4px double #0f766e; border-radius: 999px; color: #0f766e; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 18px; transform: rotate(-14deg); opacity: .82; }
${sealImageCss()}
${logoImageCss()}
.qr { position: absolute; right: 160px; top: 118px; width: 90px; text-align: center; }
.qr img { width: 90px; height: 90px; }
.qr span { display: block; font-size: 7px; color: #64748b; margin-top: 3px; }
.summary { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 28px; margin: 24px 150px 16px 0; font-size: 13px; }
.summary div { border-bottom: 1px solid #e2e8f0; padding: 7px 0; }
/* The QR block (right:160px, width:90px) sits further inward than the seal alone did — the
150px margin above only ever cleared the seal, so a QR-bearing invoice needs more room. */
.summary.summary-with-qr { margin-right: 270px; }
/* min-width: 0 overrides Grid's default min-width:auto on grid items — without it, a long
unbroken value (a 20-digit VAT number) forces its column wider to fit un-wrapped rather than
honouring overflow-wrap, which is what actually let text bleed into the seal/QR overlay
(confirmed by isolating the two: margin-right alone already positioned the box correctly;
the text itself was still escaping the box's own right edge until this was added). */
.summary div { border-bottom: 1px solid #e2e8f0; padding: 7px 0; overflow-wrap: break-word; min-width: 0; }
.summary span { color: #64748b; display: block; font-size: 11px; margin-bottom: 3px; }
table { width: 100%; border-collapse: collapse; margin-top: 18px; }
th { text-align: left; background: #f8fafc; color: #475569; }
@@ -274,6 +462,7 @@ export class InvoiceDocumentService {
<div class="doc">
<div class="top">
<div>
${logoInner}
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>${esc(model.title)} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}</h1>
</div>
@@ -283,8 +472,9 @@ export class InvoiceDocumentService {
Issued: ${esc(date(model.issuedAt))}
</div>
</div>
<div class="seal">${esc(sealText)}</div>
<div class="summary">${summaryRows}</div>
<div class="${sealCssClass}">${sealInner}</div>
${qrMarkup}
<div class="summary${model.qrImageUrl ? " summary-with-qr" : ""}">${summaryRows}</div>
<table>
<thead>
<tr>

View File

@@ -0,0 +1,35 @@
/**
* The single decision every EDR document makes about its header logo: draw
* the one uploaded company logo when configured (LogoSettingsService), or
* render nothing — the existing "Ethio-Djibouti Railway S.C." text brand next
* to it already covers the no-logo case, so there is no text fallback here
* (contrast seal-markup.util.ts, whose seal has no text of its own).
*/
function escapeHtml(value: unknown): string {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
/**
* `<img>` markup for the header logo, or "" when unset. `logoImageUrl` is
* expected to be a data URL from LogoSettingsService.getLogoImageUrl().
* `className` defaults to "doc-logo" — each document supplies that class's
* sizing in its own <style> block (see logoImageCss()).
*/
export function logoMarkup(
logoImageUrl: string | null | undefined,
className = "doc-logo",
): string {
if (!logoImageUrl) return "";
return `<img class="${className}" src="${escapeHtml(logoImageUrl)}" alt="Company logo" />`;
}
/** Default CSS for the header logo — append inside a document's <style> block. */
export function logoImageCss(className = "doc-logo"): string {
return `.${className} { display: block; max-height: 48px; max-width: 180px; margin-bottom: 6px; object-fit: contain; }`;
}

View File

@@ -15,13 +15,43 @@ const PDF_PRINT_STYLES = `
}
</style>`;
/**
* Physical roll width. Content stays within `THERMAL_MARGIN_MM` of each edge — every mainstream
* ESC/POS thermal head (Epson TM-T88, Star, Bixolon) has a dead zone near the edge of an 80mm roll
* it physically can't reach, so the page itself must stay 80mm (matching the roll the printer
* driver expects) with the safe area carved out by margin, not by shrinking the page.
*/
const THERMAL_PAGE_WIDTH_MM = 80;
const THERMAL_MARGIN_MM = 4;
/** Extra length past the measured content, so the cut isn't flush against the last line. */
const THERMAL_FEED_MM = 6;
/** Guard against a runaway line-item list producing an absurd page. */
const THERMAL_MAX_HEIGHT_MM = 1500;
export interface PdfRenderOptions {
/** Label used in logs to identify the document kind. */
label?: string;
/** Landscape A4 instead of the default portrait — wide tables need it. */
landscape?: boolean;
/**
* Render as an 80mm continuous thermal receipt instead of a fixed A4 page: content width is
* measured and the page height grows to fit it, rather than a fixed page with the format's
* `format: "A4"`.
*/
thermal?: boolean;
/**
* Refuse to degrade to a fallback PDF on failure — throw instead. For a thermal request, a
* generic A4-shaped, QR-less fallback is not an acceptable stand-in for "the thermal printer
* output" (it silently hands back a different document shape than what was asked for); the
* caller has an existing A4 download to point the user at instead. Ignored when `fallback` is
* also supplied — an explicit fallback always wins.
*/
noFallback?: boolean;
/**
* Degraded renderer used when Chromium is unavailable. Receives the
* print-prepared HTML and must return a valid PDF buffer (≥ 2KB, `%PDF-`
* header). When omitted, a generic single-page fallback is produced.
* header). When omitted (and `noFallback` is not set), a generic single-page fallback is
* produced.
*/
fallback?: (preparedHtml: string) => Buffer;
}
@@ -52,13 +82,34 @@ export class PdfRenderService {
const browser = await puppeteer.default.launch(launchOptions);
try {
const page = await browser.newPage();
await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 });
const thermal = opts.thermal ?? false;
const viewportWidth = thermal ? Math.round((THERMAL_PAGE_WIDTH_MM / 25.4) * 96) : 794;
// Thermal viewport height is deliberately tiny (not a real page height at all): scrollHeight
// is defined as the LARGER of the content's height and the viewport's own height, so a
// receipt shorter than the viewport would otherwise report the viewport height back, not
// its true content height — a real page-length trailing blank space bug, not theoretical
// (confirmed by actually rendering one). A short viewport forces content to overflow it,
// so scrollHeight always reflects the content, never the viewport.
await page.setViewport({ width: viewportWidth, height: thermal ? 100 : 1123, deviceScaleFactor: 1 });
await page.setContent(preparedHtml, { waitUntil: "load", timeout: 60_000 });
await page.emulateMediaType("print");
await new Promise((resolve) => setTimeout(resolve, 250));
const pdf = await page.pdf({
const pdf = thermal
? await page.pdf({
width: `${THERMAL_PAGE_WIDTH_MM}mm`,
height: `${await this.thermalContentHeightMm(page)}mm`,
printBackground: true,
margin: {
top: `${THERMAL_MARGIN_MM}mm`,
bottom: `${THERMAL_MARGIN_MM + THERMAL_FEED_MM}mm`,
left: `${THERMAL_MARGIN_MM}mm`,
right: `${THERMAL_MARGIN_MM}mm`,
},
})
: await page.pdf({
format: "A4",
landscape: opts.landscape ?? false,
printBackground: true,
margin: { top: "16mm", bottom: "18mm", left: "14mm", right: "14mm" },
});
@@ -76,6 +127,15 @@ export class PdfRenderService {
}
} catch (error) {
this.logger.error(`${label} PDF failed (executable=${executablePath ?? "default"}): ${error}`);
if (!opts.fallback && opts.noFallback) {
// A generic A4-shaped, QR-less fallback is not an acceptable stand-in for "the thermal
// printer output" — it silently hands back a different document than what was asked for.
// Fail loudly instead; the caller already has a working A4 download to fall back to.
throw new InternalServerErrorException(
`${label} could not be generated — thermal rendering requires Chromium. ` +
"Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH, or download the A4 PDF instead.",
);
}
const fallback = (opts.fallback ?? ((h) => this.genericFallbackPdf(h)))(preparedHtml);
if (this.isValidPdf(fallback)) {
this.logger.warn(
@@ -89,6 +149,20 @@ export class PdfRenderService {
}
}
/**
* Thermal receipts are continuous-roll — there is no fixed page height. Measures the rendered
* content's actual height and adds feed clearance, so the PDF page is exactly as long as the
* receipt, not a fixed A4-length page with blank space at the bottom.
*/
private async thermalContentHeightMm(page: import("puppeteer").Page): Promise<number> {
// String form, not a typed closure: this project's tsconfig has no `dom` lib, so `document`
// isn't a known global to type-check against — the string is evaluated in the page's own
// browser context regardless, same as the closure form would be.
const scrollPx = (await page.evaluate("document.documentElement.scrollHeight")) as number;
const contentMm = (scrollPx / 96) * 25.4 + THERMAL_MARGIN_MM * 2 + THERMAL_FEED_MM;
return Math.min(THERMAL_MAX_HEIGHT_MM, Math.round(contentMm * 100) / 100);
}
private injectPdfPrintStyles(html: string): string {
if (html.includes("edr-pdf-print-fix")) return html;
if (html.includes("</head>")) {

View File

@@ -0,0 +1,78 @@
import { sealClass, sealImageCss, sealMarkup } from "./seal-markup.util";
/**
* These three helpers are the single image-vs-text branch shared by every
* EDR document's round seal, so a regression here silently unstamps invoices,
* warehouse release papers and handover papers at once.
*/
describe("seal markup helpers", () => {
const STAMP = "data:image/png;base64,QUJD";
describe("sealMarkup", () => {
it("renders the stamp image when one is configured", () => {
expect(sealMarkup(STAMP, ["EDR", "Warehouse"])).toBe(
`<img src="${STAMP}" alt="Company stamp" />`,
);
});
it("falls back to text rings when no stamp is configured", () => {
expect(sealMarkup(null, ["EDR", "Warehouse", "Cleared"])).toBe(
"<span>EDR<br />Warehouse<br />Cleared</span>",
);
});
it("treats undefined as unset", () => {
expect(sealMarkup(undefined, "EDR")).toBe("<span>EDR</span>");
});
it("accepts a bare string as a single line", () => {
expect(sealMarkup(null, "EDR")).toBe("<span>EDR</span>");
});
it("escapes text lines so document data cannot inject markup", () => {
expect(sealMarkup(null, ['<script>alert("x")</script>'])).toBe(
"<span>&lt;script&gt;alert(&quot;x&quot;)&lt;/script&gt;</span>",
);
});
it("escapes the image src so it cannot break out of the attribute", () => {
expect(sealMarkup('data:image/png;base64,A" onerror="x', "EDR")).toBe(
'<img src="data:image/png;base64,A&quot; onerror=&quot;x" alt="Company stamp" />',
);
});
});
describe("sealClass", () => {
it("adds the image modifier only when stamped", () => {
expect(sealClass(STAMP)).toBe("seal seal-image");
expect(sealClass(null)).toBe("seal");
});
it("honours a document's own seal selector", () => {
expect(sealClass(STAMP, "sig-stamp-box")).toBe(
"sig-stamp-box sig-stamp-box-image",
);
expect(sealClass(null, "sig-stamp-box")).toBe("sig-stamp-box");
});
});
describe("sealImageCss", () => {
it("neutralizes the drawn ring and rotation for a real stamp image", () => {
const css = sealImageCss();
expect(css).toContain(".seal.seal-image { border: none;");
expect(css).toContain("transform: none;");
// The ::before pseudo-element draws the inner ring of the text seal.
expect(css).toContain(".seal.seal-image::before { content: none; }");
expect(css).toContain(".seal img { max-width: 100%;");
});
it("scopes every rule to the given selector", () => {
const css = sealImageCss("sig-stamp-box");
expect(css).not.toContain(".seal");
expect(css).toContain(".sig-stamp-box.sig-stamp-box-image");
expect(css).toContain(".sig-stamp-box img");
});
});
});

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