Merge pull request #1167 from Tria-plc/dev

to staging
This commit is contained in:
Nathnael Wondisha
2026-08-07 21:51:57 +03:00
committed by GitHub
92 changed files with 5737 additions and 891 deletions

View File

@@ -110,6 +110,7 @@ jobs:
DEPLOY_USER: tria
DOCKER_BUILDKIT: "1"
COMPOSE_DOCKER_CLI_BUILD: "1"
ENV_MANAGER_TOKEN: ${{ secrets.ENV_MANAGER_TOKEN }}
steps:
- name: Checkout
@@ -135,10 +136,10 @@ jobs:
;;
esac
- name: Sync environment from server
- name: Sync environment from Env manager app
run: |
chmod +x scripts/deploy/*.sh
./scripts/deploy/sync-env-from-server.sh "${{ matrix.service }}"
./scripts/deploy/sync-env-from-env-manager.sh "${{ matrix.service }}"
- name: Set compose project name
run: |

View File

@@ -35,7 +35,7 @@ jobs:
# Plain `self-hosted` — GitHub applies this label to every self-hosted
# runner automatically. The scan is host-agnostic, unlike the deploy jobs
# which pin to a branch-specific runner.
runs-on: self-hosted
runs-on: [self-hosted, dev]
outputs:
infected: ${{ steps.scan.outputs.infected }}
steps:

9
.gitignore vendored
View File

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

View File

@@ -126,3 +126,65 @@ EMAIL_QUEUE=email_queue
# Shared secret for service-to-service calls (payment microservice <-> freight).
# Required at boot; set ALLOW_UNAUTH_INTERNAL=true instead ONLY for local dev.
SERVICE_AUTH_TOKEN=change-me
# ── MoR EIMS e-invoicing (core.mor.gov.et) ─────────────────────────────────
# Disabled by default; every EIMS call fails fast with EIMS_NOT_CONFIGURED until enabled.
EIMS_ENABLED=false
EIMS_BASE_URL=https://core.mor.gov.et
EIMS_CLIENT_ID=
EIMS_CLIENT_SECRET=
EIMS_API_KEY=
EIMS_TIN=
# Source-system identity comes from the access token's systemNumber/systemType claims.
# Setting these turns them into expected-value checks: a mismatch against the token fails
# fast rather than one side silently winning. Leave empty to take the gateway's word.
EIMS_SYSTEM_NUMBER=
EIMS_SYSTEM_TYPE=
# Absolute paths to the INSA-issued credentials. Keep them OUTSIDE the repo; the file
# patterns are gitignored, but a path outside the working tree is safer still.
# The certificate is transmitted as base64 of this file's exact bytes — do not convert it.
EIMS_PRIVATE_KEY_PATH=
EIMS_CERTIFICATE_PATH=
# Optional tuning
EIMS_HTTP_TIMEOUT_MS=30000
EIMS_TOKEN_SKEW_SECONDS=45
# ── EIMS invoice registration (required only to register invoices) ─────────
# Seller identity: EDR's own legal details are not modelled anywhere in the DB.
# Region and Wereda are MoR *codes* (e.g. 13 / 574), not names.
EIMS_SELLER_LEGAL_NAME=
EIMS_SELLER_VAT_NUMBER=
EIMS_SELLER_PHONE=
EIMS_SELLER_EMAIL=
EIMS_SELLER_REGION=
EIMS_SELLER_WEREDA=
# Optional seller address parts; sent as null when unset.
EIMS_SELLER_CITY=
EIMS_SELLER_SUBCITY=
EIMS_SELLER_HOUSE_NUMBER=
EIMS_SELLER_LOCALITY=
# Tax treatment — REQUIRES FINANCE SIGN-OFF. The application models no tax at all
# (invoice.taxAmount is always 0), so nothing here is defaulted: registration fails
# locally, naming the missing variables, until these are set.
EIMS_TAX_CODE=0
EIMS_TAX_RATE_PERCENT=0
EIMS_EXCISE_TAX_VALUE=0
EIMS_INCOME_WITHHOLD_VALUE=0
EIMS_TRANSACTION_WITHHOLD_VALUE=0
# Document classification and payment presentation.
EIMS_TRANSACTION_TYPE=B2B
EIMS_NATURE_OF_SUPPLIES=Service
EIMS_PAYMENT_MODE=CASH
EIMS_PAYMENT_TERM=IMMIDIATE
EIMS_UNIT_DEFAULT=PCS
# MoR numeric country code for the buyer; our companies store the country name.
EIMS_BUYER_COUNTRY_CODE=
EIMS_CASHIER_NAME=
EIMS_SALESPERSON_NAME=
# Automatic filing of issued invoices (@Cron sweep, one invoice per tick).
# Independent of EIMS_ENABLED on purpose: authentication can be live long before
# filing is. Both must be true before anything is submitted automatically.
EIMS_AUTO_SUBMIT=false
EIMS_AUTO_SUBMIT_CRON=0 */5 * * * *
# MoR rejects documents older than 3 days; the sweep will not attempt those.
EIMS_AUTO_SUBMIT_MAX_AGE_DAYS=3

View File

@@ -37,7 +37,8 @@
"iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert",
"iam:migration:show": "pnpm run iam:typeorm:cli migration:show",
"migration:run": "nest build && node dist/scripts/migrate.js",
"script": "ts-node -r tsconfig-paths/register src/scripts/main.ts"
"script": "ts-node -r tsconfig-paths/register src/scripts/main.ts",
"eims:login": "ts-node -r tsconfig-paths/register src/scripts/eims-login.ts"
},
"dependencies": {
"@edr/api-common": "workspace:*",

View File

@@ -23,6 +23,7 @@ import databaseConfig from "./config/database.config";
import telebirrConfig from "./config/telebirr.config";
import rabbitmqConfig from "./config/rabbitmq.config";
import faydaConfig from "./config/fayda.config";
import eimsConfig from "./config/eims.config";
import { BookingsModule } from "./modules/bookings/bookings.module";
import { ContractsModule } from "./modules/contracts/contracts.module";
@@ -76,6 +77,7 @@ import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
// import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder";
// import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder";
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
import { FreightNotificationPermissionsSeeder } from "./seed/freight-notification-permissions.seeder";
// import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
// import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
@@ -83,6 +85,7 @@ import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-d
//New Trains, Wagons, Container and Cargo management modules
import { TrainsModule } from "./modules/trains/trains.module";
import { VerifaydaModule } from "./modules/verifayda/verifayda.module";
import { EimsModule } from "./modules/eims/eims.module";
import { FleetHistoryModule } from "./modules/fleet-history/fleet-history.module";
import { WagonsModule } from "./modules/wagons/wagons.module";
import { ContainersModule } from "./modules/container-management/containers.module";
@@ -126,6 +129,7 @@ if (!process.env.APPLICATION_NAME) {
telebirrConfig,
rabbitmqConfig,
faydaConfig,
eimsConfig,
],
}),
ScheduleModule.forRoot(),
@@ -247,6 +251,7 @@ if (!process.env.APPLICATION_NAME) {
InterchangeDocumentsModule,
ImportOperationsModule,
VerifaydaModule,
EimsModule,
FleetHistoryModule,
AiModule,
AuditModule,
@@ -257,6 +262,7 @@ if (!process.env.APPLICATION_NAME) {
FileUploadSettingsSeeder,
// YardFacilitiesSeeder,
FreightPermissionKeyMigrationSeeder,
FreightNotificationPermissionsSeeder,
// Disabled seeds — providers commented out (imports/injection/run too):
// DemoUsersSeeder,
// FreightStaffUsersSeeder,
@@ -287,6 +293,7 @@ export class AppModule implements OnApplicationBootstrap {
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
// private readonly yardFacilitiesSeeder: YardFacilitiesSeeder,
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
private readonly freightNotificationPermissionsSeeder: FreightNotificationPermissionsSeeder,
// Disabled seeds — injections commented out (imports/provider/run too):
// private readonly demoUsersSeeder: DemoUsersSeeder,
// private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
@@ -328,6 +335,16 @@ export class AppModule implements OnApplicationBootstrap {
await this.edrOrgSeeder.run();
await this.iamBaselineSeeder.run();
await this.freightPositionsSeeder.run();
// freightNotificationPermissions → seeds the <module>:get_notification
// keys and backfills them onto whoever
// already holds each desk's anchor
// permission. Runs LAST in this block so
// it sees a freshly-seeded catalog and
// freshly-seeded positions. Unlike the
// seeders above it is NOT gated behind
// SEED_EDR_ORG — without it every staff
// notification resolves to no one.
await this.freightNotificationPermissionsSeeder.run();
// File upload settings — keep enabled.
await this.fileUploadSettingsSeeder.run();

View File

@@ -95,9 +95,14 @@ export const TrainSchedulingRulesManage = () =>
* wagons:delete, …). The legacy coarse fleet:view / fleet:manage keys remain
* valid as a one-of fallback so existing role grants keep working.
*/
export const FleetView = (granular?: string) =>
export const FleetView = (granular?: string | string[]) =>
BookingStaff(
granular ? [granular, FREIGHT_PERMS.fleet.view] : FREIGHT_PERMS.fleet.view,
granular
? [
...(Array.isArray(granular) ? granular : [granular]),
FREIGHT_PERMS.fleet.view,
]
: FREIGHT_PERMS.fleet.view,
);
export const FleetManage = (granular?: string) =>

View File

@@ -9,6 +9,7 @@ import {
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { hasFreightPermission, isSuperAdmin } from './freight-permission.util';
import { readTwinOf } from '../seed/freight-permissions.registry';
// String literals on purpose (same reasoning as login-audience.middleware.ts):
// the values are wire-format constants from iam.users.user_type, and importing
@@ -22,13 +23,43 @@ const userTypeOf = (user: TCurrentUser): string | undefined =>
const isEmployee = (user: TCurrentUser): boolean =>
userTypeOf(user) === 'employee' || isSuperAdmin(user);
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
/**
* Does the caller satisfy a required permission?
*
* Holding the key outright always passes. A required `<module>:view` is ALSO
* satisfied by the weaker `<module>:read` — the key that buys API reads
* without putting the module in the backoffice sidebar — but only on a safe
* HTTP method.
*
* The method restriction is load-bearing, not caution. Nest runs class AND
* method guards, so controllers list every route key on the class gate,
* `:view` among the write keys. Without this check a `:read` holder would
* clear that class gate and then reach any write route that has no method
* gate of its own. Keying on the HTTP verb closes that by construction rather
* than by an audit that goes stale the next time a route is added.
*/
const satisfiedBy = (
user: TCurrentUser,
required: string,
method: string,
): boolean => {
if (hasFreightPermission(user, required)) return true;
if (!SAFE_METHODS.has(method)) return false;
const readTwin = readTwinOf(required);
return Boolean(readTwin && hasFreightPermission(user, readTwin));
};
export function FreightPermissionGuard(
permissions: string[],
): Type<CanActivate> {
@Injectable()
class FreightPermissionsGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<{ user?: TCurrentUser }>();
const request = context
.switchToHttp()
.getRequest<{ user?: TCurrentUser; method: string }>();
const user = request.user;
if (!user) {
@@ -39,7 +70,7 @@ export function FreightPermissionGuard(
}
if (!permissions?.length) return true;
if (permissions.some((p) => hasFreightPermission(user, p))) {
if (permissions.some((p) => satisfiedBy(user, p, request.method))) {
return true;
}
@@ -79,7 +110,9 @@ export function MixedAudienceGuard(permissions: string[]): Type<CanActivate> {
@Injectable()
class MixedAudiencesGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<{ user?: TCurrentUser }>();
const request = context
.switchToHttp()
.getRequest<{ user?: TCurrentUser; method: string }>();
const user = request.user;
if (!user) {
@@ -93,7 +126,7 @@ export function MixedAudienceGuard(permissions: string[]): Type<CanActivate> {
}
if (
!permissions?.length ||
permissions.some((p) => hasFreightPermission(user, p))
permissions.some((p) => satisfiedBy(user, p, request.method))
) {
return true;
}

View File

@@ -0,0 +1,185 @@
import { registerAs } from "@nestjs/config";
/**
* Ethiopian MoR EIMS e-invoicing gateway.
*
* Disabled by default: with `EIMS_ENABLED=false` the config resolves to a stub and every EIMS
* service throws a clear error on use, so a deployment without credentials still boots.
*
* Secrets (client secret, API key) and the credential file paths live only here and are never
* logged — validation reports missing variable *names*, never their values.
*/
export interface EimsConfig {
enabled: boolean;
baseUrl: string;
clientId: string;
clientSecret: string;
apiKey: string;
tin: string;
/**
* Optional *expectations* for the source-system identity, not inputs.
*
* The access token MoR issues carries `systemNumber` and `systemType` claims for the credentials
* that authenticated, and those are what registration uses. When these are set they are compared
* against the token and a mismatch fails fast — neither side silently wins. Leave them empty to
* take whatever the gateway says.
*/
systemNumber: string;
systemType: string;
/** Filesystem path to the INSA-issued RSA private key (PEM). Never leaves the server. */
privateKeyPath: string;
/** Filesystem path to the INSA-issued certificate bundle; sent as base64 of its exact bytes. */
certificatePath: string;
httpTimeoutMs: number;
/** Re-authenticate this many ms before the access token actually expires. */
tokenSkewMs: number;
/**
* Automatic submission of issued invoices, off by default.
*
* Invoices are produced by the workflow, so the production path is a sweep rather than a human
* action — but enabling it starts filing real documents with the tax authority, which is
* irreversible from our side. It therefore needs its own deliberate switch, separate from
* `EIMS_ENABLED`, so that authentication can be live long before filing is.
*/
autoSubmit: boolean;
autoSubmitCron: string;
/** MoR rejects a document whose date is more than 3 days old; the sweep will not attempt those. */
autoSubmitMaxAgeDays: number;
/**
* Seller identity and tax/business treatment for the invoice document.
*
* None of this is derivable from the database: EDR's own legal identity exists nowhere in the
* codebase, and the app models no tax at all. Values are required at registration time and are
* validated there rather than at boot, so a deployment can run with EIMS enabled for
* authentication before finance has signed off on the tax treatment.
*/
invoice: EimsInvoiceConfig;
}
export interface EimsInvoiceConfig {
sellerLegalName: string;
sellerVatNumber: string;
sellerPhone: string;
sellerEmail: string;
/** MoR *codes*, not names (e.g. "13" for Addis Ababa, "574"). */
sellerRegion: string;
sellerWereda: string;
sellerCity: string | null;
sellerSubCity: string | null;
sellerHouseNumber: string | null;
sellerLocality: string | null;
/** REQUIRES_BUSINESS_CONFIRMATION — no tax model exists in this application. */
taxCode: string;
taxRatePercent: number | null;
exciseTaxValue: number | null;
incomeWithholdValue: number | null;
transactionWithholdValue: number | null;
/** B2B / B2C — a tax classification, so it is configured, not inferred. */
transactionType: string;
natureOfSupplies: string;
paymentMode: string;
paymentTerm: string;
unitDefault: string;
buyerCountryCode: string | null;
cashierName: string | null;
salesPersonName: 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 positiveInt = (raw: string | undefined, fallback: number, name: string): number => {
if (raw === undefined || raw === "") return fallback;
const value = Number.parseInt(raw, 10);
if (Number.isNaN(value) || value <= 0) {
throw new Error(`${name} must be a positive integer`);
}
return value;
};
/** Unset stays null so the registration-time check can name it; a set-but-bogus value throws. */
const optionalNumber = (raw: string | undefined, name: string): number | null => {
if (raw === undefined || raw === "") return null;
const value = Number(raw);
if (!Number.isFinite(value)) throw new Error(`${name} must be a number`);
return value;
};
export default registerAs("eims", (): EimsConfig => {
const enabled = (process.env.EIMS_ENABLED ?? "false").toLowerCase() === "true";
const baseUrl = (process.env.EIMS_BASE_URL ?? "https://core.mor.gov.et").replace(/\/+$/, "");
const httpTimeoutMs = positiveInt(process.env.EIMS_HTTP_TIMEOUT_MS, 30_000, "EIMS_HTTP_TIMEOUT_MS");
const tokenSkewMs =
positiveInt(process.env.EIMS_TOKEN_SKEW_SECONDS, 45, "EIMS_TOKEN_SKEW_SECONDS") * 1000;
const base: EimsConfig = {
enabled,
baseUrl,
clientId: process.env.EIMS_CLIENT_ID ?? "",
clientSecret: process.env.EIMS_CLIENT_SECRET ?? "",
apiKey: process.env.EIMS_API_KEY ?? "",
tin: process.env.EIMS_TIN ?? "",
systemNumber: process.env.EIMS_SYSTEM_NUMBER ?? "",
systemType: process.env.EIMS_SYSTEM_TYPE ?? "",
privateKeyPath: process.env.EIMS_PRIVATE_KEY_PATH ?? "",
certificatePath: process.env.EIMS_CERTIFICATE_PATH ?? "",
httpTimeoutMs,
tokenSkewMs,
autoSubmit: (process.env.EIMS_AUTO_SUBMIT ?? "false").toLowerCase() === "true",
// Every 5 minutes by default: filing is not latency-sensitive, and a slow cadence keeps a
// misconfiguration from filing a burst of bad documents before anyone notices.
autoSubmitCron: process.env.EIMS_AUTO_SUBMIT_CRON || "0 */5 * * * *",
autoSubmitMaxAgeDays: positiveInt(
process.env.EIMS_AUTO_SUBMIT_MAX_AGE_DAYS,
3,
"EIMS_AUTO_SUBMIT_MAX_AGE_DAYS",
),
invoice: {
sellerLegalName: process.env.EIMS_SELLER_LEGAL_NAME ?? "",
sellerVatNumber: process.env.EIMS_SELLER_VAT_NUMBER ?? "",
sellerPhone: process.env.EIMS_SELLER_PHONE ?? "",
sellerEmail: process.env.EIMS_SELLER_EMAIL ?? "",
sellerRegion: process.env.EIMS_SELLER_REGION ?? "",
sellerWereda: process.env.EIMS_SELLER_WEREDA ?? "",
sellerCity: process.env.EIMS_SELLER_CITY || null,
sellerSubCity: process.env.EIMS_SELLER_SUBCITY || null,
sellerHouseNumber: process.env.EIMS_SELLER_HOUSE_NUMBER || null,
sellerLocality: process.env.EIMS_SELLER_LOCALITY || null,
taxCode: process.env.EIMS_TAX_CODE ?? "",
taxRatePercent: optionalNumber(process.env.EIMS_TAX_RATE_PERCENT, "EIMS_TAX_RATE_PERCENT"),
exciseTaxValue: optionalNumber(process.env.EIMS_EXCISE_TAX_VALUE, "EIMS_EXCISE_TAX_VALUE"),
incomeWithholdValue: optionalNumber(
process.env.EIMS_INCOME_WITHHOLD_VALUE,
"EIMS_INCOME_WITHHOLD_VALUE",
),
transactionWithholdValue: optionalNumber(
process.env.EIMS_TRANSACTION_WITHHOLD_VALUE,
"EIMS_TRANSACTION_WITHHOLD_VALUE",
),
transactionType: process.env.EIMS_TRANSACTION_TYPE ?? "",
natureOfSupplies: process.env.EIMS_NATURE_OF_SUPPLIES ?? "",
paymentMode: process.env.EIMS_PAYMENT_MODE ?? "",
paymentTerm: process.env.EIMS_PAYMENT_TERM ?? "",
unitDefault: process.env.EIMS_UNIT_DEFAULT ?? "",
buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null,
cashierName: process.env.EIMS_CASHIER_NAME || null,
salesPersonName: process.env.EIMS_SALESPERSON_NAME || null,
},
};
if (!enabled) return base;
const missing = REQUIRED_VARS.filter((name) => !process.env[name]);
if (missing.length > 0) {
throw new Error(
`EIMS integration is enabled (EIMS_ENABLED=true) but the following env vars are missing: ${missing.join(", ")}`,
);
}
return base;
});

View File

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

View File

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

View File

@@ -20,7 +20,12 @@ import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
@ApiTags("billing")
@Controller("billing")
@BookingStaff(FREIGHT_PERMS.invoices.view)
// Class gate lists every key its routes use: Nest runs class AND method
// guards, so a key missing here would deny before the route's own key runs.
@BookingStaff([
FREIGHT_PERMS.invoices.view,
FREIGHT_PERMS.invoices.export,
])
@ApiBearerAuth()
export class BillingController {
constructor(

View File

@@ -0,0 +1,214 @@
import {
EimsMapperContext,
EimsMapperInvoice,
EimsSellerDetails,
formatEimsDate,
toEimsInvoice,
} from "./eims-invoice.mapper";
const seller: EimsSellerDetails = {
City: null,
Email: "finance@edr.et",
HouseNumber: null,
LegalName: "Ethio-Djibouti Railway S.C.",
Locality: null,
Phone: "0911223344",
Region: "13",
SubCity: null,
Tin: "0016324478",
VatNumber: "3215840010",
Wereda: "574",
};
const invoice = (over: Partial<EimsMapperInvoice> = {}): EimsMapperInvoice => ({
invoiceNumber: "INV-20260807-00042",
currency: "ETB",
issuedAt: new Date(2026, 7, 7, 9, 5, 3),
totalAmount: "11000.00",
company: {
name: "ABC Trading PLC",
tin: "0999930000",
vatNumber: "123475885858",
phone: "0912345678",
email: "buyer@abc.et",
region: "13",
zone: "SHA",
woreda: "574",
kebele: "03",
houseNo: "NEW",
country: "Ethiopia",
},
lines: [
{ chargeType: "RAIL_FREIGHT", description: "Addis → Djibouti", quantity: "1.00", unitRate: "10000.00", amount: "10000.00" },
{ chargeType: "HAZARD_SURCHARGE", description: null, quantity: "2.00", unitRate: "500.00", amount: "1000.00", metadata: { unit: "CTR" } },
],
...over,
});
const context = (over: Partial<EimsMapperContext> = {}): EimsMapperContext => ({
systemNumber: "B0360154BA",
systemType: "SYS",
documentNumber: "24",
invoiceCounter: 7,
previousIrn: "",
cashierName: null,
salesPersonName: null,
transactionType: "B2B",
payment: { mode: "CASH", term: "IMMIDIATE" },
taxForLine: () => ({ code: "VAT15", ratePercent: 15, exciseTaxValue: 0 }),
natureOfSupplies: "Service",
unitDefault: "PCS",
incomeWithholdValue: 0,
transactionWithholdValue: 0,
...over,
});
describe("toEimsInvoice", () => {
it("emits the ten EIMS sections with the collection's field names", () => {
const doc = toEimsInvoice(invoice(), seller, context());
expect(Object.keys(doc)).toEqual([
"BuyerDetails",
"DocumentDetails",
"ItemList",
"PaymentDetails",
"ReferenceDetails",
"SellerDetails",
"SourceSystem",
"TransactionType",
"ValueDetails",
"Version",
]);
expect(doc.Version).toBe("1");
expect(doc.DocumentDetails).toEqual({ DocumentNumber: "24", Date: "07-08-2026T09:05:03", Type: "INV" });
expect(doc.SourceSystem.InvoiceCounter).toBe(7);
expect(doc.SellerDetails).toBe(seller);
});
it("maps the buyer from the company row and leaves unmodelled fields null", () => {
const doc = toEimsInvoice(invoice(), seller, context());
expect(doc.BuyerDetails).toEqual({
City: null,
Email: "buyer@abc.et",
HouseNumber: "NEW",
IdNumber: null,
IdType: null,
Tin: "0999930000",
LegalName: "ABC Trading PLC",
Phone: "0912345678",
Region: "13",
Country: null,
Zone: "SHA",
Kebele: "03",
VatNumber: "123475885858",
Wereda: "574",
});
});
it("applies per-line tax and totals it into ValueDetails", () => {
const doc = toEimsInvoice(
invoice(),
seller,
context({
taxForLine: (line) =>
line.chargeType === "RAIL_FREIGHT"
? { code: "VAT15", ratePercent: 15, exciseTaxValue: 0 }
: { code: "EXEMPT", ratePercent: 0, exciseTaxValue: 50 },
}),
);
expect(doc.ItemList[0]).toMatchObject({
LineNumber: 1,
ItemCode: "RAIL_FREIGHT",
ProductDescription: "Addis → Djibouti",
Quantity: 1,
UnitPrice: 10000,
PreTaxValue: 10000,
TaxCode: "VAT15",
TaxAmount: 1500,
ExciseTaxValue: 0,
TotalLineAmount: 11500,
Unit: "PCS",
NatureOfSupplies: "Service",
HarmonizationCode: null,
});
expect(doc.ItemList[1]).toMatchObject({
LineNumber: 2,
ProductDescription: "HAZARD_SURCHARGE",
TaxCode: "EXEMPT",
TaxAmount: 0,
ExciseTaxValue: 50,
TotalLineAmount: 1050,
Unit: "CTR",
});
expect(doc.ValueDetails).toEqual({
Discount: null,
ExciseValue: 50,
IncomeWithholdValue: 0,
TaxValue: 1500,
TotalValue: 12550,
TransactionWithholdValue: 0,
InvoiceCurrency: "ETB",
});
});
it("passes PreviousIrn through verbatim and defaults RelatedDocument to null", () => {
expect(toEimsInvoice(invoice(), seller, context()).ReferenceDetails).toEqual({
PreviousIrn: "",
RelatedDocument: null,
});
expect(
toEimsInvoice(invoice(), seller, context({ previousIrn: null, relatedDocument: "CN-9" }))
.ReferenceDetails,
).toEqual({ PreviousIrn: null, RelatedDocument: "CN-9" });
});
it("emits ExchangeRate only when supplied", () => {
expect(toEimsInvoice(invoice(), seller, context()).ValueDetails.ExchangeRate).toBeUndefined();
const usd = toEimsInvoice(
invoice({ currency: "USD" }),
seller,
context({ exchangeRate: 132.5 }),
);
expect(usd.ValueDetails).toMatchObject({ InvoiceCurrency: "USD", ExchangeRate: 132.5 });
});
it("honours a caller-supplied date formatter", () => {
const doc = toEimsInvoice(invoice(), seller, context({ formatDate: () => "2026-08-07T09:05:03Z" }));
expect(doc.DocumentDetails.Date).toBe("2026-08-07T09:05:03Z");
});
it("throws when tax treatment cannot be resolved for a line", () => {
expect(() =>
toEimsInvoice(
invoice(),
seller,
context({ taxForLine: () => ({ code: "", ratePercent: 15, exciseTaxValue: 0 }) }),
),
).toThrow(/unresolved tax treatment for line 1/);
});
it("throws on a missing buyer TIN, no lines, or an unissued invoice", () => {
expect(() => toEimsInvoice(invoice({ company: null }), seller, context())).toThrow(/buyer company TIN/);
expect(() => toEimsInvoice(invoice({ lines: [] }), seller, context())).toThrow(/has no lines/);
expect(() => toEimsInvoice(invoice({ issuedAt: null }), seller, context())).toThrow(/not issued/);
});
it("throws when the lines do not sum to the invoice total", () => {
expect(() => toEimsInvoice(invoice({ totalAmount: "9000.00" }), seller, context())).toThrow(
/lines sum to 11000 but the invoice total is 9000/,
);
});
it("throws on a non-ETB invoice with no exchange rate", () => {
expect(() => toEimsInvoice(invoice({ currency: "USD" }), seller, context())).toThrow(/needs an exchangeRate/);
});
});
describe("formatEimsDate", () => {
it("renders the observed dd-MM-yyyyTHH:mm:ss shape with zero padding", () => {
expect(formatEimsDate(new Date(2025, 2, 21, 0, 0, 0))).toBe("21-03-2025T00:00:00");
});
});

View File

@@ -0,0 +1,362 @@
/**
* Pure mapper from an EDR invoice onto the Ethiopian MoR EIMS registration document
* (`POST https://core.mor.gov.et/v1/register`).
*
* Field names, casing and section layout are taken verbatim from the supplied
* `EimsCoreApiMockCollection2.postman_collection.json`. Note the payload spells the district
* `Wereda` even though the collection *variable* is named `sellerWoreda`.
*
* Scope: mapping only — no HTTP, no signing, no persistence, no counter allocation. Everything
* that does not live on the invoice (document number, counters, previous IRN, seller identity,
* tax treatment) is supplied by the caller and is never guessed here.
*
* Values that the collection only *demonstrates* by example — the date format, the meaning of an
* empty `PreviousIrn`, the `SystemType` enum, `PaymentTerm` values — are treated as observed, not
* authoritative: they are passed through or overridable rather than validated against a fixed set.
*/
import { round2 } from "./invoice-settlement.util";
/** Only proven-required constant: the 400 SCHEMA ERROR sample rejects a payload without it. */
const EIMS_VERSION = "1";
/** The only `DocumentDetails.Type` observed in the supplied material. */
const EIMS_DOCUMENT_TYPE = "INV";
export interface EimsBuyerDetails {
City: string | null;
Email: string | null;
HouseNumber: string | null;
IdNumber: string | null;
IdType: string | null;
Tin: string;
LegalName: string;
Phone: string | null;
Region: string | null;
Country: string | null;
Zone: string | null;
Kebele: string | null;
VatNumber: string | null;
Wereda: string | null;
}
export interface EimsSellerDetails {
City: string | null;
Email: string | null;
HouseNumber: string | null;
LegalName: string;
Locality: string | null;
Phone: string | null;
/** MoR region *code* (e.g. "13"), not a region name. */
Region: string | null;
SubCity: string | null;
Tin: string;
VatNumber: string | null;
/** MoR wereda *code* (e.g. "574"). */
Wereda: string | null;
}
export interface EimsDocumentDetails {
DocumentNumber: string;
/** Observed format `dd-MM-yyyyTHH:mm:ss`. Rule seen in the collection: within 3 days of now. */
Date: string;
Type: string;
}
export interface EimsInvoiceItem {
Discount: number;
ExciseTaxValue: number;
HarmonizationCode: string | null;
NatureOfSupplies: string;
ItemCode: string;
ProductDescription: string;
PreTaxValue: number;
Quantity: number;
LineNumber: number;
TaxAmount: number;
TaxCode: string;
TotalLineAmount: number;
Unit: string;
UnitPrice: number;
}
export interface EimsPaymentDetails {
Mode: string;
PaymentTerm: string;
}
export interface EimsReferenceDetails {
PreviousIrn: string | null;
RelatedDocument: string | null;
}
export interface EimsSourceSystem {
CashierName: string | null;
InvoiceCounter: number;
SalesPersonName: string | null;
SystemNumber: string;
SystemType: string;
}
export interface EimsValueDetails {
Discount: number | null;
ExciseValue: number;
IncomeWithholdValue: number;
TaxValue: number;
TotalValue: number;
TransactionWithholdValue: number;
InvoiceCurrency: string;
/** Absent from the register sample, present on the verify response. Emitted only when supplied. */
ExchangeRate?: number;
}
export interface EimsInvoiceRequest {
BuyerDetails: EimsBuyerDetails;
DocumentDetails: EimsDocumentDetails;
ItemList: EimsInvoiceItem[];
PaymentDetails: EimsPaymentDetails;
ReferenceDetails: EimsReferenceDetails;
SellerDetails: EimsSellerDetails;
SourceSystem: EimsSourceSystem;
TransactionType: string;
ValueDetails: EimsValueDetails;
Version: string;
}
/** `body` of a successful `POST /v1/register`, as observed in the collection. */
export interface EimsRegisterResponseBody {
irn: string;
ackDate: string;
signedQR: string;
signedInvoice: string;
status: string;
documentNumber: string;
errorMessage: string | null;
}
/** Numeric columns arrive from pg as strings; every money field is normalised through `num`. */
export interface EimsMapperLine {
chargeType: string;
description?: string | null;
quantity: number | string;
unitRate: number | string;
amount: number | string;
metadata?: Record<string, unknown> | null;
}
export interface EimsMapperCompany {
name: string;
tin: string;
vatNumber?: string | null;
phone?: string | null;
email?: string | null;
region?: string | null;
zone?: string | null;
woreda?: string | null;
kebele?: string | null;
houseNo?: string | null;
country?: string | null;
}
/**
* Structurally what `BillingService.findById` returns — the only read path that loads the header,
* the buyer company and the lines together.
*/
export interface EimsMapperInvoice {
invoiceNumber: string;
currency: string;
issuedAt?: Date | string | null;
totalAmount: number | string;
company?: EimsMapperCompany | null;
lines: EimsMapperLine[];
}
/**
* Tax treatment for a single line. EIMS models `TaxCode`/`TaxAmount`/`ExciseTaxValue` per item, and
* different charge types may eventually be treated differently, so this is resolved per line.
*
* Nothing in this repo can supply it: `Invoice.taxAmount` is hardcoded to 0 with no caller ever
* setting it, `invoice_lines` has no tax column, and the rate catalogue has no fiscal field. That
* is the absence of a tax model, not evidence of zero-rating — hence no default here.
*/
export interface EimsLineTax {
code: string;
ratePercent: number;
exciseTaxValue: number;
}
export interface EimsMapperContext {
systemNumber: string;
/** Observed values: POS, MAN, CRM, EFD, SYS (the collection prose also mentions ERP). */
systemType: string;
/** Caller decides the source — our own `invoiceNumber` or a dedicated EIMS sequence. */
documentNumber: string;
invoiceCounter: number;
/** Passed through verbatim; the collection shows `""` used for an unchained document. */
previousIrn: string | null;
cashierName: string | null;
salesPersonName: string | null;
/** B2B / B2C — a tax classification, so the caller states it. */
transactionType: string;
payment: { mode: string; term: string };
/** Must return a treatment for every line, or throw. */
taxForLine: (line: EimsMapperLine, lineNumber: number) => EimsLineTax;
natureOfSupplies: string;
/** Used when a line carries no `metadata.unit`. */
unitDefault: string;
incomeWithholdValue: number;
transactionWithholdValue: number;
/** Null for an ordinary invoice; set only for a real related-document case. */
relatedDocument?: string | null;
/** MoR numeric country code for the buyer; our DB stores the country name. */
buyerCountryCode?: string | null;
buyerIdType?: string | null;
buyerIdNumber?: string | null;
buyerCity?: string | null;
/** Required when the invoice currency is not ETB. */
exchangeRate?: number | null;
invoiceDiscount?: number | null;
/** Override while the observed `dd-MM-yyyyTHH:mm:ss` format is unconfirmed by MoR. */
formatDate?: (issuedAt: Date) => string;
}
const num = (v: number | string): number => {
const n = Number(v);
if (!Number.isFinite(n)) throw new Error(`EIMS mapping: expected a numeric value, got ${String(v)}`);
return n;
};
const pad = (n: number, width = 2): string => String(n).padStart(width, "0");
/** Observed EIMS document-date format: `dd-MM-yyyyTHH:mm:ss`, no timezone marker. */
export const formatEimsDate = (issuedAt: Date): string =>
`${pad(issuedAt.getDate())}-${pad(issuedAt.getMonth() + 1)}-${issuedAt.getFullYear()}` +
`T${pad(issuedAt.getHours())}:${pad(issuedAt.getMinutes())}:${pad(issuedAt.getSeconds())}`;
/**
* Map one loaded invoice onto an EIMS registration document.
*
* Throws rather than emitting a payload EIMS would reject opaquely: missing buyer TIN, no lines,
* an unissued invoice, unresolved line tax, a line/total mismatch, or a non-ETB invoice with no
* exchange rate.
*/
export function toEimsInvoice(
invoice: EimsMapperInvoice,
seller: EimsSellerDetails,
context: EimsMapperContext,
): EimsInvoiceRequest {
const company = invoice.company;
if (!company || !company.tin?.trim()) {
throw new Error(`EIMS mapping: invoice ${invoice.invoiceNumber} has no buyer company TIN`);
}
if (!invoice.lines?.length) {
throw new Error(`EIMS mapping: invoice ${invoice.invoiceNumber} has no lines`);
}
if (!invoice.issuedAt) {
throw new Error(`EIMS mapping: invoice ${invoice.invoiceNumber} is not issued (issuedAt is null)`);
}
if (invoice.currency !== "ETB" && context.exchangeRate == null) {
throw new Error(
`EIMS mapping: invoice ${invoice.invoiceNumber} is in ${invoice.currency} and needs an exchangeRate`,
);
}
const issuedAt = invoice.issuedAt instanceof Date ? invoice.issuedAt : new Date(invoice.issuedAt);
if (Number.isNaN(issuedAt.getTime())) {
throw new Error(`EIMS mapping: invoice ${invoice.invoiceNumber} has an unparseable issuedAt`);
}
const ItemList: EimsInvoiceItem[] = invoice.lines.map((line, index) => {
const lineNumber = index + 1;
const tax = context.taxForLine(line, lineNumber);
if (!tax || !tax.code || !Number.isFinite(tax.ratePercent) || !Number.isFinite(tax.exciseTaxValue)) {
throw new Error(
`EIMS mapping: unresolved tax treatment for line ${lineNumber} (${line.chargeType}) ` +
`on invoice ${invoice.invoiceNumber}`,
);
}
const PreTaxValue = round2(num(line.amount));
const TaxAmount = round2((PreTaxValue * tax.ratePercent) / 100);
const ExciseTaxValue = round2(tax.exciseTaxValue);
const unit = typeof line.metadata?.unit === "string" ? line.metadata.unit : context.unitDefault;
return {
Discount: 0,
ExciseTaxValue,
HarmonizationCode: null,
NatureOfSupplies: context.natureOfSupplies,
ItemCode: line.chargeType,
ProductDescription: line.description?.trim() || line.chargeType,
PreTaxValue,
Quantity: round2(num(line.quantity)),
LineNumber: lineNumber,
TaxAmount,
TaxCode: tax.code,
TotalLineAmount: round2(PreTaxValue + TaxAmount + ExciseTaxValue),
Unit: unit,
UnitPrice: round2(num(line.unitRate)),
};
});
const preTaxTotal = round2(ItemList.reduce((sum, item) => sum + item.PreTaxValue, 0));
const invoiceTotal = round2(num(invoice.totalAmount));
if (Math.abs(preTaxTotal - invoiceTotal) > 0.01) {
throw new Error(
`EIMS mapping: invoice ${invoice.invoiceNumber} lines sum to ${preTaxTotal} ` +
`but the invoice total is ${invoiceTotal}`,
);
}
const ValueDetails: EimsValueDetails = {
Discount: context.invoiceDiscount ?? null,
ExciseValue: round2(ItemList.reduce((sum, item) => sum + item.ExciseTaxValue, 0)),
IncomeWithholdValue: context.incomeWithholdValue,
TaxValue: round2(ItemList.reduce((sum, item) => sum + item.TaxAmount, 0)),
TotalValue: round2(ItemList.reduce((sum, item) => sum + item.TotalLineAmount, 0)),
TransactionWithholdValue: context.transactionWithholdValue,
InvoiceCurrency: invoice.currency,
};
if (context.exchangeRate != null) ValueDetails.ExchangeRate = context.exchangeRate;
return {
BuyerDetails: {
City: context.buyerCity ?? null,
Email: company.email ?? null,
HouseNumber: company.houseNo ?? null,
IdNumber: context.buyerIdNumber ?? null,
IdType: context.buyerIdType ?? null,
Tin: company.tin,
LegalName: company.name,
Phone: company.phone ?? null,
Region: company.region ?? null,
Country: context.buyerCountryCode ?? null,
Zone: company.zone ?? null,
Kebele: company.kebele ?? null,
VatNumber: company.vatNumber ?? null,
Wereda: company.woreda ?? null,
},
DocumentDetails: {
DocumentNumber: context.documentNumber,
Date: (context.formatDate ?? formatEimsDate)(issuedAt),
Type: EIMS_DOCUMENT_TYPE,
},
ItemList,
PaymentDetails: { Mode: context.payment.mode, PaymentTerm: context.payment.term },
ReferenceDetails: {
PreviousIrn: context.previousIrn,
RelatedDocument: context.relatedDocument ?? null,
},
SellerDetails: seller,
SourceSystem: {
CashierName: context.cashierName,
InvoiceCounter: context.invoiceCounter,
SalesPersonName: context.salesPersonName,
SystemNumber: context.systemNumber,
SystemType: context.systemType,
},
TransactionType: context.transactionType,
ValueDetails,
Version: EIMS_VERSION,
};
}

View File

@@ -1,6 +1,7 @@
import { BaseEntity } from "@edr/api-common";
import { Freight } from "@edr/types";
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
import type { EimsInvoiceError, EimsInvoiceStatus } from "../../eims/eims-registration.types";
import { PaymentEntity } from "../../payment/entities/payment.entity";
import { Company } from "../../companies/entities/company.entity";
import { CompanyProfile } from "../../companies/entities/company-profile.entity";
@@ -105,4 +106,27 @@ export class Invoice extends BaseEntity {
@Column({ name: "due_at", type: "timestamptz" })
dueAt!: Date;
/** MoR EIMS registration state. Set only by the EIMS module; billing never writes these. */
@Column({ name: "eims_status", type: "varchar", length: 20, default: "NOT_SUBMITTED" })
eimsStatus!: EimsInvoiceStatus;
/** Invoice Reference Number returned by EIMS. Unique across invoices (partial index). */
@Column({ name: "eims_irn", type: "varchar", length: 64, nullable: true })
eimsIrn?: string | null;
/** The `SourceSystem.InvoiceCounter` this invoice consumed. */
@Column({ name: "eims_invoice_counter", type: "bigint", nullable: true })
eimsInvoiceCounter?: number | null;
@Column({ name: "eims_submitted_at", type: "timestamptz", nullable: true })
eimsSubmittedAt?: Date | null;
/** EIMS acknowledgement timestamp, stored verbatim — it is a Java ZonedDateTime string. */
@Column({ name: "eims_ack_date", type: "varchar", length: 64, nullable: true })
eimsAckDate?: string | null;
/** Sanitized last failure: the gateway's own error fields only, never our signed envelope. */
@Column({ name: "eims_last_error", type: "jsonb", nullable: true })
eimsLastError?: EimsInvoiceError | null;
}

View File

@@ -1,5 +1,6 @@
import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service';
import type { Booking } from './entities/booking.entity';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
/**
* Who hears "Operations wants changes" depends on who owns the booking. A
@@ -74,3 +75,39 @@ describe('BookingLifecycleNotifierService — operation changes requested', () =
expect(inbox.notify.mock.calls[0][0].recipients).toEqual({ companyId: 'co-1' });
});
});
/**
* Staff notifications used to go to every employee in every organization. They
* now target a desk — and the two desks are disjoint: the GL presets hold no
* bookings:view and no intake keys, so intake pings would be noise they cannot
* act on. Both branches run through the same `inAppStaff` helper, which is the
* easy place to lose the distinction again.
*/
describe('BookingLifecycleNotifierService — staff desk targeting', () => {
const booking = () =>
({ id: 'b-1', reference: 'BKG-0001', companyId: 'co-1' }) as Booking;
let inbox: { notify: jest.Mock };
let service: BookingLifecycleNotifierService;
beforeEach(() => {
inbox = { notify: jest.fn().mockResolvedValue(undefined) };
service = new BookingLifecycleNotifierService(
{ directSend: jest.fn().mockResolvedValue(undefined) } as never,
inbox as never,
{ query: jest.fn().mockResolvedValue([]) } as never,
);
});
it('routes intake items to the booking desk and clearance items to the clearance desk', () => {
service.submittedToStaff(booking());
service.clearanceDocsUploadedToStaff(booking());
expect(inbox.notify.mock.calls[0][0].recipients).toEqual({
permissionKeys: [FREIGHT_PERMS.bookings.getNotification],
});
expect(inbox.notify.mock.calls[1][0].recipients).toEqual({
permissionKeys: [FREIGHT_PERMS.bookings.clearanceGetNotification],
});
});
});

View File

@@ -11,6 +11,16 @@ import { Booking } from './entities/booking.entity';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
/**
* Clearance items are worked by the GL desks, which hold no bookings:view and
* no intake keys — so they take their own selector rather than the booking
* desk's. Every override using this deep-links to a clearance page.
*/
const CLEARANCE_DESK = {
permissionKeys: [FREIGHT_PERMS.bookings.clearanceGetNotification],
};
/**
* Customer + staff notifications for the booking lifecycle: review, clearance
@@ -89,7 +99,11 @@ export class BookingLifecycleNotifierService {
});
}
/** Persist + push an in-app item to every backoffice staff user. */
/**
* Persist + push an in-app item to the booking desk — staff holding
* `bookings:get_notification`. Callers whose item belongs to a different desk
* override `recipients` (see {@link CLEARANCE_DESK}).
*/
private inAppStaff(
b: Booking,
title: string,
@@ -97,7 +111,7 @@ export class BookingLifecycleNotifierService {
overrides: Partial<NotifyInput> = {},
): void {
void this.inbox.notify({
recipients: { allBackoffice: true },
recipients: { permissionKeys: [FREIGHT_PERMS.bookings.getNotification] },
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.REQUEST_SUBMITTED,
title,
@@ -274,6 +288,7 @@ export class BookingLifecycleNotifierService {
`the customs declaration can be filed.${note ? ` Note: "${note}"` : ''}`;
this.logger.log(`TRANSIT ASSIGNEE REQUESTED — ${this.ref(b)}`);
this.inAppStaff(b, `Transit assignee needed — ${b.reference}`, msg, {
recipients: CLEARANCE_DESK,
type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/gl-djibouti/clearance/${b.id}`,
});
@@ -288,6 +303,7 @@ export class BookingLifecycleNotifierService {
`The customs declaration can now be filed.`;
this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${this.ref(b)}`);
this.inAppStaff(b, `Transit assignee set — ${b.reference}`, msg, {
recipients: CLEARANCE_DESK,
type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/bookings/${b.id}/clearance`,
});
@@ -395,6 +411,7 @@ export class BookingLifecycleNotifierService {
'Clearance documents uploaded',
`Customer uploaded clearance documents for booking ${this.ref(b)} — review them in the clearance queue.`,
{
recipients: CLEARANCE_DESK,
type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/bookings/${b.id}/clearance`,
},
@@ -422,6 +439,7 @@ export class BookingLifecycleNotifierService {
`The customer requested a change to the draft declaration on booking ${this.ref(b)}: ` +
`"${note}". Send a corrected draft from the clearance page.`;
this.inAppStaff(b, `Draft declaration change requested — ${this.ref(b)}`, msg, {
recipients: CLEARANCE_DESK,
type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/bookings/${b.id}/clearance`,
});
@@ -440,6 +458,7 @@ export class BookingLifecycleNotifierService {
'Payment slip uploaded',
`Customer uploaded the ${label} payment slip for booking ${this.ref(b)}.`,
{
recipients: CLEARANCE_DESK,
type: NotificationType.PAYMENT_RECEIVED,
link: `/dashboard/bookings/${b.id}/clearance`,
},

View File

@@ -1112,12 +1112,14 @@ export class BookingWagonCancellationService {
private notifyStaff(booking: Booking, title: string, body: string): void {
void this.inbox.notify({
recipients: { allBackoffice: true },
recipients: { permissionKeys: [FREIGHT_PERMS.bookings.getNotification] },
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.BOOKING_STATUS,
title,
body,
link: `/bookings/${booking.id}`,
// The portal path `/bookings/:id` used to be sent here, which 404s in the
// dashboard. The staff view of these lives on the queue page.
link: '/dashboard/wagon-cancellations',
data: { bookingId: booking.id, reference: booking.reference },
});
}

View File

@@ -424,7 +424,10 @@ export class BookingsService {
const departureStation = booking.originYard?.label ?? booking.originYard?.code ?? '-';
const arrivalStation = booking.destinationYard?.label ?? booking.destinationYard?.code ?? '-';
const cargoName = booking.cargoType?.cargoTypeName ?? booking.cargoFreeText ?? '-';
// Container bookings carry no cargo type or free text — name the freight type
// rather than printing a dash in the Cargo Name column.
const cargoName =
booking.cargoType?.cargoTypeName ?? booking.cargoFreeText ?? booking.freightType ?? '-';
const currency = booking.paymentCurrency ?? 'ETB';
const totalAmount = Number(booking.adjustedTotalAmount ?? booking.totalAmount) || 0;
const prices = this.splitAmountAcrossWagons(
@@ -467,6 +470,28 @@ export class BookingsService {
)
.join('');
// The totals belong in <tbody>, not <tfoot>: the Chromium-less fallback
// renderer only parses tbody rows, so a <tfoot> silently drops every footer
// figure from the printed sheet.
const totalsRow = `<tr class="totals">
<td>TOT</td>
<td>${wagons.length} ${pendingWagons ? 'received lines' : 'wagons'}</td>
<td>${
pendingWagons
? 'pending marshalling'
: `full ${fullWagons} / empty ${wagons.length - fullWagons}`
}</td>
<td class="num">${num(totals.tare, 2)}</td>
<td class="num">${num(totals.length)}</td>
<td class="num">${num(totals.capacity)}</td>
<td></td>
<td>Gross ${num(totals.tare + totals.load)} T</td>
<td></td>
<td></td>
<td></td>
<td class="num">${money(totalAmount)}</td>
</tr>`;
return `<!doctype html>
<html>
<head>
@@ -490,7 +515,7 @@ export class BookingsService {
th { background: #f8fafc; color: #475569; text-align: left; }
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
.num { text-align: right; }
tfoot td { background: #f8fafc; font-weight: 700; }
tr.totals td { background: #f8fafc; font-weight: 700; }
.notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; }
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; }
.line { border-top: 1px solid #334155; padding-top: 7px; font-size: 9px; color: #475569; min-height: 34px; }
@@ -538,21 +563,8 @@ export class BookingsService {
</thead>
<tbody>
${rows}
${totalsRow}
</tbody>
<tfoot>
<tr>
<td colspan="3">${
pendingWagons
? `Received lines: ${wagons.length} — wagons pending marshalling`
: `Total wagons: ${wagons.length} (full ${fullWagons} / empty ${wagons.length - fullWagons})`
}</td>
<td class="num">${num(totals.tare, 2)}</td>
<td class="num">${num(totals.length)}</td>
<td class="num">${num(totals.capacity)}</td>
<td colspan="5">Gross weight (tare + load): ${num(totals.tare + totals.load)} T</td>
<td class="num">${money(totalAmount)}</td>
</tr>
</tfoot>
</table>
<div class="notice">

View File

@@ -20,7 +20,14 @@ import { CargoesService } from './cargoes.service';
@ApiTags('cargoes')
@Controller('cargoes')
@FleetView(FREIGHT_PERMS.cargoes.view)
// Class gate lists every key its routes use: Nest runs class AND method
// guards, so a key missing here would deny before the route's own key runs.
@FleetView([
FREIGHT_PERMS.cargoes.view,
FREIGHT_PERMS.cargoes.create,
FREIGHT_PERMS.cargoes.update,
FREIGHT_PERMS.cargoes.delete,
])
export class CargoesController {
constructor(private readonly cargoesService: CargoesService) {}

View File

@@ -11,6 +11,7 @@ import { Company, CompanyStatus } from "./entities/company.entity";
import { NotificationsService } from "../notifications/notifications.service";
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
import { resolveCompanyNotifyPhone } from "../notifications/resolve-company-phone.util";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
/** Account statuses that lock the customer out and therefore must be told to them. */
const PUNITIVE_STATUSES: readonly CompanyStatus[] = [
@@ -175,12 +176,9 @@ export class CompanyNotifierService {
// ── Backoffice-facing: work has arrived back in the review queue ────────────
/**
* Persist + push an in-app item to every backoffice staff user, deep-linked to
* the customer's detail page.
*
* The recipient resolver has no role/permission targeting (see
* `notification-recipients.service.ts`) — `allBackoffice` is the narrowest
* selector available, so marketing is reached by notifying all staff.
* Persist + push an in-app item to the customer desk — staff holding
* `customers:get_notification` — deep-linked to the customer's detail page,
* which is itself gated on `customers:view`.
*/
private notifyStaff(
company: Company,
@@ -189,7 +187,7 @@ export class CompanyNotifierService {
data: Record<string, unknown> = {},
): void {
void this.inbox.notify({
recipients: { allBackoffice: true },
recipients: { permissionKeys: [FREIGHT_PERMS.customers.getNotification] },
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.REQUEST_SUBMITTED,
title,

View File

@@ -11,7 +11,12 @@ import { ComplianceType } from './entities/compliance-record.entity';
@ApiTags('Vehicle Compliance')
@Controller('compliance')
@BookingStaff(FREIGHT_PERMS.compliance.view)
// Class gate lists every key its routes use: Nest runs class AND method
// guards, so a key missing here would deny before the route's own key runs.
@BookingStaff([
FREIGHT_PERMS.compliance.view,
FREIGHT_PERMS.compliance.manage,
])
export class ComplianceController {
constructor(private readonly complianceService: ComplianceService) {}

View File

@@ -17,7 +17,12 @@ import { FilterConsignmentDto } from "./dto/filter-consignment.dto";
@ApiTags("consignments")
@Controller("consignments")
@FleetView(FREIGHT_PERMS.consignments.view)
// Class gate lists every key its routes use: Nest runs class AND method
// guards, so a key missing here would deny before the route's own key runs.
@FleetView([
FREIGHT_PERMS.consignments.view,
FREIGHT_PERMS.consignments.create,
])
export class ConsignmentsController {
constructor(private readonly consignmentsService: ConsignmentsService) {}

View File

@@ -19,7 +19,14 @@ import { ContainersService } from './containers.service';
@ApiTags('containers')
@Controller('containers')
@FleetView(FREIGHT_PERMS.containers.view)
// Class gate lists every key its routes use: Nest runs class AND method
// guards, so a key missing here would deny before the route's own key runs.
@FleetView([
FREIGHT_PERMS.containers.view,
FREIGHT_PERMS.containers.create,
FREIGHT_PERMS.containers.update,
FREIGHT_PERMS.containers.delete,
])
export class ContainersController {
constructor(private readonly containersService: ContainersService) {}

View File

@@ -1,5 +1,6 @@
import { ContractExpiryService } from './contract-expiry.service';
import type { Contract } from './entities/contract.entity';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
/**
* The reminder must warn each customer once, ten days out, and must never let a
@@ -60,4 +61,18 @@ describe('ContractExpiryService — expiry reminder', () => {
inbox.notify.mockRejectedValue(new Error('inbox down'));
await expect(service.remindExpiringContracts()).resolves.toBeUndefined();
});
// The sweep-failure alert is staff-facing. It used to go to every employee;
// it belongs to the people who would notice expired contracts still listed
// as active, i.e. the contract desk.
it('alerts the contract desk when the sweep itself fails', async () => {
repo.expireLapsedContracts.mockRejectedValue(new Error('deadlock'));
await service.expireLapsedContracts();
expect(inbox.notify).toHaveBeenCalledTimes(1);
expect(inbox.notify.mock.calls[0][0].recipients).toEqual({
permissionKeys: [FREIGHT_PERMS.contracts.getNotification],
});
});
});

View File

@@ -4,6 +4,7 @@ import { NotificationAudience, NotificationType } from '@edr/types';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { ContractsRepository } from './contracts.repository';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
/**
* How many days before a contract lapses the customer is reminded. Mirrored by
@@ -79,7 +80,11 @@ export class ContractExpiryService {
);
try {
await this.inbox.notify({
recipients: { allBackoffice: true },
// The people who would notice expired contracts still listed as
// active are the ones working the contract desk.
recipients: {
permissionKeys: [FREIGHT_PERMS.contracts.getNotification],
},
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.GENERIC,
title: 'Contract expiry sweep failed',

View File

@@ -11,6 +11,16 @@ import { Contract } from './entities/contract.entity';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
/**
* Clearance items are worked by the GL desks, which hold no intake keys — so
* they take their own selector rather than the contract desk's. Every override
* using this deep-links to a clearance or shipment-request page.
*/
const CLEARANCE_DESK = {
permissionKeys: [FREIGHT_PERMS.contracts.clearanceGetNotification],
};
/**
* Customer + staff notifications for the contract lifecycle. Every customer
@@ -86,7 +96,11 @@ export class ContractNotifierService {
});
}
/** Persist + push an in-app item to every backoffice staff user. */
/**
* Persist + push an in-app item to the contract desk — staff holding
* `contracts:get_notification`. Callers whose item belongs to a different
* desk override `recipients` (see {@link CLEARANCE_DESK}).
*/
private inAppStaff(
c: Contract,
title: string,
@@ -94,7 +108,7 @@ export class ContractNotifierService {
overrides: Partial<NotifyInput> = {},
): void {
void this.inbox.notify({
recipients: { allBackoffice: true },
recipients: { permissionKeys: [FREIGHT_PERMS.contracts.getNotification] },
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.REQUEST_SUBMITTED,
title,
@@ -230,6 +244,7 @@ export class ContractNotifierService {
`the customs declaration can be filed.${note ? ` Note: "${note}"` : ''}`;
this.logger.log(`TRANSIT ASSIGNEE REQUESTED — ${c.reference}`);
this.inAppStaff(c, `Transit assignee needed — ${c.reference}`, msg, {
recipients: CLEARANCE_DESK,
type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/gl-djibouti/clearance/${c.id}`,
});
@@ -248,6 +263,7 @@ export class ContractNotifierService {
`The customs declaration can now be filed.`;
this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${c.reference}`);
this.inAppStaff(c, `Transit assignee set — ${c.reference}`, msg, {
recipients: CLEARANCE_DESK,
type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/contracts/clearance/${c.id}`,
});
@@ -264,6 +280,7 @@ export class ContractNotifierService {
`"${note}". Review and re-advise the amount on the clearance page.`;
this.logger.log(`DUTY DISPUTED — ${c.reference}`);
this.inAppStaff(c, `Duty disputed on ${c.reference}`, msg, {
recipients: CLEARANCE_DESK,
type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/contracts/clearance/${c.id}`,
});
@@ -321,6 +338,7 @@ export class ContractNotifierService {
'Clearance documents uploaded',
`Customer uploaded clearance documents for contract ${this.ref(c)} — review them in the clearance queue.`,
{
recipients: CLEARANCE_DESK,
type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/contracts/clearance/${c.id}`,
},
@@ -334,6 +352,7 @@ export class ContractNotifierService {
'Duty slip uploaded',
`Customer uploaded the duty & tax payment slip for contract ${this.ref(c)}.`,
{
recipients: CLEARANCE_DESK,
type: NotificationType.PAYMENT_RECEIVED,
link: `/dashboard/contracts/clearance/${c.id}`,
},
@@ -347,6 +366,9 @@ export class ContractNotifierService {
'New shipment request',
`Shipment request ${requestRef} was filed under contract ${this.ref(c)} and awaits GL review.`,
{
// GL reviews these, and the shipment-requests page is gated on
// contracts:create_booking — a key only the GL Ethiopia preset holds.
recipients: CLEARANCE_DESK,
link: `/dashboard/shipment-requests/${requestId}`,
data: { contractId: c.id, requestId, reference: requestRef },
},

View File

@@ -24,7 +24,14 @@ import { FleetHistoryService } from '../fleet-history/fleet-history.service';
@ApiTags('drivers')
@ApiBearerAuth()
@Controller('drivers')
@BookingStaff(FREIGHT_PERMS.drivers.view)
// Class gate lists every key its routes use: Nest runs class AND method
// guards, so a key missing here would deny before the route's own key runs.
@BookingStaff([
FREIGHT_PERMS.drivers.view,
FREIGHT_PERMS.drivers.create,
FREIGHT_PERMS.drivers.update,
FREIGHT_PERMS.drivers.delete,
])
export class DriversController {
constructor(
private readonly driversService: DriversService,

View File

@@ -0,0 +1,25 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsBoolean, IsOptional, IsString, Length } from "class-validator";
/**
* Manual reconciliation of a submission that was never acknowledged. Exactly one of the two is
* meaningful: supply the IRN confirmed with MoR, or discard the attempt.
*/
export class ResolveEimsRegistrationDto {
@ApiPropertyOptional({
description: "IRN confirmed in the MoR portal. Records the registration and resumes the chain.",
example: "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0",
})
@IsOptional()
@IsString()
@Length(1, 64)
irn?: string;
@ApiPropertyOptional({
description: "Abandon the submission: the invoice is marked FAILED and the chain is unchanged.",
example: true,
})
@IsOptional()
@IsBoolean()
discard?: boolean;
}

View File

@@ -0,0 +1,256 @@
import { HttpService } from "@nestjs/axios";
import { ConfigService } from "@nestjs/config";
import { AxiosError, AxiosHeaders } from "axios";
import { of, throwError } from "rxjs";
import { EimsConfig } from "../../config/eims.config";
import { eimsConfig, eimsToken } from "./eims-test-fixtures";
import { EimsAuthService } from "./eims-auth.service";
import { EimsSignerService } from "./eims-signer.service";
const CLIENT_SECRET = "super-secret-value";
const API_KEY = "super-secret-apikey";
const cfg = (over: Partial<EimsConfig> = {}): EimsConfig => eimsConfig(over);
const TOKEN_1 = eimsToken({ jti: "one" });
const TOKEN_2 = eimsToken({ jti: "two" });
const loginBody = (accessToken: string, expiresIn = 3600) => ({
data: { accessToken, refreshToken: "refresh-1", encryptionKey: null, expiresIn },
status: "SUCCESS",
});
/** Stub signer: the real signing path has its own spec and needs no key material here. */
const signer = {
signRequest: <T>(request: T) => ({ request, signature: "SIGNATURE", certificate: "CERTIFICATE" }),
} as unknown as EimsSignerService;
const build = (post: jest.Mock, config: EimsConfig = cfg()) =>
new EimsAuthService(
{ post } as unknown as HttpService,
{ get: () => config } as unknown as ConfigService,
signer,
);
const axiosErr = (status: number, data: unknown) =>
new AxiosError("Request failed", undefined, undefined, undefined, {
status,
statusText: "",
data,
headers: new AxiosHeaders(),
config: { headers: new AxiosHeaders() },
});
describe("EimsAuthService.getValidAccessToken", () => {
it("posts the signed login envelope to /auth/login with no Authorization header", async () => {
const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) }));
await build(post).getValidAccessToken();
expect(post).toHaveBeenCalledTimes(1);
const [url, body, options] = post.mock.calls[0];
expect(url).toBe("https://core.mor.gov.et/auth/login");
expect(options.headers).toEqual({ "Content-Type": "application/json" });
expect(options.headers.Authorization).toBeUndefined();
expect(typeof body).toBe("string");
expect(JSON.parse(body)).toEqual({
request: { clientId: "cid", clientSecret: CLIENT_SECRET, apikey: API_KEY, tin: "0000034558" },
signature: "SIGNATURE",
certificate: "CERTIFICATE",
});
});
it("returns the access token from data.accessToken", async () => {
const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) }));
await expect(build(post).getValidAccessToken()).resolves.toBe(TOKEN_1);
});
it("reuses a cached token instead of logging in again", async () => {
const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) }));
const auth = build(post);
await auth.getValidAccessToken();
await expect(auth.getValidAccessToken()).resolves.toBe(TOKEN_1);
expect(post).toHaveBeenCalledTimes(1);
});
it("re-authenticates a skew-window before the token actually expires", async () => {
const post = jest
.fn()
.mockReturnValueOnce(of({ data: loginBody(TOKEN_1, 100) })) // 100s ttl, 45s skew ⇒ usable 55s
.mockReturnValueOnce(of({ data: loginBody(TOKEN_2) }));
const auth = build(post);
const start = Date.now();
const clock = jest.spyOn(Date, "now");
try {
clock.mockReturnValue(start);
await expect(auth.getValidAccessToken()).resolves.toBe(TOKEN_1);
clock.mockReturnValue(start + 50_000); // inside the window: still cached
await expect(auth.getValidAccessToken()).resolves.toBe(TOKEN_1);
expect(post).toHaveBeenCalledTimes(1);
clock.mockReturnValue(start + 56_000); // past ttl-minus-skew, before the real 100s expiry
await expect(auth.getValidAccessToken()).resolves.toBe(TOKEN_2);
expect(post).toHaveBeenCalledTimes(2);
} finally {
clock.mockRestore();
}
});
it("logs in again after invalidate()", async () => {
const post = jest
.fn()
.mockReturnValueOnce(of({ data: loginBody(TOKEN_1) }))
.mockReturnValueOnce(of({ data: loginBody(TOKEN_2) }));
const auth = build(post);
await auth.getValidAccessToken();
auth.invalidate();
await expect(auth.getValidAccessToken()).resolves.toBe(TOKEN_2);
expect(post).toHaveBeenCalledTimes(2);
});
it("performs exactly one login for many concurrent callers", async () => {
const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) }));
const auth = build(post);
const tokens = await Promise.all(Array.from({ length: 20 }, () => auth.getValidAccessToken()));
expect(post).toHaveBeenCalledTimes(1);
expect(new Set(tokens)).toEqual(new Set([TOKEN_1]));
});
it("does not put the access token in its own log line", async () => {
const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) }));
const logged: string[] = [];
const auth = build(post);
jest
.spyOn(auth["logger"], "log")
.mockImplementation((message: unknown) => void logged.push(String(message)));
await auth.getValidAccessToken();
expect(logged.join("\n")).not.toContain(TOKEN_1);
expect(logged.join("\n")).toContain("B0360154BA");
});
it("refuses to call the gateway when EIMS is disabled", async () => {
const post = jest.fn();
await expect(build(post, cfg({ enabled: false })).getValidAccessToken()).rejects.toThrow(
/EIMS integration is disabled/,
);
expect(post).not.toHaveBeenCalled();
});
it("rejects a 200 response that carries no access token", async () => {
const post = jest.fn().mockReturnValue(of({ data: { data: {}, status: "SUCCESS" } }));
await expect(build(post).getValidAccessToken()).rejects.toThrow(/returned no accessToken/);
});
it("surfaces gateway errors without leaking credentials or the envelope", async () => {
const post = jest.fn().mockReturnValue(
throwError(() =>
axiosErr(401, {
message: "GATEWAY ERROR",
statusCode: 401,
code: "4400",
details: [{ errorMessage: "Invalid Credentials" }],
// Fields the gateway must never echo back into our logs or exceptions:
signature: "SIGNATURE",
certificate: "CERTIFICATE",
accessToken: "leaked-token",
}),
),
);
const error = (await build(post)
.getValidAccessToken()
.catch((e: Error) => e)) as Error & { response?: unknown };
const serialized = JSON.stringify({ message: error.message, response: error.response });
expect(error.message).toContain("EIMS login failed (401)");
expect(error.message).toContain("Invalid Credentials");
for (const secret of [CLIENT_SECRET, API_KEY, "SIGNATURE", "CERTIFICATE", "leaked-token"]) {
expect(serialized).not.toContain(secret);
}
});
it("maps a timeout to a TIMEOUT failure without a status", async () => {
const timeout = new AxiosError("timeout of 30000ms exceeded", "ECONNABORTED");
const post = jest.fn().mockReturnValue(throwError(() => timeout));
await expect(build(post).getValidAccessToken()).rejects.toThrow(/EIMS login timed out/);
});
it("maps an unreachable gateway to a NETWORK failure", async () => {
const refused = new AxiosError("connect ECONNREFUSED", "ECONNREFUSED");
const post = jest.fn().mockReturnValue(throwError(() => refused));
await expect(build(post).getValidAccessToken()).rejects.toThrow(/could not reach the gateway/);
});
});
describe("EimsAuthService.getSessionContext", () => {
it("takes the source system from the token's claims", async () => {
const post = jest
.fn()
.mockReturnValue(
of({ data: loginBody(eimsToken({ systemNumber: "FROM-TOKEN", systemType: "POS" })) }),
);
// Env deliberately left empty: with nothing to check against, the token is simply believed.
await expect(
build(post, cfg({ systemNumber: "", systemType: "" })).getSessionContext(),
).resolves.toEqual({ systemNumber: "FROM-TOKEN", systemType: "POS" });
});
it("serves the session from the cached login rather than re-authenticating", async () => {
const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) }));
const auth = build(post);
await auth.getSessionContext();
await expect(auth.getSessionContext()).resolves.toEqual({
systemNumber: "B0360154BA",
systemType: "SYS",
});
expect(post).toHaveBeenCalledTimes(1);
});
it.each(["systemNumber", "systemType"])("rejects a token with no %s claim", async (claim) => {
const post = jest
.fn()
.mockReturnValue(of({ data: loginBody(eimsToken({ [claim]: undefined })) }));
await expect(
build(post, cfg({ systemNumber: "", systemType: "" })).getSessionContext(),
).rejects.toThrow(new RegExp(`no ${claim} claim`));
});
it("rejects an access token that is not a decodable JWT", async () => {
const post = jest.fn().mockReturnValue(of({ data: loginBody("not-a-jwt") }));
await expect(build(post).getSessionContext()).rejects.toThrow(/not a JWT/);
});
it.each([
["systemNumber", { systemNumber: "SOMETHING-ELSE" }, /EIMS_SYSTEM_NUMBER=B0360154BA/],
["systemType", { systemType: "POS" }, /EIMS_SYSTEM_TYPE=SYS/],
])("fails fast when the configured %s disagrees with the token", async (_name, over, pattern) => {
const post = jest.fn().mockReturnValue(of({ data: loginBody(eimsToken(over)) }));
// cfg() sets EIMS_SYSTEM_NUMBER=B0360154BA and EIMS_SYSTEM_TYPE=SYS as expectations.
await expect(build(post).getSessionContext()).rejects.toThrow(pattern);
});
it("accepts a configured value that matches the token", async () => {
const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) }));
await expect(build(post).getSessionContext()).resolves.toEqual({
systemNumber: "B0360154BA",
systemType: "SYS",
});
});
});

View File

@@ -0,0 +1,212 @@
import { HttpService } from "@nestjs/axios";
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { firstValueFrom } from "rxjs";
import { EimsConfig } from "../../config/eims.config";
import { EimsSignerService, toSignedBody } from "./eims-signer.service";
import { EimsApiException, EimsConfigException, toEimsApiException } from "./eims.errors";
import { EimsLoginRequest, EimsLoginResponse } from "./eims.types";
interface TokenCache {
accessToken: string;
/** Epoch ms, already reduced by the configured skew. */
expiresAt: number;
session: EimsSessionContext;
}
/**
* Source-system identity, taken from the access token MoR issues us.
*
* The gateway stamps `systemNumber` and `systemType` into the token for the credentials that
* authenticated, which makes the token the authority on them — not our environment file. Anything
* we configured locally can only ever disagree with what MoR believes.
*/
export interface EimsSessionContext {
systemNumber: string;
systemType: string;
}
/** Decode a JWT payload without verifying it: this is MoR's token, signed with MoR's key. */
function decodeTokenClaims(accessToken: string): Record<string, unknown> {
const payload = accessToken.split(".")[1];
if (!payload) {
throw new EimsApiException("UNKNOWN", "EIMS access token is not a JWT (no payload segment)");
}
try {
return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as Record<string, unknown>;
} catch (err) {
// The token itself is never included — only that its payload would not parse.
throw new EimsApiException(
"UNKNOWN",
`EIMS access token payload could not be decoded: ${(err as Error).message}`,
);
}
}
const claimString = (claims: Record<string, unknown>, name: string): string => {
const value = claims[name];
return typeof value === "string" ? value.trim() : "";
};
/** Used when the gateway omits `expiresIn`; the observed value is 3600. */
const FALLBACK_EXPIRES_IN_SECONDS = 3600;
/**
* EIMS authentication: signed `POST /auth/login`, plus an in-memory access-token cache.
*
* Login is the one EIMS call that carries no bearer token, which is why it lives here rather than
* in the generic client. Tokens are held in memory only — never persisted, never logged, never
* returned to a frontend.
*/
@Injectable()
export class EimsAuthService {
private readonly logger = new Logger(EimsAuthService.name);
private cache: TokenCache | null = null;
private loginInFlight: Promise<string> | null = null;
constructor(
private readonly http: HttpService,
private readonly config: ConfigService,
private readonly signer: EimsSignerService,
) {}
private get cfg(): EimsConfig {
return this.config.get<EimsConfig>("eims")!;
}
/**
* A non-expired access token, logging in if needed. Concurrent callers share one login: the
* first caller stores the in-flight promise and everyone else awaits it.
*/
async getValidAccessToken(): Promise<string> {
if (this.cache && Date.now() < this.cache.expiresAt) {
return this.cache.accessToken;
}
if (this.loginInFlight) return this.loginInFlight;
this.loginInFlight = this.login();
try {
return await this.loginInFlight;
} finally {
this.loginInFlight = null;
}
}
/**
* The source-system identity MoR issued this session, refreshing the login if needed.
*
* This is the authority for `SourceSystem.SystemNumber` / `SystemType`: the gateway stamps both
* into the access token for the authenticating credentials, so a local env value could only ever
* disagree with it.
*/
async getSessionContext(): Promise<EimsSessionContext> {
await this.getValidAccessToken();
return this.cache!.session;
}
/** Drop the cached token — called after a 401 so the next request re-authenticates. */
invalidate(): void {
this.cache = null;
}
/**
* Read the source-system claims out of the token, and cross-check anything configured locally.
*
* `EIMS_SYSTEM_NUMBER` / `EIMS_SYSTEM_TYPE` are optional expectations, not inputs: when set they
* are compared and a mismatch fails immediately rather than one silently winning. Registering
* under the wrong source system is not something to discover from a rejected invoice.
*/
private readSessionContext(accessToken: string, cfg: EimsConfig): EimsSessionContext {
const claims = decodeTokenClaims(accessToken);
const systemNumber = claimString(claims, "systemNumber");
const systemType = claimString(claims, "systemType");
const missing = [
!systemNumber && "systemNumber",
!systemType && "systemType",
].filter(Boolean);
if (missing.length > 0) {
throw new EimsApiException(
"UNKNOWN",
`EIMS access token carries no ${missing.join(" or ")} claim; cannot identify the source system`,
);
}
const mismatches = [
cfg.systemNumber && cfg.systemNumber !== systemNumber
? `EIMS_SYSTEM_NUMBER=${cfg.systemNumber} but the token says ${systemNumber}`
: null,
cfg.systemType && cfg.systemType !== systemType
? `EIMS_SYSTEM_TYPE=${cfg.systemType} but the token says ${systemType}`
: null,
].filter(Boolean);
if (mismatches.length > 0) {
throw new EimsConfigException(
`EIMS source-system configuration disagrees with the issued token: ${mismatches.join("; ")}. ` +
"Correct the environment or the credentials — neither value is assumed to win.",
);
}
return { systemNumber, systemType };
}
private async login(): Promise<string> {
const cfg = this.cfg;
if (!cfg.enabled) {
throw new EimsConfigException("EIMS integration is disabled; set EIMS_ENABLED=true to use it");
}
const request: EimsLoginRequest = {
clientId: cfg.clientId,
clientSecret: cfg.clientSecret,
apikey: cfg.apiKey,
tin: cfg.tin,
};
const body = toSignedBody(this.signer.signRequest(request));
let response: EimsLoginResponse;
try {
const res = await firstValueFrom(
this.http.post<EimsLoginResponse>(`${cfg.baseUrl}/auth/login`, body, {
headers: { "Content-Type": "application/json" },
timeout: cfg.httpTimeoutMs,
}),
);
response = res.data;
} catch (err) {
const mapped = toEimsApiException(err, "login");
this.logger.error(mapped.message);
throw mapped;
}
const accessToken = response?.data?.accessToken;
if (!accessToken) {
throw new EimsApiException("UNKNOWN", "EIMS login returned no accessToken");
}
const expiresIn =
Number.isFinite(response.data.expiresIn) && response.data.expiresIn > 0
? response.data.expiresIn
: FALLBACK_EXPIRES_IN_SECONDS;
// TODO: implement `POST /auth/refresh-token` and hold `response.data.refreshToken`. The
// collection shows a bare `{refreshToken}` body with no envelope, but it also carries unsigned
// examples of calls that do require signing, so whether refresh must be signed is unconfirmed.
// Until MoR confirms it, an expired token just triggers a fresh login — `expiresIn` is 3600s,
// so that is one extra call an hour.
// Reject the session before caching it: a token we cannot identify a source system from is
// useless for registration, and a configured expectation that disagrees is a deployment fault.
const session = this.readSessionContext(accessToken, cfg);
this.cache = {
accessToken,
expiresAt: Date.now() + Math.max(expiresIn * 1000 - cfg.tokenSkewMs, 1000),
session,
};
this.logger.log(
`EIMS login succeeded; token cached for ~${expiresIn}s ` +
`(system ${session.systemNumber}, type ${session.systemType})`,
);
return accessToken;
}
}

View File

@@ -0,0 +1,139 @@
import { ConfigService } from "@nestjs/config";
import { DataSource } from "typeorm";
import { EimsConfig } from "../../config/eims.config";
import { EimsAutoSubmitService } from "./eims-auto-submit.service";
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
import { EimsInvoiceStatus } from "./eims-registration.types";
import { eimsConfig } from "./eims-test-fixtures";
const INVOICE_ID = "11111111-1111-4111-8111-111111111111";
/**
* `query` is answered by shape: the first call is the system-state guard, the second is the
* candidate lookup. Keeps the fake honest about the order the service actually asks in.
*/
const build = (
opts: {
cfg?: Partial<EimsConfig>;
state?: { in_flight_invoice_id?: string | null; blocked_reason?: string | null };
candidate?: { id: string; invoiceNumber: string } | null;
register?: jest.Mock;
} = {},
) => {
const register =
opts.register ??
jest.fn().mockResolvedValue({ eimsStatus: EimsInvoiceStatus.Registered, eimsIrn: "IRN-1" });
const query = jest.fn().mockImplementation((sql: string) => {
if (sql.includes("eims_system_state")) {
return Promise.resolve(
opts.state ? [{ in_flight_invoice_id: null, blocked_reason: null, ...opts.state }] : [],
);
}
return Promise.resolve(opts.candidate === undefined ? [] : opts.candidate ? [opts.candidate] : []);
});
const service = new EimsAutoSubmitService(
{ query } as unknown as DataSource,
{ get: () => eimsConfig({ autoSubmit: true, ...opts.cfg }) } as unknown as ConfigService,
{ registerInvoiceWithEims: register } as unknown as EimsInvoiceRegistrationService,
);
return { service, register, query };
};
const candidate = { id: INVOICE_ID, invoiceNumber: "INV-20260807-00006" };
describe("EimsAutoSubmitService.tick", () => {
it("files the oldest eligible invoice through the registration service", async () => {
const { service, register } = build({ candidate });
await service.tick();
expect(register).toHaveBeenCalledTimes(1);
expect(register).toHaveBeenCalledWith(INVOICE_ID);
});
it("files nothing when EIMS_AUTO_SUBMIT is off", async () => {
const { service, register, query } = build({ cfg: { autoSubmit: false }, candidate });
await service.tick();
expect(register).not.toHaveBeenCalled();
expect(query).not.toHaveBeenCalled();
});
it("files nothing when EIMS itself is disabled, even with auto-submit on", async () => {
const { service, register, query } = build({ cfg: { enabled: false }, candidate });
await service.tick();
expect(register).not.toHaveBeenCalled();
expect(query).not.toHaveBeenCalled();
});
it("does not submit while another submission is in flight", async () => {
const { service, register } = build({
state: { in_flight_invoice_id: "22222222-2222-4222-8222-222222222222" },
candidate,
});
await service.tick();
expect(register).not.toHaveBeenCalled();
});
it("does not submit while the system number is blocked", async () => {
const { service, register } = build({
state: { blocked_reason: "never acknowledged" },
candidate,
});
await service.tick();
expect(register).not.toHaveBeenCalled();
});
it("does nothing when no invoice is eligible", async () => {
const { service, register } = build({ candidate: null });
await service.tick();
expect(register).not.toHaveBeenCalled();
});
it("asks only for NOT_SUBMITTED invoices, so UNKNOWN and FAILED are never retried", async () => {
const { service, query } = build({ candidate });
await service.tick();
const [sql, params] = query.mock.calls.find(([s]: [string]) => s.includes("freight.invoices"))!;
expect(sql).toContain("i.eims_status = $1");
expect(params[0]).toBe(EimsInvoiceStatus.NotSubmitted);
expect(sql).toContain("i.issued_at IS NOT NULL");
});
it("survives a filing failure so the job keeps running", async () => {
const register = jest.fn().mockRejectedValue(new Error("EIMS register failed (406)"));
const { service } = build({ candidate, register });
await expect(service.tick()).resolves.toBeUndefined();
expect(register).toHaveBeenCalledTimes(1);
});
it("does not start a second tick while one is still filing", async () => {
let release: () => void = () => {};
const register = jest.fn().mockImplementation(
() => new Promise((resolve) => (release = () => resolve({ eimsStatus: "REGISTERED" }))),
);
const { service } = build({ candidate, register });
const first = service.tick();
await new Promise((r) => setImmediate(r));
await service.tick(); // overlapping tick, must be a no-op
expect(register).toHaveBeenCalledTimes(1);
release();
await first;
});
});

View File

@@ -0,0 +1,126 @@
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Cron } from "@nestjs/schedule";
import { InjectDataSource } from "@nestjs/typeorm";
import { DataSource } from "typeorm";
import { EimsConfig } from "../../config/eims.config";
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
import { EimsInvoiceStatus } from "./eims-registration.types";
/**
* Files issued invoices with MoR EIMS on a timer.
*
* Invoices are produced by the freight workflow rather than by a person, so this — not the manual
* endpoint — is the production path. It is a sweep rather than a hook on the eleven places an
* invoice can be created or issued, which buys three things: the workflow is untouched, the HTTP
* call is by construction outside the invoice's transaction, and an invoice missed through a crash
* or a restart is picked up on the next tick.
*
* `invoices.eims_status` is the queue — nothing new is persisted. Only `NOT_SUBMITTED` is eligible:
* `UNKNOWN` must never be retried automatically (the document may already be filed), and `FAILED`
* waits for an explicit retry policy rather than a timer's guess.
*
* Off unless **both** `EIMS_ENABLED` and `EIMS_AUTO_SUBMIT` are true. Enabling it starts filing
* real documents with the tax authority, and a registration cannot be undone from this side.
*/
@Injectable()
export class EimsAutoSubmitService {
private readonly logger = new Logger(EimsAutoSubmitService.name);
/** Guards against a tick starting while the previous one is still filing. */
private running = false;
constructor(
@InjectDataSource() private readonly dataSource: DataSource,
private readonly config: ConfigService,
private readonly registration: EimsInvoiceRegistrationService,
) {}
private get cfg(): EimsConfig {
return this.config.get<EimsConfig>("eims")!;
}
/**
* One invoice per tick.
*
* Deliberately not a batch: each filing consumes a counter and advances the IRN chain, an
* ambiguous result blocks the system number until a human resolves it, and a misconfiguration
* should cost one rejected document rather than a burst of them.
*/
@Cron(process.env.EIMS_AUTO_SUBMIT_CRON ?? "0 */5 * * * *", { name: "eims-auto-submit" })
async tick(): Promise<void> {
const cfg = this.cfg;
if (!cfg.enabled || !cfg.autoSubmit) return;
if (this.running) return;
this.running = true;
try {
// Rule of the chain: nothing may be filed while a submission is in flight or the system is
// blocked. The reservation would refuse anyway — checking first keeps the log quiet and
// avoids burning a tick on a guaranteed conflict.
const blocked = await this.systemBlockReason();
if (blocked) {
this.logger.warn(`EIMS auto-submit paused: ${blocked}`);
return;
}
const candidate = await this.nextCandidate();
if (!candidate) return;
const view = await this.registration.registerInvoiceWithEims(candidate.id);
this.logger.log(
`EIMS auto-submit: invoice ${candidate.invoiceNumber} -> ${view.eimsStatus}` +
(view.eimsIrn ? ` (IRN ${view.eimsIrn})` : ""),
);
} catch (err) {
// Never let a filing failure kill the job. The outcome is already persisted on the invoice
// (FAILED or UNKNOWN with the gateway's own message), and a blocked system number stops the
// next tick at the guard above.
this.logger.error(`EIMS auto-submit tick failed: ${(err as Error).message}`);
} finally {
this.running = false;
}
}
/** Why filing is currently impossible for this system number, or null when it is free. */
private async systemBlockReason(): Promise<string | null> {
const rows: { in_flight_invoice_id: string | null; blocked_reason: string | null }[] =
await this.dataSource.query(
`SELECT in_flight_invoice_id, blocked_reason
FROM freight.eims_system_state
WHERE system_number = $1 AND deleted_at IS NULL
LIMIT 1`,
[this.cfg.systemNumber],
);
const state = rows[0];
if (!state) return null;
if (state.blocked_reason) return state.blocked_reason;
if (state.in_flight_invoice_id) {
return `a submission for invoice ${state.in_flight_invoice_id} is still in flight`;
}
return null;
}
/**
* Oldest never-submitted invoice that is issued, still inside MoR's document-age window, and
* carries at least one line.
*/
private async nextCandidate(): Promise<{ id: string; invoiceNumber: string } | null> {
const rows: { id: string; invoiceNumber: string }[] = await this.dataSource.query(
`SELECT i.id, i.invoice_number AS "invoiceNumber"
FROM freight.invoices i
WHERE i.eims_status = $1
AND i.issued_at IS NOT NULL
AND i.deleted_at IS NULL
AND i.issued_at > now() - ($2 || ' days')::interval
AND EXISTS (
SELECT 1 FROM freight.invoice_lines l
WHERE l.invoice_id = i.id AND l.deleted_at IS NULL
)
ORDER BY i.issued_at ASC
LIMIT 1`,
[EimsInvoiceStatus.NotSubmitted, this.cfg.autoSubmitMaxAgeDays],
);
return rows[0] ?? null;
}
}

View File

@@ -0,0 +1,80 @@
import { HttpService } from "@nestjs/axios";
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { firstValueFrom } from "rxjs";
import { EimsConfig } from "../../config/eims.config";
import { EimsAuthService } from "./eims-auth.service";
import { EimsSignerService, toSignedBody } from "./eims-signer.service";
import { toEimsApiException } from "./eims.errors";
/**
* Foundation for EIMS's bearer-authenticated endpoints (`/v1/register`, `/v1/verify`, …).
*
* Login is not routed through here: `/auth/login` carries no bearer token and lives in
* `EimsAuthService`. Nothing calls `postSigned` yet — invoice registration is a later phase.
*/
@Injectable()
export class EimsClientService {
private readonly logger = new Logger(EimsClientService.name);
constructor(
private readonly http: HttpService,
private readonly config: ConfigService,
private readonly auth: EimsAuthService,
private readonly signer: EimsSignerService,
) {}
private get cfg(): EimsConfig {
return this.config.get<EimsConfig>("eims")!;
}
/**
* Sign `request`, POST it to `path` with a valid bearer token, and return the parsed response.
* A 401 invalidates the cached token and retries exactly once.
*/
async postSigned<TRequest, TResponse>(path: string, request: TRequest): Promise<TResponse> {
return this.send<TRequest, TResponse>(path, request, false, true);
}
/**
* POST `request` verbatim — bearer-authenticated but **not** wrapped in a signed envelope.
*
* `/v1/verify` is the only endpoint observed to work this way: the supplied collection sends a
* raw `{"irn":"…"}` body with no `signature`/`certificate` siblings. Kept as its own entry point
* so that if the live gateway turns out to require signing after all, exactly one call site
* changes — `postSigned` is already the alternative.
*/
async postBearer<TRequest, TResponse>(path: string, request: TRequest): Promise<TResponse> {
return this.send<TRequest, TResponse>(path, request, false, false);
}
private async send<TRequest, TResponse>(
path: string,
request: TRequest,
isRetry: boolean,
signed: boolean,
): Promise<TResponse> {
const cfg = this.cfg;
const token = await this.auth.getValidAccessToken();
const body = signed ? toSignedBody(this.signer.signRequest(request)) : request;
try {
const res = await firstValueFrom(
this.http.post<TResponse>(`${cfg.baseUrl}${path}`, body, {
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
timeout: cfg.httpTimeoutMs,
}),
);
return res.data;
} catch (err) {
const mapped = toEimsApiException(err, `POST ${path}`);
if (mapped.kind === "AUTH" && !isRetry) {
this.logger.warn(`EIMS rejected the token on ${path}; re-authenticating once`);
this.auth.invalidate();
return this.send<TRequest, TResponse>(path, request, true, signed);
}
this.logger.error(mapped.message);
throw mapped;
}
}
}

View File

@@ -0,0 +1,77 @@
import { readFileSync } from "node:fs";
import { KeyObject, createPrivateKey } from "node:crypto";
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { EimsConfig } from "../../config/eims.config";
import { EimsConfigException } from "./eims.errors";
/**
* Loads the INSA-issued EIMS credentials from disk, once, and keeps them in memory.
*
* The certificate is sent as base64 of the **exact bytes of the issued file** — it is deliberately
* never parsed, re-encoded or re-exported, because that is what produced a working live login.
* The private key never leaves this process: it is only ever used to produce a signature.
*/
@Injectable()
export class EimsCredentialsProvider {
private readonly logger = new Logger(EimsCredentialsProvider.name);
private privateKey: KeyObject | null = null;
private certificateBase64: string | null = null;
constructor(private readonly config: ConfigService) {}
private get cfg(): EimsConfig {
return this.config.get<EimsConfig>("eims")!;
}
/** RSA private key, parsed once. Throws a config error if the path is missing or unusable. */
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");
let key: KeyObject;
try {
key = createPrivateKey(readFileSync(path));
} catch (err) {
// The path 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}`,
);
}
if (key.asymmetricKeyType !== "rsa") {
throw new EimsConfigException(
`EIMS private key at ${path} is ${key.asymmetricKeyType ?? "of unknown type"}; EIMS requires RSA`,
);
}
this.privateKey = key;
this.logger.log(`EIMS private key loaded (RSA-${key.asymmetricKeyDetails?.modulusLength ?? "?"})`);
return key;
}
/** Base64 of the certificate file's exact bytes. No parsing, no re-encoding. */
getCertificateBase64(): string {
if (this.certificateBase64) return this.certificateBase64;
const path = this.cfg.certificatePath;
if (!path) throw new EimsConfigException("EIMS_CERTIFICATE_PATH is not set");
let bytes: Buffer;
try {
bytes = readFileSync(path);
} catch (err) {
throw new EimsConfigException(
`EIMS certificate at ${path} could not be read: ${(err as Error).message}`,
);
}
if (bytes.length === 0) {
throw new EimsConfigException(`EIMS certificate at ${path} is empty`);
}
this.certificateBase64 = bytes.toString("base64");
this.logger.log(`EIMS certificate bundle loaded (${bytes.length} bytes)`);
return this.certificateBase64;
}
}

View File

@@ -0,0 +1,117 @@
import { BadRequestException } from "@nestjs/common";
import { EimsConfig } from "../../config/eims.config";
import { EimsSessionContext } from "./eims-auth.service";
import {
EimsMapperContext,
EimsMapperLine,
EimsSellerDetails,
} from "../billing/eims-invoice.mapper";
/**
* Turns configuration into the seller identity and mapper context that `toEimsInvoice` requires.
*
* Everything here is unavailable from the database by construction: EDR's own legal identity is not
* modelled anywhere, and the application has no tax model at all (`invoice.taxAmount` is always 0,
* `invoice_lines` and the rate catalogue carry no fiscal columns). Rather than defaulting any of it,
* a missing value fails **here** — locally, before a single byte reaches the gateway — naming the
* exact environment variables to set.
*/
interface RequiredSpec {
env: string;
value: string | number | null | undefined;
}
// `systemNumber` / `systemType` are absent by design: they come from the access token, which is
// MoR's own statement of who we are. See EimsAuthService.getSessionContext.
const REQUIRED = (invoice: EimsConfig["invoice"], tin: string): RequiredSpec[] => [
{ env: "EIMS_TIN", value: tin },
{ env: "EIMS_SELLER_LEGAL_NAME", value: invoice.sellerLegalName },
{ env: "EIMS_SELLER_VAT_NUMBER", value: invoice.sellerVatNumber },
{ env: "EIMS_SELLER_PHONE", value: invoice.sellerPhone },
{ env: "EIMS_SELLER_EMAIL", value: invoice.sellerEmail },
{ env: "EIMS_SELLER_REGION", value: invoice.sellerRegion },
{ env: "EIMS_SELLER_WEREDA", value: invoice.sellerWereda },
{ env: "EIMS_TAX_CODE", value: invoice.taxCode },
{ env: "EIMS_TAX_RATE_PERCENT", value: invoice.taxRatePercent },
{ env: "EIMS_INCOME_WITHHOLD_VALUE", value: invoice.incomeWithholdValue },
{ env: "EIMS_TRANSACTION_WITHHOLD_VALUE", value: invoice.transactionWithholdValue },
{ env: "EIMS_TRANSACTION_TYPE", value: invoice.transactionType },
{ env: "EIMS_NATURE_OF_SUPPLIES", value: invoice.natureOfSupplies },
{ env: "EIMS_PAYMENT_MODE", value: invoice.paymentMode },
{ env: "EIMS_PAYMENT_TERM", value: invoice.paymentTerm },
{ env: "EIMS_UNIT_DEFAULT", value: invoice.unitDefault },
];
/** Throws naming every unset variable at once, so one round trip fixes the whole configuration. */
export function assertEimsInvoiceConfig(config: EimsConfig): void {
const missing = REQUIRED(config.invoice, config.tin)
.filter(({ value }) => value === null || value === undefined || value === "")
.map(({ env }) => env);
if (missing.length > 0) {
throw new BadRequestException({
code: "EIMS_INVOICE_CONFIG_INCOMPLETE",
message:
"EIMS invoice registration is not configured. Set these environment variables " +
`(tax values need finance sign-off — they are deliberately not defaulted): ${missing.join(", ")}`,
});
}
}
export function buildEimsSeller(config: EimsConfig): EimsSellerDetails {
const { invoice } = config;
return {
City: invoice.sellerCity,
Email: invoice.sellerEmail,
HouseNumber: invoice.sellerHouseNumber,
LegalName: invoice.sellerLegalName,
Locality: invoice.sellerLocality,
Phone: invoice.sellerPhone,
Region: invoice.sellerRegion,
SubCity: invoice.sellerSubCity,
Tin: config.tin,
VatNumber: invoice.sellerVatNumber,
Wereda: invoice.sellerWereda,
};
}
export interface EimsContextInput {
/** `DocumentDetails.DocumentNumber`. The caller decides its source. */
documentNumber: string;
invoiceCounter: number;
previousIrn: string | null;
/** Source-system identity from the access token, never from configuration. */
session: EimsSessionContext;
/** Required when the invoice currency is not ETB. */
exchangeRate?: number | null;
}
export function buildEimsContext(config: EimsConfig, input: EimsContextInput): EimsMapperContext {
const { invoice } = config;
// Validated by assertEimsInvoiceConfig; the non-null assertions below are safe after that call.
const taxCode = invoice.taxCode;
const ratePercent = invoice.taxRatePercent!;
const exciseTaxValue = invoice.exciseTaxValue ?? 0;
return {
systemNumber: input.session.systemNumber,
systemType: input.session.systemType,
documentNumber: input.documentNumber,
invoiceCounter: input.invoiceCounter,
previousIrn: input.previousIrn,
cashierName: invoice.cashierName,
salesPersonName: invoice.salesPersonName,
transactionType: invoice.transactionType,
payment: { mode: invoice.paymentMode, term: invoice.paymentTerm },
// One treatment for every line today. The mapper resolves tax per line, so a future
// charge-type-specific rule slots in here without touching the mapper.
taxForLine: (_line: EimsMapperLine) => ({ code: taxCode, ratePercent, exciseTaxValue }),
natureOfSupplies: invoice.natureOfSupplies,
unitDefault: invoice.unitDefault,
incomeWithholdValue: invoice.incomeWithholdValue!,
transactionWithholdValue: invoice.transactionWithholdValue!,
buyerCountryCode: invoice.buyerCountryCode,
exchangeRate: input.exchangeRate ?? null,
};
}

View File

@@ -0,0 +1,565 @@
import { BadRequestException, ConflictException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { DataSource } from "typeorm";
import { EimsConfig } from "../../config/eims.config";
import { Invoice } from "../billing/entities/invoice.entity";
import { EimsInvoiceRequest } from "../billing/eims-invoice.mapper";
import { eimsInvoiceConfig } from "./eims-test-fixtures";
import { EimsAuthService } from "./eims-auth.service";
import { EimsClientService } from "./eims-client.service";
import { EimsApiException } from "./eims.errors";
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
import { EimsSystemState } from "./entities/eims-system-state.entity";
import { EimsInvoiceStatus } from "./eims-registration.types";
const SYSTEM_NUMBER = "B0360154BA";
const INVOICE_ID = "11111111-1111-4111-8111-111111111111";
const OTHER_INVOICE_ID = "22222222-2222-4222-8222-222222222222";
const IRN = "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0";
const config = (over: Partial<EimsConfig["invoice"]> = {}): EimsConfig =>
({
enabled: true,
baseUrl: "https://core.mor.gov.et",
clientId: "cid",
clientSecret: "secret",
apiKey: "key",
tin: "0000034558",
systemNumber: SYSTEM_NUMBER,
systemType: "SYS",
privateKeyPath: "/dev/null",
certificatePath: "/dev/null",
httpTimeoutMs: 30_000,
tokenSkewMs: 45_000,
invoice: eimsInvoiceConfig(over),
}) as EimsConfig;
const invoiceRow = (over: Partial<Invoice> = {}): Invoice =>
({
id: INVOICE_ID,
invoiceNumber: "INV-20260807-00042",
currency: "ETB",
issuedAt: new Date(2026, 7, 7, 9, 5, 3),
totalAmount: "10000.00",
eimsStatus: EimsInvoiceStatus.NotSubmitted,
eimsIrn: null,
eimsInvoiceCounter: null,
eimsSubmittedAt: null,
eimsAckDate: null,
eimsLastError: null,
company: {
name: "ABC Trading PLC",
tin: "0999930000",
vatNumber: "123475885858",
phone: "0912345678",
email: "buyer@abc.et",
region: "13",
zone: "SHA",
woreda: "574",
kebele: "03",
houseNo: "NEW",
country: "Ethiopia",
},
...over,
}) as unknown as Invoice;
const LINES = [
{
chargeType: "RAIL_FREIGHT",
description: "Addis to Djibouti",
quantity: "1.00",
unitRate: "10000.00",
amount: "10000.00",
},
];
/**
* In-memory stand-in for the two locked rows. `update` merges, `createQueryBuilder(...).getOne()`
* returns the live object — enough to assert ordering, values and the reservation lifecycle without
* a database.
*/
class FakeDb {
invoices = new Map<string, Invoice>();
state: EimsSystemState | null = null;
/** Runs before every transaction body, to simulate a concurrent writer. */
onTransaction: (() => void) | null = null;
constructor(invoices: Invoice[], state?: Partial<EimsSystemState>) {
for (const inv of invoices) this.invoices.set(inv.id, inv);
this.state = {
id: "state-1",
systemNumber: SYSTEM_NUMBER,
nextInvoiceCounter: 7,
previousIrn: null,
inFlightInvoiceId: null,
inFlightCounter: null,
blockedReason: null,
...state,
} as EimsSystemState;
}
private manager = {
createQueryBuilder: (entity: unknown) => {
const isInvoice = entity === Invoice;
let id: string | undefined;
const builder = {
setLock: () => builder,
where: (_clause: string, params: Record<string, string>) => {
id = params.invoiceId ?? params.systemNumber;
return builder;
},
getOne: async () => (isInvoice ? (this.invoices.get(id!) ?? null) : this.state),
};
return builder;
},
findOne: async (_entity: unknown, options: { where: { id: string } }) =>
this.invoices.get(options.where.id) ?? null,
update: async (entity: unknown, id: string, patch: Record<string, unknown>) => {
if (entity === Invoice) Object.assign(this.invoices.get(id)!, patch);
else Object.assign(this.state!, patch);
},
query: async () => [],
getRepository: () => ({
findOne: async (options: { where: { id: string } }) =>
this.invoices.get(options.where.id) ?? null,
}),
};
asDataSource(): DataSource {
return {
manager: this.manager,
getRepository: this.manager.getRepository,
query: async () => LINES,
transaction: async (body: (m: unknown) => Promise<unknown>) => {
this.onTransaction?.();
return body(this.manager);
},
} as unknown as DataSource;
}
}
/** The source system comes from the access token, so the service is handed a session, not config. */
const SESSION = { systemNumber: SYSTEM_NUMBER, systemType: "SYS" };
const build = (
db: FakeDb,
postSigned: jest.Mock,
cfg: EimsConfig = config(),
postBearer: jest.Mock = jest.fn(),
getSessionContext: jest.Mock = jest.fn().mockResolvedValue(SESSION),
) =>
new EimsInvoiceRegistrationService(
db.asDataSource(),
{ get: () => cfg } as unknown as ConfigService,
{ postSigned, postBearer } as unknown as EimsClientService,
{ getSessionContext } as unknown as EimsAuthService,
);
/** Document number the fixtures register under; `/v1/verify` must echo it back. */
const DOCUMENT_NUMBER = "INV-20260807-00042";
/**
* `/v1/verify` success. The response spells the reference `Irn` while the request sends lowercase
* `irn`.
*
* The fixture is deliberately *coherent* — same IRN on both sides. The supplied Postman collection
* pairs a saved request and a saved response whose literal IRNs disagree, which is an artefact of
* the mock rather than gateway behaviour; asserting against that inconsistency would encode the
* mock's bug as a requirement. Resolution requires the returned `Irn` to match the one asked for,
* and these fixtures exercise that honestly.
*/
const verifyResponse = (over: Record<string, unknown> = {}) => ({
statusCode: 200,
message: "SUCCESS",
body: {
Irn: IRN,
TransactionType: "B2B",
DocumentDetails: { Type: "INV", DocumentNumber: DOCUMENT_NUMBER, Date: "07-08-2026T09:05:03" },
Version: "1",
...over,
},
});
const okResponse = (irn = IRN) =>
({ statusCode: 200, message: "SUCCESS", body: { irn, ackDate: "2026-08-07T09:05:03Z[Etc/UTC]" } });
const apiError = (kind: string, status?: number) =>
new EimsApiException(kind as never, `EIMS register failed (${status ?? "-"})`, status);
describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
it("registers, persists the IRN and advances the chain", async () => {
const db = new FakeDb([invoiceRow()]);
const postSigned = jest.fn().mockResolvedValue(okResponse());
const view = await build(db, postSigned).registerInvoiceWithEims(INVOICE_ID);
expect(postSigned).toHaveBeenCalledTimes(1);
expect(postSigned.mock.calls[0][0]).toBe("/v1/register");
expect(view).toMatchObject({
eimsStatus: EimsInvoiceStatus.Registered,
eimsIrn: IRN,
eimsInvoiceCounter: 7,
eimsAckDate: "2026-08-07T09:05:03Z[Etc/UTC]",
});
expect(db.state).toMatchObject({
previousIrn: IRN,
nextInvoiceCounter: 8,
inFlightInvoiceId: null,
inFlightCounter: null,
blockedReason: null,
});
});
it("sends the exact reserved counter and previous IRN to the mapper", async () => {
const db = new FakeDb([invoiceRow()], { nextInvoiceCounter: 42, previousIrn: "PRIOR-IRN" });
const postSigned = jest.fn().mockResolvedValue(okResponse());
await build(db, postSigned).registerInvoiceWithEims(INVOICE_ID);
const request = postSigned.mock.calls[0][1] as EimsInvoiceRequest;
expect(request.SourceSystem.InvoiceCounter).toBe(42);
expect(request.ReferenceDetails.PreviousIrn).toBe("PRIOR-IRN");
expect(request.DocumentDetails.DocumentNumber).toBe("INV-20260807-00042");
expect(request.SourceSystem.SystemNumber).toBe(SYSTEM_NUMBER);
});
it("takes SourceSystem from the token session, not from configuration", async () => {
const db = new FakeDb([invoiceRow()]);
const postSigned = jest.fn().mockResolvedValue(okResponse());
// Config disagrees on purpose: only the session may reach the wire.
const cfg = config();
(cfg as { systemNumber: string }).systemNumber = "CONFIG-ONLY";
(cfg as { systemType: string }).systemType = "MAN";
await build(
db,
postSigned,
cfg,
jest.fn(),
jest.fn().mockResolvedValue({ systemNumber: "FROM-TOKEN", systemType: "POS" }),
).registerInvoiceWithEims(INVOICE_ID);
const request = postSigned.mock.calls[0][1] as EimsInvoiceRequest;
expect(request.SourceSystem.SystemNumber).toBe("FROM-TOKEN");
expect(request.SourceSystem.SystemType).toBe("POS");
});
it("does not consume a counter when authentication fails", async () => {
const db = new FakeDb([invoiceRow()]);
const postSigned = jest.fn();
const getSessionContext = jest.fn().mockRejectedValue(new Error("login failed"));
await expect(
build(db, postSigned, config(), jest.fn(), getSessionContext).registerInvoiceWithEims(
INVOICE_ID,
),
).rejects.toThrow(/login failed/);
expect(postSigned).not.toHaveBeenCalled();
expect(db.state).toMatchObject({ nextInvoiceCounter: 7, inFlightInvoiceId: null });
expect(db.invoices.get(INVOICE_ID)!.eimsStatus).toBe(EimsInvoiceStatus.NotSubmitted);
});
it("is idempotent — an invoice with an IRN never reaches EIMS", async () => {
const db = new FakeDb([
invoiceRow({ eimsIrn: IRN, eimsStatus: EimsInvoiceStatus.Registered }),
]);
const postSigned = jest.fn();
const view = await build(db, postSigned).registerInvoiceWithEims(INVOICE_ID);
expect(postSigned).not.toHaveBeenCalled();
expect(view.eimsIrn).toBe(IRN);
});
it("lets only one of two concurrent calls reach EIMS", async () => {
const db = new FakeDb([invoiceRow()]);
let resolvePost: (v: unknown) => void = () => {};
const postSigned = jest
.fn()
.mockImplementation(() => new Promise((resolve) => (resolvePost = resolve)));
const service = build(db, postSigned);
const first = service.registerInvoiceWithEims(INVOICE_ID);
// Let the first reservation commit and its HTTP call start; it is now parked on `resolvePost`.
await new Promise((resolve) => setImmediate(resolve));
expect(postSigned).toHaveBeenCalledTimes(1);
const second = service.registerInvoiceWithEims(INVOICE_ID);
await expect(second).rejects.toBeInstanceOf(ConflictException);
resolvePost(okResponse());
await first;
expect(postSigned).toHaveBeenCalledTimes(1);
});
it("blocks a different invoice while a submission is in flight (survives a restart)", async () => {
// A committed reservation left behind by a dead process.
const db = new FakeDb(
[
invoiceRow({ eimsStatus: EimsInvoiceStatus.Submitting, eimsInvoiceCounter: 7 }),
invoiceRow({ id: OTHER_INVOICE_ID, invoiceNumber: "INV-20260807-00043" }),
],
{ inFlightInvoiceId: INVOICE_ID, inFlightCounter: 7, nextInvoiceCounter: 8 },
);
const postSigned = jest.fn();
await expect(
build(db, postSigned).registerInvoiceWithEims(OTHER_INVOICE_ID),
).rejects.toThrow(/already in flight/);
expect(postSigned).not.toHaveBeenCalled();
});
it("fails locally on incomplete tax configuration, with zero HTTP calls", async () => {
const db = new FakeDb([invoiceRow()]);
const postSigned = jest.fn();
await expect(
build(db, postSigned, config({ taxCode: "", taxRatePercent: null })).registerInvoiceWithEims(
INVOICE_ID,
),
).rejects.toBeInstanceOf(BadRequestException);
expect(postSigned).not.toHaveBeenCalled();
expect(db.invoices.get(INVOICE_ID)!.eimsStatus).toBe(EimsInvoiceStatus.NotSubmitted);
expect(db.state).toMatchObject({ nextInvoiceCounter: 7, inFlightInvoiceId: null });
});
it.each([
["SCHEMA_VALIDATION", 400],
["RULE_VALIDATION", 406],
])("marks %s (%i) FAILED and clears the global block", async (kind, status) => {
const db = new FakeDb([invoiceRow()]);
const postSigned = jest.fn().mockRejectedValue(apiError(kind, status));
await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf(
EimsApiException,
);
expect(db.invoices.get(INVOICE_ID)).toMatchObject({
eimsStatus: EimsInvoiceStatus.Failed,
eimsIrn: null,
});
expect(db.state).toMatchObject({
inFlightInvoiceId: null,
blockedReason: null,
previousIrn: null,
nextInvoiceCounter: 8, // consumed: the attempt reached the gateway
});
});
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: "" } });
await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toThrow(
/returned no IRN/,
);
expect(db.invoices.get(INVOICE_ID)!.eimsStatus).toBe(EimsInvoiceStatus.Failed);
expect(db.state).toMatchObject({ inFlightInvoiceId: null, blockedReason: null });
});
it("marks a timeout UNKNOWN and keeps the system blocked", async () => {
const db = new FakeDb([invoiceRow()]);
const postSigned = jest.fn().mockRejectedValue(apiError("TIMEOUT"));
await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf(
EimsApiException,
);
expect(db.invoices.get(INVOICE_ID)).toMatchObject({
eimsStatus: EimsInvoiceStatus.Unknown,
eimsIrn: null,
});
expect(db.state!.inFlightInvoiceId).toBe(INVOICE_ID);
expect(db.state!.blockedReason).toMatch(/never acknowledged/);
expect(db.state!.previousIrn).toBeNull();
});
it("an UNKNOWN result blocks a different invoice too", async () => {
const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID })]);
const postSigned = jest.fn().mockRejectedValueOnce(apiError("TIMEOUT"));
const service = build(db, postSigned);
await expect(service.registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf(
EimsApiException,
);
await expect(service.registerInvoiceWithEims(OTHER_INVOICE_ID)).rejects.toThrow(
/registration is blocked/,
);
expect(postSigned).toHaveBeenCalledTimes(1);
});
it("never reuses a counter once an attempt has begun", async () => {
const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID })]);
const postSigned = jest
.fn()
.mockRejectedValueOnce(apiError("RULE_VALIDATION", 406))
.mockResolvedValueOnce(okResponse());
const service = build(db, postSigned);
await expect(service.registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf(
EimsApiException,
);
await service.registerInvoiceWithEims(OTHER_INVOICE_ID);
expect((postSigned.mock.calls[0][1] as EimsInvoiceRequest).SourceSystem.InvoiceCounter).toBe(7);
expect((postSigned.mock.calls[1][1] as EimsInvoiceRequest).SourceSystem.InvoiceCounter).toBe(8);
});
});
describe("EimsInvoiceRegistrationService.verifyInvoiceWithEims", () => {
it("verifies the stored IRN over the unsigned bearer transport", async () => {
const db = new FakeDb([invoiceRow({ eimsIrn: IRN })]);
const postSigned = jest.fn();
const postBearer = jest.fn().mockResolvedValue(verifyResponse());
const result = await build(db, postSigned, config(), postBearer).verifyInvoiceWithEims(
INVOICE_ID,
);
// Lowercase `irn`, raw body — not a signed envelope. `postSigned` must stay untouched.
expect(postBearer).toHaveBeenCalledWith("/v1/verify", { irn: IRN });
expect(postSigned).not.toHaveBeenCalled();
expect(result.body).toMatchObject({ Irn: IRN });
});
it("rejects a 200 that carries no Irn", async () => {
const db = new FakeDb([invoiceRow({ eimsIrn: IRN })]);
const postBearer = jest.fn().mockResolvedValue({ statusCode: 200, body: { Irn: " " } });
await expect(
build(db, jest.fn(), config(), postBearer).verifyInvoiceWithEims(INVOICE_ID),
).rejects.toThrow(/returned no Irn/);
});
it("refuses to verify an invoice with no IRN", async () => {
const db = new FakeDb([invoiceRow({ eimsStatus: EimsInvoiceStatus.Unknown })]);
const postBearer = jest.fn();
await expect(
build(db, jest.fn(), config(), postBearer).verifyInvoiceWithEims(INVOICE_ID),
).rejects.toThrow(/no EIMS IRN to verify/);
expect(postBearer).not.toHaveBeenCalled();
});
});
describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => {
const blocked = () =>
new FakeDb([invoiceRow({ eimsStatus: EimsInvoiceStatus.Unknown, eimsInvoiceCounter: 7 })], {
inFlightInvoiceId: INVOICE_ID,
inFlightCounter: 7,
nextInvoiceCounter: 8,
blockedReason: "never acknowledged",
});
it("records a confirmed IRN, resumes the chain and clears the block", async () => {
const db = blocked();
const postBearer = jest.fn().mockResolvedValue(verifyResponse());
const view = await build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(
INVOICE_ID,
{ irn: IRN },
);
// The IRN is confirmed at the gateway before it is ever written.
expect(postBearer).toHaveBeenCalledWith("/v1/verify", { irn: IRN });
expect(view).toMatchObject({ eimsStatus: EimsInvoiceStatus.Registered, eimsIrn: IRN });
expect(db.state).toMatchObject({
previousIrn: IRN,
inFlightInvoiceId: null,
blockedReason: null,
});
});
it("refuses an IRN the gateway answers with a different one, leaving the block intact", async () => {
const db = blocked();
const postBearer = jest
.fn()
.mockResolvedValue(verifyResponse({ Irn: "0000000000000000000000000000000000000000" }));
await expect(
build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(INVOICE_ID, { irn: IRN }),
).rejects.toThrow(/answered the lookup for IRN/);
expect(db.invoices.get(INVOICE_ID)).toMatchObject({
eimsStatus: EimsInvoiceStatus.Unknown,
eimsIrn: null,
});
expect(db.state).toMatchObject({
inFlightInvoiceId: INVOICE_ID,
blockedReason: "never acknowledged",
previousIrn: null,
});
});
it("refuses an IRN whose document number is not this invoice, leaving the block intact", async () => {
const db = blocked();
const postBearer = jest.fn().mockResolvedValue(
verifyResponse({
DocumentDetails: { Type: "INV", DocumentNumber: "INV-20260807-99999" },
}),
);
await expect(
build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(INVOICE_ID, { irn: IRN }),
).rejects.toThrow(/not INV-20260807-00042/);
expect(db.invoices.get(INVOICE_ID)).toMatchObject({
eimsStatus: EimsInvoiceStatus.Unknown,
eimsIrn: null,
});
expect(db.state).toMatchObject({
inFlightInvoiceId: INVOICE_ID,
blockedReason: "never acknowledged",
previousIrn: null,
});
});
it("refuses an IRN the gateway does not acknowledge at all", async () => {
const db = blocked();
const postBearer = jest.fn().mockResolvedValue({ statusCode: 200, body: {} });
await expect(
build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(INVOICE_ID, { irn: IRN }),
).rejects.toThrow(/returned no Irn/);
expect(db.state!.blockedReason).toBe("never acknowledged");
});
it("discards the attempt, leaving the chain where it was", async () => {
const db = blocked();
const postBearer = jest.fn();
const view = await build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(
INVOICE_ID,
{ discard: true },
);
expect(view).toMatchObject({ eimsStatus: EimsInvoiceStatus.Failed, eimsIrn: null });
expect(postBearer).not.toHaveBeenCalled(); // nothing to confirm
expect(db.state).toMatchObject({
previousIrn: null,
inFlightInvoiceId: null,
blockedReason: null,
});
});
it("refuses to resolve an invoice that is not the in-flight one", async () => {
const db = blocked();
db.invoices.set(OTHER_INVOICE_ID, invoiceRow({ id: OTHER_INVOICE_ID }));
const postBearer = jest.fn().mockResolvedValue(verifyResponse());
await expect(
build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(OTHER_INVOICE_ID, {
irn: IRN,
}),
).rejects.toThrow(/in-flight EIMS submission is invoice/);
});
it("requires either an IRN or an explicit discard", async () => {
await expect(
build(blocked(), jest.fn()).resolveEimsRegistration(INVOICE_ID, {}),
).rejects.toBeInstanceOf(BadRequestException);
});
});

View File

@@ -0,0 +1,497 @@
import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { InjectDataSource } from "@nestjs/typeorm";
import { DataSource, EntityManager } from "typeorm";
import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity.js";
import { EimsConfig } from "../../config/eims.config";
import { Invoice } from "../billing/entities/invoice.entity";
import {
EimsInvoiceRequest,
EimsMapperLine,
toEimsInvoice,
} from "../billing/eims-invoice.mapper";
import { EimsAuthService } from "./eims-auth.service";
import { EimsClientService } from "./eims-client.service";
import { EimsApiException } from "./eims.errors";
import { EimsSystemState } from "./entities/eims-system-state.entity";
import {
assertEimsInvoiceConfig,
buildEimsContext,
buildEimsSeller,
} from "./eims-invoice-context";
import {
EimsInvoiceError,
EimsInvoiceStatus,
EimsInvoiceStatusView,
EimsRegisterResponse,
EimsVerifyRequest,
EimsVerifyResponse,
} from "./eims-registration.types";
/**
* Failure kinds where the gateway gave a complete answer: the document was rejected and is
* definitively not registered. These clear the system-wide block; anything else does not.
*/
const DETERMINISTIC_KINDS = new Set(["SCHEMA_VALIDATION", "RULE_VALIDATION", "AUTH", "FORBIDDEN"]);
interface Reservation {
stateId: string;
invoiceCounter: number;
previousIrn: string;
}
/**
* Registers a single invoice with MoR EIMS.
*
* Sequencing is a **durable reservation**: the counter is consumed and the holder recorded in a
* committed transaction *before* the request leaves the process, and the network call happens
* outside any transaction. That gives three properties the naive design could not:
*
* - a counter is never reused once an attempt has begun, even across a crash;
* - a crash mid-flight leaves the reservation standing, so nothing blindly resubmits a document
* that may already have reached MoR;
* - an ambiguous result blocks every invoice for the system number, not just its own, because
* `PreviousIrn` is unknown and any later document would chain to a stale IRN.
*
* Signing, authentication and error normalisation belong to `EimsClientService`. Manual only —
* nothing in invoice creation calls this.
*/
@Injectable()
export class EimsInvoiceRegistrationService {
private readonly logger = new Logger(EimsInvoiceRegistrationService.name);
constructor(
@InjectDataSource() private readonly dataSource: DataSource,
private readonly config: ConfigService,
private readonly client: EimsClientService,
private readonly auth: EimsAuthService,
) {}
private get cfg(): EimsConfig {
return this.config.get<EimsConfig>("eims")!;
}
async registerInvoiceWithEims(invoiceId: string): Promise<EimsInvoiceStatusView> {
const cfg = this.cfg;
// Static seller/tax configuration is validated before anything is locked, allocated or sent.
assertEimsInvoiceConfig(cfg);
const invoice = await this.loadInvoiceForMapping(invoiceId);
if (invoice.eimsIrn) return this.toView(invoice);
// Authenticate before reserving: the source system comes from the token, and the state row is
// keyed by it. A login failure here costs nothing — no counter has been consumed yet.
const session = await this.auth.getSessionContext();
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, {
// Our own invoice number is the document number; EIMS only requires it to be unique.
documentNumber: invoice.invoiceNumber,
invoiceCounter: reservation.invoiceCounter,
previousIrn: reservation.previousIrn,
session,
}),
);
let irn: string;
let ackDate: string | undefined;
try {
// Deliberately outside every transaction — no DB lock is held across the wire.
const result = await this.submit(request);
irn = result.irn;
ackDate = result.ackDate;
} catch (err) {
await this.settleFailure(invoiceId, reservation, err);
throw err;
}
await this.settleSuccess(invoiceId, reservation, irn, ackDate);
this.logger.log(
`Invoice ${invoice.invoiceNumber} registered with EIMS (counter ${reservation.invoiceCounter})`,
);
return this.getEimsStatus(invoiceId);
}
/**
* Verify a registered invoice at `POST /v1/verify`.
*
* Requires a stored IRN. An invoice whose submission was never acknowledged cannot be reconciled
* here — the gateway offers no lookup by document number — so it must be resolved with MoR and
* recorded through `resolveEimsRegistration`.
*/
async verifyInvoiceWithEims(invoiceId: string): Promise<EimsVerifyResponse> {
const invoice = await this.loadInvoiceRow(this.dataSource.manager, invoiceId);
if (!invoice.eimsIrn) {
throw new BadRequestException({
code: "EIMS_NO_IRN",
message:
`Invoice ${invoice.invoiceNumber} has no EIMS IRN to verify (status ${invoice.eimsStatus}). ` +
"EIMS can only be queried by IRN, so an unacknowledged submission must be resolved with MoR first.",
});
}
return this.queryVerify(invoice.eimsIrn);
}
/**
* `POST /v1/verify` for one IRN, with the one check that always applies: the gateway must echo
* an `Irn` back. A 200 without it is not a confirmation of anything.
*
* The request property is lowercase `irn`; the response spells it `Irn`. The two are never
* compared — the supplied collection's own fixture uses different example values on each side,
* so equality there would assert a property of the mock rather than of the gateway.
*
* Bearer-authenticated but unsigned, via `postBearer` — see that method for why.
*/
private async queryVerify(irn: string): Promise<EimsVerifyResponse> {
const response = await this.client.postBearer<EimsVerifyRequest, EimsVerifyResponse>(
"/v1/verify",
{ irn },
);
if (!response?.body?.Irn?.trim()) {
throw new EimsApiException(
"SCHEMA_VALIDATION",
"EIMS verify returned no Irn in its response body",
response?.statusCode,
);
}
return response;
}
/**
* Refuse a manual resolution unless the gateway confirms *both* halves of the claim: that this
* IRN is the one it holds, and that it belongs to this invoice.
*
* The document-number check is against `DocumentDetails.DocumentNumber`, which registration set
* from our own `invoiceNumber` — the only field tying an IRN back to a row in this database.
*
* Recording a wrong IRN is not a local mistake: it marks an unregistered invoice as filed and
* chains every later document to a stranger's reference, so both checks are refusals rather
* than warnings.
*/
private async assertIrnBelongsToInvoice(
irn: string,
expectedDocumentNumber: string,
): Promise<void> {
const response = await this.queryVerify(irn);
const returnedIrn = response.body?.Irn?.trim();
const documentNumber = response.body?.DocumentDetails?.DocumentNumber?.trim();
if (returnedIrn !== irn) {
throw new ConflictException({
code: "EIMS_RESOLVE_IRN_MISMATCH",
message:
`EIMS answered the lookup for IRN ${irn} with ${returnedIrn ?? "(none)"}. ` +
"Refusing to record it — recheck the IRN in the MoR portal.",
});
}
if (documentNumber !== expectedDocumentNumber) {
throw new ConflictException({
code: "EIMS_RESOLVE_DOCUMENT_MISMATCH",
message:
`EIMS reports IRN ${irn} against document ${documentNumber ?? "(none)"}, not ` +
`${expectedDocumentNumber}. Refusing to record it — recheck the IRN in the MoR portal.`,
});
}
}
/**
* Manual reconciliation of a blocked system number.
*
* With an `irn` (found in the MoR portal) the invoice is recorded as registered and the chain
* resumes from it. With `discard` the invoice is marked failed and the chain resumes from the
* previous IRN. Either way the block is cleared — this is the only exit from an ambiguous result.
*
* An IRN is never taken on trust: it is verified at the gateway first, and the document it
* belongs to must be *this* invoice. A transposed digit would otherwise chain every later
* document to a stranger's IRN and mark this invoice registered when it is not.
*/
async resolveEimsRegistration(
invoiceId: string,
input: { irn?: string; discard?: boolean },
): Promise<EimsInvoiceStatusView> {
const irn = input.irn?.trim();
if (!irn && !input.discard) {
throw new BadRequestException({
code: "EIMS_RESOLVE_INPUT_REQUIRED",
message: "Provide the IRN confirmed with MoR, or discard: true to abandon the submission",
});
}
// Outside the transaction: no lock is held across the wire, and a refused verification must
// leave the block exactly as it was.
if (irn) {
const invoice = await this.loadInvoiceRow(this.dataSource.manager, invoiceId);
await this.assertIrnBelongsToInvoice(irn, invoice.invoiceNumber);
}
// Same source of truth as registration: the state row is keyed by the token's system number.
const session = await this.auth.getSessionContext();
await this.dataSource.transaction(async (manager) => {
const state = await this.lockSystemState(manager, session.systemNumber);
if (state.inFlightInvoiceId && state.inFlightInvoiceId !== invoiceId) {
throw new ConflictException({
code: "EIMS_RESOLVE_WRONG_INVOICE",
message: `The in-flight EIMS submission is invoice ${state.inFlightInvoiceId}, not ${invoiceId}`,
});
}
const invoice = await this.lockInvoice(manager, invoiceId);
if (invoice.eimsIrn) {
throw new ConflictException({
code: "EIMS_ALREADY_REGISTERED",
message: `Invoice ${invoice.invoiceNumber} already has IRN ${invoice.eimsIrn}`,
});
}
await manager.update(Invoice, invoiceId, {
eimsStatus: irn ? EimsInvoiceStatus.Registered : EimsInvoiceStatus.Failed,
eimsIrn: irn ?? null,
});
await manager.update(EimsSystemState, state.id, {
// Only a confirmed IRN may advance the chain; a discard leaves it where it was.
...(irn ? { previousIrn: irn } : {}),
inFlightInvoiceId: null,
inFlightCounter: null,
blockedReason: null,
});
});
this.logger.warn(
`EIMS block on invoice ${invoiceId} resolved manually (${irn ? "IRN recorded" : "discarded"})`,
);
return this.getEimsStatus(invoiceId);
}
async getEimsStatus(invoiceId: string): Promise<EimsInvoiceStatusView> {
return this.toView(await this.loadInvoiceRow(this.dataSource.manager, invoiceId));
}
// ── transactions ─────────────────────────────────────────────────────────────────────────────
/**
* TX1. Consume a counter and record the holder, committed before any HTTP call. Returns `null`
* when the invoice turned out to be registered already (checked under the lock).
*/
private async reserve(invoiceId: string, systemNumber: string): Promise<Reservation | null> {
return this.dataSource.transaction(async (manager) => {
const state = await this.lockSystemState(manager, systemNumber);
if (state.blockedReason) {
throw new ConflictException({
code: "EIMS_SYSTEM_BLOCKED",
message:
`EIMS registration is blocked for system ${systemNumber}: ${state.blockedReason}. ` +
"Resolve the affected invoice before registering anything else.",
});
}
if (state.inFlightInvoiceId) {
throw new ConflictException({
code: "EIMS_SUBMISSION_IN_FLIGHT",
message:
`A submission for invoice ${state.inFlightInvoiceId} is already in flight on system ` +
`${systemNumber}. Wait for it to settle, or resolve it if the process was interrupted.`,
});
}
const invoice = await this.lockInvoice(manager, invoiceId);
if (invoice.eimsIrn) return null;
const invoiceCounter = Number(state.nextInvoiceCounter);
const previousIrn = state.previousIrn ?? "";
// Counter consumed here, not on success: once an attempt begins it can never be reused,
// whatever happens next. A gap is harmless at MoR; a collision is not.
await manager.update(EimsSystemState, state.id, {
nextInvoiceCounter: invoiceCounter + 1,
inFlightInvoiceId: invoiceId,
inFlightCounter: invoiceCounter,
});
await manager.update(Invoice, invoiceId, {
eimsStatus: EimsInvoiceStatus.Submitting,
eimsInvoiceCounter: invoiceCounter,
eimsSubmittedAt: new Date(),
eimsLastError: null,
});
return { stateId: state.id, invoiceCounter, previousIrn };
});
}
/** TX2a. Record the IRN, advance the chain, release the reservation. */
private async settleSuccess(
invoiceId: string,
reservation: Reservation,
irn: string,
ackDate?: string,
): Promise<void> {
await this.dataSource.transaction(async (manager) => {
await this.lockInvoice(manager, invoiceId);
await manager.update(Invoice, invoiceId, {
eimsStatus: EimsInvoiceStatus.Registered,
eimsIrn: irn,
eimsAckDate: ackDate ?? null,
eimsLastError: null,
});
await manager.update(EimsSystemState, reservation.stateId, {
previousIrn: irn,
inFlightInvoiceId: null,
inFlightCounter: null,
blockedReason: null,
});
});
}
/**
* TX2b. A deterministic rejection releases the reservation; an ambiguous result keeps it and
* blocks the system number, because `PreviousIrn` is now unknown for every later document.
* The counter stays consumed either way.
*/
private async settleFailure(
invoiceId: string,
reservation: Reservation,
err: unknown,
): Promise<void> {
const api = err instanceof EimsApiException ? err : null;
const deterministic = api ? DETERMINISTIC_KINDS.has(api.kind) : false;
const status = deterministic ? EimsInvoiceStatus.Failed : EimsInvoiceStatus.Unknown;
const lastError: EimsInvoiceError = {
kind: api?.kind ?? "UNKNOWN",
message: (err as Error)?.message ?? "unknown error",
httpStatus: api?.httpStatus,
details: api?.details,
at: new Date().toISOString(),
};
await this.dataSource.transaction(async (manager) => {
await manager.update(Invoice, invoiceId, {
eimsStatus: status,
eimsLastError: lastError,
} as QueryDeepPartialEntity<Invoice>);
await manager.update(
EimsSystemState,
reservation.stateId,
deterministic
? { inFlightInvoiceId: null, inFlightCounter: null, blockedReason: null }
: {
blockedReason:
`Invoice ${invoiceId} was submitted with counter ${reservation.invoiceCounter} but ` +
`never acknowledged (${lastError.kind}). Its IRN is unknown, so no further document ` +
"can be chained until it is resolved with MoR.",
},
);
});
this.logger.error(`Invoice ${invoiceId} EIMS registration ${status}: ${lastError.message}`);
}
// ── internals ────────────────────────────────────────────────────────────────────────────────
/** A non-empty IRN is the only success signal; anything else is a failed registration. */
private async submit(request: EimsInvoiceRequest): Promise<{ irn: string; ackDate?: string }> {
const response = await this.client.postSigned<EimsInvoiceRequest, EimsRegisterResponse>(
"/v1/register",
request,
);
const irn = response?.body?.irn;
if (!irn) {
// The gateway answered, so this is deterministic: the document is not registered.
throw new EimsApiException(
"SCHEMA_VALIDATION",
`EIMS register returned no IRN${response?.body?.errorMessage ? `: ${response.body.errorMessage}` : ""}`,
response?.statusCode,
);
}
return { irn, ackDate: response.body?.ackDate };
}
private async lockInvoice(manager: EntityManager, invoiceId: string): Promise<Invoice> {
const invoice = await manager
.createQueryBuilder(Invoice, "invoice")
.setLock("pessimistic_write")
.where("invoice.id = :invoiceId", { invoiceId })
.getOne();
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
return invoice;
}
/** Locks the system-state row, creating it on first use. */
private async lockSystemState(
manager: EntityManager,
systemNumber: string,
): Promise<EimsSystemState> {
const select = () =>
manager
.createQueryBuilder(EimsSystemState, "state")
.setLock("pessimistic_write")
.where("state.system_number = :systemNumber", { systemNumber })
.getOne();
const existing = await select();
if (existing) return existing;
await manager.query(
`INSERT INTO freight.eims_system_state (system_number) VALUES ($1)
ON CONFLICT (system_number) DO NOTHING`,
[systemNumber],
);
const created = await select();
if (!created) throw new Error(`Could not initialise EIMS system state for ${systemNumber}`);
return created;
}
/** Header + buyer + lines — everything the mapper needs. */
private async loadInvoiceForMapping(
invoiceId: string,
): Promise<Invoice & { lines: EimsMapperLine[] }> {
const invoice = await this.dataSource.getRepository(Invoice).findOne({
where: { id: invoiceId },
relations: { company: true, companyProfile: true },
});
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
const lines: EimsMapperLine[] = await this.dataSource.query(
`SELECT charge_type AS "chargeType", description, quantity, unit_rate AS "unitRate",
amount, currency, metadata
FROM freight.invoice_lines
WHERE invoice_id = $1 AND deleted_at IS NULL
ORDER BY created_at ASC`,
[invoiceId],
);
return Object.assign(invoice, { lines });
}
private async loadInvoiceRow(manager: EntityManager, invoiceId: string): Promise<Invoice> {
const invoice = await manager.findOne(Invoice, { where: { id: invoiceId } });
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
return invoice;
}
private toView(invoice: Invoice): EimsInvoiceStatusView {
const counter = invoice.eimsInvoiceCounter;
return {
invoiceId: invoice.id,
invoiceNumber: invoice.invoiceNumber,
eimsStatus: invoice.eimsStatus ?? EimsInvoiceStatus.NotSubmitted,
eimsIrn: invoice.eimsIrn ?? null,
eimsInvoiceCounter: counter === null || counter === undefined ? null : Number(counter),
eimsSubmittedAt: invoice.eimsSubmittedAt ?? null,
eimsAckDate: invoice.eimsAckDate ?? null,
eimsLastError: invoice.eimsLastError ?? null,
};
}
}

View File

@@ -0,0 +1,68 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Post } from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { BookingStaff } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { ResolveEimsRegistrationDto } from "./dto/resolve-eims-registration.dto";
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
/**
* Manual EIMS actions on an existing invoice.
*
* Invoices are produced by the freight workflow, not by a person, so these routes are **not** the
* normal production path — they exist for controlled testing and exceptional operations. Automatic
* submission after an invoice is issued is a separate phase; nothing here is called by it.
*
* `eims_register` and `eims_resolve` are intentionally left out of every role preset and assigned
* to named admins instead. They are also separate permissions: resolving clears the system-wide
* chain block and can record an IRN against an invoice, which is a supervisor action, not an
* operational one. Only `eims/status` rides on the ordinary `invoices:view`.
*
* Filing gets its own permission (`invoices:eims_register`) rather than riding on an existing key:
* registration is irreversible at MoR, so it must not follow from the right to download a PDF.
* The key is seeded through FINANCE_PERMISSIONS, which reaches `iam.permissions` via
* ADVANCED_BACKOFFICE_PERMISSIONS → BOOKING_RULE_ENGINE_PERMISSIONS → EDR_FREIGHT_PERMISSIONS.
*/
@ApiTags("eims")
@ApiBearerAuth()
@Controller("invoices")
export class EimsInvoiceController {
constructor(private readonly registration: EimsInvoiceRegistrationService) {}
@Post(":id/eims/register")
@BookingStaff(FREIGHT_PERMS.invoices.eimsRegister)
@ApiOperation({
summary:
"Register the invoice with MoR EIMS. Idempotent — an invoice that already has an IRN is returned unchanged.",
})
register(@Param("id", ParseUUIDPipe) id: string) {
return this.registration.registerInvoiceWithEims(id);
}
@Post(":id/eims/verify")
@BookingStaff(FREIGHT_PERMS.invoices.eimsRegister)
@ApiOperation({ summary: "Verify the invoice's stored IRN against EIMS" })
verify(@Param("id", ParseUUIDPipe) id: string) {
return this.registration.verifyInvoiceWithEims(id);
}
@Post(":id/eims/resolve")
@BookingStaff(FREIGHT_PERMS.invoices.eimsResolve)
@ApiOperation({
summary:
"Resolve an unacknowledged submission: record the IRN confirmed with MoR, or discard it. Clears the system-wide block.",
})
resolve(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: ResolveEimsRegistrationDto,
) {
return this.registration.resolveEimsRegistration(id, dto);
}
@Get(":id/eims/status")
@BookingStaff(FREIGHT_PERMS.invoices.view)
@ApiOperation({ summary: "EIMS registration status, IRN and last error for the invoice" })
status(@Param("id", ParseUUIDPipe) id: string) {
return this.registration.getEimsStatus(id);
}
}

View File

@@ -0,0 +1,87 @@
import { EimsErrorResponse } from "./eims.types";
/**
* Registration state of one invoice at MoR EIMS.
*
* `UNKNOWN` is not a synonym for failure: the request left this process and no answer came back,
* so the invoice may or may not be registered at the gateway. It is never auto-retried — a resend
* would risk a duplicate registration.
*/
export enum EimsInvoiceStatus {
NotSubmitted = "NOT_SUBMITTED",
Submitting = "SUBMITTING",
Registered = "REGISTERED",
Failed = "FAILED",
Unknown = "UNKNOWN",
}
/** `body` of a successful `POST /v1/register`, as observed in the collection. */
export interface EimsRegisterResponseBody {
irn: string;
ackDate?: string;
signedQR?: string;
signedInvoice?: string;
status?: string;
documentNumber?: string;
errorMessage?: string | null;
}
export interface EimsRegisterResponse {
statusCode?: number;
message?: string;
body?: EimsRegisterResponseBody;
}
/**
* Inner request of `POST /v1/verify`. The wire property is lowercase `irn` and is required —
* omitting it yields a 400 "SCHEMA ERROR" reporting `$: required property 'irn' not found`.
*/
export interface EimsVerifyRequest {
irn: string;
}
/**
* `body` of a successful `POST /v1/verify` — the stored document echoed back. Note the casing
* flip against the request: the response spells the reference `Irn`.
*
* Only the fields we actually assert on are typed; the rest of the echoed document (SellerDetails,
* BuyerDetails, ItemList, …) is carried through untyped because nothing here reads it.
*/
export interface EimsVerifyResponseBody {
Irn?: string;
TransactionType?: string;
DocumentDetails?: {
Type?: string;
DocumentNumber?: string;
Date?: string;
};
Version?: string;
[section: string]: unknown;
}
export interface EimsVerifyResponse {
statusCode?: number;
message?: string;
body?: EimsVerifyResponseBody;
}
/** Persisted failure detail. Carries the gateway's own error fields only — never our envelope. */
export interface EimsInvoiceError {
kind: string;
message: string;
httpStatus?: number;
details?: EimsErrorResponse;
at: string;
}
/** What the status endpoint returns, and what a later invoice-detail panel will render. */
export interface EimsInvoiceStatusView {
invoiceId: string;
invoiceNumber: string;
eimsStatus: EimsInvoiceStatus;
eimsIrn: string | null;
eimsInvoiceCounter: number | null;
eimsSubmittedAt: Date | null;
eimsAckDate: string | null;
eimsLastError: EimsInvoiceError | null;
}

View File

@@ -0,0 +1,118 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createVerify, generateKeyPairSync } from "node:crypto";
import { ConfigService } from "@nestjs/config";
import { EimsCredentialsProvider } from "./eims-credentials.provider";
import { EimsSignerService, toSignedBody } from "./eims-signer.service";
/**
* Test-only key material: generated per run, never a production key. The "certificate" fixture is
* an arbitrary byte blob — the point is that its exact bytes survive base64 round-tripping, not
* that it is a valid X.509 chain.
*/
const CERTIFICATE_FIXTURE = "Subject: CN=TEST\n-----BEGIN CERTIFICATE-----\nZm9vYmFy\n-----END CERTIFICATE-----\n";
let dir: string;
let keyPath: string;
let certPath: string;
let publicKeyPem: string;
let signer: EimsSignerService;
beforeAll(() => {
dir = mkdtempSync(join(tmpdir(), "eims-signer-"));
keyPath = join(dir, "private_key.key");
certPath = join(dir, "certificate.pem.txt");
const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
writeFileSync(keyPath, privateKey.export({ type: "pkcs8", format: "pem" }));
writeFileSync(certPath, CERTIFICATE_FIXTURE, "utf8");
publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString();
const config = {
get: () => ({ privateKeyPath: keyPath, certificatePath: certPath }),
} as unknown as ConfigService;
signer = new EimsSignerService(new EimsCredentialsProvider(config));
});
afterAll(() => rmSync(dir, { recursive: true, force: true }));
const login = () => ({ clientId: "cid", clientSecret: "secret", apikey: "key", tin: "0000000000" });
const verify = (payload: string, signature: string): boolean =>
createVerify("RSA-SHA512").update(payload, "utf8").verify(publicKeyPem, signature, "base64");
describe("EimsSignerService", () => {
it("produces a signature that verifies against the matching public key", () => {
const signed = signer.signRequest(login());
expect(verify(JSON.stringify(signed.request), signed.signature)).toBe(true);
});
it("fails verification when a single request field changes", () => {
const signed = signer.signRequest(login());
const tampered = JSON.stringify({ ...signed.request, tin: "9999999999" });
expect(verify(tampered, signed.signature)).toBe(false);
});
it("emits a 256-byte signature for an RSA-2048 key", () => {
const signed = signer.signRequest(login());
expect(Buffer.from(signed.signature, "base64")).toHaveLength(256);
});
it("sends the certificate as base64 of the file's exact bytes", () => {
const signed = signer.signRequest(login());
expect(signed.certificate).toBe(readFileSync(certPath).toString("base64"));
expect(Buffer.from(signed.certificate, "base64").equals(readFileSync(certPath))).toBe(true);
});
it("signs the inner request only, and the wire body carries those exact bytes", () => {
const signed = signer.signRequest(login());
const body = toSignedBody(signed);
// The signed string appears verbatim inside the transmitted envelope.
expect(body).toContain(`"request":${JSON.stringify(signed.request)}`);
// Compact, never pretty-printed.
expect(body).not.toMatch(/\n/);
expect(JSON.parse(body)).toEqual({
request: login(),
signature: signed.signature,
certificate: signed.certificate,
});
});
it("does not mutate the request object", () => {
const request = login();
const signed = signer.signRequest(request);
expect(signed.request).toBe(request);
expect(request).toEqual(login());
});
it("reuses the loaded key and certificate across calls", () => {
const first = signer.signRequest(login());
const second = signer.signRequest(login());
// PKCS#1 v1.5 is deterministic: same key + same payload ⇒ identical signature.
expect(second.signature).toBe(first.signature);
expect(second.certificate).toBe(first.certificate);
});
});
describe("EimsCredentialsProvider", () => {
const providerFor = (paths: { privateKeyPath?: string; certificatePath?: string }) =>
new EimsCredentialsProvider({ get: () => paths } as unknown as ConfigService);
it("fails clearly when the key path is unset", () => {
expect(() => providerFor({}).getPrivateKey()).toThrow(/EIMS_PRIVATE_KEY_PATH is not set/);
});
it("fails clearly when the key file is missing", () => {
expect(() => providerFor({ privateKeyPath: join(dir, "nope.key") }).getPrivateKey()).toThrow(
/could not be read or parsed/,
);
});
it("fails clearly when the certificate file is empty", () => {
const emptyPath = join(dir, "empty.txt");
writeFileSync(emptyPath, "");
expect(() => providerFor({ certificatePath: emptyPath }).getCertificateBase64()).toThrow(/is empty/);
});
});

View File

@@ -0,0 +1,37 @@
import { createSign } from "node:crypto";
import { Injectable } from "@nestjs/common";
import { EimsCredentialsProvider } from "./eims-credentials.provider";
import { EimsSignedRequest } from "./eims.types";
/**
* Signs EIMS request objects, reproducing the process that produced a working live access token:
*
* 1. compact `JSON.stringify` of the **inner** request object only,
* 2. those exact UTF-8 bytes,
* 3. RSA + SHA-512 (`SHA512withRSA`, PKCS#1 v1.5 — Node's default RSA padding),
* 4. base64 of the raw signature bytes (256 bytes for an RSA-2048 key),
* 5. base64 of the certificate file's exact bytes.
*
* The outer `{request, signature, certificate}` envelope is never itself signed, and the request
* object is never mutated after serialization.
*/
@Injectable()
export class EimsSignerService {
constructor(private readonly credentials: EimsCredentialsProvider) {}
signRequest<T>(request: T): EimsSignedRequest<T> {
const payload = JSON.stringify(request);
const signature = createSign("RSA-SHA512")
.update(payload, "utf8")
.sign(this.credentials.getPrivateKey(), "base64");
return { request, signature, certificate: this.credentials.getCertificateBase64() };
}
}
/**
* Exact wire body for a signed envelope. Serializing here (rather than handing axios an object)
* keeps one serializer in play: the `request` segment of this string is byte-identical to the
* string that was signed.
*/
export const toSignedBody = <T>(signed: EimsSignedRequest<T>): string => JSON.stringify(signed);

View File

@@ -0,0 +1,78 @@
import { EimsConfig, EimsInvoiceConfig } from "../../config/eims.config";
/**
* Fixtures shared by the EIMS specs.
*
* Deliberately not a `.spec.ts`: importing fixtures from a spec file makes jest execute that
* file's `describe` blocks inside every importing suite, so the same tests run — and report —
* twice.
*/
export const EIMS_SYSTEM_NUMBER = "B0360154BA";
export const EIMS_SYSTEM_TYPE = "SYS";
export const eimsInvoiceConfig = (over: Partial<EimsInvoiceConfig> = {}): EimsInvoiceConfig => ({
sellerLegalName: "Ethio-Djibouti Railway S.C.",
sellerVatNumber: "0000000000",
sellerPhone: "0911223344",
sellerEmail: "finance@example.et",
sellerRegion: "13",
sellerWereda: "574",
sellerCity: null,
sellerSubCity: null,
sellerHouseNumber: null,
sellerLocality: null,
taxCode: "VAT15",
taxRatePercent: 15,
exciseTaxValue: 0,
incomeWithholdValue: 0,
transactionWithholdValue: 0,
transactionType: "B2B",
natureOfSupplies: "Service",
paymentMode: "CASH",
paymentTerm: "IMMIDIATE",
unitDefault: "PCS",
buyerCountryCode: null,
cashierName: null,
salesPersonName: null,
...over,
});
export const eimsConfig = (over: Partial<EimsConfig> = {}): EimsConfig => ({
enabled: true,
baseUrl: "https://core.mor.gov.et",
clientId: "cid",
clientSecret: "super-secret-value",
apiKey: "super-secret-apikey",
tin: "0000034558",
systemNumber: EIMS_SYSTEM_NUMBER,
systemType: EIMS_SYSTEM_TYPE,
privateKeyPath: "/dev/null",
certificatePath: "/dev/null",
httpTimeoutMs: 30_000,
tokenSkewMs: 45_000,
autoSubmit: false,
autoSubmitCron: "0 */5 * * * *",
autoSubmitMaxAgeDays: 3,
invoice: eimsInvoiceConfig(),
...over,
});
/**
* A structurally real access token. MoR stamps the source-system identity into the JWT payload and
* `EimsAuthService` reads it from there; only the payload segment is meaningful, since the token is
* never verified locally — it is MoR's, signed with MoR's key.
*
* Pass a claim as `undefined` to omit it (spreading beats `delete`, which the defaults would undo).
*/
export const eimsToken = (claims: Record<string, unknown> = {}): string => {
const payload = { systemNumber: EIMS_SYSTEM_NUMBER, systemType: EIMS_SYSTEM_TYPE, ...claims };
for (const [key, value] of Object.entries(payload)) {
if (value === undefined) delete (payload as Record<string, unknown>)[key];
}
return [
"eyJhbGciOiJSUzI1NiJ9",
Buffer.from(JSON.stringify(payload)).toString("base64url"),
"signature",
].join(".");
};

View File

@@ -0,0 +1,89 @@
import { BadGatewayException, ServiceUnavailableException } from "@nestjs/common";
import { AxiosError } from "axios";
import { EimsErrorResponse } from "./eims.types";
export type EimsFailureKind =
| "NETWORK"
| "TIMEOUT"
| "SCHEMA_VALIDATION"
| "AUTH"
| "FORBIDDEN"
| "RULE_VALIDATION"
| "SERVER"
| "UNKNOWN";
/** Raised when EIMS is disabled or its credential files are unusable. */
export class EimsConfigException extends ServiceUnavailableException {
constructor(message: string) {
super({ code: "EIMS_NOT_CONFIGURED", message });
}
}
/**
* A failed EIMS call. Carries only the gateway's own error reporting — never the request body,
* signature, certificate, bearer token or any configured secret.
*/
export class EimsApiException extends BadGatewayException {
constructor(
readonly kind: EimsFailureKind,
message: string,
readonly httpStatus?: number,
readonly details?: EimsErrorResponse,
) {
super({ code: `EIMS_${kind}`, message });
}
}
const SAFE_KEYS = ["message", "statusCode", "code", "details", "body"] as const;
/**
* Keep only the gateway's error-reporting fields. Anything else a response might carry — an echoed
* request, a token, a signature — is dropped before it can reach a log or an exception payload.
*/
export function redactEimsBody(data: unknown): EimsErrorResponse | undefined {
if (!data || typeof data !== "object") return undefined;
const source = data as Record<string, unknown>;
const safe: Record<string, unknown> = {};
for (const key of SAFE_KEYS) {
if (source[key] !== undefined) safe[key] = source[key];
}
return Object.keys(safe).length > 0 ? (safe as EimsErrorResponse) : undefined;
}
const kindFor = (status: number): EimsFailureKind => {
if (status === 400) return "SCHEMA_VALIDATION";
if (status === 401) return "AUTH";
if (status === 403) return "FORBIDDEN";
if (status === 406) return "RULE_VALIDATION";
if (status >= 500) return "SERVER";
return "UNKNOWN";
};
/** First error line the gateway gives us, whichever shape it used. */
const describe = (body: EimsErrorResponse | undefined): string => {
if (!body) return "no error body";
const detail = body.details?.find((d) => d.errorMessage)?.errorMessage;
return [body.message, body.code && `code=${body.code}`, detail].filter(Boolean).join(" ") || "no error body";
};
/**
* Normalise anything thrown by an EIMS HTTP call into an `EimsApiException`. `operation` is a
* short label such as `"login"` or `"POST /v1/register"` — never a payload.
*/
export function toEimsApiException(err: unknown, operation: string): EimsApiException {
if (err instanceof EimsApiException) return err;
if (err instanceof AxiosError) {
if (err.code === "ECONNABORTED" || err.code === "ETIMEDOUT") {
return new EimsApiException("TIMEOUT", `EIMS ${operation} timed out`);
}
if (!err.response) {
return new EimsApiException("NETWORK", `EIMS ${operation} could not reach the gateway (${err.code ?? "no code"})`);
}
const status = err.response.status;
const body = redactEimsBody(err.response.data);
return new EimsApiException(kindFor(status), `EIMS ${operation} failed (${status}): ${describe(body)}`, status, body);
}
return new EimsApiException("UNKNOWN", `EIMS ${operation} failed: ${(err as Error)?.message ?? "unknown error"}`);
}

View File

@@ -0,0 +1,37 @@
import { HttpModule } from "@nestjs/axios";
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { Invoice } from "../billing/entities/invoice.entity";
import { EimsAuthService } from "./eims-auth.service";
import { EimsAutoSubmitService } from "./eims-auto-submit.service";
import { EimsClientService } from "./eims-client.service";
import { EimsCredentialsProvider } from "./eims-credentials.provider";
import { EimsInvoiceController } from "./eims-invoice.controller";
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
import { EimsSignerService } from "./eims-signer.service";
import { EimsSystemState } from "./entities/eims-system-state.entity";
/**
* MoR EIMS e-invoicing: signed transport, authentication, and manual single-invoice registration.
*
* Exports only what other modules will consume; the credential loader and signer stay internal so
* the private key has exactly one user. Nothing here is called from invoice creation.
*/
@Module({
imports: [
HttpModule.register({ timeout: Number(process.env.EIMS_HTTP_TIMEOUT_MS) || 30_000 }),
TypeOrmModule.forFeature([EimsSystemState, Invoice]),
],
controllers: [EimsInvoiceController],
providers: [
EimsCredentialsProvider,
EimsSignerService,
EimsAuthService,
EimsClientService,
EimsInvoiceRegistrationService,
EimsAutoSubmitService,
],
exports: [EimsAuthService, EimsClientService, EimsInvoiceRegistrationService],
})
export class EimsModule {}

View File

@@ -0,0 +1,46 @@
/**
* Wire types for the MoR EIMS gateway, taken from the supplied Postman collection.
*
* Every protected payload is the same envelope: the business object under `request`, a base64
* RSA-SHA512 signature over the *inner* object only, and the base64 certificate bundle.
*/
export interface EimsSignedRequest<T> {
request: T;
signature: string;
certificate: string;
}
/** Inner request of `POST /auth/login`. Note the lowercase `apikey` — that is the wire name. */
export interface EimsLoginRequest {
clientId: string;
clientSecret: string;
apikey: string;
tin: string;
}
export interface EimsLoginData {
accessToken: string;
refreshToken: string;
/** Observed as a UUID on login and `null` on refresh; unused today. */
encryptionKey: string | null;
/** Seconds. Observed value: 3600. */
expiresIn: number;
}
export interface EimsLoginResponse {
data: EimsLoginData;
status: string;
}
/**
* Error bodies differ per failure mode: gateway errors carry `message`/`code`/`details`,
* schema errors carry a JSON-Schema violation array under `body`, rule errors carry
* `[{portion, errorMessage[]}]` under `body`. Only these fields are ever surfaced or logged.
*/
export interface EimsErrorResponse {
message?: string;
statusCode?: number;
code?: string;
details?: { errorMessage?: string; field?: string }[];
body?: unknown;
}

View File

@@ -0,0 +1,42 @@
import { BaseEntity } from "@edr/api-common";
import { Column, Entity } from "typeorm";
/**
* One row per MoR system number, holding the sequence state EIMS expects across registrations:
* the next `SourceSystem.InvoiceCounter` and the IRN that the next document must chain to via
* `ReferenceDetails.PreviousIrn`.
*
* Registration locks this row `FOR UPDATE` for the duration of the submission, which is what keeps
* two concurrent registrations from claiming the same counter or breaking the IRN chain.
*/
@Entity({ schema: "freight", name: "eims_system_state" })
export class EimsSystemState extends BaseEntity {
@Column({ name: "system_number", type: "varchar", length: 32, unique: true })
systemNumber!: string;
/** Counter to send on the next registration; advanced only once an attempt has consumed it. */
@Column({ name: "next_invoice_counter", type: "bigint", default: 1 })
nextInvoiceCounter!: number;
/** IRN of the last successful registration; null until the first one succeeds. */
@Column({ name: "previous_irn", type: "varchar", length: 64, nullable: true })
previousIrn?: string | null;
/**
* Invoice holding the current reservation. Committed before the HTTP call, so it survives a
* crash and blocks a blind resubmission of a document that may already have reached MoR.
*/
@Column({ name: "in_flight_invoice_id", type: "uuid", nullable: true })
inFlightInvoiceId?: string | null;
/** Counter handed to the in-flight submission. */
@Column({ name: "in_flight_counter", type: "bigint", nullable: true })
inFlightCounter?: number | null;
/**
* Why registration is blocked for this system number. Set when a submission ends ambiguously:
* the IRN is unknown, so no further document can chain correctly until it is resolved.
*/
@Column({ name: "blocked_reason", type: "text", nullable: true })
blockedReason?: string | null;
}

View File

@@ -10,7 +10,12 @@ import { FacilitiesService } from './facilities.service';
@ApiTags('Facilities')
@Controller('facilities')
@BookingStaff(FREIGHT_PERMS.facilities.view)
// Class gate lists every key its routes use: Nest runs class AND method
// guards, so a key missing here would deny before the route's own key runs.
@BookingStaff([
FREIGHT_PERMS.facilities.view,
FREIGHT_PERMS.facilities.manage,
])
export class FacilitiesController {
constructor(private readonly facilitiesService: FacilitiesService) {}

View File

@@ -19,7 +19,12 @@ import { RegisterDeviceDto, UpdateDeviceDto } from './dto/gps-device.dto';
@ApiTags('gps-tracking')
@ApiBearerAuth()
@Controller('gps')
@BookingStaff(FREIGHT_PERMS.tracking.view)
// Class gate lists every key its routes use: Nest runs class AND method
// guards, so a key missing here would deny before the route's own key runs.
@BookingStaff([
FREIGHT_PERMS.tracking.view,
FREIGHT_PERMS.tracking.manage,
])
export class GpsTrackingController {
constructor(private readonly gps: GpsTrackingService) {}

View File

@@ -22,7 +22,14 @@ import { IncidentStatus, IncidentType } from './entities/incident.entity';
// No incidents-specific permission exists in the registry, so this reuses the
// (real) drivers.* fleet-road keys — incident records are driver-safety data
// (driver stats / incident history). TODO: add a dedicated incidents:* key.
@BookingStaff(FREIGHT_PERMS.drivers.view)
// Class gate lists every key its routes use: Nest runs class AND method
// guards, so a key missing here would deny before the route's own key runs.
@BookingStaff([
FREIGHT_PERMS.drivers.view,
FREIGHT_PERMS.drivers.create,
FREIGHT_PERMS.drivers.update,
FREIGHT_PERMS.drivers.delete,
])
export class IncidentsController {
constructor(private readonly incidentsService: IncidentsService) {}

View File

@@ -15,7 +15,14 @@ import { InterchangeDocumentsService } from './interchange-documents.service';
@ApiBearerAuth()
@Controller('interchange-documents')
// Class-level view guard; each write route adds its own manage permission below.
@BookingStaff(FREIGHT_PERMS.interchangeDocuments.view)
// Class gate lists every key its routes use: Nest runs class AND method
// guards, so a key missing here would deny before the route's own key runs.
@BookingStaff([
FREIGHT_PERMS.interchangeDocuments.view,
FREIGHT_PERMS.interchangeDocuments.generate,
FREIGHT_PERMS.interchangeDocuments.acknowledge,
FREIGHT_PERMS.interchangeDocuments.dispute,
])
export class InterchangeDocumentsController {
constructor(private readonly service: InterchangeDocumentsService) {}

View File

@@ -1,6 +1,7 @@
import { NotificationAudience } from '@edr/types';
import { MaintenanceService } from './maintenance.service';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
/**
* The daily due-alert: a SCHEDULED item that crossed its km or date threshold
@@ -37,6 +38,10 @@ describe('MaintenanceService.sendDueAlerts', () => {
expect(notify).toHaveBeenCalledWith(
expect.objectContaining({
audience: NotificationAudience.BACKOFFICE,
// The fleet desk, not every employee in the company.
recipients: {
permissionKeys: [FREIGHT_PERMS.maintenance.getNotification],
},
title: 'Maintenance due — ET-9875',
body: expect.stringContaining('driven 50200 km (due at 50000 km)'),
}),

View File

@@ -15,6 +15,7 @@ import {
UpsertMaintenanceIntervalDto,
} from './dto/create-maintenance.dto';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
@Injectable()
export class MaintenanceService {
@@ -53,7 +54,9 @@ export class MaintenanceService {
? `driven ${item.currentKm} km (due at ${item.nextDueKm} km)`
: `due ${new Date(item.nextDueDate as Date).toLocaleDateString()}`;
await this.inbox.notify({
recipients: { allBackoffice: true },
recipients: {
permissionKeys: [FREIGHT_PERMS.maintenance.getNotification],
},
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.GENERIC,
title: `Maintenance due — ${item.plateNumber}`,

View File

@@ -14,7 +14,9 @@ import { ExternalProfileRepository } from "../companies/external-profile.reposit
* - `companyProfileId` → resolved to its company, then to that company's users.
* - `organizationId` → all current employees of the org (backoffice staff).
* - `permissionKeys` → current employees (any org) holding any of these
* permission keys (e.g. department/role-scoped targeting).
* permission keys — how every staff-facing notification is targeted. There is
* deliberately no "all backoffice" selector: staff notifications belong to a
* desk, and the `<module>:get_notification` keys name which one.
*/
@Injectable()
export class NotificationRecipientsService {
@@ -69,18 +71,6 @@ export class NotificationRecipientsService {
}
}
if (recipients.allBackoffice) {
try {
for (const uid of await this.backoffice.getAllCurrentEmployeeUserIds()) {
ids.add(uid);
}
} catch (err) {
this.logger.warn(
`Failed to resolve allBackoffice recipients: ${(err as Error).message}`,
);
}
}
if (recipients.permissionKeys?.length) {
try {
for (const uid of await this.backoffice.getEmployeeUserIdsByPermission(

View File

@@ -13,7 +13,14 @@ import {
@ApiTags('Procurement & Asset Lifecycle')
@Controller('procurement')
@BookingStaff(FREIGHT_PERMS.procurement.view)
// Class gate lists every key its routes use: Nest runs class AND method
// guards, so a key missing here would deny before the route's own key runs.
@BookingStaff([
FREIGHT_PERMS.procurement.view,
FREIGHT_PERMS.procurement.vendorManage,
FREIGHT_PERMS.procurement.acquisitionManage,
FREIGHT_PERMS.procurement.disposalManage,
])
export class ProcurementController {
constructor(private readonly procurementService: ProcurementService) {}

View File

@@ -27,7 +27,15 @@ import { RoutesService } from './routes.service';
@ApiTags('routes')
@ApiBearerAuth()
@Controller('routes')
@FleetView(FREIGHT_PERMS.routes.view)
// Class gate lists every key its routes use: Nest runs class AND method
// guards, so a key missing here would deny before the route's own key runs.
@FleetView([
FREIGHT_PERMS.routes.view,
FREIGHT_PERMS.routes.create,
FREIGHT_PERMS.routes.update,
FREIGHT_PERMS.routes.hardDelete,
FREIGHT_PERMS.routes.delete,
])
export class RoutesController {
constructor(private readonly routesService: RoutesService) {}

View File

@@ -22,6 +22,7 @@ import {
PriorityRuleChangeStatus,
} from '../entities/priority-rule-change-request.entity';
import { PriorityConfigsService } from './priority-configs.service';
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
/** Backoffice rule-engine page — where both queue and rules live. */
const RULES_LINK = '/dashboard/rules/priority-configs';
@@ -221,7 +222,9 @@ export class PriorityRuleChangeRequestsService {
): void {
void this.inbox
.notify({
recipients: { allBackoffice: true },
recipients: {
permissionKeys: [FREIGHT_PERMS.ruleEngine.getNotification],
},
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.REQUEST_SUBMITTED,
title,

View File

@@ -19,6 +19,7 @@ import {
} from '../entities/rate-change-request.entity';
import { Rate } from '../entities/rate.entity';
import { RatesService } from './rates.service';
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
/** Backoffice page where both the queue and the rates live. */
const RATES_LINK = '/dashboard/rules/rates';
@@ -234,7 +235,9 @@ export class RateChangeRequestsService {
private notifyTeam(title: string, body: string, request: RateChangeRequest): void {
void this.inbox
.notify({
recipients: { allBackoffice: true },
recipients: {
permissionKeys: [FREIGHT_PERMS.ruleEngine.getNotification],
},
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.REQUEST_SUBMITTED,
title,

View File

@@ -313,6 +313,8 @@ export class BookingWindowService implements OnModuleInit {
// Fire-and-forget: a slow SMS/email gateway must not stall the tick loop
// (the `ticking` guard would otherwise delay every schedule's transition).
void this.notifyWindowOpened(schedule);
// Intercity rides along whatever train passes, export included.
void this.notifyIntercityCorridorScheduled(schedule);
this.logger.log(`Export booking window opened for schedule ${schedule.id}`);
return true;
}
@@ -365,7 +367,10 @@ export class BookingWindowService implements OnModuleInit {
}
// Only announce the first opening of the day; reopen cycles don't re-notify.
// Fire-and-forget so a slow SMS/email gateway never stalls the tick loop.
if (schedule.bookingCycleNo === 1) void this.notifyWindowOpened(schedule);
if (schedule.bookingCycleNo === 1) {
void this.notifyWindowOpened(schedule);
void this.notifyIntercityCorridorScheduled(schedule);
}
this.logger.log(
`[WINDOW] ${schedule.id} PRE_WINDOW→OPEN — booking window opened ` +
`(cycle ${schedule.bookingCycleNo})`,
@@ -710,6 +715,149 @@ export class BookingWindowService implements OnModuleInit {
}
}
/**
* SMS + email + inbox the owner of every waiting intercity booking whose
* corridor lies on this schedule's route.
*
* Intercity (DOMESTIC) bookings carry no date — the customer books a corridor
* and the cargo waits in a pool until staff ride it along a passing
* import/export train (see IntercityService). Until now that wait was silent:
* `notifyWindowOpened` only reaches companies holding an ACTIVE contract whose
* `contract_routes` match the train's exact origin→destination, and an
* intercity booking is neither contracted nor necessarily end-to-end.
*
* Corridor match mirrors `IntercityService.corridorOnRoute` exactly — both
* yards on the route with origin strictly before destination, falling back to
* the train's own origin/destination when the route has fewer than two
* milestones — so nobody is told about a train they can never be placed on.
*/
private async notifyIntercityCorridorScheduled(
schedule: TrainSchedule,
): Promise<void> {
try {
const rows: Array<{
bookingId: string;
companyId: string;
phone: string | null;
email: string | null;
corridor: string;
}> = await this.dataSource.query(
// `stops` is the schedule's stop list, with the two-stop
// origin→destination pseudo-route as the legacy fallback — the same
// shape IntercityService.milestoneSequenceOf builds in TypeScript.
`WITH ms AS (
SELECT yard_id, sequence_no
FROM freight.route_milestones
WHERE route_id = $1 AND deleted_at IS NULL
),
stops AS (
SELECT yard_id, sequence_no FROM ms WHERE (SELECT count(*) FROM ms) >= 2
UNION ALL
SELECT v.yard_id, v.seq
FROM (VALUES ($2::uuid, 1), ($3::uuid, 2)) AS v(yard_id, seq)
WHERE (SELECT count(*) FROM ms) < 2
)
SELECT DISTINCT
b.id AS "bookingId",
b.company_id AS "companyId",
${companyNotifyPhoneExpr('co')} AS phone,
COALESCE(co.email, co.general_manager_email) AS email,
COALESCE(oy.label, oy.code) || ' to ' ||
COALESCE(dy.label, dy.code) AS corridor
FROM freight.bookings b
JOIN stops o ON o.yard_id = b.origin_yard_id
JOIN stops d ON d.yard_id = b.destination_yard_id
AND d.sequence_no > o.sequence_no
JOIN freight.companies co ON co.id = b.company_id AND co.deleted_at IS NULL
JOIN freight.yards oy ON oy.id = b.origin_yard_id
JOIN freight.yards dy ON dy.id = b.destination_yard_id
${primaryContactUserJoin('co')}
WHERE b.deleted_at IS NULL
AND b.trade_direction = 'DOMESTIC'
AND b.train_schedule_id IS NULL
-- Same waiting pool IntercityService.findWaitingIntercityBookings
-- draws candidates from: commercial paid/executed, government approved.
AND ((b.is_government = false AND b.status IN ('FULLY_EXECUTED', 'PAID'))
OR (b.is_government = true AND b.status = 'APPROVED'))
-- Once per booking, not once per train. A booking can sit in the
-- pool for weeks while several trains open a window on its corridor,
-- and "trains run your corridor, you are queued" is the same message
-- every time. The inbox row written below is the marker.
-- ponytail: unindexed jsonb probe over freight.notifications; add a
-- partial index on (data->>'intercityCorridorBookingId') if the
-- table grows enough for this to show up in the tick loop.
AND NOT EXISTS (
SELECT 1 FROM freight.notifications n
WHERE n.data->>'intercityCorridorBookingId' = b.id::text)`,
[schedule.routeId, schedule.originStationId, schedule.destinationStationId],
);
if (!rows.length) return;
const depart = schedule.scheduledDepartureDate.toLocaleDateString('en-GB', {
timeZone: BATCH_TIMEZONE,
});
const msgFor = (corridors: string[]) =>
`A train is scheduled on your intercity corridor ${corridors.join(', ')}, ` +
`departing ${depart}. EDR will confirm once your cargo is placed on a train.`;
// One inbox item per booking (its `data` is the once-per-booking marker
// the query above reads), but one SMS/email per company — a customer with
// three waiting bookings gets one message naming all three corridors.
const byCompany = new Map<
string,
{ phone: string | null; email: string | null; corridors: string[] }
>();
for (const row of rows) {
const entry = byCompany.get(row.companyId) ?? {
phone: row.phone,
email: row.email,
corridors: [],
};
if (!entry.corridors.includes(row.corridor)) entry.corridors.push(row.corridor);
byCompany.set(row.companyId, entry);
await this.inbox.notify({
recipients: { companyId: row.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.SCHEDULE_UPDATE,
title: 'Train scheduled on your corridor',
body: msgFor([row.corridor]),
link: `/bookings/${row.bookingId}`,
data: {
intercityCorridorBookingId: row.bookingId,
trainScheduleId: schedule.id,
},
});
}
for (const [companyId, entry] of byCompany) {
const msg = msgFor(entry.corridors);
if (entry.phone) {
await this.notifications
.directSend('sms', entry.phone, msg)
.catch((e) =>
this.logger.warn(`Intercity corridor SMS failed: ${(e as Error).message}`),
);
}
if (entry.email) {
await this.notifications
.directSend('email', entry.email, msg)
.catch((e) =>
this.logger.warn(`Intercity corridor email failed: ${(e as Error).message}`),
);
}
this.logger.log(
`Notified company ${companyId} of ${entry.corridors.length} intercity ` +
`corridor(s) served by schedule ${schedule.id}`,
);
}
} catch (err) {
this.logger.warn(
`notifyIntercityCorridorScheduled failed for ${schedule.id}: ${(err as Error).message}`,
);
}
}
private async setPhase(
schedule: TrainSchedule,
patch: Partial<

View File

@@ -0,0 +1,110 @@
import { BookingWindowService } from './booking-window.service';
import type { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
/**
* Intercity corridor announcement: when a train's booking window opens, every
* customer with a waiting intercity booking on that corridor is told over SMS,
* email and the portal inbox.
*
* The corridor SQL itself is EXPLAIN-validated against the dev database; what
* this covers is the fan-out shape around it — one inbox row per booking
* (that row's `data` is the once-per-booking marker the query dedupes on) but
* one SMS/email per company, naming every corridor at once.
*/
describe('BookingWindowService — intercity corridor announcement', () => {
const schedule = {
id: 'sched-1',
routeId: 'route-1',
originStationId: 'yard-o',
destinationStationId: 'yard-d',
scheduledDepartureDate: new Date('2026-08-01T06:00:00.000Z'),
} as unknown as TrainSchedule;
const build = (rows: unknown[]) => {
const query = jest.fn().mockResolvedValue(rows);
const directSend = jest.fn().mockResolvedValue(undefined);
const notify = jest.fn().mockResolvedValue(undefined);
const service = new BookingWindowService(
{ query, getRepository: () => ({ update: jest.fn() }) } as never,
{ findById: jest.fn(), findAll: jest.fn() } as never,
{} as never,
{} as never,
{ directSend } as never,
{ notify } as never,
{ emitPhase: jest.fn() } as never,
);
const run = (): Promise<void> =>
(
service as unknown as {
notifyIntercityCorridorScheduled: (s: TrainSchedule) => Promise<void>;
}
).notifyIntercityCorridorScheduled(schedule);
return { run, query, directSend, notify };
};
it('sends one inbox item per booking and one SMS/email per company', async () => {
const { run, directSend, notify } = build([
{
bookingId: 'bk-1',
companyId: 'co-1',
phone: '+251900000001',
email: 'ops@co1.example',
corridor: 'Dire Dawa to Adama',
},
{
bookingId: 'bk-2',
companyId: 'co-1',
phone: '+251900000001',
email: 'ops@co1.example',
corridor: 'Adama to Mojo',
},
]);
await run();
// Per booking: the marker keeps the next train on this corridor from
// re-announcing the same thing to the same booking.
expect(notify).toHaveBeenCalledTimes(2);
expect(notify.mock.calls.map((c) => c[0].data.intercityCorridorBookingId)).toEqual([
'bk-1',
'bk-2',
]);
expect(notify.mock.calls[0][0].recipients).toEqual({ companyId: 'co-1' });
expect(notify.mock.calls[0][0].link).toBe('/bookings/bk-1');
// Per company: two bookings, one SMS and one email, both corridors named.
expect(directSend).toHaveBeenCalledTimes(2);
const [smsChannel, smsTo, smsBody] = directSend.mock.calls[0];
expect([smsChannel, smsTo]).toEqual(['sms', '+251900000001']);
expect(smsBody).toContain('Dire Dawa to Adama, Adama to Mojo');
expect(smsBody).toContain('01/08/2026');
expect(directSend.mock.calls[1][0]).toBe('email');
});
it('sends nothing when no waiting booking rides this corridor', async () => {
const { run, directSend, notify } = build([]);
await run();
expect(notify).not.toHaveBeenCalled();
expect(directSend).not.toHaveBeenCalled();
});
it('skips the channels a company has no contact for', async () => {
const { run, directSend, notify } = build([
{
bookingId: 'bk-3',
companyId: 'co-2',
phone: null,
email: 'ops@co2.example',
corridor: 'Dire Dawa to Adama',
},
]);
await run();
expect(notify).toHaveBeenCalledTimes(1);
expect(directSend).toHaveBeenCalledTimes(1);
expect(directSend.mock.calls[0][0]).toBe('email');
});
});

View File

@@ -31,7 +31,15 @@ import { TrainBuilderService } from './train-builder.service';
@ApiTags('train-builder')
@ApiBearerAuth()
@Controller('train-builder')
@FleetView(FREIGHT_PERMS.trains.view)
// Class gate lists every key its routes use: Nest runs class AND method
// guards, so a key missing here would deny before the route's own key runs.
@FleetView([
FREIGHT_PERMS.trains.view,
FREIGHT_PERMS.trains.create,
FREIGHT_PERMS.trains.update,
FREIGHT_PERMS.trains.assignWagons,
FREIGHT_PERMS.trains.delete,
])
export class TrainBuilderController {
constructor(private readonly trainBuilderService: TrainBuilderService) {}

View File

@@ -19,7 +19,14 @@ import { TrainsService } from "./trains.service";
@ApiTags("trains")
@Controller("trains")
@FleetView(FREIGHT_PERMS.trains.view)
// Class gate lists every key its routes use: Nest runs class AND method
// guards, so a key missing here would deny before the route's own key runs.
@FleetView([
FREIGHT_PERMS.trains.view,
FREIGHT_PERMS.trains.create,
FREIGHT_PERMS.trains.update,
FREIGHT_PERMS.trains.delete,
])
export class TrainsController {
constructor(private readonly trainsService: TrainsService) {}

View File

@@ -20,7 +20,14 @@ import { FleetHistoryService } from '../fleet-history/fleet-history.service';
@ApiTags('vehicles')
@ApiBearerAuth()
@Controller('vehicles')
@BookingStaff(FREIGHT_PERMS.vehicles.view)
// Class gate lists every key its routes use: Nest runs class AND method
// guards, so a key missing here would deny before the route's own key runs.
@BookingStaff([
FREIGHT_PERMS.vehicles.view,
FREIGHT_PERMS.vehicles.create,
FREIGHT_PERMS.vehicles.update,
FREIGHT_PERMS.vehicles.delete,
])
export class VehiclesController {
constructor(
private readonly vehiclesService: VehiclesService,

View File

@@ -8,6 +8,7 @@ import { NotificationInboxService } from '../notification-inbox/notification-inb
import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto';
import { FeeRuleBasis, FeeRuleType, WarehouseFeeRule, WarehouseFeeTier } from './entities/warehouse-fee-rule.entity';
import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
interface ItemAttributes {
arrivedAt: Date | null;
@@ -178,7 +179,11 @@ export class WarehouseFeeService {
const total = alerts.reduce((sum, r) => sum + r.accruedAmount, 0);
try {
await this.inbox.notify({
recipients: { allBackoffice: true },
recipients: {
permissionKeys: [
FREIGHT_PERMS.warehouseFeeInvoices.getNotification,
],
},
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.BOOKING_STATUS,
title: 'Warehouse fee accruals need attention',

View File

@@ -23,9 +23,13 @@ import { WarehouseInspectionService } from './warehouse-inspection.service';
// Baseline read: inspection reports are opened from inventory screens too —
// either view permission grants reads; writes stack their own per route.
@Controller()
// Class gate lists every key its routes use: Nest runs class AND method
// guards, so a key missing here would deny before the route's own key runs.
@BookingStaff([
FREIGHT_PERMS.warehouseInspectionReports.view,
FREIGHT_PERMS.warehouseInventory.view,
FREIGHT_PERMS.warehouseInspectionReports.create,
FREIGHT_PERMS.warehouseInspectionReports.update,
])
export class WarehouseInspectionController {
constructor(private readonly inspectionService: WarehouseInspectionService) {}

View File

@@ -12,7 +12,13 @@ import { WarehouseZonesService } from './warehouse-zones.service';
// receive/move pickers) — either view permission grants reads; writes stack
// their specific permission per route.
@Controller('warehouse-zones')
@BookingStaff([FREIGHT_PERMS.warehouseZones.view, FREIGHT_PERMS.warehouseInventory.view])
// Class gate lists every key its routes use: Nest runs class AND method
// guards, so a key missing here would deny before the route's own key runs.
@BookingStaff([
FREIGHT_PERMS.warehouseZones.view,
FREIGHT_PERMS.warehouseInventory.view,
FREIGHT_PERMS.warehouseZones.update,
])
export class WarehouseZonesController {
constructor(private readonly zonesService: WarehouseZonesService) {}

View File

@@ -0,0 +1,40 @@
import "dotenv/config";
import axios from "axios";
import { HttpService } from "@nestjs/axios";
import { ConfigService } from "@nestjs/config";
import eimsConfig, { EimsConfig } from "../config/eims.config";
import { EimsAuthService } from "../modules/eims/eims-auth.service";
import { EimsCredentialsProvider } from "../modules/eims/eims-credentials.provider";
import { EimsSignerService } from "../modules/eims/eims-signer.service";
/**
* Manual, developer-run live check of EIMS authentication.
*
* Run explicitly: pnpm --filter @edr/freight-api eims:login
*
* Reads credentials from the local .env only. Never runs at boot, never runs in the test suite,
* and prints no token, secret, signature or certificate — only whether login succeeded.
*/
async function main(): Promise<void> {
const config = eimsConfig() as EimsConfig;
if (!config.enabled) {
throw new Error("EIMS_ENABLED is not true — set it in .env before running this check");
}
const configService = { get: () => config } as unknown as ConfigService;
const http = new HttpService(axios.create());
const credentials = new EimsCredentialsProvider(configService);
const auth = new EimsAuthService(http, configService, new EimsSignerService(credentials));
console.log(`POST ${config.baseUrl}/auth/login (tin=${config.tin})`);
const token = await auth.getValidAccessToken();
console.log(`✔ login succeeded — access token received (${token.length} chars, not printed)`);
const cached = await auth.getValidAccessToken();
console.log(`✔ second call served from cache: ${cached === token}`);
}
main().catch((err: Error) => {
console.error(`${err.message}`);
process.exitCode = 1;
});

View File

@@ -1,6 +1,7 @@
import {
BOOKING_RULE_ENGINE_PERMISSIONS,
BOOKING_RULE_ENGINE_PERMISSION_KEYS,
deriveReadPermissions,
POSITION_PERMISSION_PRESETS,
ROLE_PERMISSION_PRESETS,
} from './freight-permissions.registry';
@@ -190,7 +191,7 @@ const POSITION_TYPE_PERMISSIONS = [
},
] as const;
export const EDR_FREIGHT_PERMISSIONS = [
const EDR_FREIGHT_VIEWABLE_PERMISSIONS = [
...EMPLOYEE_REGISTRATION_PERMISSIONS,
...ROLE_ASSIGNMENT_PERMISSIONS,
...HIERARCHY_UNIT_PERMISSIONS,
@@ -200,6 +201,15 @@ export const EDR_FREIGHT_PERMISSIONS = [
...BOOKING_RULE_ENGINE_PERMISSIONS,
];
export const EDR_FREIGHT_PERMISSIONS = [
...EDR_FREIGHT_VIEWABLE_PERMISSIONS,
// API-read twin of every `:view` key above — grants the module's GET routes
// without putting it in the backoffice sidebar. Seeded so they can be
// assigned; no role or position preset below grants one, that stays
// hand-curated in iam.position_type_permissions.
...deriveReadPermissions(EDR_FREIGHT_VIEWABLE_PERMISSIONS),
];
export { BOOKING_RULE_ENGINE_PERMISSION_KEYS } from './freight-permissions.registry';
export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [

View File

@@ -0,0 +1,103 @@
import { Injectable, Logger } from '@nestjs/common';
import { Application, Permission } from '@tria-plc/iamapi-common';
import { DataSource } from 'typeorm';
import {
NOTIFICATION_PERMISSIONS,
NOTIFICATION_PERMISSION_ANCHORS,
} from './freight-permissions.registry';
const EDR_FREIGHT_APP_KEY = 'edr_freight_app';
/**
* The three tables a permission grant can arrive on, with the column naming
* the grantee. These are compile-time constants — the only values interpolated
* into the SQL below.
*/
const CARRIERS = [
['iam.position_permissions', 'position_id'],
['iam.position_type_permissions', 'position_type_id'],
['iam.role_permissions', 'role_id'],
] as const;
/**
* Backfills the `<module>:get_notification` keys.
*
* Those keys are new, so on deploy nobody holds them and every staff
* notification would resolve to zero recipients — silently, because
* `NotificationInboxService.notify` logs an empty resolve at debug level. This
* grants each new key to whoever already holds that desk's anchor key (the
* permission gating the page the notification links to), across all three
* carriers.
*
* Deliberately NOT gated behind SEED_EDR_ORG: that flag is off everywhere
* except e2e/integration, and this has to run wherever the notifications do.
* For the same reason it inserts the permission rows itself rather than
* trusting EdrOrgSeeder. Idempotent — safe on every boot, and safe to leave in
* place permanently.
*/
@Injectable()
export class FreightNotificationPermissionsSeeder {
private readonly logger = new Logger(FreightNotificationPermissionsSeeder.name);
constructor(private readonly dataSource: DataSource) {}
async run(): Promise<void> {
const application = await this.dataSource
.getRepository(Application)
.findOne({ where: { key: EDR_FREIGHT_APP_KEY }, select: { id: true } });
if (!application?.id) {
this.logger.warn(
`Application '${EDR_FREIGHT_APP_KEY}' not found — notification permission backfill skipped`,
);
return;
}
// Ids are left to the column default and never sent: iam.permissions has
// two unique columns (PK id, UQ key) and ON CONFLICT can only target one,
// so a hand-minted id that some retired key already owns would slip past
// ON CONFLICT (key) and die on the PK. Same reasoning as EdrOrgSeeder.
await this.dataSource
.getRepository(Permission)
.createQueryBuilder()
.insert()
.values(
NOTIFICATION_PERMISSIONS.map((permission) => ({
key: permission.key,
name: { ...permission.name },
applicationId: application.id as string,
})),
)
.orIgnore()
.execute();
let granted = 0;
for (const [key, anchors] of Object.entries(
NOTIFICATION_PERMISSION_ANCHORS,
)) {
for (const [table, granteeColumn] of CARRIERS) {
const result: unknown = await this.dataSource.query(
`INSERT INTO ${table} (${granteeColumn}, permission_id)
SELECT DISTINCT g.${granteeColumn}, target.id
FROM ${table} g
JOIN iam.permissions anchor
ON anchor.id = g.permission_id AND anchor.key = ANY($1)
JOIN iam.permissions target ON target.key = $2
WHERE NOT EXISTS (
SELECT 1
FROM ${table} existing
WHERE existing.${granteeColumn} = g.${granteeColumn}
AND existing.permission_id = target.id)`,
[anchors, key],
);
// node-postgres returns [rows, rowCount] for a bare INSERT.
granted += (Array.isArray(result) ? (result[1] as number) : 0) ?? 0;
}
}
this.logger.log(
`Ensured ${NOTIFICATION_PERMISSIONS.length} notification permissions, backfilled ${granted} grant(s)`,
);
}
}

View File

@@ -1,4 +1,9 @@
import { EDR_FREIGHT_PERMISSIONS } from './edr-freight.seed';
import {
NOTIFICATION_PERMISSIONS,
NOTIFICATION_PERMISSION_ANCHORS,
readTwinOf,
} from './freight-permissions.registry';
describe('EDR_FREIGHT_PERMISSIONS', () => {
// The seeder inserts the whole catalog in one ON CONFLICT (key) DO UPDATE
@@ -10,4 +15,73 @@ describe('EDR_FREIGHT_PERMISSIONS', () => {
expect(duplicates).toEqual([]);
});
// The `:read` ids are derived rather than hand-written, so a collision
// would surface here instead of as a primary-key violation on a fresh
// database.
it('has no duplicate ids', () => {
const ids = EDR_FREIGHT_PERMISSIONS.map((permission) => permission.id);
const duplicates = [...new Set(ids.filter((id, i) => ids.indexOf(id) !== i))];
expect(duplicates).toEqual([]);
});
// `:read` grants a module's GET routes without putting it in the backoffice
// sidebar. Every `:view` needs its twin or that module has no way to be
// granted API-only access.
it('gives every :view key a :read twin', () => {
const keys = new Set(EDR_FREIGHT_PERMISSIONS.map((p) => p.key));
const missing = [...keys]
.filter((key) => key.endsWith(':view'))
.map((key) => readTwinOf(key))
.filter((twin): twin is string => twin !== null && !keys.has(twin));
expect(missing).toEqual([]);
});
it('mints every :read id in the v5 block', () => {
const reads = EDR_FREIGHT_PERMISSIONS.filter((p) => p.key.endsWith(':read'));
expect(reads.length).toBeGreaterThan(0);
// Version nibble 5 — the source `:view` ids are all v4, so the two sets
// cannot overlap however many keys are added.
for (const read of reads) {
expect(read.id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/,
);
}
});
});
describe('NOTIFICATION_PERMISSION_ANCHORS', () => {
const catalog = new Set(EDR_FREIGHT_PERMISSIONS.map((p) => p.key));
// FreightNotificationPermissionsSeeder backfills each get_notification key
// onto whoever holds its anchor. A typo'd anchor matches no permission row,
// so the key is granted to nobody and every notification on that desk
// silently resolves to zero recipients — notify() logs that at debug level.
it('anchors every notification key on keys that exist', () => {
const missing = Object.values(NOTIFICATION_PERMISSION_ANCHORS)
.flat()
.filter((key) => !catalog.has(key));
expect(missing).toEqual([]);
});
it('seeds every notification key into the catalog', () => {
const missing = NOTIFICATION_PERMISSIONS.map((p) => p.key).filter(
(key) => !catalog.has(key),
);
expect(missing).toEqual([]);
});
it('gives every notification key an anchor to backfill from', () => {
const anchored = new Set(Object.keys(NOTIFICATION_PERMISSION_ANCHORS));
const unanchored = NOTIFICATION_PERMISSIONS.map((p) => p.key).filter(
(key) => !anchored.has(key),
);
expect(unanchored).toEqual([]);
});
});

View File

@@ -31,6 +31,15 @@ export type RuleEngineResourceSlug =
const slugToResourceKey = (slug: RuleEngineResourceSlug): string =>
slug.replace(/-/g, "_");
const VIEW_KEY_SUFFIX = ":view";
const READ_KEY_SUFFIX = ":read";
/** The `:read` twin of a `:view` key, or null if `key` is not a view key. */
export const readTwinOf = (key: string): string | null =>
key.endsWith(VIEW_KEY_SUFFIX)
? `${key.slice(0, -VIEW_KEY_SUFFIX.length)}${READ_KEY_SUFFIX}`
: null;
const perm = (id: string, key: string, en: string): FreightPermissionSeed => ({
id,
key,
@@ -458,6 +467,21 @@ export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [
"edr_freight_app:invoices:export",
"Download invoice document",
),
// Filing with the tax authority is its own grant: registration is irreversible at MoR, so it
// must not ride along with the right to download an invoice PDF.
perm(
"d2b00001-0001-4000-8000-000000000005",
"edr_freight_app:invoices:eims_register",
"Register invoice with MoR EIMS",
),
// Separate from registering: resolving an unacknowledged submission clears the
// system-wide chain block and can record an IRN against an invoice, so it is a
// supervisor/admin action rather than an operational one.
perm(
"d2b00001-0001-4000-8000-000000000006",
"edr_freight_app:invoices:eims_resolve",
"Resolve a blocked MoR EIMS submission",
),
];
// E. First / last mile operations
@@ -1333,6 +1357,54 @@ export const AUDIENCE_GAP_PERMISSIONS: FreightPermissionSeed[] = [
),
];
// O. Notification recipient selectors. NOT route guards — these are never
// passed to FreightPermissionGuard/assertFreightPermission and never appear in
// the frontend's RequirePermission. They exist so ops can tune who gets pinged
// WITHOUT changing who can open the page. Before them every staff notification
// used the `allBackoffice` selector, i.e. every current employee in every org.
export const NOTIFICATION_PERMISSIONS: FreightPermissionSeed[] = [
perm(
"f3a00001-0001-4000-8000-000000000001",
"edr_freight_app:bookings:get_notification",
"Receive booking desk notifications",
),
perm(
"f3a00001-0001-4000-8000-000000000002",
"edr_freight_app:bookings:clearance_get_notification",
"Receive booking clearance notifications",
),
perm(
"f3a00001-0001-4000-8000-000000000003",
"edr_freight_app:contracts:get_notification",
"Receive contract desk notifications",
),
perm(
"f3a00001-0001-4000-8000-000000000004",
"edr_freight_app:contracts:clearance_get_notification",
"Receive contract clearance notifications",
),
perm(
"f3a00001-0001-4000-8000-000000000005",
"edr_freight_app:customers:get_notification",
"Receive customer desk notifications",
),
perm(
"f3a00001-0001-4000-8000-000000000006",
"edr_freight_app:maintenance:get_notification",
"Receive maintenance due notifications",
),
perm(
"f3a00001-0001-4000-8000-000000000007",
"edr_freight_app:rule_engine:get_notification",
"Receive rule-change approval notifications",
),
perm(
"f3a00001-0001-4000-8000-000000000008",
"edr_freight_app:warehouse_fee_invoices:get_notification",
"Receive warehouse fee accrual notifications",
),
];
export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [
...CUSTOMER_PERMISSIONS,
...FINANCE_PERMISSIONS,
@@ -1346,6 +1418,7 @@ export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [
...STAFF_IAM_PERMISSIONS,
...AUDIENCE_GAP_PERMISSIONS,
...GRANULAR_SPLIT_PERMISSIONS,
...NOTIFICATION_PERMISSIONS,
];
export const BOOKING_RULE_ENGINE_PERMISSIONS = [
@@ -1359,6 +1432,59 @@ export const BOOKING_RULE_ENGINE_PERMISSIONS = [
export const BOOKING_RULE_ENGINE_PERMISSION_KEYS =
BOOKING_RULE_ENGINE_PERMISSIONS.map((p) => p.key);
/**
* Id for a derived `:read` key: the source `:view` id with its version nibble
* moved 4 → 5.
*
* Unique because the source ids are, and disjoint from every hand-written id
* because those are all v4-shaped. Nothing index-derived, so a new `:view`
* landing mid-list cannot shift ids already seeded — the trap the
* RULE_ENGINE_RESOURCE_SLUGS comment warns about.
*
* (`EdrOrgSeeder.ensurePermissions` deliberately never sends an id — the
* column default wins and `key` is the identity every consumer resolves by.
* These exist to satisfy the seed type and keep the array self-consistent.)
*/
const readPermissionId = (viewId: string): string =>
`${viewId.slice(0, 14)}5${viewId.slice(15)}`;
/**
* API-read twin of every `:view` key.
*
* `:view` does three jobs at once — backoffice sidebar entry (App.tsx), route
* admission (RequirePermission), and API read. That bundling means a user who
* only needs another module's list endpoint for a form dropdown has to be
* granted the whole module, page and all. `<module>:read` unbundles it: the
* guard accepts it wherever the matching `:view` is required on a GET, and the
* frontend never looks at it, so the module stays out of the menu.
*
* Derived rather than hand-written so a new `:view` key gets its twin for
* free. Grants stay hand-curated in `iam.position_type_permissions` — nothing
* here hands a `:read` to anyone.
*/
export const deriveReadPermissions = (
seeds: readonly FreightPermissionSeed[],
): FreightPermissionSeed[] =>
seeds
.filter((p) => p.key.endsWith(VIEW_KEY_SUFFIX))
.map((p) =>
perm(
readPermissionId(p.id),
readTwinOf(p.key) as string,
`Read ${p.name.en.replace(/^View /, "")} (API only)`,
),
);
/**
* Read twins of the freight-domain catalog, for `PERMISSIONS_CATALOG`. The
* IAM/hierarchy keys seeded alongside it live in `edr-freight.seed.ts` and get
* theirs there — each twin is derived from its own source row, so the two call
* sites agree on any key they share without coordination.
*/
export const FREIGHT_READ_PERMISSIONS = deriveReadPermissions(
BOOKING_RULE_ENGINE_PERMISSIONS,
);
export const FREIGHT_PERMS = {
bookings: {
view: "edr_freight_app:bookings:view",
@@ -1384,6 +1510,10 @@ export const FREIGHT_PERMS = {
wagonCancellationVoid: "edr_freight_app:bookings:wagon_cancellation_void",
wagonCancellationRebook:
"edr_freight_app:bookings:wagon_cancellation_rebook",
// Notification selectors, not route guards — see NOTIFICATION_PERMISSIONS.
getNotification: "edr_freight_app:bookings:get_notification",
clearanceGetNotification:
"edr_freight_app:bookings:clearance_get_notification",
},
contracts: {
view: "edr_freight_app:contracts:view",
@@ -1420,6 +1550,10 @@ export const FREIGHT_PERMS = {
editDocument: "edr_freight_app:contracts:edit_document",
finalInvoiceRaise: "edr_freight_app:contracts:final_invoice_raise",
finalInvoiceConfirm: "edr_freight_app:contracts:final_invoice_confirm",
// Notification selectors, not route guards — see NOTIFICATION_PERMISSIONS.
getNotification: "edr_freight_app:contracts:get_notification",
clearanceGetNotification:
"edr_freight_app:contracts:clearance_get_notification",
},
trainScheduling: {
view: "edr_freight_app:train_scheduling:view",
@@ -1448,6 +1582,10 @@ export const FREIGHT_PERMS = {
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:delete`,
approve: (slug: RuleEngineApprovableSlug) =>
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:approve`,
// Notification selector, not a route guard — see NOTIFICATION_PERMISSIONS.
// One key for the whole rules desk: every preset grants the rule-engine
// view keys as a block, and both producers link to /dashboard/rules/*.
getNotification: "edr_freight_app:rule_engine:get_notification",
},
allocation: {
manage: "edr_freight_app:allocation:manage",
@@ -1459,6 +1597,8 @@ export const FREIGHT_PERMS = {
deactivate: "edr_freight_app:customers:deactivate",
verify: "edr_freight_app:customers:verify",
resetPassword: "edr_freight_app:customers:reset-password",
// Notification selector, not a route guard — see NOTIFICATION_PERMISSIONS.
getNotification: "edr_freight_app:customers:get_notification",
},
payments: {
view: "edr_freight_app:payments:view",
@@ -1466,6 +1606,8 @@ export const FREIGHT_PERMS = {
invoices: {
view: "edr_freight_app:invoices:view",
export: "edr_freight_app:invoices:export",
eimsRegister: "edr_freight_app:invoices:eims_register",
eimsResolve: "edr_freight_app:invoices:eims_resolve",
},
firstMile: {
view: "edr_freight_app:first_mile:view",
@@ -1584,6 +1726,8 @@ export const FREIGHT_PERMS = {
create: "edr_freight_app:maintenance:create",
update: "edr_freight_app:maintenance:update",
delete: "edr_freight_app:maintenance:delete",
// Notification selector, not a route guard — see NOTIFICATION_PERMISSIONS.
getNotification: "edr_freight_app:maintenance:get_notification",
},
fleetReports: {
view: "edr_freight_app:fleet_reports:view",
@@ -1653,6 +1797,8 @@ export const FREIGHT_PERMS = {
generate: "edr_freight_app:warehouse_fee_invoices:generate",
cancel: "edr_freight_app:warehouse_fee_invoices:cancel",
pay: "edr_freight_app:warehouse_fee_invoices:pay",
// Notification selector, not a route guard — see NOTIFICATION_PERMISSIONS.
getNotification: "edr_freight_app:warehouse_fee_invoices:get_notification",
},
settings: {
fileUpload: {
@@ -1757,6 +1903,40 @@ export const FREIGHT_PERMS = {
},
} as const;
/**
* Backfill sources for the `<module>:get_notification` keys: a new key is
* granted to whoever already holds ANY of its anchors. The anchor is the key
* that gates the page the notification deep-links to — if you cannot open the
* page, you were never the intended recipient. The rule-engine desk has no page
* of its own, so it anchors on the keys that let you ACT on a filed change.
*
* Consumed only by FreightNotificationPermissionsSeeder. Keeping it here means
* the registry spec can assert every anchor still exists in the catalog — a
* typo'd anchor backfills nobody, silently.
*/
export const NOTIFICATION_PERMISSION_ANCHORS: Record<string, string[]> = {
[FREIGHT_PERMS.bookings.getNotification]: [FREIGHT_PERMS.bookings.view],
[FREIGHT_PERMS.bookings.clearanceGetNotification]: [
FREIGHT_PERMS.bookings.clearanceView,
],
[FREIGHT_PERMS.contracts.getNotification]: [FREIGHT_PERMS.contracts.view],
[FREIGHT_PERMS.contracts.clearanceGetNotification]: [
FREIGHT_PERMS.contracts.clearanceReview,
FREIGHT_PERMS.contracts.clearanceEtActions,
FREIGHT_PERMS.contracts.clearanceDjActions,
FREIGHT_PERMS.contracts.opsClearanceReview,
],
[FREIGHT_PERMS.customers.getNotification]: [FREIGHT_PERMS.customers.view],
[FREIGHT_PERMS.maintenance.getNotification]: [FREIGHT_PERMS.maintenance.view],
[FREIGHT_PERMS.ruleEngine.getNotification]: [
FREIGHT_PERMS.ruleEngine.approve("rates"),
FREIGHT_PERMS.ruleEngine.update("priority-configs"),
],
[FREIGHT_PERMS.warehouseFeeInvoices.getNotification]: [
FREIGHT_PERMS.warehouseFeeInvoices.view,
],
};
/** Both arms of a freight-type-split permission (for one-of route guards). */
export const bothFreightTypes = (p: {
bulk: string;
@@ -1815,6 +1995,19 @@ const STAFF_DASHBOARD_KEYS: string[] = [
FREIGHT_PERMS.reports.view,
];
// Notification desks — recipient selectors, not access. A preset gets a desk
// key only where it actually works that queue, which is why the GL presets take
// the clearance pair and nothing else: they hold no bookings:view and no intake
// keys, so intake pings would only be noise they cannot act on.
const BOOKING_DESK_NOTIFICATION_KEYS: string[] = [
FREIGHT_PERMS.bookings.getNotification,
FREIGHT_PERMS.contracts.getNotification,
];
const CLEARANCE_DESK_NOTIFICATION_KEYS: string[] = [
FREIGHT_PERMS.bookings.clearanceGetNotification,
FREIGHT_PERMS.contracts.clearanceGetNotification,
];
export const ROLE_PERMISSION_PRESETS = {
// Marketing / line staff: drives a booking from intake through line-staff
// approval and contract generation/signing — i.e. until the contract is ready
@@ -1837,6 +2030,8 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.contracts.approveLineStaff,
FREIGHT_PERMS.contracts.editDocument,
...allRuleEngineViewKeys(),
...BOOKING_DESK_NOTIFICATION_KEYS,
FREIGHT_PERMS.ruleEngine.getNotification,
],
// Operations Officer: train scheduling + wagon allocation + transit/complete
// + fleet management (wagons, trains, locomotives, routes, containers, cargo).
@@ -1869,6 +2064,11 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.bookings.reviewDocuments,
FREIGHT_PERMS.bookings.finalizeClearance,
...allRuleEngineViewKeys(),
...BOOKING_DESK_NOTIFICATION_KEYS,
// They run Path A clearance review from the booking detail, so the booking
// clearance desk is theirs too — but not the contract one, which is GL's.
FREIGHT_PERMS.bookings.clearanceGetNotification,
FREIGHT_PERMS.ruleEngine.getNotification,
],
director: [
...STAFF_DASHBOARD_KEYS,
@@ -1880,6 +2080,7 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.contracts.approveDirector,
FREIGHT_PERMS.contracts.generateContract,
...allRuleEngineViewKeys(),
...BOOKING_DESK_NOTIFICATION_KEYS,
],
ceo: [
...STAFF_DASHBOARD_KEYS,
@@ -1889,12 +2090,17 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.contracts.view,
FREIGHT_PERMS.contracts.approveCeo,
...allRuleEngineViewKeys(),
...BOOKING_DESK_NOTIFICATION_KEYS,
],
finance: [
...STAFF_DASHBOARD_KEYS,
FREIGHT_PERMS.bookings.view,
FREIGHT_PERMS.invoices.view,
FREIGHT_PERMS.invoices.export,
// Deliberately NOT granted here: invoices:eims_register and invoices:eims_resolve.
// Invoices are filed with MoR by the workflow, not by a person, so filing is not a
// Finance job function — the endpoints exist for controlled testing and exceptional
// operations, and are assigned to named admins rather than a role preset.
FREIGHT_PERMS.payments.view,
FREIGHT_PERMS.bookings.wagonCancellationView,
],
@@ -1920,6 +2126,7 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.bookings.uploadClearanceOutput,
FREIGHT_PERMS.bookings.finalizeClearance,
FREIGHT_PERMS.bookings.operations,
...CLEARANCE_DESK_NOTIFICATION_KEYS,
],
// GL Djibouti (edr_gl_djibouti): DO/RO collection, gatepass, loading milestones,
// damage reports. Read-only on the contract; no booking creation.
@@ -1931,6 +2138,7 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.bookings.clearanceView,
FREIGHT_PERMS.bookings.uploadClearanceOutput,
FREIGHT_PERMS.bookings.operations,
...CLEARANCE_DESK_NOTIFICATION_KEYS,
],
// Marketing handles intake through contract (same as line staff here) and,
// for non-customs bookings, reviews/finalizes the customer's clearance
@@ -1960,6 +2168,7 @@ export const ROLE_PERMISSION_PRESETS = {
...bothFreightTypes(FREIGHT_PERMS.contracts.signStaff),
FREIGHT_PERMS.contracts.suspend,
FREIGHT_PERMS.contracts.editDocument,
...BOOKING_DESK_NOTIFICATION_KEYS,
],
orgManager: [...BOOKING_RULE_ENGINE_PERMISSION_KEYS],
} as const;
@@ -1986,6 +2195,8 @@ export const POSITION_PERMISSION_PRESETS = {
FREIGHT_PERMS.customers.view,
FREIGHT_PERMS.customers.verify,
FREIGHT_PERMS.customers.deactivate,
// …and therefore the profile-review pings company-notifier emits.
FREIGHT_PERMS.customers.getNotification,
// The chief also owns the customer-facing support inbox.
FREIGHT_PERMS.support.agentView,
FREIGHT_PERMS.support.agentSend,
@@ -2079,7 +2290,10 @@ export const POSITION_PERMISSION_PRESETS = {
/** Derive the module bucket from the resource segment of a permission key. */
const moduleOf = (key: string): string => key.split(":")[1] ?? "other";
export const PERMISSIONS_CATALOG = BOOKING_RULE_ENGINE_PERMISSIONS.map((p) => ({
export const PERMISSIONS_CATALOG = [
...BOOKING_RULE_ENGINE_PERMISSIONS,
...FREIGHT_READ_PERMISSIONS,
].map((p) => ({
key: p.key,
label: p.name.en,
module: moduleOf(p.key),

View File

@@ -1,36 +1,3 @@
import {
ArrowLeftRight,
Boxes,
Building2,
BarChart3,
Container,
FileSignature,
FileText,
Hammer,
History,
LayoutDashboard,
LayoutGrid,
MapPin,
Network,
Package,
PackageCheck,
PackageOpen,
Paperclip,
Receipt,
ScrollText,
Send,
Settings,
ShieldCheck,
Ship,
SlidersHorizontal,
Train,
Truck,
Users,
Wallet,
LifeBuoy,
TrainFront,
XCircle,
} from "lucide-react";
import { useEffect } from "react";
import {
Navigate,
@@ -42,11 +9,7 @@ import {
useParams,
} from "react-router-dom";
import {
FreightDashboardLayout,
type SidebarItem,
type SidebarSection,
} from "@/components/layout";
import { FreightDashboardLayout, type SidebarItem } from "@/components/layout";
import { useAuth } from "./auth/useAuth";
import LoadingScreen from "./components/LoadingScreen";
import LoginPage from "./pages/auth/LoginPage";
@@ -84,11 +47,12 @@ import AuditLogsPage from "./pages/audit/AuditLogsPage";
import { RequirePermission } from "./components/auth/RequirePermission";
import {
FREIGHT_PERMS,
hasPermission as hasFreightPermission,
isDjiboutiGl,
isEthiopianGl,
isSuperAdmin,
} from "./lib/permissions";
import { NO_ACCESS_PATH, resolveLandingPath } from "./lib/landing";
import NoAccessPage from "./pages/NoAccessPage";
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage";
import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage";
@@ -108,7 +72,6 @@ import CompliancePage from "./pages/fleet/CompliancePage";
import IncidentsPage from "./pages/fleet/IncidentsPage";
import WorkOrdersPage from "./pages/fleet/WorkOrdersPage";
import ProcurementPage from "./pages/fleet/ProcurementPage";
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
import CargoTypesPage from "./pages/ruleEngine/CargoTypesPage";
@@ -151,615 +114,15 @@ import FaydaCallbackPage from "./pages/FaydaCallbackPage";
import { UserManagementRoutes } from "./user-management/route";
import SetPassword from "./shared/components/SetPassword";
import SupportInboxPage from "./pages/support/SupportInboxPage";
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
{
title: "Main menu",
items: [
{
label: "Overview",
href: "/dashboard/overview",
icon: <LayoutDashboard />,
permission: FREIGHT_PERMS.overview.view,
},
{
label: "Reports",
href: "/dashboard/reports",
icon: <BarChart3 />,
permission: FREIGHT_PERMS.reports.view,
},
{
label: "Customers",
href: "/dashboard/customers",
icon: <Building2 />,
permission: FREIGHT_PERMS.customers.view,
},
{
label: "Contracts",
href: "/dashboard/contract-requests",
icon: <FileSignature />,
permission: FREIGHT_PERMS.contracts.view,
},
{
label: "Bookings",
href: "/dashboard/booking-requests",
icon: <FileText />,
permission: FREIGHT_PERMS.bookings.view,
},
{
label: "Wagon cancellations",
href: "/dashboard/wagon-cancellations",
icon: <XCircle />,
permission: FREIGHT_PERMS.bookings.wagonCancellationView,
},
// Operations hub: per-shipment clearance-document review for services
// WITHOUT customs clearing (self-clearance) — bookings only.
{
label: "Clearance Documents",
href: "/dashboard/contracts/clearance-documents",
icon: <ShieldCheck />,
permission: FREIGHT_PERMS.contracts.opsClearanceReview,
},
{
label: "Payments",
href: "/dashboard/payments",
icon: <Wallet />,
permission: FREIGHT_PERMS.payments.view,
},
{
label: "Invoices",
href: "/dashboard/invoices",
icon: <Receipt />,
permission: FREIGHT_PERMS.invoices.view,
},
{
label: "Support",
href: "/dashboard/support",
icon: <LifeBuoy />,
permission: FREIGHT_PERMS.support.agentView,
},
...demoItems,
],
},
{
// title: "Port & Terminal",
items: [
{
label: "Operations",
icon: <Settings />,
children: [
{
label: "Clearance",
href: "/dashboard/contracts/clearance",
icon: <ShieldCheck />,
permission: [
FREIGHT_PERMS.contracts.clearanceReview,
FREIGHT_PERMS.contracts.clearanceEtActions,
],
},
// {
// label: "Shipment Requests",
// href: "/dashboard/shipment-requests",
// icon: <Send />,
// permission: FREIGHT_PERMS.contracts.createBooking,
// },
// Operations Path A queue: per-booking self-clearance review for
// GENERAL non-customs booking instances (and legacy self-clear bookings).
// {
// label: "Self-Clearance Review",
// href: "/dashboard/contracts/ops-clearance",
// icon: <ShieldCheck />,
// permission: FREIGHT_PERMS.contracts.opsClearanceReview,
// },
{
label: "GL Djibouti Clearance",
href: "/dashboard/gl-djibouti/clearance",
icon: <Ship />,
permission: FREIGHT_PERMS.contracts.clearanceDjActions,
},
{
label: "Train Schedules",
href: "/dashboard/operations/train-scheduling-v2",
icon: <Train />,
permission: FREIGHT_PERMS.trainScheduling.view,
},
{
label: "Batch Board",
href: "/dashboard/operations/batch-board",
icon: <LayoutGrid />,
permission: FREIGHT_PERMS.trainScheduling.view,
},
{
label: "First Mile",
href: "/dashboard/operations/first-mile",
icon: <Truck />,
permission: FREIGHT_PERMS.firstMile.view,
},
{
label: "Last Mile",
href: "/dashboard/operations/last-mile",
icon: <Truck />,
permission: FREIGHT_PERMS.lastMile.view,
},
],
},
{
label: "Fleet Management",
icon: <Truck />,
children: [
{
label: "Fleet Dashboard",
href: "/dashboard/fleet-dashboard",
icon: <LayoutDashboard />,
permission: FREIGHT_PERMS.fleetDashboard.view,
},
{
label: "Routes",
href: "/dashboard/routes",
icon: <Network />,
permission: [FREIGHT_PERMS.routes.view, FREIGHT_PERMS.fleet.view],
},
{
label: "Locomotives",
href: "/dashboard/locomotives",
icon: <Train />,
permission: [
FREIGHT_PERMS.locomotives.view,
FREIGHT_PERMS.fleet.view,
],
},
{
label: "Train Builder",
href: "/dashboard/train-builder",
icon: <Hammer />,
permission: [FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view],
},
// {
// label: "Wagon types",
// href: "/dashboard/wagon-types",
// icon: <Boxes />,
// },
{
label: "Wagons",
href: "/dashboard/wagons",
icon: <Truck />,
permission: [FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view],
},
{
label: "Wagon Transfers",
href: "/dashboard/wagon-transfers",
icon: <ArrowLeftRight />,
permission: [
FREIGHT_PERMS.wagons.transferView,
FREIGHT_PERMS.wagons.view,
],
},
{
label: "Vehicles",
href: "/dashboard/vehicles",
icon: <Truck />,
permission: FREIGHT_PERMS.vehicles.view,
},
{
label: "Drivers",
href: "/dashboard/drivers",
icon: <Users />,
permission: FREIGHT_PERMS.drivers.view,
},
{
label: "Track Vehicles",
href: "/dashboard/tracking",
icon: <MapPin />,
permission: FREIGHT_PERMS.tracking.view,
},
{
label: "Fuel Purchases",
href: "/dashboard/fuel-purchases",
icon: <Truck />,
permission: FREIGHT_PERMS.fuel.view,
},
{
label: "Fuel Analytics",
href: "/dashboard/fuel-stats",
icon: <Truck />,
permission: FREIGHT_PERMS.fuel.view,
},
{
label: "Maintenance",
href: "/dashboard/maintenance",
icon: <Truck />,
permission: FREIGHT_PERMS.maintenance.view,
},
{
label: "Work Orders",
href: "/dashboard/work-orders",
icon: <SlidersHorizontal />,
permission: FREIGHT_PERMS.maintenance.view,
},
{
label: "Compliance & Alerts",
href: "/dashboard/compliance",
icon: <ShieldCheck />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Incidents",
href: "/dashboard/incidents",
icon: <FileText />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Procurement",
href: "/dashboard/procurement",
icon: <Package />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Financial Reports",
href: "/dashboard/financial-reports",
icon: <Wallet />,
permission: FREIGHT_PERMS.fleetReports.view,
},
// {
// label: "Containers",
// href: "/dashboard/containers",
// icon: <Container />,
// },
// {
// label: "Cargoes",
// href: "/dashboard/cargoes",
// icon: <Package />,
// },
],
},
{
label: "Imports",
href: "/dashboard/import-warehouse",
icon: <PackageOpen />,
permission: FREIGHT_PERMS.warehouseInventory.view,
children: [
{
label: "Import Overview",
href: "/dashboard/import-warehouse",
icon: <PackageOpen />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Arrival Queue",
href: "/dashboard/arrival-queue",
icon: <PackageOpen />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Import Trucks",
href: "/dashboard/import-trucks",
icon: <Truck />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "EDR Last Mile Returns",
href: "/dashboard/edr-last-mile-returns",
icon: <Container />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Container Returns",
href: "/dashboard/container-returns",
icon: <Container />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Terminal Inventory",
href: "/dashboard/warehouse-inventory?direction=IMPORT",
icon: <Package />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Inventory Inquiry",
href: "/dashboard/inventory-inquiry",
icon: <Boxes />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
],
},
{
label: "Exports",
href: "/dashboard/export-warehouse",
icon: <Truck />,
permission: FREIGHT_PERMS.warehouseInventory.view,
children: [
{
label: "Export Overview",
href: "/dashboard/export-warehouse",
icon: <Truck />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Loading Queue",
href: "/dashboard/loading-queue",
icon: <Truck />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Loaded Inventory",
href: "/dashboard/loaded-inventory",
icon: <PackageCheck />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Dispatch Queue",
href: "/dashboard/dispatch-queue",
icon: <Send />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Djibouti Unloading",
href: "/dashboard/export-djibouti-unloading",
icon: <PackageOpen />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Interchange Documents",
href: "/dashboard/interchange-documents",
icon: <FileText />,
permission: FREIGHT_PERMS.interchangeDocuments.view,
},
{
label: "Terminal Inventory",
href: "/dashboard/warehouse-inventory?direction=EXPORT",
icon: <Package />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
],
},
{
label: "Intercity",
href: "/dashboard/intercity",
icon: <TrainFront />,
permission: FREIGHT_PERMS.trainScheduling.view,
children: [
{
label: "Intercity Cargo",
href: "/dashboard/intercity",
icon: <TrainFront />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
],
},
{
label: "Warehouse Management",
icon: <Container />,
children: [
{
label: "Warehouse Dashboard",
href: "/dashboard/warehouse-dashboard",
icon: <LayoutDashboard />,
permission: FREIGHT_PERMS.warehouseDashboard.view,
},
{
// Yard-wide, not per-direction: the gate sees import and export
// trucks at the same barrier.
label: "Trucks on Site",
href: "/dashboard/trucks-on-site",
icon: <Truck />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Warehouses",
href: "/dashboard/warehouses",
icon: <Container />,
permission: FREIGHT_PERMS.warehouses.view,
},
{
label: "Allocation & Fees",
href: "/dashboard/warehouse-rules",
icon: <SlidersHorizontal />,
permission: [
FREIGHT_PERMS.warehouseAllocationRules.view,
FREIGHT_PERMS.warehouseFeeRules.view,
],
},
{
label: "Fee Invoices",
href: "/dashboard/warehouse-fee-invoices",
icon: <Wallet />,
permission: FREIGHT_PERMS.warehouseFeeInvoices.view,
},
],
},
],
},
{
title: "Freight configuration",
mutedTitle: true,
items: [
{
label: "File settings",
href: "/dashboard/file-settings",
icon: <Paperclip />,
permission: FREIGHT_PERMS.admin,
},
{
label: "Dropdown settings",
href: "/dashboard/dropdown-settings",
icon: <Settings />,
permission: FREIGHT_PERMS.admin,
},
{
label: "Contract templates",
href: "/dashboard/contract-templates",
icon: <ScrollText />,
permission: FREIGHT_PERMS.admin,
},
{
label: "Audit logs",
href: "/dashboard/audit-logs",
icon: <History />,
permission: FREIGHT_PERMS.audit.view,
},
{
label: "Configuration",
href: "/dashboard/configuration",
icon: <Boxes />,
children: [
...getCategorySidebarChildren("configuration"),
{
label: "Train scheduling rules",
href: "/dashboard/configuration/train-scheduling-rules",
permission: FREIGHT_PERMS.trainScheduling.rulesManage,
},
{
label: "Trade access",
href: "/dashboard/configuration/trade-access",
permission: FREIGHT_PERMS.admin,
},
{
label: "Exchange rate",
href: "/dashboard/configuration/exchange-rate",
permission: FREIGHT_PERMS.admin,
},
],
},
{
label: "Rules",
href: "/dashboard/rules",
icon: <SlidersHorizontal />,
children: getCategorySidebarChildren("rules"),
},
{
label: "Staff",
href: "/user-management",
icon: <Users />,
permission: [
FREIGHT_PERMS.admin,
FREIGHT_PERMS.staff.roles.view,
FREIGHT_PERMS.staff.employeeRegistration.view,
FREIGHT_PERMS.staff.roleAssignment.view,
],
},
],
},
];
/** Hrefs of the two document-clearance menu items (stable identifiers). */
const ET_CLEARANCE_HREF = "/dashboard/contracts/clearance";
const DJ_CLEARANCE_HREF = "/dashboard/gl-djibouti/clearance";
// Routes a GL officer may reach beyond their clearance hub. Path B booking is
// part of their job (create/rebook under a cleared contract, then view that
// booking's clearance), but those routes live outside the clearance prefix —
// without this allowlist the single-prefix lock bounces them out of their own
// workflow. Matched against location.pathname (no query string).
const GL_WORKFLOW_PATH_PATTERNS: RegExp[] = [
/^\/dashboard\/contracts\/[^/]+\/create-booking(\/|$)/,
/^\/dashboard\/bookings\/[^/]+\/clearance(\/|$)/,
// The ET hub's rows open the shipment clearance detail at this URL.
/^\/dashboard\/clearance\/[^/]+(\/|$)/,
];
const isEtClearanceItem = (item: SidebarItem): boolean =>
item.href === ET_CLEARANCE_HREF;
const isDjClearanceItem = (item: SidebarItem): boolean =>
item.href === DJ_CLEARANCE_HREF;
const isClearanceItem = (item: SidebarItem): boolean =>
isEtClearanceItem(item) || isDjClearanceItem(item);
/**
* Keep only items the user is permitted to see; drop now-empty sections.
*
* Position-scoped visibility (super_admin sees everything):
* - Super Admin → sees all items (all permissions pass, all tabs visible)
* - Ethiopian GL → sees ONLY the ET document-clearance page.
* - Djibouti GL → sees ONLY the DJ clearance page.
* - Everyone else → sees everything they have permission for, EXCEPT the two
* clearance pages (those are GL-only).
*/
const filterSidebarByPermission = (
sections: SidebarSection[],
user: ReturnType<typeof useAuth>["user"],
): SidebarSection[] => {
// Superadmin sees every section and item — no permission filtering.
if (isSuperAdmin(user)) return sections;
const etGl = isEthiopianGl(user);
const djGl = isDjiboutiGl(user);
const permissionAllowed = (item: SidebarItem): boolean => {
if (!item.permission) return true;
const keys = Array.isArray(item.permission)
? item.permission
: [item.permission];
return keys.some((key) => hasFreightPermission(user, key));
};
// Recursive: children are filtered first; a group (item with children) stays
// only while it still has visible children — so parents without their own
// permission key never leak a whole subtree the user cannot open.
const filterItems = (items: SidebarItem[]): SidebarItem[] =>
items
.map((item) =>
item.children
? { ...item, children: filterItems(item.children) }
: item,
)
.filter((item) => {
if (etGl || djGl) {
// GL positions are locked to their single clearance page (parents
// survive only as the path to that page).
const isTarget = etGl ? isEtClearanceItem : isDjClearanceItem;
return isTarget(item) || (item.children?.length ?? 0) > 0;
}
// Everyone else: hide the GL-only clearance pages entirely.
if (isClearanceItem(item)) return false;
if (!permissionAllowed(item)) return false;
if (item.children) return item.children.length > 0;
return true;
});
return sections
.map((section) => ({
...section,
items: filterItems(section.items),
}))
.filter((section) => section.items.length > 0);
};
const APP_TITLE = "EDR Freight Backoffice";
/** Flatten sidebar sections (incl. nested children) into {href, label} pairs. */
const flattenSidebarItems = (
sections: SidebarSection[],
): { href: string; label: string }[] =>
sections.flatMap((section) =>
section.items.flatMap((item) => [
...(item.href ? [{ href: item.href, label: item.label }] : []),
...(item.children ?? [])
.filter((child): child is SidebarItem & { href: string } =>
Boolean(child.href),
)
.map((child) => ({ href: child.href, label: child.label })),
]),
);
/** Find the sidebar label whose href matches (exactly or as a prefix of) the current path. */
const findActiveSidebarLabel = (
pathname: string,
sections: SidebarSection[],
): string | undefined => {
const path = pathname.toLowerCase();
const candidates = flattenSidebarItems(sections)
.map(({ href, label }) => ({
label,
href: href.split("?")[0].toLowerCase(),
}))
.sort((a, b) => b.href.length - a.href.length);
return candidates.find(
({ href }) => path === href || path.startsWith(`${href}/`),
)?.label;
};
import {
APP_TITLE,
buildSidebarSections,
DJ_CLEARANCE_HREF,
ET_CLEARANCE_HREF,
filterSidebarByPermission,
findActiveSidebarLabel,
GL_WORKFLOW_PATH_PATTERNS,
} from "@/components/layout/sidebar-sections";
const DashboardShell = () => {
const navigate = useNavigate();
@@ -840,17 +203,19 @@ const App = () => {
);
}
const landingPath = resolveLandingPath(user);
return (
<Routes>
{UserManagementRoutes()}
{/* <Route path="/um/*" element={<UserManagementHostPage />} /> */}
<Route path="/health" element={<HealthCheck />} />
<Route path="/fayda/callback" element={<FaydaCallbackPage />} />
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
<Route
path="/dashboard"
element={<Navigate to="/dashboard/overview" replace />}
/>
<Route path={NO_ACCESS_PATH} element={<NoAccessPage />} />
{/* Landing is per-user: /dashboard/overview is gated on overview:view, so
a fixed target strands anyone without that key on a blank page. */}
<Route path="/" element={<Navigate to={landingPath} replace />} />
<Route path="/dashboard" element={<Navigate to={landingPath} replace />} />
<Route path="/dashboard" element={<DashboardShell />}>
<Route path="overview" element={<RequirePermission permission={FREIGHT_PERMS.overview.view}><OverviewPage /></RequirePermission>} />
<Route path="reports" element={<RequirePermission permission={FREIGHT_PERMS.reports.view}><ReportsHubPage /></RequirePermission>} />
@@ -1554,7 +919,9 @@ const App = () => {
<Route path="org-structure/*" element={<Navigate to="/um" replace />} />
</Route>
<Route path="*" element={<Navigate to="/dashboard/overview" replace />} />
{/* Post-login landing: the browser is still on /auth, which matches
nothing here, so this is what actually decides where users start. */}
<Route path="*" element={<Navigate to={landingPath} replace />} />
</Routes>
);
};
@@ -1575,3 +942,4 @@ function LegacyGlEthiopiaClearanceRedirect() {
}
export default App;

View File

@@ -1,30 +1,42 @@
import type { ReactNode } from "react";
import { Navigate } from "react-router-dom";
import { Navigate, useLocation } from "react-router-dom";
import { useAuth } from "@/auth/useAuth";
import { resolveLandingPath } from "@/lib/landing";
import { hasPermission } from "@/lib/permissions";
import NoAccessPage from "@/pages/NoAccessPage";
interface RequirePermissionProps {
/** Permission key(s); access is granted if the user has ANY of them. */
permission: string | string[];
/** Where to send users who lack the permission. */
/** Where to send users who lack the permission. Defaults to their landing page. */
redirectTo?: string;
children: ReactNode;
}
/**
* Page-level guard: renders children only when the current user holds one of
* the given permissions, otherwise redirects (default: overview).
* the given permissions, otherwise redirects to a page they can actually reach.
*
* The fallback must not be a fixed path. It used to be `/dashboard/overview`,
* which is itself gated on `overview:view` — a user without that key was sent
* to the page that had just rejected them, and React Router rendered a blank
* frame instead of navigating.
*/
export function RequirePermission({
permission,
redirectTo = "/dashboard/overview",
redirectTo,
children,
}: RequirePermissionProps) {
const { user } = useAuth();
const location = useLocation();
const keys = Array.isArray(permission) ? permission : [permission];
const allowed = keys.some((key) => hasPermission(user, key));
if (!allowed) return <Navigate to={redirectTo} replace />;
return <>{children}</>;
if (allowed) return <>{children}</>;
const target = redirectTo ?? resolveLandingPath(user);
// Belt and braces: never navigate to the page we are already on.
if (target === location.pathname) return <NoAccessPage />;
return <Navigate to={target} replace />;
}

View File

@@ -16,6 +16,7 @@ import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { api } from "@/services/api";
import type {
CompanyNationality,
CompanyProfile,
CompanyStatus,
CompanyType,
@@ -88,6 +89,31 @@ export function CompanyTypeBadge({ type }: { type: CompanyType }) {
);
}
const NATIONALITY_COLOR: Record<CompanyNationality, string> = {
ethiopian: "edr-green",
foreign: "blue",
};
export function CompanyNationalityBadge({
nationality,
}: {
nationality?: CompanyNationality | null;
}) {
if (!nationality) return null;
return (
<Badge
color={NATIONALITY_COLOR[nationality] ?? "gray"}
variant="light"
size="sm"
radius="md"
fw={600}
style={badgeStyle}
>
{humanize(nationality)}
</Badge>
);
}
/**
* Profile chips for a company row: one chip per role (Importer / Exporter / …)
* carrying its reference code. Caps at three (a company has at most three

View File

@@ -1,5 +1,6 @@
export {
BookingStatusBadge,
CompanyNationalityBadge,
CompanyStatusBadge,
CompanyTypeBadge,
InvoiceStatusBadge,

View File

@@ -0,0 +1,660 @@
import {
ArrowLeftRight,
Boxes,
Building2,
BarChart3,
Container,
FileSignature,
FileText,
Hammer,
History,
LayoutDashboard,
LayoutGrid,
MapPin,
Network,
Package,
PackageCheck,
PackageOpen,
Paperclip,
Receipt,
ScrollText,
Send,
Settings,
ShieldCheck,
Ship,
SlidersHorizontal,
Train,
Truck,
Users,
Wallet,
LifeBuoy,
TrainFront,
XCircle,
} from "lucide-react";
import type { AuthUser } from "@/auth/types";
import type { SidebarItem, SidebarSection } from "./types";
import {
FREIGHT_PERMS,
hasPermission as hasFreightPermission,
isDjiboutiGl,
isEthiopianGl,
isSuperAdmin,
} from "@/lib/permissions";
import { getCategorySidebarChildren } from "@/pages/ruleEngine/config/resources";
/**
* Backoffice sidebar model: the nav tree, its permission filter, and the
* flatten/lookup helpers. Lives outside App.tsx so `lib/landing.ts` can resolve
* a user's first reachable route without importing the route tree (App.tsx
* imports RequirePermission, which imports landing — that would cycle).
*/
export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
{
title: "Main menu",
items: [
{
label: "Overview",
href: "/dashboard/overview",
icon: <LayoutDashboard />,
permission: FREIGHT_PERMS.overview.view,
},
{
label: "Reports",
href: "/dashboard/reports",
icon: <BarChart3 />,
permission: FREIGHT_PERMS.reports.view,
},
{
label: "Customers",
href: "/dashboard/customers",
icon: <Building2 />,
permission: FREIGHT_PERMS.customers.view,
},
{
label: "Contracts",
href: "/dashboard/contract-requests",
icon: <FileSignature />,
permission: FREIGHT_PERMS.contracts.view,
},
{
label: "Bookings",
href: "/dashboard/booking-requests",
icon: <FileText />,
permission: FREIGHT_PERMS.bookings.view,
},
{
label: "Wagon cancellations",
href: "/dashboard/wagon-cancellations",
icon: <XCircle />,
permission: FREIGHT_PERMS.bookings.wagonCancellationView,
},
// Operations hub: per-shipment clearance-document review for services
// WITHOUT customs clearing (self-clearance) — bookings only.
{
label: "Clearance Documents",
href: "/dashboard/contracts/clearance-documents",
icon: <ShieldCheck />,
permission: FREIGHT_PERMS.contracts.opsClearanceReview,
},
{
label: "Payments",
href: "/dashboard/payments",
icon: <Wallet />,
permission: FREIGHT_PERMS.payments.view,
},
{
label: "Invoices",
href: "/dashboard/invoices",
icon: <Receipt />,
permission: FREIGHT_PERMS.invoices.view,
},
{
label: "Support",
href: "/dashboard/support",
icon: <LifeBuoy />,
permission: FREIGHT_PERMS.support.agentView,
},
...demoItems,
],
},
{
// title: "Port & Terminal",
items: [
{
label: "Operations",
icon: <Settings />,
children: [
{
label: "Clearance",
href: "/dashboard/contracts/clearance",
icon: <ShieldCheck />,
permission: [
FREIGHT_PERMS.contracts.clearanceReview,
FREIGHT_PERMS.contracts.clearanceEtActions,
],
},
// {
// label: "Shipment Requests",
// href: "/dashboard/shipment-requests",
// icon: <Send />,
// permission: FREIGHT_PERMS.contracts.createBooking,
// },
// Operations Path A queue: per-booking self-clearance review for
// GENERAL non-customs booking instances (and legacy self-clear bookings).
// {
// label: "Self-Clearance Review",
// href: "/dashboard/contracts/ops-clearance",
// icon: <ShieldCheck />,
// permission: FREIGHT_PERMS.contracts.opsClearanceReview,
// },
{
label: "GL Djibouti Clearance",
href: "/dashboard/gl-djibouti/clearance",
icon: <Ship />,
permission: FREIGHT_PERMS.contracts.clearanceDjActions,
},
{
label: "Train Schedules",
href: "/dashboard/operations/train-scheduling-v2",
icon: <Train />,
permission: FREIGHT_PERMS.trainScheduling.view,
},
{
label: "Batch Board",
href: "/dashboard/operations/batch-board",
icon: <LayoutGrid />,
permission: FREIGHT_PERMS.trainScheduling.view,
},
{
label: "First Mile",
href: "/dashboard/operations/first-mile",
icon: <Truck />,
permission: FREIGHT_PERMS.firstMile.view,
},
{
label: "Last Mile",
href: "/dashboard/operations/last-mile",
icon: <Truck />,
permission: FREIGHT_PERMS.lastMile.view,
},
],
},
{
label: "Fleet Management",
icon: <Truck />,
children: [
{
label: "Fleet Dashboard",
href: "/dashboard/fleet-dashboard",
icon: <LayoutDashboard />,
permission: FREIGHT_PERMS.fleetDashboard.view,
},
{
label: "Routes",
href: "/dashboard/routes",
icon: <Network />,
permission: [FREIGHT_PERMS.routes.view, FREIGHT_PERMS.fleet.view],
},
{
label: "Locomotives",
href: "/dashboard/locomotives",
icon: <Train />,
permission: [
FREIGHT_PERMS.locomotives.view,
FREIGHT_PERMS.fleet.view,
],
},
{
label: "Train Builder",
href: "/dashboard/train-builder",
icon: <Hammer />,
permission: [FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view],
},
// {
// label: "Wagon types",
// href: "/dashboard/wagon-types",
// icon: <Boxes />,
// },
{
label: "Wagons",
href: "/dashboard/wagons",
icon: <Truck />,
permission: [FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view],
},
{
label: "Wagon Transfers",
href: "/dashboard/wagon-transfers",
icon: <ArrowLeftRight />,
permission: [
FREIGHT_PERMS.wagons.transferView,
FREIGHT_PERMS.wagons.view,
],
},
{
label: "Vehicles",
href: "/dashboard/vehicles",
icon: <Truck />,
permission: FREIGHT_PERMS.vehicles.view,
},
{
label: "Drivers",
href: "/dashboard/drivers",
icon: <Users />,
permission: FREIGHT_PERMS.drivers.view,
},
{
label: "Track Vehicles",
href: "/dashboard/tracking",
icon: <MapPin />,
permission: FREIGHT_PERMS.tracking.view,
},
{
label: "Fuel Purchases",
href: "/dashboard/fuel-purchases",
icon: <Truck />,
permission: FREIGHT_PERMS.fuel.view,
},
{
label: "Fuel Analytics",
href: "/dashboard/fuel-stats",
icon: <Truck />,
permission: FREIGHT_PERMS.fuel.view,
},
{
label: "Maintenance",
href: "/dashboard/maintenance",
icon: <Truck />,
permission: FREIGHT_PERMS.maintenance.view,
},
{
label: "Work Orders",
href: "/dashboard/work-orders",
icon: <SlidersHorizontal />,
permission: FREIGHT_PERMS.maintenance.view,
},
{
label: "Compliance & Alerts",
href: "/dashboard/compliance",
icon: <ShieldCheck />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Incidents",
href: "/dashboard/incidents",
icon: <FileText />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Procurement",
href: "/dashboard/procurement",
icon: <Package />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Financial Reports",
href: "/dashboard/financial-reports",
icon: <Wallet />,
permission: FREIGHT_PERMS.fleetReports.view,
},
// {
// label: "Containers",
// href: "/dashboard/containers",
// icon: <Container />,
// },
// {
// label: "Cargoes",
// href: "/dashboard/cargoes",
// icon: <Package />,
// },
],
},
{
label: "Imports",
href: "/dashboard/import-warehouse",
icon: <PackageOpen />,
permission: FREIGHT_PERMS.warehouseInventory.view,
children: [
{
label: "Import Overview",
href: "/dashboard/import-warehouse",
icon: <PackageOpen />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Arrival Queue",
href: "/dashboard/arrival-queue",
icon: <PackageOpen />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Import Trucks",
href: "/dashboard/import-trucks",
icon: <Truck />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "EDR Last Mile Returns",
href: "/dashboard/edr-last-mile-returns",
icon: <Container />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Container Returns",
href: "/dashboard/container-returns",
icon: <Container />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Terminal Inventory",
href: "/dashboard/warehouse-inventory?direction=IMPORT",
icon: <Package />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Inventory Inquiry",
href: "/dashboard/inventory-inquiry",
icon: <Boxes />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
],
},
{
label: "Exports",
href: "/dashboard/export-warehouse",
icon: <Truck />,
permission: FREIGHT_PERMS.warehouseInventory.view,
children: [
{
label: "Export Overview",
href: "/dashboard/export-warehouse",
icon: <Truck />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Loading Queue",
href: "/dashboard/loading-queue",
icon: <Truck />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Loaded Inventory",
href: "/dashboard/loaded-inventory",
icon: <PackageCheck />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Dispatch Queue",
href: "/dashboard/dispatch-queue",
icon: <Send />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Djibouti Unloading",
href: "/dashboard/export-djibouti-unloading",
icon: <PackageOpen />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Interchange Documents",
href: "/dashboard/interchange-documents",
icon: <FileText />,
permission: FREIGHT_PERMS.interchangeDocuments.view,
},
{
label: "Terminal Inventory",
href: "/dashboard/warehouse-inventory?direction=EXPORT",
icon: <Package />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
],
},
{
label: "Intercity",
href: "/dashboard/intercity",
icon: <TrainFront />,
permission: FREIGHT_PERMS.trainScheduling.view,
children: [
{
label: "Intercity Cargo",
href: "/dashboard/intercity",
icon: <TrainFront />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
],
},
{
label: "Warehouse Management",
icon: <Container />,
children: [
{
label: "Warehouse Dashboard",
href: "/dashboard/warehouse-dashboard",
icon: <LayoutDashboard />,
permission: FREIGHT_PERMS.warehouseDashboard.view,
},
{
// Yard-wide, not per-direction: the gate sees import and export
// trucks at the same barrier.
label: "Trucks on Site",
href: "/dashboard/trucks-on-site",
icon: <Truck />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Warehouses",
href: "/dashboard/warehouses",
icon: <Container />,
permission: FREIGHT_PERMS.warehouses.view,
},
{
label: "Allocation & Fees",
href: "/dashboard/warehouse-rules",
icon: <SlidersHorizontal />,
permission: [
FREIGHT_PERMS.warehouseAllocationRules.view,
FREIGHT_PERMS.warehouseFeeRules.view,
],
},
{
label: "Fee Invoices",
href: "/dashboard/warehouse-fee-invoices",
icon: <Wallet />,
permission: FREIGHT_PERMS.warehouseFeeInvoices.view,
},
],
},
],
},
{
title: "Freight configuration",
mutedTitle: true,
items: [
{
label: "File settings",
href: "/dashboard/file-settings",
icon: <Paperclip />,
permission: FREIGHT_PERMS.admin,
},
{
label: "Dropdown settings",
href: "/dashboard/dropdown-settings",
icon: <Settings />,
permission: FREIGHT_PERMS.admin,
},
{
label: "Contract templates",
href: "/dashboard/contract-templates",
icon: <ScrollText />,
permission: FREIGHT_PERMS.admin,
},
{
label: "Audit logs",
href: "/dashboard/audit-logs",
icon: <History />,
permission: FREIGHT_PERMS.audit.view,
},
{
label: "Configuration",
href: "/dashboard/configuration",
icon: <Boxes />,
children: [
...getCategorySidebarChildren("configuration"),
{
label: "Train scheduling rules",
href: "/dashboard/configuration/train-scheduling-rules",
permission: FREIGHT_PERMS.trainScheduling.rulesManage,
},
{
label: "Trade access",
href: "/dashboard/configuration/trade-access",
permission: FREIGHT_PERMS.admin,
},
{
label: "Exchange rate",
href: "/dashboard/configuration/exchange-rate",
permission: FREIGHT_PERMS.admin,
},
],
},
{
label: "Rules",
href: "/dashboard/rules",
icon: <SlidersHorizontal />,
children: getCategorySidebarChildren("rules"),
},
{
label: "Staff",
href: "/user-management",
icon: <Users />,
permission: [
FREIGHT_PERMS.admin,
FREIGHT_PERMS.staff.roles.view,
FREIGHT_PERMS.staff.employeeRegistration.view,
FREIGHT_PERMS.staff.roleAssignment.view,
],
},
],
},
];
/** Hrefs of the two document-clearance menu items (stable identifiers). */
export const ET_CLEARANCE_HREF = "/dashboard/contracts/clearance";
export const DJ_CLEARANCE_HREF = "/dashboard/gl-djibouti/clearance";
// Routes a GL officer may reach beyond their clearance hub. Path B booking is
// part of their job (create/rebook under a cleared contract, then view that
// booking's clearance), but those routes live outside the clearance prefix —
// without this allowlist the single-prefix lock bounces them out of their own
// workflow. Matched against location.pathname (no query string).
export const GL_WORKFLOW_PATH_PATTERNS: RegExp[] = [
/^\/dashboard\/contracts\/[^/]+\/create-booking(\/|$)/,
/^\/dashboard\/bookings\/[^/]+\/clearance(\/|$)/,
// The ET hub's rows open the shipment clearance detail at this URL.
/^\/dashboard\/clearance\/[^/]+(\/|$)/,
];
const isEtClearanceItem = (item: SidebarItem): boolean =>
item.href === ET_CLEARANCE_HREF;
const isDjClearanceItem = (item: SidebarItem): boolean =>
item.href === DJ_CLEARANCE_HREF;
const isClearanceItem = (item: SidebarItem): boolean =>
isEtClearanceItem(item) || isDjClearanceItem(item);
/**
* Keep only items the user is permitted to see; drop now-empty sections.
*
* Position-scoped visibility (super_admin sees everything):
* - Super Admin → sees all items (all permissions pass, all tabs visible)
* - Ethiopian GL → sees ONLY the ET document-clearance page.
* - Djibouti GL → sees ONLY the DJ clearance page.
* - Everyone else → sees everything they have permission for, EXCEPT the two
* clearance pages (those are GL-only).
*/
export const filterSidebarByPermission = (
sections: SidebarSection[],
user: AuthUser | null | undefined,
): SidebarSection[] => {
// Superadmin sees every section and item — no permission filtering.
if (isSuperAdmin(user)) return sections;
const etGl = isEthiopianGl(user);
const djGl = isDjiboutiGl(user);
const permissionAllowed = (item: SidebarItem): boolean => {
if (!item.permission) return true;
const keys = Array.isArray(item.permission)
? item.permission
: [item.permission];
return keys.some((key) => hasFreightPermission(user, key));
};
// Recursive: children are filtered first; a group (item with children) stays
// only while it still has visible children — so parents without their own
// permission key never leak a whole subtree the user cannot open.
const filterItems = (items: SidebarItem[]): SidebarItem[] =>
items
.map((item) =>
item.children
? { ...item, children: filterItems(item.children) }
: item,
)
.filter((item) => {
if (etGl || djGl) {
// GL positions are locked to their single clearance page (parents
// survive only as the path to that page).
const isTarget = etGl ? isEtClearanceItem : isDjClearanceItem;
return isTarget(item) || (item.children?.length ?? 0) > 0;
}
// Everyone else: hide the GL-only clearance pages entirely.
if (isClearanceItem(item)) return false;
if (!permissionAllowed(item)) return false;
if (item.children) return item.children.length > 0;
return true;
});
return sections
.map((section) => ({
...section,
items: filterItems(section.items),
}))
.filter((section) => section.items.length > 0);
};
export const APP_TITLE = "EDR Freight Backoffice";
/** Flatten sidebar sections (incl. nested children) into {href, label} pairs. */
export const flattenSidebarItems = (
sections: SidebarSection[],
): { href: string; label: string }[] =>
sections.flatMap((section) =>
section.items.flatMap((item) => [
...(item.href ? [{ href: item.href, label: item.label }] : []),
...(item.children ?? [])
.filter((child): child is SidebarItem & { href: string } =>
Boolean(child.href),
)
.map((child) => ({ href: child.href, label: child.label })),
]),
);
/** Find the sidebar label whose href matches (exactly or as a prefix of) the current path. */
export const findActiveSidebarLabel = (
pathname: string,
sections: SidebarSection[],
): string | undefined => {
const path = pathname.toLowerCase();
const candidates = flattenSidebarItems(sections)
.map(({ href, label }) => ({
label,
href: href.split("?")[0].toLowerCase(),
}))
.sort((a, b) => b.href.length - a.href.length);
return candidates.find(
({ href }) => path === href || path.startsWith(`${href}/`),
)?.label;
};

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import type { AuthUser } from "@/auth/types";
import { FREIGHT_PERMS } from "./permissions";
import { NO_ACCESS_PATH, resolveLandingPath } from "./landing";
const withPermissions = (...keys: string[]): AuthUser => ({
permissionKeys: keys,
});
const withRole = (roleKey: string): AuthUser => ({ roles: [{ key: roleKey }] });
describe("resolveLandingPath", () => {
it("keeps normal staff on the overview", () => {
expect(resolveLandingPath(withPermissions(FREIGHT_PERMS.overview.view))).toBe(
"/dashboard/overview",
);
});
it("sends super admins to the overview (they pass every check)", () => {
expect(resolveLandingPath(withRole("super_admin"))).toBe(
"/dashboard/overview",
);
});
it("sends IAM-only admins to the user-management dashboard", () => {
// A pure unit_admin holds `can:*` IAM keys and no edr_freight_app:* key, so
// the freight sidebar filters down to nothing. Landing them on the overview
// is what produced the blank page.
for (const role of ["unit_admin", "admin", "organization_admin"]) {
expect(resolveLandingPath(withRole(role))).toBe(
"/user-management/user_management-dashboard",
);
}
});
it("lands staff without overview:view on a page they can see", () => {
const target = resolveLandingPath(
withPermissions(FREIGHT_PERMS.warehouses.view),
);
expect(target).not.toBe("/dashboard/overview");
expect(target.startsWith("/dashboard/")).toBe(true);
});
it("never returns /user-management for a freight-only staff key", () => {
// The Staff sidebar item points at /user-management, which redirects by
// ROLE — returning it here would ping-pong for a user with no IAM role.
expect(resolveLandingPath(withPermissions(FREIGHT_PERMS.staff.roles.view))).toBe(
NO_ACCESS_PATH,
);
});
it("falls back to the no-access page when nothing is granted", () => {
expect(resolveLandingPath({})).toBe(NO_ACCESS_PATH);
expect(resolveLandingPath(null)).toBe(NO_ACCESS_PATH);
});
});

View File

@@ -0,0 +1,51 @@
import {
buildSidebarSections,
filterSidebarByPermission,
flattenSidebarItems,
} from "@/components/layout/sidebar-sections";
import type { AuthUser } from "@/auth/types";
import { isSuperAdmin } from "./permissions";
/**
* IAM roles that grant the user-management subtree but no freight permissions.
* Mirrors the `allowedRoles` on the org-admin route group in
* `user-management/route.tsx` — a role outside this list is bounced by its
* PrivateRoute, so landing anyone else there would just bounce again.
*/
const IAM_ADMIN_ROLES = ["admin", "organization_admin", "unit_admin"];
/** Terminal page for accounts with nothing granted. Never permission-gated. */
export const NO_ACCESS_PATH = "/no-access";
/**
* The first route a user can actually reach.
*
* Every redirect in the app funnels through here instead of hardcoding
* `/dashboard/overview`: that page is itself gated on `overview:view`, so
* sending a user who lacks the key there redirects them to the page that just
* rejected them — React Router renders nothing and the user sees a blank frame.
* A pure `unit_admin` holds only `can:*` IAM keys and no `edr_freight_app:*`
* key at all, so this hit them on every login.
*/
export function resolveLandingPath(user: AuthUser | null | undefined): string {
const visible = filterSidebarByPermission(buildSidebarSections([]), user);
// Only /dashboard/* items are safe landings. The "Staff" item points at
// /user-management, which re-redirects by ROLE — a freight user holding
// staff:* keys but no IAM role would ping-pong between the two.
const firstFreightPage = flattenSidebarItems(visible).find((item) =>
item.href.startsWith("/dashboard/"),
)?.href;
if (firstFreightPage) return firstFreightPage;
if (isSuperAdmin(user)) return "/user-management/dashboard";
const roleKeys = (user?.roles ?? []).map((role) => role.key ?? "");
if (roleKeys.some((key) => IAM_ADMIN_ROLES.includes(key))) {
return "/user-management/user_management-dashboard";
}
return NO_ACCESS_PATH;
}

View File

@@ -0,0 +1,32 @@
import { ShieldOff } from "lucide-react";
import { useAuth } from "@/auth/useAuth";
import { Button } from "@/components/ui/button";
/**
* Terminal page for an account with no freight permissions and no IAM admin
* role. Reached via `resolveLandingPath`, which needs one destination that can
* never bounce — every other page is permission-gated.
*/
export default function NoAccessPage() {
const { user, logout } = useAuth();
return (
<div className="flex min-h-screen items-center justify-center p-6">
<div className="w-full max-w-md space-y-4 rounded-lg border p-8 text-center">
<ShieldOff className="mx-auto h-10 w-10 text-muted-foreground" />
<h1 className="text-lg font-semibold">No access assigned</h1>
<p className="text-sm text-muted-foreground">
Your account has no permissions assigned yet, so there are no pages to
show. Contact your administrator to have a position or role assigned.
</p>
{user?.email ? (
<p className="text-xs text-muted-foreground">Signed in as {user.email}</p>
) : null}
<Button variant="outline" onClick={logout}>
Log out
</Button>
</div>
</div>
);
}

View File

@@ -64,7 +64,9 @@ const LoginPage = () => {
try {
await verifyMfa({ email: normalizedIdentifier, otp: otp.trim() });
navigate("/dashboard/overview", { replace: true });
// "/" resolves to the user's own landing page once the session lands —
// not every user can see the overview.
navigate("/", { replace: true });
} catch (err) {
setError(extractApiError(err).message);
} finally {

View File

@@ -812,6 +812,14 @@ export default function CustomerDetailPage() {
}
/>
<InfoField label="Country" value={company.country} />
<InfoField
label="Nationality"
value={
company.nationality
? humanize(company.nationality)
: undefined
}
/>
<InfoField label="Address" value={company.address} />
<InfoField label="Website" value={company.website} />
<InfoField label="Email" value={company.email} />

View File

@@ -31,6 +31,7 @@ import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
CompanyNationalityBadge,
CompanyStatusBadge,
ProfileChips,
formatDate,
@@ -149,6 +150,7 @@ export default function CustomersPage() {
<Text fw={600} c="edr-text" truncate>
{c.name}
</Text>
<CompanyNationalityBadge nationality={c.nationality} />
</Group>
<Text size="xs" c="dimmed">
TIN {c.tin}

View File

@@ -30,6 +30,7 @@ import {
} from "lucide-react";
import { useAuth } from "@/auth/useAuth";
import { resolveLandingPath } from "@/lib/landing";
import { canAccessRuleEngineResource } from "@/lib/permissions";
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
import {
@@ -239,8 +240,8 @@ const CargoTypesPage = () => {
[levelNodes, term],
);
if (!config) return <Navigate to="/dashboard/overview" replace />;
if (!canView) return <Navigate to="/dashboard/overview" replace />;
if (!config) return <Navigate to={resolveLandingPath(user)} replace />;
if (!canView) return <Navigate to={resolveLandingPath(user)} replace />;
// A bad/stale :id (after data loads) → fall back to the root list.
if (!isLoading && currentId && !current) return <Navigate to={BASE_PATH} replace />;

View File

@@ -1,4 +1,5 @@
import { useAuth } from "@/auth/useAuth";
import { resolveLandingPath } from "@/lib/landing";
import {
canAccessRuleEngineResource,
canApproveRuleEngineChange,
@@ -584,7 +585,7 @@ const RuleEngineResourcePage = () => {
}
if (!canView) {
return <Navigate to="/dashboard/overview" replace />;
return <Navigate to={resolveLandingPath(user)} replace />;
}
const openCreate = () => {

View File

@@ -2,19 +2,15 @@ import type { ReactElement } from "react";
import { Navigate } from "react-router-dom";
import { useAuth } from "@/auth/useAuth";
import { resolveLandingPath } from "@/lib/landing";
/**
* Root landing redirect for the vendored IAM pages. Sends super admins to the
* user-management dashboard and everyone else to the freight overview. Minimal
* replacement for the source app's @/routes/RootRedirect (not copied over).
* Root landing redirect for the vendored IAM pages. Delegates to the shared
* landing resolver so it cannot drift from the app's other redirect sinks.
*/
export function RootRedirect(): ReactElement {
const { user } = useAuth();
const roles = (user?.roles ?? []).map((r) => r.key).filter(Boolean);
if (roles.includes("super_admin")) {
return <Navigate to="/user-management/dashboard" replace />;
}
return <Navigate to="/dashboard/overview" replace />;
return <Navigate to={resolveLandingPath(user)} replace />;
}
export default RootRedirect;

View File

@@ -21,6 +21,9 @@ export type CompanyStatus = "active" | "pending" | "suspended" | "blacklisted";
/** Mirrors backend `CompanyKind` — commercial customer vs. government entity. */
export type CompanyKind = "commercial" | "government";
/** Mirrors backend `CompanyNationality`. */
export type CompanyNationality = "ethiopian" | "foreign";
/** Mirrors backend `ProfileType` (the role a company plays). */
export type ProfileType =
| "importer"
@@ -207,6 +210,7 @@ export interface Company {
vatNumber?: string | null;
fanNumber?: string | null;
country: string;
nationality?: CompanyNationality | null;
address?: string | null;
phone?: string | null;
email?: string | null;

View File

@@ -40,8 +40,10 @@ export const AppLayout = () => {
drives collapse on desktop and the Sheet drawer on mobile. */}
<Sidebar collapsible="icon">
<SidebarHeader className="gap-1 border-b border-sidebar-border">
{/* "/" resolves to the user's own landing page — an IAM-only admin
has no overview to go back to. */}
<Link
to="/dashboard/overview"
to="/"
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
>
<ArrowLeft className="size-4 shrink-0" />

View File

@@ -2,6 +2,7 @@ import type { ReactElement } from "react";
import { Navigate, Outlet, Route } from "react-router-dom";
import { useAuth } from "@/auth/useAuth";
import { NO_ACCESS_PATH, resolveLandingPath } from "@/lib/landing";
import { isSuperAdmin } from "@/lib/permissions";
import { WithPermission } from "@/shared/hooks/useHas";
import PendingExternalUsers from "@/super-admin/components/externalUsers/PendingExternalUsers";
@@ -85,7 +86,7 @@ function PrivateRoute({ allowedRoles }: { allowedRoles: string[] }): ReactElemen
.filter((k): k is string => Boolean(k));
const allowed =
isSuperAdmin(user) || allowedRoles.some((r) => roleKeys.includes(r));
return allowed ? <Outlet /> : <Navigate to="/dashboard/overview" replace />;
return allowed ? <Outlet /> : <Navigate to={resolveLandingPath(user)} replace />;
}
export const UserManagementRedirect = () => {
@@ -100,7 +101,9 @@ export const UserManagementRedirect = () => {
return <Navigate to="/user-management/user_management-dashboard" replace />;
}
return <Navigate to="/" replace />; // fallback
// No IAM role: "/" resolves back here for anyone whose only sidebar entry is
// Staff, so bounce to the terminal page instead of ping-ponging.
return <Navigate to={NO_ACCESS_PATH} replace />;
};
/**

View File

@@ -1,4 +1,3 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: "class",

View File

@@ -1,119 +0,0 @@
-- Position-type grant mapping for the granular permission system rollout.
-- Grants the NEW granular keys to every hand-curated position type that holds
-- the old broad key whose routes the new keys took over. Idempotent (unique
-- constraint on (position_type_id, permission_id) + ON CONFLICT DO NOTHING).
--
-- PREREQUISITE: run the seeded API once first (SEED_EDR_ORG=true) so
-- EdrOrgSeeder has created the new permission rows this script references.
-- Running it too early is not destructive but silently under-applies: keys that
-- do not exist yet simply match nothing (measured: 11 of 42 rows land pre-seed,
-- because invoices:view/export and payments:view already exist on dev). Re-run
-- after seeding — it is safe to run any number of times.
--
-- Verified 2026-08-07 on a virgin restore of the live dev DB: seeded boot, then
-- this script → 42 rows inserted, second run → 0 rows, final per-key grant
-- counts identical to the reference environment. Per-user API probes across 20
-- departmental test accounts confirm the keys resolve through /me and gate
-- routes correctly.
BEGIN;
-- Helper shape used throughout:
-- holders of <old key> => also grant <new keys>
-- 1. bookings:view holders => dashboard/read keys that replaced blanket access
INSERT INTO iam.position_type_permissions (position_type_id, permission_id)
SELECT DISTINCT ptp.position_type_id, pnew.id
FROM iam.position_type_permissions ptp
JOIN iam.permissions pold ON pold.id = ptp.permission_id
AND pold.key = 'edr_freight_app:bookings:view'
JOIN iam.permissions pnew ON pnew.key IN (
'edr_freight_app:overview:view',
'edr_freight_app:reports:view',
'edr_freight_app:invoices:view',
'edr_freight_app:invoices:export',
'edr_freight_app:payments:view'
)
ON CONFLICT (position_type_id, permission_id) DO NOTHING;
-- 2. train_scheduling:update holders => the write actions split out of it
INSERT INTO iam.position_type_permissions (position_type_id, permission_id)
SELECT DISTINCT ptp.position_type_id, pnew.id
FROM iam.position_type_permissions ptp
JOIN iam.permissions pold ON pold.id = ptp.permission_id
AND pold.key = 'edr_freight_app:train_scheduling:update'
JOIN iam.permissions pnew ON pnew.key IN (
'edr_freight_app:train_scheduling:dispatch',
'edr_freight_app:train_scheduling:mark_paid',
'edr_freight_app:train_scheduling:expire_booking'
)
ON CONFLICT (position_type_id, permission_id) DO NOTHING;
-- 3. GL Djibouti clearance holders => final-invoice raise + confirm
INSERT INTO iam.position_type_permissions (position_type_id, permission_id)
SELECT DISTINCT ptp.position_type_id, pnew.id
FROM iam.position_type_permissions ptp
JOIN iam.permissions pold ON pold.id = ptp.permission_id
AND pold.key = 'edr_freight_app:contracts:clearance_dj_actions'
JOIN iam.permissions pnew ON pnew.key IN (
'edr_freight_app:contracts:final_invoice_raise',
'edr_freight_app:contracts:final_invoice_confirm'
)
ON CONFLICT (position_type_id, permission_id) DO NOTHING;
-- 4. GL Ethiopia clearance holders => final-invoice confirm
INSERT INTO iam.position_type_permissions (position_type_id, permission_id)
SELECT DISTINCT ptp.position_type_id, pnew.id
FROM iam.position_type_permissions ptp
JOIN iam.permissions pold ON pold.id = ptp.permission_id
AND pold.key = 'edr_freight_app:contracts:clearance_et_actions'
JOIN iam.permissions pnew ON pnew.key = 'edr_freight_app:contracts:final_invoice_confirm'
ON CONFLICT (position_type_id, permission_id) DO NOTHING;
-- 5. Contract-intake holders (any staff_accept flavour) => edit_document
INSERT INTO iam.position_type_permissions (position_type_id, permission_id)
SELECT DISTINCT ptp.position_type_id, pnew.id
FROM iam.position_type_permissions ptp
JOIN iam.permissions pold ON pold.id = ptp.permission_id
AND pold.key IN (
'edr_freight_app:bookings:staff_accept',
'edr_freight_app:contracts:staff_accept:bulk',
'edr_freight_app:contracts:staff_accept:container'
)
JOIN iam.permissions pnew ON pnew.key = 'edr_freight_app:contracts:edit_document'
ON CONFLICT (position_type_id, permission_id) DO NOTHING;
-- 6. Support inbox ownership (decision 2026-08-07): marketing department types
INSERT INTO iam.position_type_permissions (position_type_id, permission_id)
SELECT pt.id, p.id
FROM iam.position_types pt
CROSS JOIN iam.permissions p
WHERE (pt.name::text ILIKE '%marketing%' OR pt.key ILIKE '%marketing%')
AND p.key IN (
'edr_freight_app:support:agent_view',
'edr_freight_app:support:agent_send'
)
ON CONFLICT (position_type_id, permission_id) DO NOTHING;
-- 7. Companion view keys.
-- Most freight controllers carry a class-level `<module>:view` guard, and Nest
-- runs class AND method guards — so a type holding only `<module>:<action>` is
-- denied before the action key is ever checked. Grant the module's view key
-- alongside every action key the type already holds. View-only, so it widens
-- reads within a module the type already operates in, never across modules.
INSERT INTO iam.position_type_permissions (position_type_id, permission_id)
SELECT DISTINCT ptp.position_type_id, pview.id
FROM iam.position_type_permissions ptp
JOIN iam.permissions pact ON pact.id = ptp.permission_id
AND pact.key LIKE 'edr_freight_app:%'
JOIN iam.permissions pview ON pview.key = regexp_replace(pact.key, ':[^:]+$', ':view')
ON CONFLICT (position_type_id, permission_id) DO NOTHING;
COMMIT;
-- Verification: expected non-zero counts per new key after running.
-- SELECT p.key, count(*) FROM iam.position_type_permissions ptp
-- JOIN iam.permissions p ON p.id = ptp.permission_id
-- WHERE p.key IN ('edr_freight_app:overview:view','edr_freight_app:support:agent_view',
-- 'edr_freight_app:train_scheduling:dispatch','edr_freight_app:contracts:final_invoice_raise')
-- GROUP BY p.key;

View File

@@ -90,13 +90,16 @@ export interface NotificationRecipients {
companyProfileId?: string;
/** Backoffice: all current employees of this organization. */
organizationId?: string;
/** Backoffice: every current employee across all organizations. */
allBackoffice?: boolean;
/**
* Backoffice: current employees (any org) who hold ANY of these permission
* keys — e.g. notify only marketing, not every employee. Super/org admins
* are not implicitly included; add `allBackoffice`/explicit userIds too if
* admins should also see it.
* keys. Resolution mirrors the request-time guard `hasFreightPermission` —
* IAM role grants, direct position grants, position TYPE grants, delegated
* positions, and the `super_admin` bypass. `organization_admin` is NOT
* implicitly included (it only bypasses approval steps, not permission
* checks); grant it a key explicitly if it should be notified.
*
* Prefer the dedicated `<module>:get_notification` keys over reusing a domain
* key: they let ops tune who gets pinged without touching who has access.
*/
permissionKeys?: string[];
}

View File

@@ -0,0 +1,86 @@
#!/usr/bin/env bash
# Sync .env files from the Env Manager API into the repo.
#
# Usage:
# ENV_MANAGER_TOKEN=xxx ./scripts/deploy/sync-env-from-server.sh freight-api freight-portal freight-backoffice
#
# You normally only need to pass the token via the action secret, and BRANCH
# via the job-level env (e.g. `BRANCH: ${{ github.ref_name }}` in the workflow):
# env:
# BRANCH: ${{ github.ref_name }}
# ENV_MANAGER_TOKEN: ${{ secrets.ENV_MANAGER_TOKEN }}
#
# API layout (one endpoint per service):
# GET https://env.smart.aaca.gov.et/api/env/edr/<branch>/<service>?format=dotenv
# Header: Authorization: Bearer <ENV_MANAGER_TOKEN>
set -euo pipefail
PROJECT="edr"
ENV_MANAGER_URL="https://env.smart.aaca.gov.et"
BRANCH="${BRANCH:?BRANCH is required}"
ENV_MANAGER_TOKEN="${ENV_MANAGER_TOKEN:?ENV_MANAGER_TOKEN is required}"
declare -A SERVICE_ENV_TARGET=(
["freight_api"]="apps/edr-freight-api/.env"
["freight_portal"]="apps/edr-freight-web/portal/.env"
["freight_backoffice"]="apps/edr-freight-web/backoffice/.env"
["gps_tracker"]="apps/edr-gps-tracker/.env"
["passenger_api"]="apps/edr-passenger-api/.env"
["passenger_portal"]="apps/edr-passenger-web/portal/.env"
["passenger_backoffice"]="apps/edr-passenger-web/backoffice/.env"
["payment_api"]="apps/edr-payment-api/.env"
)
for service in "$@"; do
branch_api_name="${BRANCH//-/_}"
service_api_name="${service//-/_}"
dest="${SERVICE_ENV_TARGET[${service_api_name}]:-}"
if [[ -z "${dest}" ]]; then
echo "Unknown service: ${service}" >&2
exit 1
fi
url="${ENV_MANAGER_URL}/api/env/${PROJECT}/${branch_api_name}/${service_api_name}?format=dotenv"
mkdir -p "$(dirname "${dest}")"
tmp_file="$(mktemp)"
trap 'rm -f "${tmp_file}"' RETURN 2>/dev/null || true
http_status=$(curl -fsS -o "${tmp_file}" -w "%{http_code}" \
-H "Authorization: Bearer ${ENV_MANAGER_TOKEN}" \
"${url}") || {
echo "Failed to fetch env for '${service}' from ${url}" >&2
rm -f "${tmp_file}"
exit 1
}
if [[ "${http_status}" != "200" ]]; then
echo "Env Manager returned HTTP ${http_status} for '${service}' (${url})" >&2
rm -f "${tmp_file}"
exit 1
fi
if [[ ! -s "${tmp_file}" ]]; then
echo "Env Manager returned an empty response for '${service}' (${url})" >&2
rm -f "${tmp_file}"
exit 1
fi
mv "${tmp_file}" "${dest}"
echo "Synced ${url} -> ${dest}"
port_value=$(sed -n -E 's/^[[:space:]]*PORT[[:space:]]*=[[:space:]]*"?([^"#]+)"?[[:space:]]*(#.*)?$/\1/p' "${dest}" | head -n1 | tr -d '[:space:]')
if [[ -z "${port_value}" ]]; then
echo "Missing required PORT in env file for '${service}' (${dest})" >&2
exit 1
fi
if [[ -n "${GITHUB_ENV:-}" ]]; then
service_var=$(echo "${service}" | tr '[:lower:]-' '[:upper:]_')
echo "${service_var}_PORT=${port_value}" >> "${GITHUB_ENV}"
echo "Exported ${service_var}_PORT from ${dest}"
# Forward NEXT_PUBLIC_* and VITE_* vars so docker compose build can inject them as build args.
grep -E '^[[:space:]]*(NEXT_PUBLIC_|VITE_)[A-Za-z0-9_]+=' "${dest}" \
| sed -E 's/^[[:space:]]*//' >> "${GITHUB_ENV}" || true
fi
done