mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
5
.github/workflows/deploy.yml
vendored
5
.github/workflows/deploy.yml
vendored
@@ -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: |
|
||||
|
||||
2
.github/workflows/malware-scan.yml
vendored
2
.github/workflows/malware-scan.yml
vendored
@@ -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
9
.gitignore
vendored
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
185
apps/edr-freight-api/src/config/eims.config.ts
Normal file
185
apps/edr-freight-api/src/config/eims.config.ts
Normal 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;
|
||||
});
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
362
apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts
Normal file
362
apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`,
|
||||
},
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
256
apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts
Normal file
256
apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
212
apps/edr-freight-api/src/modules/eims/eims-auth.service.ts
Normal file
212
apps/edr-freight-api/src/modules/eims/eims-auth.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
80
apps/edr-freight-api/src/modules/eims/eims-client.service.ts
Normal file
80
apps/edr-freight-api/src/modules/eims/eims-client.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
117
apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts
Normal file
117
apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
37
apps/edr-freight-api/src/modules/eims/eims-signer.service.ts
Normal file
37
apps/edr-freight-api/src/modules/eims/eims-signer.service.ts
Normal 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);
|
||||
78
apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts
Normal file
78
apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts
Normal 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(".");
|
||||
};
|
||||
89
apps/edr-freight-api/src/modules/eims/eims.errors.ts
Normal file
89
apps/edr-freight-api/src/modules/eims/eims.errors.ts
Normal 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"}`);
|
||||
}
|
||||
37
apps/edr-freight-api/src/modules/eims/eims.module.ts
Normal file
37
apps/edr-freight-api/src/modules/eims/eims.module.ts
Normal 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 {}
|
||||
46
apps/edr-freight-api/src/modules/eims/eims.types.ts
Normal file
46
apps/edr-freight-api/src/modules/eims/eims.types.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -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)'),
|
||||
}),
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
40
apps/edr-freight-api/src/scripts/eims-login.ts
Normal file
40
apps/edr-freight-api/src/scripts/eims-login.ts
Normal 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;
|
||||
});
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export {
|
||||
BookingStatusBadge,
|
||||
CompanyNationalityBadge,
|
||||
CompanyStatusBadge,
|
||||
CompanyTypeBadge,
|
||||
InvoiceStatusBadge,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
57
apps/edr-freight-web/backoffice/src/lib/landing.test.ts
Normal file
57
apps/edr-freight-web/backoffice/src/lib/landing.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
51
apps/edr-freight-web/backoffice/src/lib/landing.ts
Normal file
51
apps/edr-freight-web/backoffice/src/lib/landing.ts
Normal 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;
|
||||
}
|
||||
32
apps/edr-freight-web/backoffice/src/pages/NoAccessPage.tsx
Normal file
32
apps/edr-freight-web/backoffice/src/pages/NoAccessPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 />;
|
||||
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 />;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: "class",
|
||||
|
||||
@@ -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;
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
86
scripts/deploy/sync-env-from-env-manager.sh
Normal file
86
scripts/deploy/sync-env-from-env-manager.sh
Normal 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
|
||||
Reference in New Issue
Block a user