diff --git a/CLAUDE.md b/CLAUDE.md index b90d3b1dd..a20fbaee7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,102 +1,362 @@ # EDR Platform — Developer Guide +> This file is the contract. If something here contradicts the code, the code is the +> truth and this file is a bug — fix it in the same PR. + +**Looking for where something lives? Read [`docs/MAP.md`](docs/MAP.md) first.** It routes +you to the right module or page without a repo-wide grep. + ## Overview -Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Freight Management and Passenger Management applications, plus shared types, NestJS utilities, and React component libraries. +Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Freight +Management and Passenger Management applications, a payment microservice, plus shared +types, NestJS utilities, and React component libraries. + +The freight domain is the largest and most active area. Its core flow is: +**booking → receive to warehouse → store → load onto train → dispatch → arrive → unload +→ customer truck (self-haul) or EDR last mile → handover → exit paper → delivered.** +Fees (storage, demurrage, double handling, truck detention) and allocation rules +(warehouse/yard/zone) hang off the warehouse stage. ## Apps -| App | Package name | Purpose | Port | -| ------------------------------ | --------------------------- | -------------------------------------------------- | ---- | -| `edr-freight-api` | `@edr/freight-api` | NestJS API for freight management | 3001 | -| `edr-freight-web/portal` | `@edr/freight-portal` | React frontend for freight customer/portal users | 5173 | -| `edr-freight-web/backoffice` | `@edr/freight-backoffice` | React frontend for freight backoffice employees | 5183 | -| `edr-passenger-api` | `@edr/passenger-api` | NestJS API for passenger management | 3002 | -| `edr-payment-api` | `@edr/payment-api` | NestJS payment microservice (intents, webhooks) | 3003 | -| `edr-passenger-web/portal` | `@edr/passenger-portal` | React frontend for passenger customer/portal users | 5174 | -| `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | React frontend for passenger backoffice employees | 5184 | +The two domains are **not built the same way**. Check which stack you are in before +copying a pattern across: -`edr-freight-web` and `edr-passenger-web` are grouping folders, not workspace packages. Each holds a `portal/` and `backoffice/` sub-app, both of which are independent pnpm workspace packages (declared in `pnpm-workspace.yaml`). The existing `pnpm dev:freight` / `pnpm dev:passenger` turbo filters (`@edr/freight-*` / `@edr/passenger-*`) cover all four web apps + their APIs. +| App | Package name | Stack | Default port | +| ------------------------------ | --------------------------- | ---------------------- | ------------ | +| `edr-freight-api` | `@edr/freight-api` | NestJS + **TypeORM** | 3001 | +| `edr-freight-web/portal` | `@edr/freight-portal` | React + **Vite** | 5273 | +| `edr-freight-web/backoffice` | `@edr/freight-backoffice` | React + **Vite** | 5283 | +| `edr-passenger-api` | `@edr/passenger-api` | NestJS + **Prisma** | 4000 | +| `edr-passenger-web/portal` | `@edr/passenger-portal` | **Next.js** | 5174 | +| `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | **Next.js** | 5184 | +| `edr-payment-api` | `@edr/payment-api` | NestJS + **TypeORM** | 3003 | + +Those are the **fallbacks compiled into the code**, not what you will be running. Every +port is overridden by `PORT` in the app's `.env` / `.env.development`; the freight vite +apps read it in `vite.config.ts` (`Number(env.PORT) || 5273`). This machine is shared by +the whole team and the low ports are contested — see the workspace root `CLAUDE.md` and +`./wt ports` for who currently holds what. + +`edr-freight-web` and `edr-passenger-web` are grouping folders, not workspace packages. +Each holds a `portal/` and `backoffice/` sub-app, both independent pnpm workspace +packages (see `pnpm-workspace.yaml`). + +`apps/edr-landing/` exists on disk but has **no `package.json`** — it is not a workspace +package and is not built, linted, or type-checked. Leave it alone unless asked. + +`apps/edr-gps-tracker/` is a separate service with its own `.env.example`. ## Packages -| Package | Purpose | -| ---------------------- | ---------------------------------------------------------------------------------- | -| `@edr/types` | Shared TypeScript interfaces and enums | -| `@edr/api-common` | Shared NestJS decorators, filters, interceptors, pipes, BaseEntity, BaseRepository | -| `@edr/iam-seed` | IAM baseline seeder for the apps sharing the `iam` schema (freight + passenger) | -| `@edr/ui-common` | Shared React components and theme | -| `@edr/eslint-config` | Shared ESLint configurations (base/nestjs/react) | -| `@edr/tsconfig` | Shared TypeScript configurations | -| `@edr/prettier-config` | Shared Prettier configuration | +| Package | Location | Purpose | +| ----------------------- | ----------------------------- | ------------------------------------------------------------- | +| `@edr/types` | `packages/types` | Shared TypeScript interfaces and enums | +| `@edr/api-common` | `packages/api-common` | NestJS decorators, filters, interceptors, pipes, BaseEntity, BaseRepository | +| `@edr/ui-common` | `packages/ui-common` | Shared React components and theme | +| `@edr/iam-seed` | `packages/iam-seed` | IAM baseline seeder for apps sharing the `iam` schema | +| `@edr/payment-providers`| `packages/payment-providers` | Payment gateway integrations | +| `@edr/eslint-config` | `packages/config/eslint-config` | Shared ESLint configs (base/nestjs/react) | +| `@edr/tsconfig` | `packages/config/tsconfig` | Shared TypeScript configs | +| `@edr/prettier-config` | `packages/config/prettier-config` | Shared Prettier config | + +The three `config/*` packages are nested one level deeper than the rest — `packages/config` +itself is not a package. + +**`@edr/types` is consumed as its built `dist/`** (`main: ./dist/index.js`). Editing a +type in `packages/types/src` changes nothing for consumers until you rebuild: + +```bash +pnpm turbo build --filter=@edr/types +``` + +If a type-check fails on a field you just added to `@edr/types`, this is why. ## Commands -| Command | Description | -| -------------------- | ---------------------------------- | -| `pnpm install` | Install all workspace dependencies | -| `pnpm dev` | Run every app in dev mode | -| `pnpm dev:freight` | Run only freight API + web | -| `pnpm dev:passenger` | Run only passenger API + web | -| `pnpm build` | Build every package and app | -| `pnpm test` | Run all tests | -| `pnpm lint` | Lint everything | -| `pnpm type-check` | Type-check every package | -| `pnpm format` | Format all files with Prettier | +| Command | Description | +| ----------------------------- | ---------------------------------------- | +| `pnpm install` | Install all workspace dependencies | +| `pnpm dev` | Run every app in dev mode | +| `pnpm dev:freight` | Freight API + portal + backoffice | +| `pnpm dev:freight:api` | Freight API only | +| `pnpm dev:freight:portal` | Freight portal only | +| `pnpm dev:freight:backoffice` | Freight backoffice only | +| `pnpm dev:passenger` | Passenger API + web | +| `pnpm dev:payment` | Payment API | +| `pnpm build` | Build every package and app | +| `pnpm test` | Run all tests (turbo) | +| `pnpm type-check` | Type-check every package | +| `pnpm format` | Format all files with Prettier | +| `pnpm lint` | **Does not work** — see below | -## Standards +**`pnpm lint` fails.** `eslint` is not installed anywhere in the workspace, so +`turbo run lint` dies with `eslint: not found` even though every package declares a +`lint` script and `@edr/eslint-config` exists. Until someone adds the dependency, +tsc's `noUnusedLocals` is the only working unused-code check. Do not claim a change is +"lint clean". -- **TypeScript strict mode** is enabled in every package and app. -- **pnpm** is the only supported package manager — never run `npm install` or `yarn`. -- **Conventional commits** are enforced via commitlint on every commit. -- **NestJS modules** follow the 4-layer pattern: `module → controller → service → repository` (entities and DTOs live alongside). +`pnpm format` uses bare `prettier`, which ignores `@edr/prettier-config` — it is wired to +nothing. On the single-quoted passenger apps it will re-quote the whole file. Pass +`--config` explicitly there. + +Prefer targeted turbo filters over whole-repo runs — they are minutes faster: + +```bash +pnpm turbo type-check --filter=@edr/freight-api --filter=@edr/freight-backoffice +``` + +`apps/edr-freight-api` also carries many `seed:*` scripts (demo bookings, wagons, trains, +gate-pass scenarios). Read the script before running one; several write real rows. + +## Environment & database + +- Postgres is **external**. There is no postgres service in `docker-compose.yaml`, and + no port `5433`/`5434` is published anywhere in the repo. +- Freight API connection comes from `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, + `DB_NAME` (defaults: `localhost:5433`, `edr_freight`). Development points these at a + remote database. +- The connection sits behind a **connection pooler**. Do **not** pass + `extra.options: '-c search_path=…'` — the pooler rejects it with + `08P01 unsupported startup parameter in options: search_path`. `search_path` is applied + per-connection in a pool `connect` handler instead. See + `apps/edr-freight-api/src/config/database.config.ts` before touching connection options. +- Each app owns its own database. **No cross-database joins**; cross-domain data flows + through API calls or message queues. +- IAM tables live in their own `iam` schema (`iam.users`, `iam.user_credentials`), + freight tables in `freight`. +- `psql` is not installed on the dev machine. To query the database, use the `edr-db` + skill (below) or write a short Node script using `pg` and run it from + `apps/edr-freight-api`, where `pg` resolves. + +## Hard rules + +These are non-negotiable. Everything else is a strong default. + +- **pnpm only.** Never run `npm install` or `yarn`. +- **TypeScript strict mode** is on in every package and app. Do not weaken it, and do not + reach for `any` to make an error go away. +- **Never `synchronize: true`.** Not in production, not anywhere. It is currently `false` + in every config and it has already corrupted this database twice (see *Migrations*). + All schema changes go through migrations. - **All entities** use UUID primary keys (`@PrimaryGeneratedColumn('uuid')`). -- **All entities** have `createdAt`, `updatedAt`, `deletedAt` (soft delete) via `@edr/api-common`'s `BaseEntity`. -- **All columns** use `snake_case` in the database (`@Column({ name: 'snake_case' })`); TypeScript properties use `camelCase`. -- **Never use `synchronize: true`** in production database config. All schema changes go through TypeORM migrations. -- **ESLint + Prettier** run on pre-commit via Husky + lint-staged. -- **Services** never inject TypeORM `Repository` directly — they inject the custom repository class. -- **Controllers** never contain business logic. +- **All entities** extend `BaseEntity` from `@edr/api-common` — `createdAt`, `updatedAt`, + `deletedAt` (soft delete). +- **All columns** are `snake_case` in the database (`@Column({ name: 'snake_case' })`); + TypeScript properties are `camelCase`. +- **Controllers contain no business logic.** They validate, delegate, and shape the response. +- **Conventional commits.** `fix(warehouses): …`, `feat(bookings): …`. +- **Do not commit or push unless asked.** Propose the change; let the human decide when it lands. +- **Do not break working behaviour to add new behaviour.** When a fix is risky, say so and + offer the safe version. -## Auth +## Architecture -Authentication is handled by an external package (`@edr/iamui-common` or equivalent) that will be integrated later. **Do not** implement any auth, login, logout, JWT verification, password hashing, or user management code in this repo. +### NestJS module shape -When auth integration is needed, use placeholder TODO comments: +`module → controller → service → repository`, with `entities/` and `dto/` alongside. +`docs/MAP.md` lists the ~60 freight modules grouped by domain. -- `// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth` -- `// TODO: integrate @edr/auth — replace stub @CurrentUser with real one` +### Data access — the real model -The `@CurrentUser`, `@Roles`, and `@Public` decorators in `@edr/api-common` are bare metadata setters with no guard wiring — they exist so controllers can be annotated correctly without depending on auth infrastructure yet. +There are two sanctioned ways to read and write, and you must pick the right one: -## Port Assignments +1. **Entity CRUD → the custom repository class.** Extends `BaseRepository` from + `@edr/api-common`. Services inject the repository class, never `Repository` directly. +2. **Read projections, queue endpoints, cross-table reports → raw SQL** via + `this.dataSource.query(...)` or `manager.query(...)` inside a transaction. -- `edr-freight-api`: 3001 -- `edr-freight-web/portal`: 5173 -- `edr-freight-web/backoffice`: 5183 -- `edr-passenger-api`: 3002 -- `edr-payment-api`: 3003 -- `edr-passenger-web/portal`: 5174 -- `edr-passenger-web/backoffice`: 5184 +Raw SQL is normal here, not a smell — the warehouse and scheduling modules are built on it. +It carries one obligation: -## Database Layout +> **HARD RULE — validate every raw SQL statement against a real database before you ship it.** +> A typo'd column name is a runtime 500 that no type-checker will catch. Run it through +> `EXPLAIN` against the dev database. Column drift is real (see *Migrations*). -- `postgres-freight` (port 5433): database `edr_freight` — freight API only. -- `postgres-passenger` (port 5434): database `edr_passenger` — passenger API only. -- `edr_payment` schema — lives in the same Postgres database as the domain system (whatever the passenger `DATABASE_URL` points at) but is owned exclusively by `apps/edr-payment-api`. Dedicated DB user, no cross-schema FKs, domain apps have no grants on it (see `docs/payment-service/`). -- Each app owns its own DB. No cross-database joins; cross-domain data flows through API calls or message queues. +Writes inside a transaction use `manager.getRepository(Entity)`, not the injected repository, +so they join the caller's transaction. + +**Never do slow I/O inside a database transaction.** Queue the work and fan it out after +commit. An SMS awaited inside a transaction once held capacity locks open for the whole +gateway timeout. Any outbound HTTP call must set an explicit `timeout` — axios defaults to +no timeout and will wait forever. + +### Migrations + +Migrations are the most dangerous surface in this repo. Two production-grade incidents have +already come from it. **Freight and payment use TypeORM migrations; passenger uses Prisma** +(`apps/edr-passenger-api/prisma/migrations`) — the rules below are about the TypeORM side. + +- `migrationsRun: false` — **migrations do NOT run on API boot.** They run as a separate + one-shot step, via the Dockerfile's `migration` build target (`docker build --target + migration`), with `migrationsTransactionMode: 'each'`. + - CI: `.github/workflows/deploy.yml` builds the `migration` image and runs it + (`docker run --rm --env-file ...`) *before* building/deploying the app image. + - e2e: `docker-compose.e2e.yaml`'s `freight-migration-e2e` service runs once and + `freight-api-e2e` depends on it (`condition: service_completed_successfully`). + - Local dev (`docker-compose.yaml`) has no equivalent migration service yet — run + migrations yourself before `docker compose up freight-api`, e.g. + `docker build --target migration -f apps/edr-freight-api/Dockerfile -t freight-migration .` + then `docker run --rm --env-file apps/edr-freight-api/.env freight-migration`. Don't + use `pnpm run migrate` for this — it runs via `ts-node`, which never writes compiled + output to `dist/`, and the freight migrations glob only matches `dist/migrations/*.js`. + It silently applies zero freight migrations while exiting 0. +- Consequences you must design for: + - A watch-mode hot reload does **not** re-run migrations. If you add a column that new + code reads, apply it to the dev database yourself (idempotently) or fully restart. + - `apps/edr-freight-api/src/config/database.config.ts`'s `iamEntities` array is a + hand-maintained list of `@tria-plc/iamapi-common` entity classes. The live app never + notices when it's stale (`autoLoadEntities: true` papers over gaps via IAM's own + `forFeature()` registrations), but the standalone migration `DataSource` + (`data-source.ts`, no `autoLoadEntities`) does not have that fallback — a missing + entity throws `Entity metadata for X#y was not found` at `initialize()`, before a + single migration runs. **Every `@tria-plc/iamapi-common` version bump is a candidate + for this to break again** — diff the package's entity classes against `iamEntities` + when bumping it. +- **Give every migration a unique timestamp.** `apps/edr-freight-api/src/migrations` holds + 39 files, and 8 timestamps are shared by two or more of them. TypeORM orders by timestamp + and breaks ties non-deterministically. Check before adding one: + + ```bash + ls apps/edr-freight-api/src/migrations | grep -oE '^[0-9]+' | sort | uniq -d + ``` + + The prefix must be unused *and* higher than the newest recorded row. Note the + `freight.migrations` table has far more rows (~309) than this folder has files — most + come from `@tria-plc/iamapi-common`'s own migrations, which run from the same data source. +- **Write idempotent DDL**: `ADD COLUMN IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`, and + backfills guarded by `WHERE col IS NULL`. +- **Never assume a recorded migration actually applied.** `AddGrnNumberToWarehouseInventory` + was recorded in `migrations` while its column was absent — it had been dropped out of band. + TypeORM will never re-run a recorded migration, so the fix is a *new repair migration*. +- **A repair migration's `down()` should be a no-op.** Reverting a repair must not + re-introduce the outage it fixed. + +### Auth + +Auth **is implemented in this repo.** Do not add TODO stubs, and do not write your own. + +- `@CurrentUser()` (`@edr/api-common`) is a real `createParamDecorator`, not a metadata stub. +- Route protection uses `@UseGuards(JwtGuard)` and `@UseGuards(PermissionGuard([...]))`. +- Freight-domain checks use `hasFreightPermission(user, FREIGHT_PERMS..)`. +- Permissions are declared in `apps/edr-freight-api/src/seed/freight-permissions.registry.ts`. + Add a permission there before referencing it. +- Login is freight-api's own `POST /api/auth/login` (SharedAuthModule from + `@tria-plc/api-common`). Every login call needs an **`x-client-app` header** — + `backoffice` for employees, `portal` for customers. Without it the API 403s with + "Missing or unrecognized x-client-app header". Browsers send it; curl must add it. +- IAM has its own migrations, run ahead of freight migrations from the same data source, and + its own CLI scripts (`iam:migration:run`, `iam:seed:run`). + +Ownership checks are separate from permission checks. A staff user passes +`hasFreightPermission`; a customer must additionally pass an ownership assertion such as +`assertCustomerCanAccessBooking`. Do not drop the ownership check because the permission check passed. + +## Frontend conventions + +- The **freight** web apps use **Mantine v9** (`^9.3.0`). Its APIs differ from v6/v7 — + check the installed version before copying a snippet. +- `@edr/ui-common` holds shared components and theme; it is imported in ~94 files across the + freight web apps. Prefer it over re-implementing a component. +- **Blob downloads need the async error decoder.** A request with `responseType: 'blob'` + delivers the JSON error body as a `Blob`, so the synchronous `extractErrorMessage` finds no + `.message` and degrades to `"Request failed with status code 400"`. Use + `await extractDownloadErrorMessage(error)` in every PDF/blob catch block. Mutation catches + keep the synchronous version — their bodies are already parsed JSON. +- Server-side guards must be reflected in the UI. If the API will reject the action, the + button should be disabled, hidden, or explain the blocker — not fire and surface a 400. +- Prefer disabling a control with a visible reason over silently hiding it. + +## Notifications + +In-app notifications resolve recipients from the company's **linked portal users**. If a +company has none, `notify()` logs `0 recipients — skipped` and stores nothing, with no error. +SMS and email still send, because they address the company's phone and email directly. Check +this before debugging a "missing notification". + +## PDF generation + +Chromium is not installed in every environment. PDF paths must fall back to the hand-rolled +generators (`styled-pdf.util.ts`, `buildFallbackPdf`, `buildTabularFallbackPdf`) rather than +assume a headless browser exists. ## Adding a new module to a NestJS app -1. Create `modules//` with `entities/`, `dto/`, and the four `.{module,controller,service,repository}.ts` files. +1. Create `modules//` with `entities/`, `dto/`, and the four + `.{module,controller,service,repository}.ts` files. 2. The entity extends `BaseEntity` from `@edr/api-common`. 3. The repository extends `BaseRepository` from `@edr/api-common`. 4. The service injects the repository class (not `Repository` directly). -5. The controller uses `@ApiTags()` + `@ApiOperation()` for Swagger. +5. The controller uses `@ApiTags()` + `@ApiOperation()` for Swagger, and guards the route. 6. Register the module in the app's `app.module.ts`. ## Adding a new shared component to `@edr/ui-common` 1. Create `src/components//.tsx` and `src/components//index.ts`. 2. Export from `src/index.ts`. -3. Component is a functional component with a `ComponentNameProps` interface (named-exported alongside the default). +3. Component is a functional component with a `ComponentNameProps` interface + (named-exported alongside the default). + +## Definition of done + +A change is done when **all** of these hold. State explicitly which you ran. + +1. **It type-checks.** `pnpm turbo type-check --filter=` passes. + If you edited `packages/types`, you ran `pnpm turbo build --filter=@edr/types` first. +2. **Raw SQL is verified.** Every new or edited SQL statement ran under `EXPLAIN` against the + dev database without error. +3. **Migrations are safe.** Unique timestamp, idempotent DDL, and — if the migration adds + something the new code reads — applied to the dev database, since watch mode will not run it. +4. **No new test failures.** `pnpm test` for `@edr/freight-api` has been red on `dev`, so a + fully green suite is not the bar — but confirm that for yourself rather than assuming it, + then run the specs covering what you touched and confirm you introduced no new failure. +5. **Formatting is clean** for the files you touched. Git hooks do **not** run automatically + (see below), and `pnpm lint` does not work at all, so `noUnusedLocals` from the + type-check is your only unused-code signal. +6. **The behaviour was actually observed**, not merely compiled — you drove the flow, hit the + endpoint, or ran the query. If you could not, say so plainly. +7. **Report honestly.** If a check was skipped, tests failed, or a fix is unverified, say it in + the summary. Never describe unverified work as done. + +### Hooks do not run + +`commitlint.config.js` and a `lint-staged` config both exist, and husky's shims are installed +at `.husky/_/`. But there are **no user hook scripts** (`.husky/pre-commit`, +`.husky/commit-msg`), so husky's shim exits 0 and **neither lint-staged nor commitlint ever +fire.** Nothing validates your commit message or formats your staged files. Run the checks by +hand; do not assume the hook caught it. + +## Known traps + +| Trap | What happens | What to do | +| --- | --- | --- | +| Schema drift | A recorded migration's column is missing; queries and inserts 500 | Write a new repair migration; never edit the recorded one | +| Duplicate migration timestamps | Non-deterministic ordering; a migration can be skipped | Pick a fresh, higher timestamp | +| `@edr/types` not rebuilt | Consumers can't see your new field | `pnpm turbo build --filter=@edr/types` | +| Slow I/O in a transaction | Locks held for the gateway timeout | Queue it; fan out after commit; always set an HTTP timeout | +| Blob error bodies | Real 400 message replaced by "Request failed with status code 400" | `await extractDownloadErrorMessage(error)` | +| Company with no portal user | In-app notification silently vanishes | Check portal users before debugging | +| Watch-mode reload | New code, old schema → 500 | Apply the migration to the dev DB or restart fully | +| Login 403 from curl | "Missing or unrecognized x-client-app header" | Send `x-client-app: backoffice` or `portal` | +| Copying a passenger pattern into freight | Passenger is Prisma + Next.js, freight is TypeORM + Vite | Check which stack you are in first | + +## Project skills + +Reusable workflows live in `.claude/skills/`. Use them instead of re-deriving the steps: + +| Skill | Use for | +| --- | --- | +| `edr-db` | Query / `EXPLAIN`-validate / inspect the remote dev DB (`node .claude/skills/edr-db/query.cjs …`). psql is not installed — this is the sanctioned path. Also carries the 400/500 diagnosis loop. | +| `verify` | The definition-of-done runner: targeted type-check, `@edr/types` rebuild, SQL validation, migration checklist, honest test bar. Run before calling anything finished. | +| `standup` | "What did I do today / this week" reports for tickets, grounded in `git log` — including the check that commit subjects match their contents. | + +## Working style + +- **Verify before asserting.** Read the code or query the database. Do not infer behaviour + from a filename. +- **Investigate, then propose.** For anything risky or wide-reaching, present the plan and the + trade-off before changing files. +- **Small, reviewable commits**, one logical change each, conventional message. +- **Branch from `dev`; PRs target `dev`.** +- When a finding turns out to be wrong, say so and retract it. A rejected finding is a result. diff --git a/CLAUDE_NEW.md b/CLAUDE_NEW.md deleted file mode 100644 index a99f64d0a..000000000 --- a/CLAUDE_NEW.md +++ /dev/null @@ -1,313 +0,0 @@ -# EDR Platform — Developer Guide - -> This file is the contract. If something here contradicts the code, the code is the -> truth and this file is a bug — fix it in the same PR. - -## Overview - -Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Freight -Management and Passenger Management applications, a payment microservice, plus shared -types, NestJS utilities, and React component libraries. - -The freight domain is the largest and most active area. Its core flow is: -**booking → receive to warehouse → store → load onto train → dispatch → arrive → unload -→ customer truck (self-haul) or EDR last mile → handover → exit paper → delivered.** -Fees (storage, demurrage, double handling, truck detention) and allocation rules -(warehouse/yard/zone) hang off the warehouse stage. - -## Apps - -| App | Package name | Purpose | Default port | -| ------------------------------ | --------------------------- | -------------------------------------------------- | ------------ | -| `edr-freight-api` | `@edr/freight-api` | NestJS API for freight management | 3001 | -| `edr-freight-web/portal` | `@edr/freight-portal` | React frontend for freight customer/portal users | 5173 | -| `edr-freight-web/backoffice` | `@edr/freight-backoffice` | React frontend for freight backoffice employees | 5183 | -| `edr-passenger-api` | `@edr/passenger-api` | NestJS API for passenger management | 3002 | -| `edr-payment-api` | `@edr/payment-api` | NestJS payment microservice (intents, webhooks) | 3003 | -| `edr-passenger-web/portal` | `@edr/passenger-portal` | React frontend for passenger customer/portal users | 5174 | -| `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | React frontend for passenger backoffice employees | 5184 | - -`edr-freight-web` and `edr-passenger-web` are grouping folders, not workspace packages. -Each holds a `portal/` and `backoffice/` sub-app, both independent pnpm workspace -packages (see `pnpm-workspace.yaml`). - -`apps/edr-landing/` exists on disk but has **no `package.json`** — it is not a workspace -package and is not built, linted, or type-checked. Leave it alone unless asked. - -## Packages - -| Package | Purpose | -| ---------------------- | ---------------------------------------------------------------------------------- | -| `@edr/types` | Shared TypeScript interfaces and enums | -| `@edr/api-common` | Shared NestJS decorators, filters, interceptors, pipes, BaseEntity, BaseRepository | -| `@edr/ui-common` | Shared React components and theme | -| `@edr/eslint-config` | Shared ESLint configurations (base/nestjs/react) | -| `@edr/tsconfig` | Shared TypeScript configurations | -| `@edr/prettier-config` | Shared Prettier configuration | - -**`@edr/types` is consumed as its built `dist/`** (`main: ./dist/index.js`). Editing a -type in `packages/types/src` changes nothing for consumers until you rebuild: - -```bash -pnpm turbo build --filter=@edr/types -``` - -If a type-check fails on a field you just added to `@edr/types`, this is why. - -## Commands - -| Command | Description | -| --------------------------- | ---------------------------------------- | -| `pnpm install` | Install all workspace dependencies | -| `pnpm dev` | Run every app in dev mode | -| `pnpm dev:freight` | Freight API + portal + backoffice | -| `pnpm dev:freight:api` | Freight API only | -| `pnpm dev:freight:portal` | Freight portal only | -| `pnpm dev:freight:backoffice` | Freight backoffice only | -| `pnpm dev:passenger` | Passenger API + web | -| `pnpm dev:payment` | Payment API | -| `pnpm build` | Build every package and app | -| `pnpm test` | Run all tests (turbo) | -| `pnpm lint` | Lint everything | -| `pnpm type-check` | Type-check every package | -| `pnpm format` | Format all files with Prettier | - -Prefer targeted turbo filters over whole-repo runs — they are minutes faster: - -```bash -pnpm turbo type-check --filter=@edr/freight-api --filter=@edr/freight-backoffice -``` - -`apps/edr-freight-api` also carries many `seed:*` scripts (demo bookings, wagons, trains, -gate-pass scenarios). Read the script before running one; several write real rows. - -## Environment & database - -- Postgres is **external**. There is no postgres service in `docker-compose.yaml`, and - no port `5433`/`5434` is published anywhere in the repo. -- Freight API connection comes from `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, - `DB_NAME` (defaults: `localhost:5433`, `edr_freight`). Development points these at a - remote database. -- The connection sits behind a **connection pooler**. Do **not** pass - `extra.options: '-c search_path=…'` — the pooler rejects it with - `08P01 unsupported startup parameter in options: search_path`. `search_path` is applied - per-connection in a pool `connect` handler instead. See - `apps/edr-freight-api/src/config/database.config.ts` before touching connection options. -- Each app owns its own database. **No cross-database joins**; cross-domain data flows - through API calls or message queues. -- `psql` is not installed on the dev machine. To query the database, write a short Node - script using the `pg` client and run it from `apps/edr-freight-api` (where `pg` resolves). - -## Hard rules - -These are non-negotiable. Everything else is a strong default. - -- **pnpm only.** Never run `npm install` or `yarn`. -- **TypeScript strict mode** is on in every package and app. Do not weaken it, and do not - reach for `any` to make an error go away. -- **Never `synchronize: true`.** Not in production, not anywhere. It is currently `false` - in every config and it has already corrupted this database twice (see *Migrations*). - All schema changes go through TypeORM migrations. -- **All entities** use UUID primary keys (`@PrimaryGeneratedColumn('uuid')`). -- **All entities** extend `BaseEntity` from `@edr/api-common` — `createdAt`, `updatedAt`, - `deletedAt` (soft delete). -- **All columns** are `snake_case` in the database (`@Column({ name: 'snake_case' })`); - TypeScript properties are `camelCase`. -- **Controllers contain no business logic.** They validate, delegate, and shape the response. -- **Conventional commits.** `fix(warehouses): …`, `feat(bookings): …`. -- **Do not commit or push unless asked.** Propose the change; let the human decide when it lands. -- **Do not break working behaviour to add new behaviour.** When a fix is risky, say so and - offer the safe version. - -## Architecture - -### NestJS module shape - -`module → controller → service → repository`, with `entities/` and `dto/` alongside. - -### Data access — the real model - -There are two sanctioned ways to read and write, and you must pick the right one: - -1. **Entity CRUD → the custom repository class.** Extends `BaseRepository` from - `@edr/api-common`. Services inject the repository class, never `Repository` directly. -2. **Read projections, queue endpoints, cross-table reports → raw SQL** via - `this.dataSource.query(...)` or `manager.query(...)` inside a transaction. - -Raw SQL is normal here, not a smell — the warehouse and scheduling modules are built on it. -It carries one obligation: - -> **HARD RULE — validate every raw SQL statement against a real database before you ship it.** -> A typo'd column name is a runtime 500 that no type-checker will catch. Run it through -> `EXPLAIN` against the dev database. Column drift is real (see *Migrations*). - -Writes inside a transaction use `manager.getRepository(Entity)`, not the injected repository, -so they join the caller's transaction. - -**Never do slow I/O inside a database transaction.** Queue the work and fan it out after -commit. An SMS awaited inside a transaction once held capacity locks open for the whole -gateway timeout. Any outbound HTTP call must set an explicit `timeout` — axios defaults to -no timeout and will wait forever. - -### Migrations - -Migrations are the most dangerous surface in this repo. Two production-grade incidents have -already come from it. - -- `migrationsRun: false` — **migrations do NOT run on API boot.** They run as a separate - one-shot step, via the Dockerfile's `migration` build target (`docker build --target - migration`), with `migrationsTransactionMode: 'each'`. - - CI: `.github/workflows/deploy.yml` builds the `migration` image and runs it - (`docker run --rm --env-file ...`) *before* building/deploying the app image. - - e2e: `docker-compose.e2e.yaml`'s `freight-migration-e2e` service runs once and - `freight-api-e2e` depends on it (`condition: service_completed_successfully`). - - Local dev (`docker-compose.yaml`) has no equivalent migration service yet — run - migrations yourself before `docker compose up freight-api`, e.g. - `docker build --target migration -f apps/edr-freight-api/Dockerfile -t freight-migration .` - then `docker run --rm --env-file apps/edr-freight-api/.env freight-migration`. Don't - use `pnpm run migrate` for this — it runs via `ts-node`, which never writes compiled - output to `dist/`, and the freight migrations glob only matches `dist/migrations/*.js`. - It silently applies zero freight migrations while exiting 0. -- Consequences you must design for: - - A watch-mode hot reload does **not** re-run migrations. If you add a column that new - code reads, apply it to the dev database yourself (idempotently) or fully restart. - - `apps/edr-freight-api/src/config/database.config.ts`'s `iamEntities` array is a - hand-maintained list of `@tria-plc/iamapi-common` entity classes. The live app never - notices when it's stale (`autoLoadEntities: true` papers over gaps via IAM's own - `forFeature()` registrations), but the standalone migration `DataSource` - (`data-source.ts`, no `autoLoadEntities`) does not have that fallback — a missing - entity throws `Entity metadata for X#y was not found` at `initialize()`, before a - single migration runs. **Every `@tria-plc/iamapi-common` version bump is a candidate - for this to break again** — diff the package's entity classes against `iamEntities` - when bumping it. -- **Give every migration a unique timestamp.** 34 timestamps are currently shared by two or - more migrations. TypeORM orders by timestamp and breaks ties non-deterministically. Before - adding one, check the filename prefix is unused *and* higher than the newest recorded row. -- **Write idempotent DDL**: `ADD COLUMN IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`, and - backfills guarded by `WHERE col IS NULL`. -- **Never assume a recorded migration actually applied.** `AddGrnNumberToWarehouseInventory` - was recorded in `migrations` while its column was absent — it had been dropped out of band. - TypeORM will never re-run a recorded migration, so the fix is a *new repair migration*. -- **A repair migration's `down()` should be a no-op.** Reverting a repair must not - re-introduce the outage it fixed. - -### Auth - -Auth **is implemented in this repo.** Do not add TODO stubs, and do not write your own. - -- `@CurrentUser()` (`@edr/api-common`) is a real `createParamDecorator`, not a metadata stub. -- Route protection uses `@UseGuards(JwtGuard)` and `@UseGuards(PermissionGuard([...]))`. -- Freight-domain checks use `hasFreightPermission(user, FREIGHT_PERMS..)`. -- Permissions are declared in `apps/edr-freight-api/src/seed/freight-permissions.registry.ts`. - Add a permission there before referencing it. -- IAM has its own migrations, run ahead of freight migrations from the same data source, and - its own CLI scripts (`iam:migration:run`, `iam:seed:run`). - -Ownership checks are separate from permission checks. A staff user passes -`hasFreightPermission`; a customer must additionally pass an ownership assertion such as -`assertCustomerCanAccessBooking`. Do not drop the ownership check because the permission check passed. - -## Frontend conventions - -- The web apps use **Mantine v9**. Its APIs differ from v6/v7 — check the installed version - before copying a snippet. -- `@edr/ui-common` holds shared components and theme; it is imported in ~94 files across the - freight web apps. Prefer it over re-implementing a component. -- **Blob downloads need the async error decoder.** A request with `responseType: 'blob'` - delivers the JSON error body as a `Blob`, so the synchronous `extractErrorMessage` finds no - `.message` and degrades to `"Request failed with status code 400"`. Use - `await extractDownloadErrorMessage(error)` in every PDF/blob catch block. Mutation catches - keep the synchronous version — their bodies are already parsed JSON. -- Server-side guards must be reflected in the UI. If the API will reject the action, the - button should be disabled, hidden, or explain the blocker — not fire and surface a 400. -- Prefer disabling a control with a visible reason over silently hiding it. - -## Notifications - -In-app notifications resolve recipients from the company's **linked portal users**. If a -company has none, `notify()` logs `0 recipients — skipped` and stores nothing, with no error. -SMS and email still send, because they address the company's phone and email directly. Check -this before debugging a "missing notification". - -## PDF generation - -Chromium is not installed in every environment. PDF paths must fall back to the hand-rolled -generators (`styled-pdf.util.ts`, `buildFallbackPdf`, `buildTabularFallbackPdf`) rather than -assume a headless browser exists. - -## Adding a new module to a NestJS app - -1. Create `modules//` with `entities/`, `dto/`, and the four - `.{module,controller,service,repository}.ts` files. -2. The entity extends `BaseEntity` from `@edr/api-common`. -3. The repository extends `BaseRepository` from `@edr/api-common`. -4. The service injects the repository class (not `Repository` directly). -5. The controller uses `@ApiTags()` + `@ApiOperation()` for Swagger, and guards the route. -6. Register the module in the app's `app.module.ts`. - -## Adding a new shared component to `@edr/ui-common` - -1. Create `src/components//.tsx` and `src/components//index.ts`. -2. Export from `src/index.ts`. -3. Component is a functional component with a `ComponentNameProps` interface - (named-exported alongside the default). - -## Definition of done - -A change is done when **all** of these hold. State explicitly which you ran. - -1. **It type-checks.** `pnpm turbo type-check --filter=` passes. - If you edited `packages/types`, you ran `pnpm turbo build --filter=@edr/types` first. -2. **Raw SQL is verified.** Every new or edited SQL statement ran under `EXPLAIN` against the - dev database without error. -3. **Migrations are safe.** Unique timestamp, idempotent DDL, and — if the migration adds - something the new code reads — applied to the dev database, since watch mode will not run it. -4. **No new test failures.** `pnpm test` for `@edr/freight-api` is **currently red on `dev`**, - so a fully green suite is not the bar. Run the specs covering what you touched and confirm - you introduced no new failure. -5. **Lint and format are clean** for the files you touched. Git hooks do **not** run these - automatically (see below), so run them yourself. -6. **The behaviour was actually observed**, not merely compiled — you drove the flow, hit the - endpoint, or ran the query. If you could not, say so plainly. -7. **Report honestly.** If a check was skipped, tests failed, or a fix is unverified, say it in - the summary. Never describe unverified work as done. - -### Hooks do not run - -`commitlint.config.js` and a `lint-staged` config both exist, and husky's shims are installed -at `.husky/_/`. But there are **no user hook scripts** (`.husky/pre-commit`, -`.husky/commit-msg`), so husky's shim exits 0 and **neither lint-staged nor commitlint ever -fire.** Nothing validates your commit message or formats your staged files. Run the checks by -hand; do not assume the hook caught it. - -## Known traps - -| Trap | What happens | What to do | -| --- | --- | --- | -| Schema drift | A recorded migration's column is missing; queries and inserts 500 | Write a new repair migration; never edit the recorded one | -| Duplicate migration timestamps | Non-deterministic ordering; a migration can be skipped | Pick a fresh, higher timestamp | -| `@edr/types` not rebuilt | Consumers can't see your new field | `pnpm turbo build --filter=@edr/types` | -| Slow I/O in a transaction | Locks held for the gateway timeout | Queue it; fan out after commit; always set an HTTP timeout | -| Blob error bodies | Real 400 message replaced by "Request failed with status code 400" | `await extractDownloadErrorMessage(error)` | -| Company with no portal user | In-app notification silently vanishes | Check portal users before debugging | -| Watch-mode reload | New code, old schema → 500 | Apply the migration to the dev DB or restart fully | - -## Project skills - -Reusable workflows live in `.claude/skills/`. Use them instead of re-deriving the steps: - -| Skill | Use for | -| --- | --- | -| `edr-db` | Query / `EXPLAIN`-validate / inspect the remote dev DB (`node .claude/skills/edr-db/query.cjs …`). psql is not installed — this is the sanctioned path. Also carries the 400/500 diagnosis loop. | -| `verify` | The definition-of-done runner: targeted type-check, `@edr/types` rebuild, SQL validation, migration checklist, honest test bar. Run before calling anything finished. | -| `standup` | "What did I do today / this week" reports for tickets, grounded in `git log` — including the check that commit subjects match their contents. | - -## Working style - -- **Verify before asserting.** Read the code or query the database. Do not infer behaviour - from a filename. -- **Investigate, then propose.** For anything risky or wide-reaching, present the plan and the - trade-off before changing files. -- **Small, reviewable commits**, one logical change each, conventional message. -- **Branch from `dev`; PRs target `dev`.** -- When a finding turns out to be wrong, say so and retract it. A rejected finding is a result. diff --git a/apps/edr-freight-api/src/common/utils/iam-user-name.util.ts b/apps/edr-freight-api/src/common/utils/iam-user-name.util.ts new file mode 100644 index 000000000..e4a3465d0 --- /dev/null +++ b/apps/edr-freight-api/src/common/utils/iam-user-name.util.ts @@ -0,0 +1,49 @@ +import { DataSource } from "typeorm"; + +/** + * `iam.users.name` is a localized object ({ en, am, … }), not a string — a + * plain `String(name)` there yields "[object Object]" in an audit trail. + */ +export interface IamUserRow { + name?: Record | string | null; + username?: string | null; + email?: string | null; +} + +/** Best display name for a user row: English label → any locale → login → email. */ +export function pickUserName(user: IamUserRow): string | null { + const { name } = user; + if (typeof name === "string" && name.trim()) return name.trim(); + if (name && typeof name === "object") { + const localized = + name.en ?? + Object.values(name).find((v) => typeof v === "string" && v.trim()); + if (localized?.trim()) return localized.trim(); + } + return user.username?.trim() || user.email?.trim() || null; +} + +/** + * Display names for a set of IAM user ids — one query for the whole set. + * `iam.users` is owned by the auth system and has no entity here, so it is read + * directly. A miss is not an error: the caller still holds the id and can fall + * back to it. + */ +export async function resolveIamUserNames( + dataSource: DataSource, + userIds: (string | null | undefined)[], +): Promise> { + const resolved = new Map(); + const ids = [...new Set(userIds.filter((id): id is string => Boolean(id)))]; + if (ids.length === 0) return resolved; + + const rows = (await dataSource.query( + `SELECT id, name, username, email FROM iam.users WHERE id = ANY($1::uuid[])`, + [ids], + )) as Array; + for (const row of rows) { + const name = pickUserName(row); + if (name) resolved.set(row.id, name); + } + return resolved; +} diff --git a/apps/edr-freight-api/src/config/eims.config.spec.ts b/apps/edr-freight-api/src/config/eims.config.spec.ts new file mode 100644 index 000000000..a6aa3895d --- /dev/null +++ b/apps/edr-freight-api/src/config/eims.config.spec.ts @@ -0,0 +1,72 @@ +import eimsConfigFactory from "./eims.config"; + +const REQUIRED = { + EIMS_ENABLED: "true", + EIMS_CLIENT_ID: "cid", + EIMS_CLIENT_SECRET: "secret", + EIMS_API_KEY: "apikey", + EIMS_TIN: "0000000000", +}; + +const withEnv = (vars: Record, fn: () => void) => { + const prior: Record = {}; + for (const [key, value] of Object.entries(vars)) { + prior[key] = process.env[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + try { + fn(); + } finally { + for (const [key, value] of Object.entries(prior)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +}; + +describe("eims.config — private key / certificate resolution", () => { + it("unescapes a literal \\n when the PEM was pasted without real newlines", () => { + withEnv( + { ...REQUIRED, EIMS_PRIVATE_KEY: "line1\\nline2", EIMS_CERTIFICATE_PATH: "/dev/null" }, + () => { + expect(eimsConfigFactory().privateKeyPem).toBe("line1\nline2"); + }, + ); + }); + + it("leaves a PEM with real newlines untouched", () => { + withEnv( + { ...REQUIRED, EIMS_PRIVATE_KEY: "line1\nline2", EIMS_CERTIFICATE_PATH: "/dev/null" }, + () => { + expect(eimsConfigFactory().privateKeyPem).toBe("line1\nline2"); + }, + ); + }); + + it("throws naming all three key/cert options when none are set", () => { + withEnv( + { + ...REQUIRED, + EIMS_PRIVATE_KEY_PATH: undefined, + EIMS_PRIVATE_KEY_BASE64: undefined, + EIMS_PRIVATE_KEY: undefined, + EIMS_CERTIFICATE_PATH: "/dev/null", + }, + () => { + expect(() => eimsConfigFactory()).toThrow( + /EIMS_PRIVATE_KEY_PATH or EIMS_PRIVATE_KEY_BASE64 or EIMS_PRIVATE_KEY/, + ); + }, + ); + }); + + it("is satisfied by any single one of the three key options", () => { + withEnv( + { ...REQUIRED, EIMS_PRIVATE_KEY: "x", EIMS_CERTIFICATE_PATH: "/dev/null" }, + () => { + expect(() => eimsConfigFactory()).not.toThrow(); + }, + ); + }); +}); diff --git a/apps/edr-freight-api/src/config/eims.config.ts b/apps/edr-freight-api/src/config/eims.config.ts index cf77072e0..9d99ae455 100644 --- a/apps/edr-freight-api/src/config/eims.config.ts +++ b/apps/edr-freight-api/src/config/eims.config.ts @@ -30,6 +30,23 @@ export interface EimsConfig { privateKeyPath: string; /** Filesystem path to the INSA-issued certificate bundle; sent as base64 of its exact bytes. */ certificatePath: string; + /** + * Inline alternative to `privateKeyPath` — the key file's own bytes, base64-encoded, so a + * container that can't be given a host bind mount can still receive it as a plain env var. + * Either one must be present when EIMS is enabled. Precedence: `privateKeyPem` > `privateKeyBase64` + * > `privateKeyPath`. + */ + privateKeyBase64: string; + /** Inline alternative to `certificatePath`, same precedence rule as the key. */ + certificateBase64: string; + /** + * The PEM key pasted directly into the env var, no encoding step at all — the most direct of the + * three inline forms, and the hardest for a broken transport step to mangle since there's no + * decode stage to get wrong. Wins over `privateKeyBase64`/`privateKeyPath` when set. + */ + privateKeyPem: string; + /** Inline alternative to `certificateBase64`, same precedence rule. */ + certificatePem: string; httpTimeoutMs: number; /** Re-authenticate this many ms before the access token actually expires. */ tokenSkewMs: number; @@ -80,7 +97,19 @@ export interface EimsInvoiceConfig { 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; /** * 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 @@ -89,6 +118,14 @@ export interface EimsInvoiceConfig { buyerRegionCodes: Record; /** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */ buyerWeredaCodes: Record; + /** + * 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; /** * 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 @@ -115,14 +152,14 @@ export interface EimsInvoiceConfig { buyerIdNumber: string | null; } -const REQUIRED_VARS = [ - "EIMS_CLIENT_ID", - "EIMS_CLIENT_SECRET", - "EIMS_API_KEY", - "EIMS_TIN", - "EIMS_PRIVATE_KEY_PATH", - "EIMS_CERTIFICATE_PATH", -] as const; +const REQUIRED_VARS = ["EIMS_CLIENT_ID", "EIMS_CLIENT_SECRET", "EIMS_API_KEY", "EIMS_TIN"] as const; + +// Key/cert each have three ways in (file path, inline base64, or raw PEM) — checked separately +// from REQUIRED_VARS since it's "at least one of", not "this exact var". +const REQUIRED_ANY_OF: string[][] = [ + ["EIMS_PRIVATE_KEY_PATH", "EIMS_PRIVATE_KEY_BASE64", "EIMS_PRIVATE_KEY"], + ["EIMS_CERTIFICATE_PATH", "EIMS_CERTIFICATE_BASE64", "EIMS_CERTIFICATE"], +]; const positiveInt = (raw: string | undefined, fallback: number, name: string): number => { if (raw === undefined || raw === "") return fallback; @@ -143,6 +180,14 @@ const parseCodeMap = (raw: string | undefined): Record => { return map; }; +// Some env stores (single-line .env files, certain secret managers) can't hold a literal newline +// and expect the caller to write "\n" as two characters instead. If the raw value already has a +// real newline, leave it alone; otherwise unescape "\n" so a PEM pasted that way still parses. +const normalizePem = (raw: string | undefined): string => { + if (!raw) return ""; + return raw.includes("\n") ? raw : raw.replace(/\\n/g, "\n"); +}; + /** Unset stays null so the registration-time check can name it; a set-but-bogus value throws. */ const optionalNumber = (raw: string | undefined, name: string): number | null => { if (raw === undefined || raw === "") return null; @@ -169,6 +214,10 @@ export default registerAs("eims", (): EimsConfig => { systemType: process.env.EIMS_SYSTEM_TYPE ?? "", privateKeyPath: process.env.EIMS_PRIVATE_KEY_PATH ?? "", certificatePath: process.env.EIMS_CERTIFICATE_PATH ?? "", + privateKeyBase64: process.env.EIMS_PRIVATE_KEY_BASE64 ?? "", + privateKeyPem: normalizePem(process.env.EIMS_PRIVATE_KEY), + certificatePem: normalizePem(process.env.EIMS_CERTIFICATE), + certificateBase64: process.env.EIMS_CERTIFICATE_BASE64 ?? "", httpTimeoutMs, tokenSkewMs, autoSubmit: (process.env.EIMS_AUTO_SUBMIT ?? "false").toLowerCase() === "true", @@ -208,8 +257,10 @@ export default registerAs("eims", (): EimsConfig => { 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), buyerRegionCodes: parseCodeMap(process.env.EIMS_BUYER_REGION_CODES), buyerWeredaCodes: parseCodeMap(process.env.EIMS_BUYER_WEREDA_CODES), + buyerCityCodes: 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), @@ -223,7 +274,10 @@ export default registerAs("eims", (): EimsConfig => { if (!enabled) return base; - const missing = REQUIRED_VARS.filter((name) => !process.env[name]); + const missing: string[] = REQUIRED_VARS.filter((name) => !process.env[name]); + for (const vars of REQUIRED_ANY_OF) { + if (vars.every((name) => !process.env[name])) missing.push(vars.join(" or ")); + } if (missing.length > 0) { throw new Error( `EIMS integration is enabled (EIMS_ENABLED=true) but the following env vars are missing: ${missing.join(", ")}`, diff --git a/apps/edr-freight-api/src/modules/billing/billing.controller.ts b/apps/edr-freight-api/src/modules/billing/billing.controller.ts index b0da6ac24..810c1a55a 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.controller.ts @@ -92,7 +92,7 @@ export class BillingController { @Get("offline-usd") @ApiOperation({ summary: - "Finance worklist: USD invoices settled offline by bank transfer, with booking pay-window context", + "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); @@ -104,7 +104,7 @@ export class BillingController { @ApiConsumes("multipart/form-data") @ApiOperation({ summary: - "Finance confirms a USD invoice paid by bank transfer — slip file required, settles the full balance", + "Finance confirms an invoice (USD or ETB) paid manually — slip file required, settles the full balance", }) confirmOffline( @Param("id", ParseUUIDPipe) id: string, diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 0825ab6dc..44e194a8d 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -16,6 +16,7 @@ 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 { EimsConfig } from "../../config/eims.config"; import { CompaniesService } from "../companies/companies.service"; import { EimsInvoiceStatus } from "../eims/eims-registration.types"; @@ -48,10 +49,18 @@ export interface PayInvoiceOptions { 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. */ @@ -340,22 +349,23 @@ export class BillingService { } /** - * Finance's offline-settlement worklist: USD invoices (paid by bank transfer, - * never through the gateway), open ones by default or a single status when - * filtered. Booking-sourced rows carry the booking's reference and pay-window - * deadline so the UI can show the countdown and link to the booking. + * 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: (Invoice & { booking: OfflineUsdBookingInfo | null })[]; - total: 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; @@ -364,11 +374,16 @@ export class BillingService { .getRepository(Invoice) .createQueryBuilder("invoice") .leftJoinAndSelect("invoice.company", "company") - .where("UPPER(invoice.currency) = 'USD'") + .where("UPPER(invoice.currency) IN ('USD', 'ETB')") .orderBy("invoice.issuedAt", "DESC") .skip((page - 1) * pageSize) .take(pageSize); + if (filter.currency) { + qb.andWhere("UPPER(invoice.currency) = :currency", { + currency: filter.currency, + }); + } if (filter.status) { qb.andWhere("invoice.status = :status", { status: filter.status }); } else { @@ -381,7 +396,8 @@ export class BillingService { ); } - const [items, total] = await qb.getManyAndCount(); + const [rawItems, total] = await qb.getManyAndCount(); + const items = await this.attachShippingLineCompanies(rawItems); const bookingIds = items .filter((i) => i.source === "booking") @@ -389,11 +405,43 @@ export class BillingService { const bookings = bookingIds.length ? await this.dataSource.getRepository(Booking).find({ where: { id: In(bookingIds) }, - select: ["id", "reference", "paymentDeadline", "paymentStatus"], + 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); + } + return { items: items.map((inv) => { const b = byId.get(inv.sourceId); @@ -403,19 +451,22 @@ export class BillingService { ? { id: b.id, reference: b.reference, + tradeDirection: b.tradeDirection ?? null, paymentDeadline: b.paymentDeadline ?? null, paymentStatus: b.paymentStatus, } : null, - } as Invoice & { booking: OfflineUsdBookingInfo | null }; + bookings: bookingsByInvoice.get(inv.id) ?? [], + } as OfflineUsdInvoiceRow; }), total, }; } /** - * Finance confirms a USD invoice as paid by bank transfer: stores the slip - * against the invoice and settles the FULL outstanding balance through + * Finance confirms an invoice (USD or ETB) as paid manually — bank transfer + * or counter payment: 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. @@ -434,11 +485,6 @@ export class BillingService { ): Promise { const invoice = await this.invoices.findById(invoiceId); if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`); - if (invoice.currency?.toUpperCase() !== "USD") { - throw new BadRequestException( - "Offline confirmation is only for USD invoices — this invoice is paid online.", - ); - } if (!file) { throw new BadRequestException("The bank payment slip file is required."); } diff --git a/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts b/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts index 21676c160..3767637a7 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts @@ -84,7 +84,13 @@ export class PdfRenderService { const page = await browser.newPage(); const thermal = opts.thermal ?? false; const viewportWidth = thermal ? Math.round((THERMAL_PAGE_WIDTH_MM / 25.4) * 96) : 794; - await page.setViewport({ width: viewportWidth, height: 1123, deviceScaleFactor: 1 }); + // 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)); @@ -154,7 +160,7 @@ export class PdfRenderService { // 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, contentMm); + return Math.min(THERMAL_MAX_HEIGHT_MM, Math.round(contentMm * 100) / 100); } private injectPdfPrintStyles(html: string): string { diff --git a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts index e8942d586..91327946c 100644 --- a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts +++ b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts @@ -39,4 +39,11 @@ export class FilterInvoiceDto { @IsOptional() @IsIn(Object.values(Freight.InvoiceStatus)) status?: Freight.InvoiceStatus; + + /** Manual-payments worklist only: restrict to one currency. */ + @ApiPropertyOptional({ enum: ["USD", "ETB"] }) + @IsOptional() + @Transform(({ value }: { value: unknown }) => String(value).toUpperCase()) + @IsIn(["USD", "ETB"]) + currency?: "USD" | "ETB"; } diff --git a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts index 7406613fc..6d58a7bec 100644 --- a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts @@ -60,8 +60,11 @@ const context = (over: Partial = {}): EimsMapperContext => ({ unitDefault: "PCS", incomeWithholdValue: 0, transactionWithholdValue: 0, + buyerCountryCode: "231", // test-only, not a confirmed real MoR code + buyerCountryCodes: {}, buyerRegionCodes: { "Addis Ababa": "13" }, buyerWeredaCodes: {}, + buyerCityCodes: {}, ...over, }); @@ -92,6 +95,9 @@ describe("toEimsInvoice", () => { expect(doc.BuyerDetails).toEqual({ City: null, + // company.country is "Ethiopia" (the domestic default) — resolves to context's flat + // buyerCountryCode fallback, not null, per resolveCountryCode. + Country: "231", Email: "buyer@abc.et", HouseNumber: "NEW", IdNumber: null, @@ -100,7 +106,6 @@ describe("toEimsInvoice", () => { LegalName: "ABC Trading PLC", Phone: "0912345678", Region: "13", - Country: null, Zone: "SHA", Kebele: "03", VatNumber: "123475885858", @@ -335,6 +340,52 @@ describe("toEimsInvoice — MoR field constraints", () => { ).toThrow(/buyer Wereda "Yeka".*EIMS_BUYER_WEREDA_CODES/); }); + it("derives City from the buyer's zone via the city code map", () => { + const doc = toEimsInvoice( + invoice({ company: { ...invoice().company!, zone: "Kirkos" } }), + seller, + context({ buyerCityCodes: { Kirkos: "101" } }), + ); + expect(doc.BuyerDetails.City).toBe("101"); + }); + + it("leaves City null (not a throw) when the buyer's zone has no city mapping — City is optional", () => { + const doc = toEimsInvoice( + invoice({ company: { ...invoice().company!, zone: "Somewhere Else" } }), + seller, + context({ buyerCityCodes: {} }), + ); + expect(doc.BuyerDetails.City).toBeNull(); + }); + + it("maps a buyer country name to its code via the country code map", () => { + const doc = toEimsInvoice( + invoice({ company: { ...invoice().company!, country: "Djibouti" } }), + seller, + context({ buyerCountryCodes: { Djibouti: "071" } }), + ); + expect(doc.BuyerDetails.Country).toBe("071"); + }); + + it("falls back to the flat domestic country code only for Ethiopia, not any unmapped country", () => { + const doc = toEimsInvoice( + invoice({ company: { ...invoice().company!, country: "Ethiopia" } }), + seller, + context({ buyerCountryCode: "231", buyerCountryCodes: {} }), + ); + expect(doc.BuyerDetails.Country).toBe("231"); + }); + + it("refuses a genuinely foreign buyer country with no mapping — never silently files it as Ethiopia", () => { + expect(() => + toEimsInvoice( + invoice({ company: { ...invoice().company!, country: "Kenya" } }), + seller, + context({ buyerCountryCode: "231", buyerCountryCodes: {} }), + ), + ).toThrow(/buyer Country "Kenya".*EIMS_BUYER_COUNTRY_CODES/); + }); + it("emits NatureOfSupplies lowercase, whatever case it was configured in", () => { const doc = toEimsInvoice(invoice(), seller, context({ natureOfSupplies: "Service" })); expect(doc.ItemList[0].NatureOfSupplies).toBe("service"); diff --git a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts index 582e3d326..b8755c709 100644 --- a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts +++ b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts @@ -234,8 +234,13 @@ export interface EimsMapperContext { * from a registered invoice"). */ relatedDocument?: string | null; - /** MoR numeric country code for the buyer; our DB stores the country name. */ + /** + * Domestic fallback only, applied when `company.country` is empty or "Ethiopia" and not already + * in `buyerCountryCodes` — see that field. Never applied to a genuinely foreign buyer. + */ buyerCountryCode?: string | null; + /** Country name → MoR code. Format unconfirmed, so looked up by name only, not digit-validated. */ + buyerCountryCodes: Record; /** * Region name → MoR numeric code, for buyers whose stored region is free text. * @@ -252,9 +257,15 @@ export interface EimsMapperContext { * fail locally on an unmapped name rather than file a guess. */ buyerWeredaCodes: Record; + /** + * Buyer *zone* name → MoR City code. `Company` has no dedicated city column; Zone is the + * closest match in EDR's own data. Unlike Region/Wereda, City is optional — MoR has already + * accepted a live filing with it null — so an unmapped zone resolves to null, it does not fail + * the mapping. + */ + buyerCityCodes: Record; buyerIdType?: string | null; buyerIdNumber?: string | null; - buyerCity?: string | null; /** Required when the invoice currency is not ETB. */ exchangeRate?: number | null; invoiceDiscount?: number | null; @@ -299,17 +310,22 @@ export const formatEimsDate = (issuedAt: Date): string => * exchange rate. */ /** - * A buyer's location value (Region or Wereda) as a MoR code: passed through when already numeric, - * otherwise looked up by name (case- and space-insensitive). Throws when neither applies — sending - * a guessed code onto a tax document is worse than refusing to file. + * A buyer's location value (Region, Wereda or City) as a MoR code: passed through when already + * numeric, otherwise looked up by name (case- and space-insensitive). + * + * Region/Wereda are required: an unmapped value throws — sending a guessed code onto a tax + * document is worse than refusing to file. City is optional (`required: false`, City's own + * caller) — MoR has already accepted a live filing with it null, so an unmapped zone resolves to + * null instead of blocking the invoice. */ function resolveLocationCode( - field: "Region" | "Wereda", + field: "Region" | "Wereda" | "City", value: string | null | undefined, codes: Record, envVar: string, invoiceNumber: string, -): string { + opts: { required?: boolean } = {}, +): string | null { const raw = (value ?? "").trim(); if (LOCATION_CODE.test(raw)) return raw; @@ -319,12 +335,61 @@ function resolveLocationCode( )?.[1]; if (mapped && LOCATION_CODE.test(mapped)) return mapped; + if (opts.required === false) return null; + throw new Error( `EIMS mapping: invoice ${invoiceNumber} has buyer ${field} ${raw ? `"${raw}"` : "(unset)"}, ` + `which is not a MoR ${field} code and has no mapping. Add it to ${envVar}.`, ); } +/** + * A buyer's `Country` as a MoR code: looked up by name in `codes` first; when unmapped, applies + * `domesticFallback` only if the stored country is empty or "Ethiopia" (the DB column's default). + * A genuinely foreign, unmapped country throws rather than silently filing as Ethiopia — same + * "fail locally, don't guess" rule as `resolveLocationCode`, but never digit-validated: MoR's + * Country code format is unconfirmed, unlike Region/Wereda's proven `^[0-9]{1,3}$`. + */ +function resolveCountryCode( + country: string | null | undefined, + codes: Record, + domesticFallback: string | null, + invoiceNumber: string, +): string | null { + const raw = (country ?? "").trim(); + const key = raw.toLowerCase().replace(/\s+/g, " "); + const mapped = Object.entries(codes).find( + ([name]) => name.trim().toLowerCase().replace(/\s+/g, " ") === key, + )?.[1]; + if (mapped) return mapped; + + if ((!raw || key === "ethiopia") && domesticFallback) return domesticFallback; + + throw new Error( + `EIMS mapping: invoice ${invoiceNumber} has buyer Country "${raw || "(unset)"}", which has no ` + + "MoR country code mapping. Add it to EIMS_BUYER_COUNTRY_CODES.", + ); +} + +/** + * Same name-or-code resolution as `resolveLocationCode`, for a caller with no invoice to attach an + * error to and that must never throw — currently only `EimsSellerCacheService`, resolving + * e-Trade's region/zone/woreda *names* for EDR's own seller identity. Pass-through numeric code, + * name lookup, `undefined` on no match — the caller falls back to static config either way. + */ +export function resolveOptionalCode( + value: string | null | undefined, + codes: Record, +): string | undefined { + const raw = (value ?? "").trim(); + if (LOCATION_CODE.test(raw)) return raw; + const key = raw.toLowerCase().replace(/\s+/g, " "); + const mapped = Object.entries(codes).find( + ([name]) => name.trim().toLowerCase().replace(/\s+/g, " ") === key, + )?.[1]; + return mapped && LOCATION_CODE.test(mapped) ? mapped : undefined; +} + export function toEimsInvoice( invoice: EimsMapperInvoice, seller: EimsSellerDetails, @@ -440,7 +505,16 @@ export function toEimsInvoice( return { BuyerDetails: { - City: context.buyerCity ?? null, + // No dedicated city column on Company — Zone is the closest match; optional (see + // resolveLocationCode's City comment). + City: resolveLocationCode( + "City", + company.zone, + context.buyerCityCodes, + "EIMS_BUYER_CITY_CODES", + invoice.invoiceNumber, + { required: false }, + ), Email: company.email ?? null, HouseNumber: company.houseNo ?? null, IdNumber: context.buyerIdNumber ?? null, @@ -455,7 +529,12 @@ export function toEimsInvoice( "EIMS_BUYER_REGION_CODES", invoice.invoiceNumber, ), - Country: context.buyerCountryCode ?? null, + Country: resolveCountryCode( + company.country, + context.buyerCountryCodes, + context.buyerCountryCode ?? null, + invoice.invoiceNumber, + ), Zone: company.zone ?? null, Kebele: company.kebele ?? null, VatNumber: company.vatNumber ?? null, diff --git a/apps/edr-freight-api/src/modules/companies/companies.module.ts b/apps/edr-freight-api/src/modules/companies/companies.module.ts index b990aa8f0..666634cb8 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.module.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts @@ -67,6 +67,9 @@ import { VerifaydaModule } from "../verifayda/verifayda.module"; // Consumed by NotificationInboxModule for portal recipient targeting. ExternalProfileRepository, CompanyProfileRepository, + // Consumed by EimsModule's EimsSellerCacheService — same e-Trade business-registry lookup + // already used for every customer company at onboarding, reused for EDR's own TIN. + ETradeService, ], }) export class CompaniesModule { } diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 98b30f006..bf8184833 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -7,6 +7,7 @@ import { ForbiddenException, } from "@nestjs/common"; import { DataSource, EntityManager } from "typeorm"; +import { resolveIamUserNames } from "../../common/utils/iam-user-name.util"; import { CompaniesRepository } from "./companies.repository"; import { CompanyProfileRepository } from "./company-profile.repository"; import { CompanyChangeRequestRepository } from "./company-change-request.repository"; @@ -1141,16 +1142,55 @@ export class CompaniesService { return new ProfileResponseDto(profile, live, request); } - /** List a company's change requests, newest first (backoffice review). */ + /** + * List a company's change requests, newest first (backoffice review). Actor + * ids are resolved to display names here — the history screen has to say who + * asked for a change and who sent it back, not print two uuids. + */ async listChangeRequests(companyId: string): Promise { await this.findCompanyById(companyId); - return this.changeRequestRepo.findByCompanyId(companyId); + const requests = await this.changeRequestRepo.findByCompanyId(companyId); + const names = await this.resolveActorNames( + requests.flatMap((r) => [r.submittedBy, r.reviewedBy]), + ); + for (const request of requests) { + request.submittedByName = request.submittedBy + ? (names.get(request.submittedBy) ?? null) + : null; + request.reviewedByName = request.reviewedBy + ? (names.get(request.reviewedBy) ?? null) + : null; + } + return requests; } /** Onboarding-phase edit history (see {@link recordCompanyRevision}), newest first. */ async listCompanyRevisions(companyId: string): Promise { await this.findCompanyById(companyId); - return this.revisionRepo.findByCompanyId(companyId); + const revisions = await this.revisionRepo.findByCompanyId(companyId); + const names = await this.resolveActorNames(revisions.map((r) => r.actorId)); + for (const revision of revisions) { + revision.actorName = revision.actorId + ? (names.get(revision.actorId) ?? null) + : null; + } + return revisions; + } + + /** + * Display names for actor ids, one query for the whole list. A lookup failure + * degrades the history to ids rather than failing the request — the entry is + * still worth showing without the name. + */ + private async resolveActorNames( + actorIds: (string | null | undefined)[], + ): Promise> { + try { + return await resolveIamUserNames(this.dataSource, actorIds); + } catch (err) { + this.logger.warn(`Could not resolve actor names: ${String(err)}`); + return new Map(); + } } /** @@ -1519,6 +1559,7 @@ export class CompaniesService { } await this.discardLicenseChanges(request); await this.discardDocumentChanges(request); + await this.notifyChangeRequestReturned(request, "rejected", note, reviewerId); return ( (await this.changeRequestRepo.update(id, { status: ChangeRequestStatus.Rejected, @@ -1556,6 +1597,12 @@ export class CompaniesService { `Change request ${id} is already ${request.status}`, ); } + await this.notifyChangeRequestReturned( + request, + "changes_requested", + note, + reviewerId, + ); return ( (await this.changeRequestRepo.update(id, { status: ChangeRequestStatus.ChangesRequested, @@ -1566,6 +1613,35 @@ export class CompaniesService { ); } + /** + * Tell the customer desk a change request came back unapproved. Best-effort: + * a missing company or an unresolvable reviewer name must not fail the + * reviewer's decision, which is already the point of the try/catch. + */ + private async notifyChangeRequestReturned( + request: CompanyChangeRequest, + outcome: "rejected" | "changes_requested", + note: string, + reviewerId?: string, + ): Promise { + try { + const company = await this.companiesRepo.findById(request.companyId); + if (!company) return; + const names = await this.resolveActorNames([reviewerId]); + this.companyNotifier.changeRequestReturned( + company, + request.id, + outcome, + note, + reviewerId ? (names.get(reviewerId) ?? null) : null, + ); + } catch (err) { + this.logger.warn( + `Could not notify the customer desk about ${request.id}: ${String(err)}`, + ); + } + } + async deleteCompany(id: string): Promise { await this.findCompanyById(id); await this.companiesRepo.softDelete(id); diff --git a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts index 6bcd21f08..d3a556f50 100644 --- a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts @@ -244,6 +244,35 @@ export class CompanyNotifierService { ); } + /** + * A reviewer did NOT approve a customer's profile changes — they rejected it + * or sent it back for correction. The customer desk (Marketing included, via + * the `customers:get_notification` key) owns the follow-up with the customer, + * so the decision has to reach their inbox; without this it was silent, and + * only visible to whoever happened to reopen the customer's History tab. + */ + changeRequestReturned( + company: Company, + changeRequestId: string, + outcome: "rejected" | "changes_requested", + note: string, + reviewerName?: string | null, + ): void { + const rejected = outcome === "rejected"; + const by = reviewerName?.trim() ? ` by ${reviewerName.trim()}` : ""; + this.logger.log(`CHANGE_REQUEST_${outcome.toUpperCase()} — ${company.id}`); + this.notifyStaff( + company, + rejected + ? "Customer profile changes rejected" + : "Customer profile changes sent back for correction", + `${company.name}'s profile changes were ` + + `${rejected ? "rejected" : "sent back for correction"}${by}. ` + + `Reason: ${note}`, + { changeRequestId, outcome, note, reviewerName: reviewerName ?? null }, + ); + } + // ── Customer-facing: a specific document needs correcting ────────────────── /** diff --git a/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts index 4a931dae3..2a4f65708 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts @@ -23,8 +23,12 @@ export class ChangeRequestResponseDto { documentChanges: DocumentChangeIntent[]; note: string | null; submittedBy: string | null; + /** Who filed the request, for the history screen (null when unresolvable). */ + submittedByName: string | null; submittedAt: Date | null; reviewedBy: string | null; + /** Who approved / rejected / sent it back. */ + reviewedByName: string | null; reviewedAt: Date | null; createdAt: Date; updatedAt: Date; @@ -39,8 +43,10 @@ export class ChangeRequestResponseDto { this.documentChanges = req.documents?.documentChanges ?? []; this.note = req.note ?? null; this.submittedBy = req.submittedBy ?? null; + this.submittedByName = req.submittedByName ?? null; this.submittedAt = req.submittedAt ?? null; this.reviewedBy = req.reviewedBy ?? null; + this.reviewedByName = req.reviewedByName ?? null; this.reviewedAt = req.reviewedAt ?? null; this.createdAt = req.createdAt; this.updatedAt = req.updatedAt; diff --git a/apps/edr-freight-api/src/modules/companies/dto/company-revision-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/company-revision-response.dto.ts index c93c7387c..198a44e06 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/company-revision-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/company-revision-response.dto.ts @@ -8,6 +8,8 @@ export class CompanyRevisionResponseDto { id: string; companyId: string; actorId: string | null; + /** Who made the edit, for the history screen (null when unresolvable). */ + actorName: string | null; summary: string; changes: CompanyRevisionChange[]; createdAt: Date; @@ -16,6 +18,7 @@ export class CompanyRevisionResponseDto { this.id = revision.id; this.companyId = revision.companyId; this.actorId = revision.actorId ?? null; + this.actorName = revision.actorName ?? null; this.summary = revision.summary; this.changes = revision.changes ?? []; this.createdAt = revision.createdAt; diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts index 2ba39ecad..02b49f849 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts @@ -110,4 +110,12 @@ export class CompanyChangeRequest extends BaseEntity { @Column({ name: "reviewed_at", type: "timestamptz", nullable: true }) reviewedAt?: Date | null; + + /** + * Display names for {@link submittedBy} / {@link reviewedBy}, resolved from + * `iam.users` on read. Not columns — the history screen has to name the + * person who asked for the change, and an opaque uuid does not. + */ + submittedByName?: string | null; + reviewedByName?: string | null; } diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-revision.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-revision.entity.ts index 222a8364f..533f08d7a 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company-revision.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company-revision.entity.ts @@ -43,4 +43,10 @@ export class CompanyRevision extends BaseEntity { @Column({ name: "changes", type: "jsonb", default: () => `'[]'::jsonb` }) changes!: CompanyRevisionChange[]; + + /** + * Display name for {@link actorId}, resolved from `iam.users` on read. Not a + * column — history has to name who made the edit, and a uuid does not. + */ + actorName?: string | null; } diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts index 8c127e840..b29a4cfa5 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts @@ -110,6 +110,7 @@ function makeService(overrides?: { .fn() .mockResolvedValue({ id: 'ta-1', name: 'Ahmed Bourhan' }), } as never, // transit agents + { findAll: jest.fn().mockResolvedValue([]) } as never, // contracts repository ); return { diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts index e0d30a2be..43176da75 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts @@ -1,4 +1,5 @@ import { BadRequestException, Injectable } from '@nestjs/common'; +import { In } from 'typeorm'; import { ContractDocPhase, isDeliveryOrderFileCode, @@ -29,6 +30,7 @@ import { ClearanceMilestoneService } from './clearance-milestone.service'; import { GlOperationsService } from './gl-operations.service'; import { GlExchangeService } from './gl-exchange.service'; import { TransitAgentsService } from '../transit-agents/transit-agents.service'; +import { ContractsRepository } from './contracts.repository'; import { AdviseContractDutyDto } from './dto/phased-clearance.dto'; import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDeliveryOrderUploads, persistDraftDeclarationUploads, persistReleaseOrderUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util'; @@ -155,6 +157,7 @@ export class BookingClearanceService { private readonly notifier: BookingLifecycleNotifierService, private readonly glExchangeService: GlExchangeService, private readonly transitAgentsService: TransitAgentsService, + private readonly contractsRepository: ContractsRepository, ) {} private async assertPhasedCustoms(booking: Booking): Promise { @@ -988,7 +991,39 @@ export class BookingClearanceService { const milestones = await this.workflowService.listMilestonesForBooking(b.id); if (belongsOnEtClearanceQueue(milestones)) filtered.push(b); } - return filtered; + return this.attachContractSummary(filtered); + } + + /** + * Queue rows show the parent contract's reference and lane. Booking has no + * contract relation, and a bare initiated instance may not carry yards yet — + * so batch-load the contracts (with routes) and fill in what's missing: + * `contractReference` always, origin/destination yards only when the booking + * lacks them (its own route wins). + */ + private async attachContractSummary(bookings: Booking[]): Promise { + const ids = [...new Set(bookings.map((b) => b.contractId).filter(Boolean))] as string[]; + if (!ids.length) return bookings; + const contracts = await this.contractsRepository.findAll({ + where: { id: In(ids) }, + relations: { routes: { originYard: true, destinationYard: true } }, + }); + const byId = new Map(contracts.map((c) => [c.id, c])); + for (const b of bookings) { + const contract = b.contractId ? byId.get(b.contractId) : undefined; + if (!contract) continue; + const row = b as Booking & { contractReference?: string | null }; + row.contractReference = contract.reference ?? null; + if (b.originYard && b.destinationYard) continue; + const routes = contract.routes ?? []; + const route = + routes.find((r) => r.id === b.contractRouteId) ?? + (routes.length === 1 ? routes[0] : undefined); + if (!route) continue; + b.originYard = b.originYard ?? route.originYard; + b.destinationYard = b.destinationYard ?? route.destinationYard; + } + return bookings; } async djQueue(): Promise { @@ -1008,6 +1043,6 @@ export class BookingClearanceService { filtered.push(b); } } - return filtered; + return this.attachContractSummary(filtered); } } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts index e38f737c6..1f1469182 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts @@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import { DataSource, Repository } from 'typeorm'; +import { resolveIamUserNames } from '../../common/utils/iam-user-name.util'; import { ContractDocumentChange, diffSnapshots, @@ -20,28 +21,6 @@ export interface RecordRevisionInput { stepId?: string | null; } -/** - * `iam.users.name` is a localized object ({ en, am, … }), not a string — a - * plain `String(name)` there yields "[object Object]" in the audit trail. - */ -interface IamUserRow { - name?: Record | string | null; - username?: string | null; - email?: string | null; -} - -/** Best display name for a user row: English label → any locale → login → email. */ -function pickUserName(user: IamUserRow): string | null { - const { name } = user; - if (typeof name === 'string' && name.trim()) return name.trim(); - if (name && typeof name === 'object') { - const localized = - name.en ?? Object.values(name).find((v) => typeof v === 'string' && v.trim()); - if (localized?.trim()) return localized.trim(); - } - return user.username?.trim() || user.email?.trim() || null; -} - /** Pre-computed changes (contract fields), rather than a document diff. */ export interface RecordChangesInput { contractId: string; @@ -120,23 +99,12 @@ export class ContractDocumentHistoryService { private async resolveActorNames( actorIds: string[], ): Promise> { - const resolved = new Map(); - const ids = [...new Set(actorIds.filter(Boolean))]; - if (ids.length === 0) return resolved; - try { - const rows = (await this.dataSource.query( - `SELECT id, name, username, email FROM iam.users WHERE id = ANY($1::uuid[])`, - [ids], - )) as Array; - for (const row of rows) { - const name = pickUserName(row); - if (name) resolved.set(row.id, name); - } + return await resolveIamUserNames(this.dataSource, actorIds); } catch (err) { this.logger.warn(`Could not resolve actor names: ${String(err)}`); + return new Map(); } - return resolved; } /** Revision history for a contract, newest first. */ diff --git a/apps/edr-freight-api/src/modules/eims/eims-credentials.provider.ts b/apps/edr-freight-api/src/modules/eims/eims-credentials.provider.ts index b68a4a32f..a5f837c1e 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-credentials.provider.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-credentials.provider.ts @@ -5,6 +5,17 @@ import { ConfigService } from "@nestjs/config"; import { EimsConfig } from "../../config/eims.config"; import { EimsConfigException } from "./eims.errors"; +const PEM_HEADER = /-----BEGIN [A-Z ]*(PRIVATE KEY|CERTIFICATE)-----/; + +/** + * A safe-to-log fingerprint of decoded key/cert bytes: length + a printable-only preview of the + * first line. Never the actual key material — PEM headers aren't secret, the base64 body is. + */ +const describeBytes = (bytes: Buffer): string => { + const preview = bytes.toString("utf8", 0, 40).replace(/[^\x20-\x7e]/g, "?"); + return `${bytes.length} bytes, starts with "${preview}"`; +}; + /** * Loads the INSA-issued EIMS credentials from disk, once, and keeps them in memory. * @@ -24,25 +35,61 @@ export class EimsCredentialsProvider { return this.config.get("eims")!; } - /** RSA private key, parsed once. Throws a config error if the path is missing or unusable. */ + /** + * RSA private key, parsed once. Three ways in, checked in this order: `privateKeyPem` (the PEM + * text itself, no encoding step to get wrong), `privateKeyBase64` (for stores that can't hold a + * literal newline), `privateKeyPath` (the original file-on-disk form). Throws a config error if + * none is usable. + */ getPrivateKey(): KeyObject { if (this.privateKey) return this.privateKey; - const path = this.cfg.privateKeyPath; - if (!path) throw new EimsConfigException("EIMS_PRIVATE_KEY_PATH is not set"); + const { privateKeyPem, privateKeyBase64, privateKeyPath: path } = this.cfg; + const source = privateKeyPem + ? "EIMS_PRIVATE_KEY" + : privateKeyBase64 + ? "EIMS_PRIVATE_KEY_BASE64" + : `EIMS_PRIVATE_KEY_PATH (${path})`; + if (!privateKeyPem && !privateKeyBase64 && !path) { + throw new EimsConfigException("EIMS_PRIVATE_KEY_PATH is not set"); + } + + let bytes: Buffer; + try { + bytes = privateKeyPem + ? Buffer.from(privateKeyPem, "utf8") + : privateKeyBase64 + ? Buffer.from(privateKeyBase64, "base64") + : readFileSync(path); + } catch (err) { + throw new EimsConfigException( + `EIMS private key from ${source} could not be read or parsed: ${(err as Error).message}`, + ); + } + + // Fail with a diagnosable message before handing possibly-garbled bytes to OpenSSL, whose own + // error ("unsupported") gives no hint whether the problem is truncation, double-encoding, or a + // genuinely wrong file — all indistinguishable from outside without seeing the decoded bytes. + if (!PEM_HEADER.test(bytes.toString("utf8", 0, 100))) { + throw new EimsConfigException( + `EIMS private key from ${source} does not look like a PEM key after decoding ` + + `(${describeBytes(bytes)}) — check it's base64 of the raw key file with no line-wrapping ` + + `or truncation, and not base64 applied twice.`, + ); + } let key: KeyObject; try { - key = createPrivateKey(readFileSync(path)); + key = createPrivateKey(bytes); } catch (err) { - // The path is operational information, not a secret; the key material never appears. + // The source is operational information, not a secret; the key material never appears. throw new EimsConfigException( - `EIMS private key at ${path} could not be read or parsed: ${(err as Error).message}`, + `EIMS private key from ${source} could not be read or parsed: ${(err as Error).message}`, ); } if (key.asymmetricKeyType !== "rsa") { throw new EimsConfigException( - `EIMS private key at ${path} is ${key.asymmetricKeyType ?? "of unknown type"}; EIMS requires RSA`, + `EIMS private key from ${source} is ${key.asymmetricKeyType ?? "of unknown type"}; EIMS requires RSA`, ); } @@ -51,11 +98,26 @@ export class EimsCredentialsProvider { return key; } - /** Base64 of the certificate file's exact bytes. No parsing, no re-encoding. */ + /** + * Base64 of the certificate file's exact bytes. No parsing, no re-encoding of what MoR issued. + * `certificatePem`/`certificateBase64` config win when set (used as-is, or re-encoded from the + * pasted text respectively); otherwise read from `certificatePath`. + */ getCertificateBase64(): string { if (this.certificateBase64) return this.certificateBase64; - const path = this.cfg.certificatePath; + const { certificatePem: pem, certificateBase64: inline, certificatePath: path } = this.cfg; + if (pem) { + this.certificateBase64 = Buffer.from(pem, "utf8").toString("base64"); + this.logger.log(`EIMS certificate bundle loaded from EIMS_CERTIFICATE`); + return this.certificateBase64; + } + if (inline) { + this.certificateBase64 = inline; + this.logger.log(`EIMS certificate bundle loaded from EIMS_CERTIFICATE_BASE64`); + return this.certificateBase64; + } + if (!path) throw new EimsConfigException("EIMS_CERTIFICATE_PATH is not set"); let bytes: Buffer; diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts index b77804eda..4c1c82c10 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts @@ -206,8 +206,10 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E incomeWithholdValue: invoice.incomeWithholdValue!, transactionWithholdValue: invoice.transactionWithholdValue!, buyerCountryCode: invoice.buyerCountryCode, + buyerCountryCodes: invoice.buyerCountryCodes, buyerRegionCodes: invoice.buyerRegionCodes, buyerWeredaCodes: invoice.buyerWeredaCodes, + buyerCityCodes: invoice.buyerCityCodes, // TEMPORARY — see EimsInvoiceConfig.buyerIdType. buyerIdType: invoice.buyerIdType, buyerIdNumber: invoice.buyerIdNumber, diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts index 36c355bed..f560ff917 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts @@ -10,8 +10,10 @@ import { NotificationInboxService } from "../notification-inbox/notification-inb import { NotificationsService } from "../notifications/notifications.service"; import { EimsAuthService } from "./eims-auth.service"; import { EimsClientService } from "./eims-client.service"; -import { EimsApiException } from "./eims.errors"; +import { EimsApiException, EimsConfigException } from "./eims.errors"; +import { buildEimsSeller } from "./eims-invoice-context"; import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service"; +import { EimsSellerCacheService } from "./eims-seller-cache.service"; import { EimsSystemState } from "./entities/eims-system-state.entity"; import { EimsInvoiceStatus } from "./eims-registration.types"; @@ -172,6 +174,9 @@ const build = ( } as unknown as EimsAuthService, { notify } as unknown as NotificationInboxService, { directSend } as unknown as NotificationsService, + // Same static-config seller the real EimsSellerCacheService falls back to when it has never + // successfully fetched e-Trade — matches prior behavior for every test in this file. + { getSellerDetails: (c: EimsConfig) => buildEimsSeller(c) } as unknown as EimsSellerCacheService, ); /** @@ -474,6 +479,59 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => { }); }); + it("a config error (bad key, never reached MoR) rolls back both counters, no system block", async () => { + const db = new FakeDb([invoiceRow()]); + const postSigned = jest + .fn() + .mockRejectedValue(new EimsConfigException("EIMS private key ... could not be read or parsed")); + + await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf( + EimsConfigException, + ); + + expect(db.invoices.get(INVOICE_ID)).toMatchObject({ + eimsStatus: EimsInvoiceStatus.Failed, + eimsIrn: null, + eimsLastError: expect.objectContaining({ kind: "CONFIG" }), + }); + expect(db.state).toMatchObject({ + inFlightInvoiceId: null, + blockedReason: null, + previousIrn: null, + nextInvoiceCounter: 7, + }); + }); + + it("a mapper failure after reservation (e.g. unmapped buyer country) also releases the reservation", async () => { + // Regression: toEimsInvoice/buildEimsContext used to sit outside the try/catch that calls + // settleFailure — a throw here left the reservation permanently orphaned (a real live incident: + // 500 on register, then every subsequent attempt 409'd "already in flight" until manually + // resolved). This never reaches postSigned at all — the mapper throws before submit() is called. + const db = new FakeDb([ + invoiceRow({ company: { ...invoiceRow().company, country: "France" } as never }), + ]); + const postSigned = jest.fn(); + + // The mapper throws a plain Error (it's a pure function, not a NestJS layer) — that's the + // point: settleFailure must treat *any* non-EimsApiException as pre-wire, not just its own + // known exception types. + await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toThrow( + /no MoR country code mapping/, + ); + + expect(postSigned).not.toHaveBeenCalled(); + expect(db.invoices.get(INVOICE_ID)).toMatchObject({ + eimsStatus: EimsInvoiceStatus.Failed, + eimsLastError: expect.objectContaining({ kind: "LOCAL" }), + }); + expect(db.state).toMatchObject({ + inFlightInvoiceId: null, + blockedReason: null, + previousIrn: null, + nextInvoiceCounter: 7, + }); + }); + it("treats a success response with no IRN as a failed registration", async () => { const db = new FakeDb([invoiceRow()]); const postSigned = jest.fn().mockResolvedValue({ statusCode: 200, body: { irn: "" } }); diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts index ab9f953c5..a823e5ddf 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts @@ -26,13 +26,10 @@ import { toEimsInvoiceStatusView } from "./eims-invoice-view.util"; import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { EimsAuthService } from "./eims-auth.service"; import { EimsClientService } from "./eims-client.service"; -import { EimsApiException } from "./eims.errors"; +import { EimsApiException, EimsConfigException } from "./eims.errors"; +import { EimsSellerCacheService } from "./eims-seller-cache.service"; import { EimsSystemState } from "./entities/eims-system-state.entity"; -import { - assertEimsInvoiceConfig, - buildEimsContext, - buildEimsSeller, -} from "./eims-invoice-context"; +import { assertEimsInvoiceConfig, buildEimsContext } from "./eims-invoice-context"; import { EimsInvoiceError, EimsInvoiceStatus, @@ -83,6 +80,7 @@ export class EimsInvoiceRegistrationService { private readonly auth: EimsAuthService, private readonly inbox: NotificationInboxService, private readonly notifications: NotificationsService, + private readonly sellerCache: EimsSellerCacheService, ) {} private get cfg(): EimsConfig { @@ -125,27 +123,29 @@ export class EimsInvoiceRegistrationService { const reservation = await this.reserve(invoiceId, session.systemNumber); if (!reservation) return this.getEimsStatus(invoiceId); - // The request can only be built now: InvoiceCounter and PreviousIrn come from the reservation. - const request = toEimsInvoice( - invoice, - buildEimsSeller(cfg), - buildEimsContext(cfg, { - // Allocated from the system state, not our invoiceNumber: MoR validates DocumentNumber - // against ^(0|[1-9][0-9]{0,8})$, which "INV-20260807-00006" can never satisfy. - documentNumber: reservation.documentNumber, - invoiceCounter: reservation.invoiceCounter, - previousIrn: reservation.previousIrn, - session, - documentType, - reason: invoice.eimsReason, - relatedDocument, - }), - ); - let irn: string; let ackDate: string | undefined; let signedQR: string | undefined; try { + // The request can only be built now: InvoiceCounter and PreviousIrn come from the + // reservation. Building it — and everything after — stays inside this try: a reservation is + // held from here on, and *any* failure past this point, mapper or wire, must release it + // through settleFailure rather than leave it orphaned as a permanent system-wide block. + const request = toEimsInvoice( + invoice, + this.sellerCache.getSellerDetails(cfg), + buildEimsContext(cfg, { + // Allocated from the system state, not our invoiceNumber: MoR validates DocumentNumber + // against ^(0|[1-9][0-9]{0,8})$, which "INV-20260807-00006" can never satisfy. + documentNumber: reservation.documentNumber, + invoiceCounter: reservation.invoiceCounter, + previousIrn: reservation.previousIrn, + session, + documentType, + reason: invoice.eimsReason, + relatedDocument, + }), + ); // Deliberately outside every transaction — no DB lock is held across the wire. const result = await this.submit(request); irn = result.irn; @@ -471,6 +471,15 @@ export class EimsInvoiceRegistrationService { * when two rejected self-test attempts deadlocked the sequence until a manual DB reset. * * An ambiguous result keeps both: MoR may have counted and stored the document. + * + * Any error that is *not* an `EimsApiException` is also deterministic, on a different basis: + * every error that actually touches the wire is normalized to `EimsApiException` before it gets + * here (`EimsClientService.send()`'s catch calls `toEimsApiException` on whatever the HTTP call + * threw). The try block this feeds covers request-building (`toEimsInvoice`/`buildEimsContext` — + * pure, no I/O) and `submit()`; nothing in that span can produce another exception shape by + * touching MoR. So a non-`EimsApiException` here — a mapper validation error (unmapped buyer + * country, say), `EimsConfigException` from a bad signing key, or a bug — failed strictly before + * any HTTP call went out, and releasing the reservation is always safe, never a guess. */ private async settleFailure( invoiceId: string, @@ -478,10 +487,12 @@ export class EimsInvoiceRegistrationService { err: unknown, ): Promise { const api = err instanceof EimsApiException ? err : null; - const deterministic = api ? DETERMINISTIC_KINDS.has(api.kind) : false; + // Never touched the wire (see the doc comment above) — always safe to release, whatever it is. + const deterministic = api ? DETERMINISTIC_KINDS.has(api.kind) : true; const status = deterministic ? EimsInvoiceStatus.Failed : EimsInvoiceStatus.Unknown; + const localKind = err instanceof EimsConfigException ? "CONFIG" : "LOCAL"; const lastError: EimsInvoiceError = { - kind: api?.kind ?? "UNKNOWN", + kind: api?.kind ?? localKind, message: (err as Error)?.message ?? "unknown error", httpStatus: api?.httpStatus, details: api?.details, @@ -588,10 +599,14 @@ export class EimsInvoiceRegistrationService { type: NotificationType.GENERIC, priority: deterministic ? NotificationPriority.NORMAL : NotificationPriority.HIGH, title: deterministic - ? "EIMS rejected an invoice" + ? error.kind === "CONFIG" || error.kind === "LOCAL" + ? "EIMS filing failed before reaching MoR" + : "EIMS rejected an invoice" : "EIMS filing unresolved — all further filing is blocked", body: deterministic - ? `MoR rejected the filing (${error.kind}): ${error.message}. The invoice is marked FAILED; correct it and file again.` + ? error.kind === "CONFIG" || error.kind === "LOCAL" + ? `${error.kind === "CONFIG" ? "EIMS is misconfigured" : "Filing failed locally"}: ${error.message}. Nothing was sent to MoR; fix it and file again.` + : `MoR rejected the filing (${error.kind}): ${error.message}. The invoice is marked FAILED; correct it and file again.` : `A submission was sent but never acknowledged (${error.kind}). Its IRN is unknown, so no further invoice can be filed until it is resolved with MoR.`, link: `/dashboard/invoices/${invoiceId}`, data: { invoiceId, eimsStatus: status, kind: error.kind, action: "EIMS_FILING_FAILED" }, diff --git a/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.spec.ts new file mode 100644 index 000000000..7b4ff1b3f --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.spec.ts @@ -0,0 +1,155 @@ +import { ConfigService } from "@nestjs/config"; + +import { EimsConfig } from "../../config/eims.config"; +import { ETradeService } from "../companies/services/etrade.service"; +import { eimsConfig, eimsInvoiceConfig } from "./eims-test-fixtures"; +import { EimsSellerCacheService } from "./eims-seller-cache.service"; + +const registrationData = (over: Record = {}) => ({ + companyName: "Ethio-Djibouti Railway PLC (eTrade)", + region: "Addis Ababa", + zone: "Bole", + woreda: "Yeka", + mobilePhone: "0911000000", + regularPhone: "", + ...over, +}); + +const build = (cfg: EimsConfig = eimsConfig()) => { + const resolveCompanyData = jest.fn(); + const extractRegistrationData = jest.fn().mockReturnValue(registrationData()); + const etrade = { resolveCompanyData, extractRegistrationData } as unknown as ETradeService; + const config = { get: () => cfg } as unknown as ConfigService; + const service = new EimsSellerCacheService(etrade, config); + return { service, resolveCompanyData, extractRegistrationData, cfg }; +}; + +const CODES = { + buyerRegionCodes: { "Addis Ababa": "13" }, + buyerWeredaCodes: { Yeka: "99" }, + buyerCityCodes: { Bole: "101" }, +}; + +describe("EimsSellerCacheService.getSellerDetails", () => { + it("static config wins over a conflicting e-Trade value", async () => { + const cfg = eimsConfig({ + invoice: eimsInvoiceConfig({ sellerLegalName: "Ethio-Djibouti Railway S.C.", ...CODES }), + }); + const { service, resolveCompanyData } = build(cfg); + resolveCompanyData.mockResolvedValue({ + companyInfo: {}, + businessInfo: {}, // presence is all that matters — extractRegistrationData is mocked + }); + + await service.refresh(); + const seller = service.getSellerDetails(cfg); + + // The static sellerLegalName ("Ethio-Djibouti Railway S.C.") must survive, not e-Trade's + // differently-punctuated "Ethio-Djibouti Railway PLC (eTrade)". + expect(seller.LegalName).toBe("Ethio-Djibouti Railway S.C."); + }); + + it("e-Trade fills a field only when the static value is blank", async () => { + const cfg = eimsConfig({ + invoice: eimsInvoiceConfig({ + sellerLegalName: "", + sellerRegion: "", + sellerWereda: "", + sellerCity: null, + ...CODES, + }), + }); + const { service, resolveCompanyData } = build(cfg); + resolveCompanyData.mockResolvedValue({ companyInfo: {}, businessInfo: {} }); + + await service.refresh(); + const seller = service.getSellerDetails(cfg); + + expect(seller.LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)"); + expect(seller.Region).toBe("13"); + expect(seller.Wereda).toBe("99"); + expect(seller.City).toBe("101"); + }); + + it("VatNumber and Email are always the static value, never touched by e-Trade", async () => { + const cfg = eimsConfig({ + invoice: eimsInvoiceConfig({ sellerVatNumber: "0000000000", sellerEmail: "finance@example.et", ...CODES }), + }); + const { service, resolveCompanyData } = build(cfg); + resolveCompanyData.mockResolvedValue({ companyInfo: {}, businessInfo: {} }); + + await service.refresh(); + const seller = service.getSellerDetails(cfg); + + expect(seller.VatNumber).toBe("0000000000"); + expect(seller.Email).toBe("finance@example.et"); + }); + + it("falls back to the static config entirely when e-Trade has never been reachable", () => { + const cfg = eimsConfig(); + const { service } = build(cfg); + + // No refresh() ever called/succeeded — cached stays null. + const seller = service.getSellerDetails(cfg); + + expect(seller.LegalName).toBe(cfg.invoice.sellerLegalName); + expect(seller.Region).toBe(cfg.invoice.sellerRegion); + }); + + it("does no I/O at all — filing never triggers an e-Trade request", () => { + const { service, resolveCompanyData, cfg } = build(); + + service.getSellerDetails(cfg); + service.getSellerDetails(cfg); + + expect(resolveCompanyData).not.toHaveBeenCalled(); + }); +}); + +describe("EimsSellerCacheService.refresh", () => { + it("keeps the previous snapshot when a refresh fails", async () => { + const cfg = eimsConfig({ invoice: eimsInvoiceConfig({ sellerLegalName: "", ...CODES }) }); + const { service, resolveCompanyData } = build(cfg); + resolveCompanyData.mockResolvedValueOnce({ companyInfo: {}, businessInfo: {} }); + await service.refresh(); + expect(service.getSellerDetails(cfg).LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)"); + + resolveCompanyData.mockRejectedValueOnce(new Error("eTrade down")); + await service.refresh(); + + expect(service.getSellerDetails(cfg).LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)"); + }); + + it("keeps the previous snapshot on timeout, without waiting for the slow request", async () => { + jest.useFakeTimers(); + try { + const cfg = eimsConfig({ invoice: eimsInvoiceConfig({ sellerLegalName: "", ...CODES }) }); + const { service, resolveCompanyData } = build(cfg); + resolveCompanyData.mockResolvedValueOnce({ companyInfo: {}, businessInfo: {} }); + await service.refresh(); + expect(service.getSellerDetails(cfg).LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)"); + + resolveCompanyData.mockReturnValueOnce(new Promise(() => {})); // never resolves + const refreshing = service.refresh(); + await jest.advanceTimersByTimeAsync(10_000); + await refreshing; + + expect(service.getSellerDetails(cfg).LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)"); + } finally { + jest.useRealTimers(); + } + }); + + it("does not start a second e-Trade request while one is already in flight", async () => { + const { service, resolveCompanyData } = build(); + let resolveCall: (value: unknown) => void = () => {}; + resolveCompanyData.mockReturnValue(new Promise((resolve) => (resolveCall = resolve))); + + const first = service.refresh(); + const second = service.refresh(); + resolveCall({ companyInfo: {}, businessInfo: {} }); + await Promise.all([first, second]); + + expect(resolveCompanyData).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.ts b/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.ts new file mode 100644 index 000000000..a8d242929 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.ts @@ -0,0 +1,147 @@ +import { Injectable, Logger, OnModuleInit } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; + +import { EimsConfig } from "../../config/eims.config"; +import { ETradeService } from "../companies/services/etrade.service"; +import { EimsSellerDetails, resolveOptionalCode } from "../billing/eims-invoice.mapper"; +import { buildEimsSeller } from "./eims-invoice-context"; + +const has = (value: string | null | undefined): value is string => Boolean(value && value.trim()); + +/** + * EDR's own EIMS seller identity (LegalName/Phone/Region/Wereda/City), enriched from the same + * e-Trade business-registry lookup already used for every customer company at onboarding — instead + * of the whole thing being hand-maintained `EIMS_SELLER_*` config. + * + * **Static config is the source of truth, e-Trade is bootstrap/enrichment only.** MoR validates + * `SellerDetails` against its own taxpayer registry (rule 7017, already cleared and live-tested + * with the current static values) — e-Trade filling a gap is fine, e-Trade silently overriding a + * value already confirmed against MoR is not. `getSellerDetails` therefore only reaches for the + * e-Trade-derived value when the static one is blank; a static value, once set, is never replaced. + * This also means the durable fallback is the static config, not this cache — the in-memory + * snapshot disappearing on a process restart is harmless, not a reliability gap: every field it + * could supply already has a working static value today, so filing is unaffected either way. + * + * `VatNumber` and `Email` are never sourced here — confirmed by reading e-Trade's actual response + * shapes (`ETradeCompanyInfo`, `ETradeBusinessInfo`, `CompanyRegistrationData`): neither field + * exists anywhere in what e-Trade returns. They stay on static config permanently, same as + * `SubCity`/`Locality`/`HouseNumber`, which this pass doesn't touch. + * + * Cache shape follows `PositionTypePermissionsCache`'s precedent (`src/common/ + * position-type-permissions.cache.ts`) for "external/slow data, not fetched per request": a plain + * field refreshed on a raw `setInterval`, `unref()`'d so it never holds the process open, and a + * refresh failure keeps serving the previous snapshot rather than clearing it. Two deliberate + * deviations from that precedent, both because `ETradeService` has no request timeout configured + * at all (confirmed by reading it) and is a third-party dependency, unlike the DB: + * - the first fetch is fire-and-forget in `onModuleInit`, never awaited by boot; + * - `refresh()` is wrapped in a local timeout, and a second call while one is already in flight + * returns the same in-flight promise instead of starting a duplicate request. + * + * `getSellerDetails` is fully synchronous — zero I/O at call time — so a live invoice registration + * never depends on e-Trade being reachable at that moment, whether or not it ever has been. + */ +@Injectable() +export class EimsSellerCacheService implements OnModuleInit { + private readonly logger = new Logger(EimsSellerCacheService.name); + + /** Only the e-Trade-derived fields, used solely to fill a blank static value. */ + private cached: Partial | null = null; + /** Concurrency guard — a second `refresh()` call while one is running joins it. */ + private refreshing: Promise | null = null; + + // ponytail: daily refresh, no invalidation hook — a change at e-Trade takes up to 24h to reach a + // filed invoice. Wire a manual refresh() call (e.g. from an admin action) if that lag ever + // matters; EDR's own business registration changes rarely enough that this is a generous + // ceiling, not a real one. + private static readonly REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000; + /** Bounded locally since `ETradeService` itself sets none — see the class comment. */ + private static readonly REFRESH_TIMEOUT_MS = 10_000; + + constructor( + private readonly etrade: ETradeService, + private readonly config: ConfigService, + ) {} + + onModuleInit(): void { + void this.refresh(); + const timer = setInterval(() => void this.refresh(), EimsSellerCacheService.REFRESH_INTERVAL_MS); + timer.unref?.(); + } + + /** + * Static config wins whenever it's non-blank — that's the value already confirmed against MoR. + * e-Trade fills a field only when the static one is empty. Synchronous, no I/O: safe to call on + * every registration. + */ + getSellerDetails(cfg: EimsConfig): EimsSellerDetails { + const fallback = buildEimsSeller(cfg); + const e = this.cached; + return { + ...fallback, + LegalName: has(fallback.LegalName) ? fallback.LegalName : (e?.LegalName ?? fallback.LegalName), + Phone: has(fallback.Phone) ? fallback.Phone : (e?.Phone ?? fallback.Phone), + Region: has(fallback.Region) ? fallback.Region : (e?.Region ?? fallback.Region), + Wereda: has(fallback.Wereda) ? fallback.Wereda : (e?.Wereda ?? fallback.Wereda), + City: has(fallback.City) ? fallback.City : (e?.City ?? fallback.City), + }; + } + + /** Reload the cache. Concurrency-safe (see class comment); public so a caller can force one. */ + async refresh(): Promise { + if (this.refreshing) return this.refreshing; + this.refreshing = this.doRefresh().finally(() => { + this.refreshing = null; + }); + return this.refreshing; + } + + private async doRefresh(): Promise { + try { + const cfg = this.config.get("eims")!; + const { companyInfo, businessInfo } = await this.withTimeout( + this.etrade.resolveCompanyData(cfg.tin), + EimsSellerCacheService.REFRESH_TIMEOUT_MS, + ); + if (!businessInfo) return; // no licence on file yet — keep the previous snapshot + const data = this.etrade.extractRegistrationData(businessInfo, companyInfo); + const codes = cfg.invoice; + this.cached = { + LegalName: data.companyName || undefined, + Phone: data.mobilePhone || data.regularPhone || undefined, + // e-Trade returns region/zone/woreda as names ("Addis Ababa", "Bole") — resolved via the + // same buyer code maps, since the geography is objective, not buyer-specific, despite the + // env var's "BUYER_" prefix. Never throws: an unmapped name just leaves that field to + // getSellerDetails' static-config fallback. + Region: resolveOptionalCode(data.region, codes.buyerRegionCodes), + Wereda: resolveOptionalCode(data.woreda, codes.buyerWeredaCodes), + City: resolveOptionalCode(data.zone, codes.buyerCityCodes), + }; + } catch (err) { + this.logger.warn( + `EIMS seller e-Trade refresh failed, keeping previous snapshot: ${(err as Error).message}`, + ); + } + } + + /** + * `ETradeService` sets no request timeout of its own, so one is enforced here. Note this only + * stops *waiting* on the request — nothing cancels the underlying HTTP call (no + * `AbortController` wired into `ETradeService`), so a timed-out request may still complete in + * the background; its result is simply never read. + */ + private withTimeout(promise: Promise, ms: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`e-Trade lookup timed out after ${ms}ms`)), ms); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (err) => { + clearTimeout(timer); + reject(err); + }, + ); + }); + } +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-signer.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-signer.service.spec.ts index 5e408ddf7..f8210cab6 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-signer.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-signer.service.spec.ts @@ -97,8 +97,14 @@ describe("EimsSignerService", () => { }); describe("EimsCredentialsProvider", () => { - const providerFor = (paths: { privateKeyPath?: string; certificatePath?: string }) => - new EimsCredentialsProvider({ get: () => paths } as unknown as ConfigService); + const providerFor = (cfg: { + privateKeyPath?: string; + certificatePath?: string; + privateKeyBase64?: string; + certificateBase64?: string; + privateKeyPem?: string; + certificatePem?: string; + }) => new EimsCredentialsProvider({ get: () => cfg } as unknown as ConfigService); it("fails clearly when the key path is unset", () => { expect(() => providerFor({}).getPrivateKey()).toThrow(/EIMS_PRIVATE_KEY_PATH is not set/); @@ -115,4 +121,52 @@ describe("EimsCredentialsProvider", () => { writeFileSync(emptyPath, ""); expect(() => providerFor({ certificatePath: emptyPath }).getCertificateBase64()).toThrow(/is empty/); }); + + it("loads the key from inline base64, no file involved", () => { + const keyBase64 = readFileSync(keyPath).toString("base64"); + const key = providerFor({ privateKeyBase64: keyBase64 }).getPrivateKey(); + expect(key.asymmetricKeyType).toBe("rsa"); + }); + + it("prefers inline base64 over the path when both are set", () => { + const keyBase64 = readFileSync(keyPath).toString("base64"); + // A path that would fail if it were ever actually read. + const key = providerFor({ privateKeyBase64: keyBase64, privateKeyPath: join(dir, "nope.key") }).getPrivateKey(); + expect(key.asymmetricKeyType).toBe("rsa"); + }); + + it("loads the certificate from inline base64 as-is, no re-encoding", () => { + const certBase64 = Buffer.from(CERTIFICATE_FIXTURE, "utf8").toString("base64"); + expect(providerFor({ certificateBase64: certBase64 }).getCertificateBase64()).toBe(certBase64); + }); + + it("fails with a decoded-bytes preview when the base64 doesn't decode to a PEM key", () => { + // Simulates the real failure this guards against: a truncated/mangled env var still decodes + // as *some* bytes, but not a key — OpenSSL's own error here gives no hint why. + const notAKey = Buffer.from("not actually a pem file", "utf8").toString("base64"); + expect(() => providerFor({ privateKeyBase64: notAKey }).getPrivateKey()).toThrow( + /does not look like a PEM key.*23 bytes, starts with "not actually a pem file"/s, + ); + }); + + it("loads the key from the raw PEM env var directly, no encoding step", () => { + const pem = readFileSync(keyPath).toString("utf8"); + const key = providerFor({ privateKeyPem: pem }).getPrivateKey(); + expect(key.asymmetricKeyType).toBe("rsa"); + }); + + it("prefers the raw PEM var over base64 and path when all three are set", () => { + const pem = readFileSync(keyPath).toString("utf8"); + const key = providerFor({ + privateKeyPem: pem, + privateKeyBase64: Buffer.from("garbage").toString("base64"), + privateKeyPath: join(dir, "nope.key"), + }).getPrivateKey(); + expect(key.asymmetricKeyType).toBe("rsa"); + }); + + it("loads the certificate from the raw PEM env var, re-encoded to base64", () => { + const base64 = providerFor({ certificatePem: CERTIFICATE_FIXTURE }).getCertificateBase64(); + expect(base64).toBe(Buffer.from(CERTIFICATE_FIXTURE, "utf8").toString("base64")); + }); }); diff --git a/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts b/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts index 93df29da2..a55951db4 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts @@ -33,8 +33,10 @@ export const eimsInvoiceConfig = (over: Partial = {}): EimsIn paymentTerm: "IMMIDIATE", unitDefault: "PCS", buyerCountryCode: null, + buyerCountryCodes: { Ethiopia: "231" }, // test-only, not a confirmed real MoR code buyerRegionCodes: { "Addis Ababa": "13" }, buyerWeredaCodes: { Yeka: "99" }, // test-only, not a real MoR code + buyerCityCodes: { Kirkos: "101" }, // test-only, not a confirmed real MoR code taxCodeByChargeType: {}, taxRateByChargeType: {}, exciseByChargeType: {}, @@ -57,6 +59,10 @@ export const eimsConfig = (over: Partial = {}): EimsConfig => ({ systemType: EIMS_SYSTEM_TYPE, privateKeyPath: "/dev/null", certificatePath: "/dev/null", + privateKeyBase64: "", + certificateBase64: "", + privateKeyPem: "", + certificatePem: "", httpTimeoutMs: 30_000, tokenSkewMs: 45_000, autoSubmit: false, diff --git a/apps/edr-freight-api/src/modules/eims/eims.errors.ts b/apps/edr-freight-api/src/modules/eims/eims.errors.ts index 3be21fdd3..853b32c29 100644 --- a/apps/edr-freight-api/src/modules/eims/eims.errors.ts +++ b/apps/edr-freight-api/src/modules/eims/eims.errors.ts @@ -10,7 +10,9 @@ export type EimsFailureKind = | "FORBIDDEN" | "RULE_VALIDATION" | "SERVER" - | "UNKNOWN"; + | "UNKNOWN" + | "CONFIG" + | "LOCAL"; /** Raised when EIMS is disabled or its credential files are unusable. */ export class EimsConfigException extends ServiceUnavailableException { diff --git a/apps/edr-freight-api/src/modules/eims/eims.module.ts b/apps/edr-freight-api/src/modules/eims/eims.module.ts index 0ee22ec88..5d55a7ee8 100644 --- a/apps/edr-freight-api/src/modules/eims/eims.module.ts +++ b/apps/edr-freight-api/src/modules/eims/eims.module.ts @@ -4,6 +4,7 @@ import { TypeOrmModule } from "@nestjs/typeorm"; import { Invoice } from "../billing/entities/invoice.entity"; import { DocumentsModule } from "../billing/documents/documents.module"; +import { CompaniesModule } from "../companies/companies.module"; import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module"; import { NotificationsModule } from "../notifications/notifications.module"; import { EimsAuthService } from "./eims-auth.service"; @@ -14,6 +15,7 @@ import { EimsCredentialsProvider } from "./eims-credentials.provider"; import { EimsInvoiceController } from "./eims-invoice.controller"; import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service"; import { EimsReceiptService } from "./eims-receipt.service"; +import { EimsSellerCacheService } from "./eims-seller-cache.service"; import { EimsSignerService } from "./eims-signer.service"; import { EimsReceipt } from "./entities/eims-receipt.entity"; import { EimsSystemState } from "./entities/eims-system-state.entity"; @@ -33,6 +35,10 @@ import { EimsSystemState } from "./entities/eims-system-state.entity"; // For EimsReceiptService.document() — the shared sealed invoice/receipt PDF layout. No domain // deps of its own (StampSettingsService/LogoSettingsService are both @Global), so no cycle. DocumentsModule, + // For EimsSellerCacheService's ETradeService — CompaniesModule has a forwardRef cycle with + // ShippingLineCompaniesModule -> BillingModule, but nothing in that chain imports EimsModule, + // so this stays a plain one-directional import, not a new cycle. + CompaniesModule, ], controllers: [EimsInvoiceController], providers: [ @@ -44,6 +50,7 @@ import { EimsSystemState } from "./entities/eims-system-state.entity"; EimsAutoSubmitService, EimsCancellationService, EimsReceiptService, + EimsSellerCacheService, ], exports: [ EimsAuthService, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index 0fb6e16ab..03535135d 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -1368,7 +1368,7 @@ export class TrainSchedulingService { * in the future) using the CURRENT global-rules config. Schedules already OPEN or * past their window are left untouched — customers may have booked against the * times they were shown, so those stay frozen. Returns the count re-stamped. - */ + */ async restampPendingWindows(): Promise { const cfg = await this.getWindowConfig(); const now = new Date(); diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 4043430e2..01c1d67d0 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -2425,6 +2425,8 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.invoices.view, FREIGHT_PERMS.invoices.export, + // Manual settlement (bank transfer / counter) of USD and ETB invoices. + FREIGHT_PERMS.invoices.confirmOffline, // Deliberately NOT granted here: invoices:eims_register, eims_resolve, eims_cancel, // eims_receipt_register, eims:memo_issue. Automatic filing needs no human permission at all // (the cron sweep runs as the system); these are the *manual* exceptional-operations @@ -2506,6 +2508,12 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.contracts.suspend, FREIGHT_PERMS.contracts.editDocument, ...BOOKING_DESK_NOTIFICATION_KEYS, + // Marketing follows up with the customer when a reviewer sends profile + // changes back, so they sit on the customer desk: read-only on the customer + // record (no verify/deactivate — the decision stays with the chief) plus the + // desk key the change-request pings are addressed to. + FREIGHT_PERMS.customers.view, + FREIGHT_PERMS.customers.getNotification, ], orgManager: [...BOOKING_RULE_ENGINE_PERMISSION_KEYS], } as const; diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 2ea1c1890..d4f99be8d 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -324,7 +324,7 @@ const App = () => { } /> {/* Merged Invoices / Payments / USD Payments hub — tabs switch via - ?tab=invoices|payments|usd-payments (default invoices). Access is + ?tab=invoices|payments|manual-payments (default invoices). Access is OR'd across both keys so a user with just one still gets in; each tab hides itself if the user lacks the permission it used to be routed on. */} @@ -361,7 +361,7 @@ const App = () => { /> } + element={} /> - void fetchViewableFile( + openFileInNewTab( c.fileId, c.fileName ?? humanize(c.code), - ).then(view) + ) } style={{ textDecoration: @@ -382,12 +380,7 @@ export function ChangeRequestReview({ company }: { company: Company }) { component="button" type="button" size="sm" - onClick={() => - void fetchViewableFile( - fileId, - `Document ${i + 1}`, - ).then(view) - } + onClick={() => openFileInNewTab(fileId, `Document ${i + 1}`)} > Document {i + 1} @@ -421,10 +414,10 @@ export function ChangeRequestReview({ company }: { company: Company }) { type="button" size="sm" onClick={() => - void fetchViewableFile( + openFileInNewTab( c.fileId, c.fileName ?? "License document", - ).then(view) + ) } style={{ textDecoration: @@ -532,8 +525,6 @@ export function ChangeRequestReview({ company }: { company: Company }) { - - {viewer} ); } diff --git a/apps/edr-freight-web/backoffice/src/components/customers/CompanyTimeline.tsx b/apps/edr-freight-web/backoffice/src/components/customers/CompanyTimeline.tsx index cd421d9bd..8469e4031 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/CompanyTimeline.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/CompanyTimeline.tsx @@ -1,9 +1,8 @@ import { Alert, Anchor, Badge, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core"; import { useQuery } from "@tanstack/react-query"; import { FilePlus2, FileX2, History } from "lucide-react"; -import { useFileViewer } from "@edr/ui-common"; -import { fetchViewableFile } from "@/services/files.service"; +import { openFileInNewTab } from "@/services/files.service"; import { api } from "@/services/api"; import type { Company, @@ -36,6 +35,12 @@ interface TimelineEntry { at: string; note?: string | null; summary?: string; + /** Who filed the change (the customer, or staff editing during onboarding). */ + requestedBy?: string | null; + /** When they filed it — the "asked" half of the ask/decide pair below. */ + requestedAt?: string | null; + /** Who decided (approved / rejected / sent it back to marketing). */ + decidedBy?: string | null; fieldDiffs: FieldDiff[]; docDiffs: DocDiff[]; } @@ -43,10 +48,18 @@ interface TimelineEntry { const KIND_BADGE: Record = { approved: { label: "Approved", color: "edr-green" }, rejected: { label: "Rejected", color: "red" }, - changes_requested: { label: "Changes requested", color: "yellow" }, + // Sending a request back is what "reverted to marketing" means here: the + // request stays open and marketing owns the follow-up with the customer. + changes_requested: { label: "Sent back to marketing", color: "yellow" }, revision: { label: "Recorded", color: "blue" }, }; +/** "Requested by X" / "Reviewed by X", with the id-less case reading sanely. */ +function actorLine(verb: string, who?: string | null, when?: string | null) { + if (!who && !when) return null; + return `${verb}${who ? ` by ${who}` : ""}${when ? ` · ${formatDate(when)}` : ""}`; +} + /** * Pair adjacent remove-then-add intents into one before/after doc diff — a * "replace" is always staged as `[{op:'remove'}, {op:'add'}]` pushed together @@ -132,6 +145,9 @@ function fromChangeRequest( kind: r.status as TimelineEntry["kind"], at: r.reviewedAt ?? r.updatedAt, note: r.note, + requestedBy: r.submittedByName, + requestedAt: r.submittedAt ?? r.createdAt, + decidedBy: r.reviewedByName, fieldDiffs, docDiffs, }; @@ -156,6 +172,7 @@ function fromRevision(rev: CompanyRevision): TimelineEntry { kind: "revision", at: rev.createdAt, summary: rev.summary, + requestedBy: rev.actorName, fieldDiffs, docDiffs, }; @@ -170,7 +187,6 @@ function fromRevision(rev: CompanyRevision): TimelineEntry { * single answer instead of two places to check. */ export function CompanyTimeline({ company }: { company: Company }) { - const { view, viewer } = useFileViewer(); const changeRequestsQuery = useQuery( api.customers.changeRequests.queryOptions({ input: { id: company.id } }), ); @@ -186,7 +202,7 @@ export function CompanyTimeline({ company }: { company: Company }) { ].sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime()); const openFile = (file: { id: string; name: string }) => - void fetchViewableFile(file.id, file.name).then(view); + openFileInNewTab(file.id, file.name); if (entries.length === 0) { return ( @@ -205,6 +221,20 @@ export function CompanyTimeline({ company }: { company: Company }) { {entries.map((entry) => { const badge = KIND_BADGE[entry.kind]; + const requestedLine = actorLine( + entry.kind === "revision" ? "Edited" : "Requested", + entry.requestedBy, + entry.requestedAt, + ); + const decidedLine = actorLine( + entry.kind === "changes_requested" + ? "Sent back to marketing" + : entry.kind === "rejected" + ? "Rejected" + : "Approved", + entry.decidedBy, + entry.kind === "revision" ? null : entry.at, + ); return ( @@ -224,10 +254,33 @@ export function CompanyTimeline({ company }: { company: Company }) { + {/* Who asked, and who decided. Without this the feed said what + changed and when, but never named a person — the first thing + anyone auditing a returned request needs. */} + {(requestedLine || decidedLine) && ( + + {requestedLine && ( + + {requestedLine} + + )} + {decidedLine && ( + + {decidedLine} + + )} + + )} + {entry.note && ( - Note: {entry.note} + + {entry.kind === "changes_requested" + ? "What was asked for:" + : "Note:"} + {" "} + {entry.note} )} @@ -293,7 +346,6 @@ export function CompanyTimeline({ company }: { company: Company }) { ); })} - {viewer} ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx index db57819d5..9cbc0e5e0 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx @@ -3,34 +3,28 @@ import { ActionIcon, Box, Card, - Group, - Select, Stack, Text, - TextInput, ThemeIcon, } from "@mantine/core"; -import { DatePickerInput } from "@mantine/dates"; -import { getDateRangePresets } from "@/components/common/dateRangePresets"; -import { useDebouncedValue } from "@mantine/hooks"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; -import { FileText, Inbox, RefreshCw, Search, Ship, User, X } from "lucide-react"; -import { useCallback, useMemo, useState } from "react"; +import { FileText, Inbox, RefreshCw, Ship, User } from "lucide-react"; +import { useMemo } from "react"; import { useNavigate } from "react-router-dom"; import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink"; import { bookingTable } from "@/components/bookings/booking-ui.styles"; import { PageContainer, PageHeader } from "@/components/page"; -import { bookingsService } from "@/services/bookings.service"; +import { bookingsService, type BookingListFilter } from "@/services/bookings.service"; import type { BookingDetail } from "@/types/booking"; import { Badge, DataTable, DataTableFooter, - usePagination, type ColumnDef, } from "@edr/ui-common"; +import { dateRangeParams, FilterBar, useFilters, type FilterDef } from "@/components/filters"; /** * Operations "Clearance Documents" hub — the worklist for self-clearance @@ -43,16 +37,14 @@ import { const PAGE_SIZE = 10; /** - * Status filter options (values = `statuses` param). FULLY_EXECUTED is the - * post-approval status of intercity (domestic) bookings — kept in the list as - * history, otherwise an approved intercity row vanishes from the hub. + * The hub's baseline scope — FULLY_EXECUTED is the post-approval status of + * intercity (domestic) bookings, kept in as history so an approved intercity + * row doesn't just vanish. Sent whenever the Status pill has no narrower pick. */ -const BOOKING_STATUS_OPTIONS = [ - { - value: - "AWAITING_DOCUMENTS,DOCUMENTS_UNDER_REVIEW,CLEARANCE_READY,FULLY_EXECUTED", - label: "All statuses", - }, +const DEFAULT_STATUSES = + "AWAITING_DOCUMENTS,DOCUMENTS_UNDER_REVIEW,CLEARANCE_READY,FULLY_EXECUTED"; + +const STATUS_OPTIONS = [ { value: "AWAITING_DOCUMENTS", label: "Awaiting documents" }, { value: "DOCUMENTS_UNDER_REVIEW", label: "Under review" }, { value: "CLEARANCE_READY", label: "Clearance ready" }, @@ -81,77 +73,59 @@ const CUSTOMER_KIND_OPTIONS = [ { value: "CUSTOMER", label: "Customer" }, ]; -function startOfDayIso(d: Date): string { - const x = new Date(d); - x.setHours(0, 0, 0, 0); - return x.toISOString(); -} - -function endOfDayIso(d: Date): string { - const x = new Date(d); - x.setHours(23, 59, 59, 999); - return x.toISOString(); -} - export default function ClearanceDocumentsPage() { const navigate = useNavigate(); - const [query, setQuery] = useState(""); - const [debouncedQuery] = useDebouncedValue(query, 300); - const [bookingStatuses, setBookingStatuses] = useState( - BOOKING_STATUS_OPTIONS[0].value, - ); const { filterOptions } = useMyTradeAccess(); - const [directionFilter, setDirectionFilter] = useState(null); - const [freightTypeFilter, setFreightTypeFilter] = useState(null); - const [ownershipFilter, setOwnershipFilter] = useState(null); - const [customerKindFilter, setCustomerKindFilter] = useState(null); - const [createdFrom, setCreatedFrom] = useState(null); - const [createdTo, setCreatedTo] = useState(null); - const { pagination, setPagination } = usePagination({ pageSize: PAGE_SIZE }); - const search = debouncedQuery.trim() || undefined; + const filterDefs: FilterDef[] = useMemo( + () => [ + { + key: "status", + label: "Status", + type: "enum", + multiple: false, + options: STATUS_OPTIONS, + // No pick ⇒ no `statuses` param at all; the query fills in + // DEFAULT_STATUSES itself, same as the old Select's "All statuses" row. + toParams: ({ v }) => ({ statuses: v[0] }), + }, + { + key: "tradeDirection", + label: "Direction", + type: "enum", + multiple: false, + options: filterOptions(TRADE_DIRECTION_OPTIONS), + }, + { key: "freightType", label: "Freight", type: "enum", multiple: false, options: FREIGHT_TYPE_OPTIONS }, + { key: "customerKind", label: "Booked by", type: "enum", multiple: false, options: CUSTOMER_KIND_OPTIONS }, + { key: "isGovernment", label: "Ownership", type: "enum", multiple: false, options: OWNERSHIP_OPTIONS }, + { + key: "created", + label: "Created", + type: "date", + toParams: dateRangeParams("createdFrom", "createdTo"), + }, + ], + [filterOptions], + ); - const resetPage = useCallback(() => { - setPagination({ pageIndex: 0, pageSize: PAGE_SIZE }); - }, [setPagination]); + const controls = useFilters(filterDefs, { pageSize: PAGE_SIZE }); - const page = pagination.pageIndex + 1; + const filter: BookingListFilter = useMemo( + () => ({ + ...(controls.params as unknown as BookingListFilter), + // Self-clearance instances carry bookingType=ONE_TIME whatever their + // contract kind, so customsClearingEnabled=false + the status scope + // above are what isolate exactly this worklist. + customsClearingEnabled: "false", + statuses: (controls.params.statuses as string | undefined) ?? DEFAULT_STATUSES, + }), + [controls.params], + ); const bookingsQuery = useQuery({ - queryKey: [ - "clearance-documents", - "bookings", - bookingStatuses, - directionFilter, - freightTypeFilter, - ownershipFilter, - customerKindFilter, - createdFrom, - createdTo, - page, - search, - ], - queryFn: () => - // Self-clearance instances carry bookingType=ONE_TIME whatever their - // contract kind, so customsClearingEnabled=false + the three per-booking - // clearance statuses are what isolate exactly this worklist. - bookingsService.list({ - statuses: bookingStatuses, - customsClearingEnabled: "false", - page, - pageSize: PAGE_SIZE, - search, - ...(directionFilter ? { tradeDirection: directionFilter } : {}), - ...(freightTypeFilter ? { freightType: freightTypeFilter } : {}), - ...(ownershipFilter - ? { isGovernment: ownershipFilter as "true" | "false" } - : {}), - ...(customerKindFilter - ? { customerKind: customerKindFilter as "SHIPPING_LINE" | "CUSTOMER" } - : {}), - ...(createdFrom ? { createdFrom: startOfDayIso(createdFrom) } : {}), - ...(createdTo ? { createdTo: endOfDayIso(createdTo) } : {}), - }), + queryKey: ["clearance-documents", "bookings", filter], + queryFn: () => bookingsService.list(filter), placeholderData: keepPreviousData, }); @@ -256,7 +230,6 @@ export default function ClearanceDocumentsPage() { ); const total = bookingsQuery.data?.total ?? 0; - const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE)); const showEmpty = !bookingsQuery.isLoading && !bookingsQuery.isError && bookingRows.length === 0; const tableStatus = bookingsQuery.isLoading @@ -288,116 +261,12 @@ export default function ClearanceDocumentsPage() { - - } - value={query} - onChange={(e) => { - setQuery(e.target.value); - resetPage(); - }} - rightSection={ - query && ( - { - setQuery(""); - resetPage(); - }} - > - - - ) - } - style={{ flex: 1, minWidth: "200px" }} - radius="lg" - /> - { - setDirectionFilter(v); - resetPage(); - }} - clearable - radius="lg" - style={{ minWidth: 130 }} - aria-label="Filter by direction" - /> - { - setCustomerKindFilter(v); - resetPage(); - }} - clearable - radius="lg" - style={{ minWidth: 140 }} - aria-label="Filter by booked by" - /> - ` row + // read, just reshaped into FilterDefs. Falls back to a plain client-only + // Status filter for the 3 slugs with no server-side list filters at all. + const filterDefs: FilterDef[] = useMemo(() => { + const dateDef: FilterDef = { + key: "created", + label: "Registered", + type: "date", + secondary: true, + toParams: dateRangeParams("createdFrom", "createdTo"), + }; + if (config?.listFilters?.length) { + return [ + ...config.listFilters.map((filter): FilterDef => ({ + key: filter.key, + label: filter.label, + type: "enum", + multiple: false, + options: filter.dynamicOptions + ? (dynamicOptions[filter.dynamicOptions] ?? []) + : (filter.options ?? []), + })), + dateDef, + ]; + } + const fallback = FALLBACK_STATUS_OPTIONS[slug]; + return fallback + ? [{ key: "status", label: "Status", type: "enum", multiple: false, options: fallback }, dateDef] + : [dateDef]; + }, [config, dynamicOptions, slug]); + + const controls = useFilters(filterDefs, { pageSize: 10 }); + + // On the server-paged path the page window, the search and the registration + // date range are all resolved by the API — nothing is filtered client-side. + // `controls.params` already carries every filter's mapped param name (status/ + // currentYardId/wagonTypeId/… default to `{key: value}`, "created" maps to + // createdFrom/createdTo) plus search/page/pageSize — it IS the paged filter + // object; the unpaged one is the same minus pagination and the date range + // (which stays client-only for the non-server-paged slugs, see below). + const serverListFilters = useMemo((): FleetListFilters | undefined => { + if (!SERVER_FILTERED_SLUGS.includes(slug)) return undefined; + const { page: _page, pageSize: _pageSize, createdFrom: _cf, createdTo: _ct, ...rest } = controls.params; + return rest as FleetListFilters; + }, [slug, controls.params]); + + const pagedFilters = useMemo( + (): FleetListFilters => controls.params as unknown as FleetListFilters, + [controls.params], + ); + + const listQuery = useQuery({ + ...api.fleet.list.queryOptions({ input: { slug, filters: serverListFilters } }), + enabled: !serverPaged, + }); + const pagedQuery = useQuery({ + ...api.fleet.listPaged.queryOptions({ input: { slug, filters: pagedFilters } }), + enabled: serverPaged, + placeholderData: keepPreviousData, + }); + + const activeQuery = serverPaged ? pagedQuery : listQuery; + const { isLoading, isError, error } = activeQuery; + const allRows = useMemo( + () => (serverPaged ? (pagedQuery.data?.items ?? []) : (listQuery.data ?? [])), + [serverPaged, pagedQuery.data, listQuery.data], + ); + const create = useMutation(api.fleet.create.mutationOptions()); + const update = useMutation(api.fleet.update.mutationOptions()); + const remove = useMutation(api.fleet.remove.mutationOptions()); + const purge = useMutation(api.fleet.purge.mutationOptions()); useEffect(() => { registerFleetOptionLabels("wagonTypeId", dynamicOptions.wagonTypes); @@ -349,16 +322,22 @@ const FleetResourcePage = () => { // The API already applied every filter and cut the page — re-filtering here // would drop rows the server deliberately returned. if (serverPaged) return allRows; - const term = search.trim().toLowerCase(); return allRows.filter((row) => { const record = row as unknown as Record; // The date range applies even when the API already filtered the list — // it is not one of the server-side filters. - if (!matchesDayRange(record.createdAt, dateFrom, dateTo)) return false; - if (usesServerListFilters) return true; - if (statusFilter !== "ALL" && String(record.status ?? "") !== statusFilter) { + const created = controls.values.created; + if (created && !matchesDayRange(record.createdAt, created.v[0]?.slice(0, 10) ?? null, created.v[1]?.slice(0, 10) ?? null)) { return false; } + // Every other filter (status/yard/wagon type/…) was already applied + // server-side for these slugs — re-checking here against a plain field + // equality would be wrong for one (a wagon's "trainNumber" filter + // matches either of two DIFFERENT columns server-side, not one). + if (usesServerListFilters) return true; + const status = controls.values.status; + if (status && String(record.status ?? "") !== status.v[0]) return false; + const term = controls.searchText.trim().toLowerCase(); if (!term) return true; return config.searchKeys.some((key) => String(record[key] ?? "") @@ -366,19 +345,23 @@ const FleetResourcePage = () => { .includes(term), ); }); - }, [allRows, search, statusFilter, config, usesServerListFilters, dateFrom, dateTo, serverPaged]); + }, [allRows, config, usesServerListFilters, controls.values, controls.searchText, serverPaged]); const totalCount = serverPaged ? (pagedQuery.data?.meta.total ?? 0) : filteredRows.length; const pageCount = serverPaged ? Math.max(1, pagedQuery.data?.meta.totalPages ?? 1) - : Math.max(1, Math.ceil(filteredRows.length / pagination.pageSize)); + : Math.max(1, Math.ceil(filteredRows.length / controls.pageSize)); const pagedRows = useMemo(() => { if (serverPaged) return filteredRows; - const start = pagination.pageIndex * pagination.pageSize; - return filteredRows.slice(start, start + pagination.pageSize); - }, [filteredRows, pagination.pageIndex, pagination.pageSize, serverPaged]); + const start = (controls.page - 1) * controls.pageSize; + return filteredRows.slice(start, start + controls.pageSize); + }, [filteredRows, controls.page, controls.pageSize, serverPaged]); + + // Same {pagination, tableOptions} shape DataTable takes directly; FleetCardGrid + // (not a DataTable) just needs the raw pieces out of it below. + const { pagination: dtPagination, tableOptions: dtTableOptions } = controls.tableProps(totalCount); const columns = useMemo((): ColumnDef[] => { if (!config) return []; @@ -604,77 +587,41 @@ const FleetResourcePage = () => { - - { - setDateFrom(from); - setDateTo(to); - }} - presets={getDateRangePresets()} - clearable - size="sm" - radius="lg" - w={240} - /> - {listFilterSelects ? ( - - {listFilterSelects.map((filter) => ( - setContainerSize(val as EmptyContainerSize | null)} + data={[ + { value: "20", label: "20 ft" }, + { value: "40", label: "40 ft" }, + ]} + /> +