Merge pull request #1191 from Tria-plc/dev

freight
This commit is contained in:
marshal
2026-08-08 21:08:01 +03:00
committed by GitHub
286 changed files with 18306 additions and 5188 deletions

View File

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

9
.gitignore vendored
View File

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

View File

@@ -76,6 +76,12 @@ SEED_EDR_ORG=true
SEED_FREIGHT_STAFF=true SEED_FREIGHT_STAFF=true
SEED_EXPORT_DJIBOUTI_INTERCHANGE_DEMO=false SEED_EXPORT_DJIBOUTI_INTERCHANGE_DEMO=false
# Limits GET /staff/users to employees of this IAM organization (iam.organizations.key).
# Unset = every employee. A key matching no organization returns no users.
# Dev seed key: edr_freight
# Production: ETHIO_DJIBOUTI_STANDARD_GAUGE_RAILWAY_SHARE_COMPANY_001
FREIGHT_ORG_KEY=edr_freight
# MinIO (used by @tria-plc/iamapi-common for file storage) # MinIO (used by @tria-plc/iamapi-common for file storage)
MINIO_ENDPOINT=localhost MINIO_ENDPOINT=localhost
MINIO_PORT=9000 MINIO_PORT=9000
@@ -126,3 +132,75 @@ EMAIL_QUEUE=email_queue
# Shared secret for service-to-service calls (payment microservice <-> freight). # Shared secret for service-to-service calls (payment microservice <-> freight).
# Required at boot; set ALLOW_UNAUTH_INTERNAL=true instead ONLY for local dev. # Required at boot; set ALLOW_UNAUTH_INTERNAL=true instead ONLY for local dev.
SERVICE_AUTH_TOKEN=change-me SERVICE_AUTH_TOKEN=change-me
# ── MoR EIMS e-invoicing (core.mor.gov.et) ─────────────────────────────────
# Disabled by default; every EIMS call fails fast with EIMS_NOT_CONFIGURED until enabled.
EIMS_ENABLED=false
EIMS_BASE_URL=https://core.mor.gov.et
EIMS_CLIENT_ID=
EIMS_CLIENT_SECRET=
EIMS_API_KEY=
EIMS_TIN=
# Source-system identity comes from the access token's systemNumber/systemType claims.
# Setting these turns them into expected-value checks: a mismatch against the token fails
# fast rather than one side silently winning. Leave empty to take the gateway's word.
EIMS_SYSTEM_NUMBER=
EIMS_SYSTEM_TYPE=
# Absolute paths to the INSA-issued credentials. Keep them OUTSIDE the repo; the file
# patterns are gitignored, but a path outside the working tree is safer still.
# The certificate is transmitted as base64 of this file's exact bytes — do not convert it.
EIMS_PRIVATE_KEY_PATH=
EIMS_CERTIFICATE_PATH=
# Optional tuning
EIMS_HTTP_TIMEOUT_MS=30000
EIMS_TOKEN_SKEW_SECONDS=45
# ── EIMS invoice registration (required only to register invoices) ─────────
# Seller identity: EDR's own legal details are not modelled anywhere in the DB.
# Region and Wereda are MoR *codes* (e.g. 13 / 574), not names.
EIMS_SELLER_LEGAL_NAME=
EIMS_SELLER_VAT_NUMBER=
EIMS_SELLER_PHONE=
EIMS_SELLER_EMAIL=
EIMS_SELLER_REGION=
EIMS_SELLER_WEREDA=
# Optional seller address parts; sent as null when unset.
EIMS_SELLER_CITY=
EIMS_SELLER_SUBCITY=
EIMS_SELLER_HOUSE_NUMBER=
EIMS_SELLER_LOCALITY=
# Tax treatment — REQUIRES FINANCE SIGN-OFF. The application models no tax at all
# (invoice.taxAmount is always 0), so nothing here is defaulted: registration fails
# locally, naming the missing variables, until these are set.
# Required, and deliberately unset: the choice is a tax position, not a default.
# MoR's enum (from its own 400): TOT10 TOT2 VAT15 VWHT TWHT VATEX VATWH WHOP2 WTHOI VAT0 VWTH
# Pending finance confirmation of VAT0 (zero-rated) vs VATEX (exempt).
EIMS_TAX_CODE=
EIMS_TAX_RATE_PERCENT=0
EIMS_EXCISE_TAX_VALUE=0
EIMS_INCOME_WITHHOLD_VALUE=0
EIMS_TRANSACTION_WITHHOLD_VALUE=0
# Document classification and payment presentation.
EIMS_TRANSACTION_TYPE=B2B
# Lowercase constant: MoR's oneOf branches require exactly 'goods' or 'service'.
EIMS_NATURE_OF_SUPPLIES=service
EIMS_PAYMENT_MODE=CASH
EIMS_PAYMENT_TERM=IMMIDIATE
EIMS_UNIT_DEFAULT=PCS
# MoR numeric country code for the buyer; our companies store the country name.
EIMS_BUYER_COUNTRY_CODE=
# Buyer region name -> MoR numeric code. companies.region holds names; MoR wants ^[0-9]{1,3}$.
# An unmapped region fails locally rather than being filed with a guess.
EIMS_BUYER_REGION_CODES=Addis Ababa=13
# Same mechanism for Wereda. MoR has never named a Wereda regex in an error (only Region's is
# confirmed), so this is precautionary — but an unmapped name still fails locally, not filed as a guess.
EIMS_BUYER_WEREDA_CODES=
EIMS_CASHIER_NAME=
EIMS_SALESPERSON_NAME=
# Automatic filing of issued invoices (@Cron sweep, one invoice per tick).
# Independent of EIMS_ENABLED on purpose: authentication can be live long before
# filing is. Both must be true before anything is submitted automatically.
EIMS_AUTO_SUBMIT=false
EIMS_AUTO_SUBMIT_CRON=0 */5 * * * *
# MoR rejects documents older than 3 days; the sweep will not attempt those.
EIMS_AUTO_SUBMIT_MAX_AGE_DAYS=3

View File

@@ -37,7 +37,8 @@
"iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert", "iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert",
"iam:migration:show": "pnpm run iam:typeorm:cli migration:show", "iam:migration:show": "pnpm run iam:typeorm:cli migration:show",
"migration:run": "nest build && node dist/scripts/migrate.js", "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": { "dependencies": {
"@edr/api-common": "workspace:*", "@edr/api-common": "workspace:*",

View File

@@ -23,6 +23,7 @@ import databaseConfig from "./config/database.config";
import telebirrConfig from "./config/telebirr.config"; import telebirrConfig from "./config/telebirr.config";
import rabbitmqConfig from "./config/rabbitmq.config"; import rabbitmqConfig from "./config/rabbitmq.config";
import faydaConfig from "./config/fayda.config"; import faydaConfig from "./config/fayda.config";
import eimsConfig from "./config/eims.config";
import { BookingsModule } from "./modules/bookings/bookings.module"; import { BookingsModule } from "./modules/bookings/bookings.module";
import { ContractsModule } from "./modules/contracts/contracts.module"; import { ContractsModule } from "./modules/contracts/contracts.module";
@@ -49,6 +50,7 @@ import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-up
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module"; import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
import { ExchangeSettingsModule } from "./modules/exchange-settings/exchange-settings.module"; import { ExchangeSettingsModule } from "./modules/exchange-settings/exchange-settings.module";
import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module"; import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module";
import { SupportContentModule } from "./modules/support-content/support-content.module";
import { OtpModule } from "./modules/otp/otp.module"; import { OtpModule } from "./modules/otp/otp.module";
import { HealthModule } from "./modules/health/health.module"; import { HealthModule } from "./modules/health/health.module";
import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module"; import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module";
@@ -66,6 +68,7 @@ import { FreightPositionsSeeder } from "./seed/freight-positions.seeder";
import { PaymentModule } from "./modules/payment/payment.module"; import { PaymentModule } from "./modules/payment/payment.module";
// import { PricingDataSeeder } from "./seed/pricing-data.seeder"; // import { PricingDataSeeder } from "./seed/pricing-data.seeder";
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder"; import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
import { SupportContentSeeder } from "./seed/support-content.seeder";
// import { YardFacilitiesSeeder } from "./seed/yard-facilities.seeder"; // import { YardFacilitiesSeeder } from "./seed/yard-facilities.seeder";
// import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder"; // import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder";
// import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder"; // import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder";
@@ -76,6 +79,7 @@ import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
// import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder"; // import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder";
// import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder"; // import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder";
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.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 { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
// import { GovCompaniesSeeder } from "./seed/gov-companies.seeder"; // import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder"; import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
@@ -83,6 +87,7 @@ import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-d
//New Trains, Wagons, Container and Cargo management modules //New Trains, Wagons, Container and Cargo management modules
import { TrainsModule } from "./modules/trains/trains.module"; import { TrainsModule } from "./modules/trains/trains.module";
import { VerifaydaModule } from "./modules/verifayda/verifayda.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 { FleetHistoryModule } from "./modules/fleet-history/fleet-history.module";
import { WagonsModule } from "./modules/wagons/wagons.module"; import { WagonsModule } from "./modules/wagons/wagons.module";
import { ContainersModule } from "./modules/container-management/containers.module"; import { ContainersModule } from "./modules/container-management/containers.module";
@@ -126,6 +131,7 @@ if (!process.env.APPLICATION_NAME) {
telebirrConfig, telebirrConfig,
rabbitmqConfig, rabbitmqConfig,
faydaConfig, faydaConfig,
eimsConfig,
], ],
}), }),
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
@@ -216,6 +222,7 @@ if (!process.env.APPLICATION_NAME) {
DropdownSettingsModule, DropdownSettingsModule,
ExchangeSettingsModule, ExchangeSettingsModule,
ContractTemplatesModule, ContractTemplatesModule,
SupportContentModule,
OtpModule, OtpModule,
HealthModule, HealthModule,
RuleEngineModule, RuleEngineModule,
@@ -247,6 +254,7 @@ if (!process.env.APPLICATION_NAME) {
InterchangeDocumentsModule, InterchangeDocumentsModule,
ImportOperationsModule, ImportOperationsModule,
VerifaydaModule, VerifaydaModule,
EimsModule,
FleetHistoryModule, FleetHistoryModule,
AiModule, AiModule,
AuditModule, AuditModule,
@@ -255,8 +263,10 @@ if (!process.env.APPLICATION_NAME) {
EdrOrgSeeder, EdrOrgSeeder,
FreightPositionsSeeder, FreightPositionsSeeder,
FileUploadSettingsSeeder, FileUploadSettingsSeeder,
SupportContentSeeder,
// YardFacilitiesSeeder, // YardFacilitiesSeeder,
FreightPermissionKeyMigrationSeeder, FreightPermissionKeyMigrationSeeder,
FreightNotificationPermissionsSeeder,
// Disabled seeds — providers commented out (imports/injection/run too): // Disabled seeds — providers commented out (imports/injection/run too):
// DemoUsersSeeder, // DemoUsersSeeder,
// FreightStaffUsersSeeder, // FreightStaffUsersSeeder,
@@ -285,8 +295,10 @@ export class AppModule implements OnApplicationBootstrap {
private readonly edrOrgSeeder: EdrOrgSeeder, private readonly edrOrgSeeder: EdrOrgSeeder,
private readonly freightPositionsSeeder: FreightPositionsSeeder, private readonly freightPositionsSeeder: FreightPositionsSeeder,
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder, private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
private readonly supportContentSeeder: SupportContentSeeder,
// private readonly yardFacilitiesSeeder: YardFacilitiesSeeder, // private readonly yardFacilitiesSeeder: YardFacilitiesSeeder,
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder, private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
private readonly freightNotificationPermissionsSeeder: FreightNotificationPermissionsSeeder,
// Disabled seeds — injections commented out (imports/provider/run too): // Disabled seeds — injections commented out (imports/provider/run too):
// private readonly demoUsersSeeder: DemoUsersSeeder, // private readonly demoUsersSeeder: DemoUsersSeeder,
// private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder, // private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
@@ -328,10 +340,24 @@ export class AppModule implements OnApplicationBootstrap {
await this.edrOrgSeeder.run(); await this.edrOrgSeeder.run();
await this.iamBaselineSeeder.run(); await this.iamBaselineSeeder.run();
await this.freightPositionsSeeder.run(); await this.freightPositionsSeeder.run();
// freightNotificationPermissions → seeds the <module>:get_notification
// keys and backfills them onto whoever
// already holds each desk's anchor
// permission. Runs LAST in this block so
// it sees a freshly-seeded catalog and
// freshly-seeded positions. Unlike the
// seeders above it is NOT gated behind
// SEED_EDR_ORG — without it every staff
// notification resolves to no one.
await this.freightNotificationPermissionsSeeder.run();
// File upload settings — keep enabled. // File upload settings — keep enabled.
await this.fileUploadSettingsSeeder.run(); await this.fileUploadSettingsSeeder.run();
// Portal help/FAQ/legal copy — keep enabled. Idempotent by emptiness, so
// it fills an empty table once and never touches admin edits afterwards.
await this.supportContentSeeder.run();
// Flags which yards can load/unload cargo (Indode, Sebeta, Modjo, Adama, // Flags which yards can load/unload cargo (Indode, Sebeta, Modjo, Adama,
// Dire Dawa). Idempotent; creates no yards. // Dire Dawa). Idempotent; creates no yards.
// await this.yardFacilitiesSeeder.run(); // await this.yardFacilitiesSeeder.run();

View File

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

View File

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

View File

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

View File

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

View File

@@ -215,8 +215,11 @@ export function buildFreightMigrationDataSourceOptions(): DataSourceOptions {
}; };
} }
export default registerAs("database", (): TypeOrmModuleOptions => ({ export default registerAs(
...buildDataSourceOptions(), "database",
autoLoadEntities: true, (): TypeOrmModuleOptions => ({
migrationsRun: false, ...buildDataSourceOptions(),
})); autoLoadEntities: true,
migrationsRun: false,
}),
);

View File

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

View File

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

View File

@@ -0,0 +1,32 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Audit trail for wagon status flips (Available ⇄ Maintenance and any other
* bulk-status change): who moved which wagon from what to what, when, and why.
* Written inside the same transaction as the status update itself.
*/
export class WagonStatusLogs3310000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.wagon_status_logs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
wagon_id uuid NOT NULL REFERENCES freight.wagons(id),
from_status varchar(30) NOT NULL,
to_status varchar(30) NOT NULL,
changed_by_user_id uuid,
note text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_wagon_status_logs_wagon
ON freight.wagon_status_logs (wagon_id, created_at DESC)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_status_logs`);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -46,7 +46,8 @@ export class BackofficeService {
/** /**
* IAM user ids of every current employee across all organizations — used by * IAM user ids of every current employee across all organizations — used by
* the notification recipients resolver's `allBackoffice` selector. * support chat for staff room membership. Notifications deliberately do NOT
* use this: they target a desk via `getEmployeeUserIdsByPermission`.
*/ */
async getAllCurrentEmployeeUserIds(): Promise<string[]> { async getAllCurrentEmployeeUserIds(): Promise<string[]> {
const employees = await this.employeeRepository.find({ 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 * IAM user ids of current employees (any org) holding ANY of the given
* permission keys — used by the notification recipients resolver's * permission keys — used by the notification recipients resolver's
* `permissionKeys` selector for department/role-scoped targeting. * `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( async getEmployeeUserIdsByPermission(
permissionKeys: string[], permissionKeys: string[],
): Promise<string[]> { ): Promise<string[]> {
if (!permissionKeys.length) return []; if (!permissionKeys.length) return [];
const rows: { userId: string | null }[] = await this.employeeRepository const rows: { userId: string }[] = await this.dataSource.query(
.createQueryBuilder("employee") `WITH target AS (SELECT id FROM iam.permissions WHERE key = ANY($1))
.innerJoin("employee.employeePositions", "employeePosition") -- 1. IAM role grants (user_roles -> role_permissions).
.innerJoin("employeePosition.position", "position") SELECT e.user_id AS "userId"
.innerJoin("position.positionPermission", "positionPermission") FROM iam.employees e
.innerJoin("positionPermission.permission", "permission") JOIN iam.user_roles ur ON ur.user_id = e.user_id
.where("employee.isCurrent = :isCurrent", { isCurrent: true }) JOIN iam.role_permissions rp ON rp.role_id = ur.role_id
.andWhere("permission.key IN (:...permissionKeys)", { permissionKeys }) WHERE e.is_current AND e.user_id IS NOT NULL
.select("DISTINCT employee.user_id", "userId") AND rp.permission_id IN (SELECT id FROM target)
.getRawMany(); UNION
return rows -- 2. Direct position grants. A delegate keeps their own position AND
.map((r) => r.userId) -- gains the one they stand in for, so both columns count.
.filter((id): id is string => Boolean(id)); 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( async createOrganizationUser(

View File

@@ -1,26 +1,44 @@
import { import {
Body,
Controller, Controller,
Get, Get,
Param, Param,
ParseUUIDPipe, ParseUUIDPipe,
Post,
Query, Query,
Res, Res,
UploadedFile,
UseInterceptors,
} from "@nestjs/common"; } from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; import { FileInterceptor } from "@nestjs/platform-express";
import {
ApiBearerAuth,
ApiConsumes,
ApiOperation,
ApiTags,
} from "@nestjs/swagger";
import type { Response } from "express"; import type { Response } from "express";
import { CurrentUser } from "@edr/api-common"; import { CurrentUser } from "@edr/api-common";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { BookingStaff } from "../../common/booking-guards"; import { BookingStaff } from "../../common/booking-guards";
import { resolveAuthUserId } from "../../common/resolve-auth-user-id";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { actorLabel } from "../warehouses/current-actor.util";
import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service"; import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service";
import { BillingService } from "./billing.service"; import { BillingService } from "./billing.service";
import { FilterInvoiceDto } from "./dto/filter-invoice.dto"; import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
@ApiTags("billing") @ApiTags("billing")
@Controller("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,
FREIGHT_PERMS.invoices.confirmOffline,
])
@ApiBearerAuth() @ApiBearerAuth()
export class BillingController { export class BillingController {
constructor( constructor(
@@ -51,6 +69,36 @@ export class BillingController {
return this.billingService.findById(id); return this.billingService.findById(id);
} }
@Get("offline-usd")
@ApiOperation({
summary:
"Finance worklist: USD invoices settled offline by bank transfer, with booking pay-window context",
})
findOfflineUsd(@Query() query: FilterInvoiceDto) {
return this.billingService.findOfflineUsdPaginated(query);
}
@Post("invoices/:id/confirm-offline")
@BookingStaff(FREIGHT_PERMS.invoices.confirmOffline)
@UseInterceptors(FileInterceptor("file"))
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary:
"Finance confirms a USD invoice paid by bank transfer — slip file required, settles the full balance",
})
confirmOffline(
@Param("id", ParseUUIDPipe) id: string,
@UploadedFile() file: Express.Multer.File | undefined,
@Body("reference") reference: string | undefined,
@CurrentUser() user: TCurrentUser,
) {
return this.billingService.confirmOfflinePayment(id, file, {
reference: reference?.trim() || null,
userId: resolveAuthUserId(user),
userName: actorLabel(user) ?? null,
});
}
@Get("invoices/:id/document") @Get("invoices/:id/document")
@BookingStaff(FREIGHT_PERMS.invoices.export) @BookingStaff(FREIGHT_PERMS.invoices.export)
@ApiOperation({ summary: "Download the sealed invoice PDF" }) @ApiOperation({ summary: "Download the sealed invoice PDF" })

View File

@@ -13,6 +13,7 @@ import { InvoiceRepository } from "./invoice.repository";
import { InvoiceLineRepository } from "./invoice-line.repository"; import { InvoiceLineRepository } from "./invoice-line.repository";
import { PaymentModule } from "../payment/payment.module"; import { PaymentModule } from "../payment/payment.module";
import { CompaniesModule } from "../companies/companies.module"; import { CompaniesModule } from "../companies/companies.module";
import { FilesModule } from "../files/files.module";
@Module({ @Module({
imports: [ imports: [
@@ -21,6 +22,7 @@ import { CompaniesModule } from "../companies/companies.module";
CompaniesModule, CompaniesModule,
DocumentsModule, DocumentsModule,
UserTradeAccessModule, UserTradeAccessModule,
FilesModule,
], ],
controllers: [BillingController, PortalBillingController, PaymentController], controllers: [BillingController, PortalBillingController, PaymentController],
providers: [BillingService, InvoiceRepository, InvoiceLineRepository], providers: [BillingService, InvoiceRepository, InvoiceLineRepository],

View File

@@ -79,6 +79,7 @@ describe("BillingService.generateInvoice", () => {
{} as never, // payment {} as never, // payment
{} as never, // companies {} as never, // companies
{} as never, // invoiceDocuments {} as never, // invoiceDocuments
{} as never, // files
); );
}); });
@@ -140,6 +141,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
{} as never, // payment {} as never, // payment
{} as never, // companies {} as never, // companies
{} as never, // invoiceDocuments {} as never, // invoiceDocuments
{} as never, // files
); );
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
@@ -193,6 +195,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
{} as never, // payment {} as never, // payment
{} as never, // companies {} as never, // companies
{} as never, // invoiceDocuments {} as never, // invoiceDocuments
{} as never, // files
); );
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
@@ -236,6 +239,7 @@ describe("BillingService.settleByPaymentId", () => {
{} as never, // payment {} as never, // payment
{} as never, // companies {} as never, // companies
{} as never, // invoiceDocuments {} as never, // invoiceDocuments
{} as never, // files
); );
return { service, mg, events }; return { service, mg, events };
} }
@@ -347,6 +351,7 @@ describe("BillingService.recordPayment", () => {
{} as never, // payment {} as never, // payment
{} as never, // companies {} as never, // companies
{} as never, // invoiceDocuments {} as never, // invoiceDocuments
{} as never, // files
); );
return { service, mg, events }; return { service, mg, events };
} }
@@ -462,6 +467,7 @@ describe("BillingService.expirePayable — locked write runs in a transaction",
{} as never, {} as never,
{} as never, {} as never,
{} as never, {} as never,
{} as never,
); );
return { service, defaultManager, txManager, transaction }; return { service, defaultManager, txManager, transaction };
}; };
@@ -533,6 +539,7 @@ describe("BillingService.issuePayable", () => {
{} as never, {} as never,
{} as never, {} as never,
{} as never, {} as never,
{} as never,
); );
return { service, manager }; return { service, manager };
}; };
@@ -622,6 +629,7 @@ describe("BillingService — CAC Bank (OTP debit)", () => {
payment as never, payment as never,
{} as never, {} as never,
{} as never, {} as never,
{} as never,
); );
return { service, repo }; return { service, repo };
}; };
@@ -703,6 +711,7 @@ describe("BillingService — CBE bill amounts round UP to whole birr", () => {
payment as never, payment as never,
{} as never, {} as never,
{} as never, {} as never,
{} as never,
); );
return { service, repo }; return { service, repo };
}; };

View File

@@ -12,6 +12,7 @@ import { DataSource, EntityManager, In } from "typeorm";
import { Booking } from "../bookings/entities/booking.entity"; import { Booking } from "../bookings/entities/booking.entity";
import { CompaniesService } from "../companies/companies.service"; import { CompaniesService } from "../companies/companies.service";
import { FilesService } from "../files/files.service";
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util"; import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
import { PaymentService } from "../payment/payment.service"; import { PaymentService } from "../payment/payment.service";
import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto"; import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto";
@@ -35,6 +36,14 @@ export interface PayInvoiceOptions {
failureUrl?: string; failureUrl?: string;
} }
/** Booking context attached to a finance offline-USD invoice row. */
export interface OfflineUsdBookingInfo {
id: string;
reference: string;
paymentDeadline: Date | null;
paymentStatus: string;
}
/** A single manual/offline settlement to record against an invoice. */ /** A single manual/offline settlement to record against an invoice. */
export interface RecordPaymentInput { export interface RecordPaymentInput {
/** Amount settled by this payment; must be > 0. */ /** Amount settled by this payment; must be > 0. */
@@ -150,6 +159,7 @@ export class BillingService {
private readonly payment: PaymentService, private readonly payment: PaymentService,
private readonly companies: CompaniesService, private readonly companies: CompaniesService,
private readonly invoiceDocuments: InvoiceDocumentService, private readonly invoiceDocuments: InvoiceDocumentService,
private readonly files: FilesService,
) { } ) { }
// ── Reads ────────────────────────────────────────────────────────────────── // ── Reads ──────────────────────────────────────────────────────────────────
@@ -214,6 +224,146 @@ export class BillingService {
return { items, total }; return { items, total };
} }
/**
* Finance's offline-settlement worklist: USD invoices (paid by bank transfer,
* never through the gateway), open ones by default or a single status when
* filtered. Booking-sourced rows carry the booking's reference and pay-window
* deadline so the UI can show the countdown and link to the booking.
*/
async findOfflineUsdPaginated(
filter: {
status?: Freight.InvoiceStatus;
search?: string;
page?: number;
pageSize?: number;
} = {},
): Promise<{
items: (Invoice & { booking: OfflineUsdBookingInfo | null })[];
total: number;
}> {
const page = filter.page && filter.page > 0 ? filter.page : 1;
const pageSize =
filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20;
const qb = this.dataSource
.getRepository(Invoice)
.createQueryBuilder("invoice")
.leftJoinAndSelect("invoice.company", "company")
.where("UPPER(invoice.currency) = 'USD'")
.orderBy("invoice.issuedAt", "DESC")
.skip((page - 1) * pageSize)
.take(pageSize);
if (filter.status) {
qb.andWhere("invoice.status = :status", { status: filter.status });
} else {
qb.andWhere("invoice.status IN (:...open)", { open: OPEN_STATUSES });
}
if (filter.search) {
qb.andWhere(
"(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)",
{ search: `%${filter.search}%` },
);
}
const [items, total] = await qb.getManyAndCount();
const bookingIds = items
.filter((i) => i.source === "booking")
.map((i) => i.sourceId);
const bookings = bookingIds.length
? await this.dataSource.getRepository(Booking).find({
where: { id: In(bookingIds) },
select: ["id", "reference", "paymentDeadline", "paymentStatus"],
})
: [];
const byId = new Map(bookings.map((b) => [b.id, b]));
return {
items: items.map((inv) => {
const b = byId.get(inv.sourceId);
return {
...inv,
booking: b
? {
id: b.id,
reference: b.reference,
paymentDeadline: b.paymentDeadline ?? null,
paymentStatus: b.paymentStatus,
}
: null,
} as Invoice & { booking: OfflineUsdBookingInfo | null };
}),
total,
};
}
/**
* Finance confirms a USD invoice as paid by bank transfer: stores the slip
* against the invoice and settles the FULL outstanding balance through
* {@link recordPayment}, which flips the invoice to PAID and (for bookings)
* emits `booking.invoice.paid` — the same event an online payment fires, so
* the booking advances exactly as if it had been paid through the gateway.
*
* Guarded by the booking's pay window: past the deadline the booking expires
* like any unpaid one, so confirmation is refused.
*/
async confirmOfflinePayment(
invoiceId: string,
file: Express.Multer.File | undefined,
input: {
reference?: string | null;
userId?: string | null;
userName?: string | null;
},
): Promise<Invoice> {
const invoice = await this.invoices.findById(invoiceId);
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
if (invoice.currency?.toUpperCase() !== "USD") {
throw new BadRequestException(
"Offline confirmation is only for USD invoices — this invoice is paid online.",
);
}
if (!file) {
throw new BadRequestException("The bank payment slip file is required.");
}
if (invoice.source === "booking") {
const booking = await this.dataSource.getRepository(Booking).findOne({
where: { id: invoice.sourceId },
select: ["id", "paymentDeadline"],
});
const deadline = booking?.paymentDeadline;
if (deadline && new Date(deadline).getTime() < Date.now()) {
throw new BadRequestException(
"The payment window has closed — this booking can no longer be confirmed as paid.",
);
}
}
const slip = await this.files.upload({
resource: "invoice",
resourceId: invoice.id,
code: "OFFLINE_PAYMENT_SLIP",
file,
title: "Bank payment slip",
uploadedByUserId: input.userId ?? null,
uploadedByName: input.userName ?? null,
});
return this.recordPayment(invoiceId, {
amount: Number(invoice.balanceAmount),
method: "BANK_TRANSFER",
reference: input.reference || slip.name,
metadata: {
offline: true,
slipFileId: slip.id,
confirmedByUserId: input.userId ?? null,
confirmedByName: input.userName ?? null,
},
});
}
/** Invoice header plus its line items. */ /** Invoice header plus its line items. */
async findById(id: string): Promise<Invoice & { lines: InvoiceLine[] }> { async findById(id: string): Promise<Invoice & { lines: InvoiceLine[] }> {
const invoice = await this.invoices.findById(id, { const invoice = await this.invoices.findById(id, {

View File

@@ -0,0 +1,287 @@
import {
EimsMapperContext,
EimsMapperInvoice,
EimsSellerDetails,
formatEimsDate,
toEimsInvoice,
} from "./eims-invoice.mapper";
const seller: EimsSellerDetails = {
City: null,
Email: "finance@edr.et",
HouseNumber: null,
LegalName: "Ethio-Djibouti Railway S.C.",
Locality: null,
Phone: "0911223344",
Region: "13",
SubCity: null,
Tin: "0016324478",
VatNumber: "3215840010",
Wereda: "574",
};
const invoice = (over: Partial<EimsMapperInvoice> = {}): EimsMapperInvoice => ({
invoiceNumber: "INV-20260807-00042",
currency: "ETB",
issuedAt: new Date(2026, 7, 7, 9, 5, 3),
totalAmount: "11000.00",
company: {
name: "ABC Trading PLC",
tin: "0999930000",
vatNumber: "123475885858",
phone: "0912345678",
email: "buyer@abc.et",
region: "13",
zone: "SHA",
woreda: "574",
kebele: "03",
houseNo: "NEW",
country: "Ethiopia",
},
lines: [
{ chargeType: "RAIL_FREIGHT", description: "Addis → Djibouti", quantity: "1.00", unitRate: "10000.00", amount: "10000.00" },
{ chargeType: "HAZARD_SURCHARGE", description: null, quantity: "2.00", unitRate: "500.00", amount: "1000.00", metadata: { unit: "CTR" } },
],
...over,
});
const context = (over: Partial<EimsMapperContext> = {}): EimsMapperContext => ({
systemNumber: "B0360154BA",
systemType: "SYS",
documentNumber: "24",
invoiceCounter: 7,
previousIrn: "",
cashierName: null,
salesPersonName: null,
transactionType: "B2B",
payment: { mode: "CASH", term: "IMMIDIATE" },
taxForLine: () => ({ code: "VAT15", ratePercent: 15, exciseTaxValue: 0 }),
natureOfSupplies: "Service",
unitDefault: "PCS",
incomeWithholdValue: 0,
transactionWithholdValue: 0,
buyerRegionCodes: { "Addis Ababa": "13" },
buyerWeredaCodes: {},
...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("toEimsInvoice — MoR field constraints", () => {
it("passes a buyer region through when it is already a MoR code", () => {
const doc = toEimsInvoice(invoice(), seller, context());
expect(doc.BuyerDetails.Region).toBe("13");
});
it("maps a region name to its code, ignoring case and spacing", () => {
const doc = toEimsInvoice(
invoice({ company: { ...invoice().company!, region: " addis ababa " } }),
seller,
context({ buyerRegionCodes: { "Addis Ababa": "13" } }),
);
expect(doc.BuyerDetails.Region).toBe("13");
});
it("refuses to file a buyer whose region has no mapping", () => {
expect(() =>
toEimsInvoice(
invoice({ company: { ...invoice().company!, region: "Somewhere Else" } }),
seller,
context(),
),
).toThrow(/not a MoR Region code and has no mapping/);
});
it("refuses a buyer with no region at all rather than guessing one", () => {
expect(() =>
toEimsInvoice(
invoice({ company: { ...invoice().company!, region: null } }),
seller,
context(),
),
).toThrow(/buyer Region \(unset\)/);
});
it("passes a buyer wereda through when it is already a MoR code", () => {
const doc = toEimsInvoice(invoice(), seller, context());
expect(doc.BuyerDetails.Wereda).toBe("574");
});
it("maps a wereda name to its code", () => {
const doc = toEimsInvoice(
invoice({ company: { ...invoice().company!, woreda: "Yeka" } }),
seller,
context({ buyerWeredaCodes: { Yeka: "99" } }),
);
expect(doc.BuyerDetails.Wereda).toBe("99");
});
it("refuses to file a buyer whose wereda has no mapping", () => {
expect(() =>
toEimsInvoice(
invoice({ company: { ...invoice().company!, woreda: "Yeka" } }),
seller,
context({ buyerWeredaCodes: {} }),
),
).toThrow(/buyer Wereda "Yeka".*EIMS_BUYER_WEREDA_CODES/);
});
it("emits NatureOfSupplies lowercase, whatever case it was configured in", () => {
const doc = toEimsInvoice(invoice(), seller, context({ natureOfSupplies: "Service" }));
expect(doc.ItemList[0].NatureOfSupplies).toBe("service");
});
it("rejects a NatureOfSupplies MoR does not accept", () => {
expect(() =>
toEimsInvoice(invoice(), seller, context({ natureOfSupplies: "Services" })),
).toThrow(/must be one of goods, service/);
});
});
describe("formatEimsDate", () => {
it("renders the observed dd-MM-yyyyTHH:mm:ss shape with zero padding", () => {
expect(formatEimsDate(new Date(2025, 2, 21, 0, 0, 0))).toBe("21-03-2025T00:00:00");
});
});

View File

@@ -0,0 +1,441 @@
/**
* Pure mapper from an EDR invoice onto the Ethiopian MoR EIMS registration document
* (`POST https://core.mor.gov.et/v1/register`).
*
* Field names, casing and section layout are taken verbatim from the supplied
* `EimsCoreApiMockCollection2.postman_collection.json`. Note the payload spells the district
* `Wereda` even though the collection *variable* is named `sellerWoreda`.
*
* Scope: mapping only — no HTTP, no signing, no persistence, no counter allocation. Everything
* that does not live on the invoice (document number, counters, previous IRN, seller identity,
* tax treatment) is supplied by the caller and is never guessed here.
*
* Values that the collection only *demonstrates* by example — the date format, the meaning of an
* empty `PreviousIrn`, the `SystemType` enum, `PaymentTerm` values — are treated as observed, not
* authoritative: they are passed through or overridable rather than validated against a fixed set.
*/
import { round2 } from "./invoice-settlement.util";
/** Only proven-required constant: the 400 SCHEMA ERROR sample rejects a payload without it. */
const EIMS_VERSION = "1";
/** The only `DocumentDetails.Type` observed in the supplied material. */
const EIMS_DOCUMENT_TYPE = "INV";
export interface EimsBuyerDetails {
City: string | null;
Email: string | null;
HouseNumber: string | null;
IdNumber: string | null;
IdType: string | null;
Tin: string;
LegalName: string;
Phone: string | null;
Region: string | null;
Country: string | null;
Zone: string | null;
Kebele: string | null;
VatNumber: string | null;
Wereda: string | null;
}
export interface EimsSellerDetails {
City: string | null;
Email: string | null;
HouseNumber: string | null;
LegalName: string;
Locality: string | null;
Phone: string | null;
/** MoR region *code* (e.g. "13"), not a region name. */
Region: string | null;
SubCity: string | null;
Tin: string;
VatNumber: string | null;
/** MoR wereda *code* (e.g. "574"). */
Wereda: string | null;
}
export interface EimsDocumentDetails {
DocumentNumber: string;
/** Observed format `dd-MM-yyyyTHH:mm:ss`. Rule seen in the collection: within 3 days of now. */
Date: string;
Type: string;
}
export interface EimsInvoiceItem {
Discount: number;
ExciseTaxValue: number;
HarmonizationCode: string | null;
NatureOfSupplies: string;
ItemCode: string;
ProductDescription: string;
PreTaxValue: number;
Quantity: number;
LineNumber: number;
TaxAmount: number;
TaxCode: string;
TotalLineAmount: number;
Unit: string;
UnitPrice: number;
}
export interface EimsPaymentDetails {
Mode: string;
PaymentTerm: string;
}
export interface EimsReferenceDetails {
PreviousIrn: string | null;
RelatedDocument: string | null;
}
export interface EimsSourceSystem {
CashierName: string | null;
InvoiceCounter: number;
SalesPersonName: string | null;
SystemNumber: string;
SystemType: string;
}
export interface EimsValueDetails {
Discount: number | null;
ExciseValue: number;
IncomeWithholdValue: number;
TaxValue: number;
TotalValue: number;
TransactionWithholdValue: number;
InvoiceCurrency: string;
/** Absent from the register sample, present on the verify response. Emitted only when supplied. */
ExchangeRate?: number;
}
export interface EimsInvoiceRequest {
BuyerDetails: EimsBuyerDetails;
DocumentDetails: EimsDocumentDetails;
ItemList: EimsInvoiceItem[];
PaymentDetails: EimsPaymentDetails;
ReferenceDetails: EimsReferenceDetails;
SellerDetails: EimsSellerDetails;
SourceSystem: EimsSourceSystem;
TransactionType: string;
ValueDetails: EimsValueDetails;
Version: string;
}
/** `body` of a successful `POST /v1/register`, as observed in the collection. */
export interface EimsRegisterResponseBody {
irn: string;
ackDate: string;
signedQR: string;
signedInvoice: string;
status: string;
documentNumber: string;
errorMessage: string | null;
}
/** Numeric columns arrive from pg as strings; every money field is normalised through `num`. */
export interface EimsMapperLine {
chargeType: string;
description?: string | null;
quantity: number | string;
unitRate: number | string;
amount: number | string;
metadata?: Record<string, unknown> | null;
}
export interface EimsMapperCompany {
name: string;
tin: string;
vatNumber?: string | null;
phone?: string | null;
email?: string | null;
region?: string | null;
zone?: string | null;
woreda?: string | null;
kebele?: string | null;
houseNo?: string | null;
country?: string | null;
}
/**
* Structurally what `BillingService.findById` returns — the only read path that loads the header,
* the buyer company and the lines together.
*/
export interface EimsMapperInvoice {
invoiceNumber: string;
currency: string;
issuedAt?: Date | string | null;
totalAmount: number | string;
company?: EimsMapperCompany | null;
lines: EimsMapperLine[];
}
/**
* Tax treatment for a single line. EIMS models `TaxCode`/`TaxAmount`/`ExciseTaxValue` per item, and
* different charge types may eventually be treated differently, so this is resolved per line.
*
* Nothing in this repo can supply it: `Invoice.taxAmount` is hardcoded to 0 with no caller ever
* setting it, `invoice_lines` has no tax column, and the rate catalogue has no fiscal field. That
* is the absence of a tax model, not evidence of zero-rating — hence no default here.
*/
export interface EimsLineTax {
code: string;
ratePercent: number;
exciseTaxValue: number;
}
export interface EimsMapperContext {
systemNumber: string;
/** Observed values: POS, MAN, CRM, EFD, SYS (the collection prose also mentions ERP). */
systemType: string;
/** Caller decides the source — our own `invoiceNumber` or a dedicated EIMS sequence. */
documentNumber: string;
invoiceCounter: number;
/** Passed through verbatim; the collection shows `""` used for an unchained document. */
previousIrn: string | null;
cashierName: string | null;
salesPersonName: string | null;
/** B2B / B2C — a tax classification, so the caller states it. */
transactionType: string;
payment: { mode: string; term: string };
/** Must return a treatment for every line, or throw. */
taxForLine: (line: EimsMapperLine, lineNumber: number) => EimsLineTax;
natureOfSupplies: string;
/** Used when a line carries no `metadata.unit`. */
unitDefault: string;
incomeWithholdValue: number;
transactionWithholdValue: number;
/** Null for an ordinary invoice; set only for a real related-document case. */
relatedDocument?: string | null;
/** MoR numeric country code for the buyer; our DB stores the country name. */
buyerCountryCode?: string | null;
/**
* Region name → MoR numeric code, for buyers whose stored region is free text.
*
* `companies.region` holds names ("Addis Ababa") while MoR validates `BuyerDetails.Region`
* against `^[0-9]{1,3}$`. A stored value that is already a code passes through; anything else
* must be in this map or the mapping **fails locally** — sending a guessed region code onto a
* tax document is worse than refusing to file.
*/
buyerRegionCodes: Record<string, string>;
/**
* Wereda name → MoR code, same shape as `buyerRegionCodes`. `companies.woreda` holds names
* ("Yeka") or codes inconsistently; unlike Region, MoR has never named a Wereda regex in an
* error, so this is precautionary rather than confirmed — but the fix is identical either way:
* fail locally on an unmapped name rather than file a guess.
*/
buyerWeredaCodes: Record<string, string>;
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;
}
/**
* MoR's own constraint on `Region`: one to three digits, confirmed by its 400 SCHEMA ERROR. Reused
* as the pass-through test for `Wereda` too — every Wereda value MoR has actually shown us (seller
* "12"/"13", the collection's "574") fits the same shape, though MoR has not named a Wereda regex
* the way it named Region's.
*/
const LOCATION_CODE = /^[0-9]{1,3}$/;
/**
* The only two values MoR accepts for `NatureOfSupplies`, lowercase.
*
* Its schema branches on this as a `oneOf` with a `const` per branch, so `"Service"` fails the
* whole `ItemList` — the error reads "must be the constant value 'service'".
*/
const NATURE_OF_SUPPLIES = ["goods", "service"] as const;
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.
*/
/**
* A buyer's location value (Region or Wereda) as a MoR code: passed through when already numeric,
* otherwise looked up by name (case- and space-insensitive). Throws when neither applies — sending
* a guessed code onto a tax document is worse than refusing to file.
*/
function resolveLocationCode(
field: "Region" | "Wereda",
value: string | null | undefined,
codes: Record<string, string>,
envVar: string,
invoiceNumber: string,
): string {
const raw = (value ?? "").trim();
if (LOCATION_CODE.test(raw)) return raw;
const key = raw.toLowerCase().replace(/\s+/g, " ");
const mapped = Object.entries(codes).find(
([name]) => name.trim().toLowerCase().replace(/\s+/g, " ") === key,
)?.[1];
if (mapped && LOCATION_CODE.test(mapped)) return mapped;
throw new Error(
`EIMS mapping: invoice ${invoiceNumber} has buyer ${field} ${raw ? `"${raw}"` : "(unset)"}, ` +
`which is not a MoR ${field} code and has no mapping. Add it to ${envVar}.`,
);
}
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 natureOfSupplies = context.natureOfSupplies.trim().toLowerCase();
if (!NATURE_OF_SUPPLIES.includes(natureOfSupplies as (typeof NATURE_OF_SUPPLIES)[number])) {
throw new Error(
`EIMS mapping: NatureOfSupplies must be one of ${NATURE_OF_SUPPLIES.join(", ")}, ` +
`got "${context.natureOfSupplies}"`,
);
}
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: 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: resolveLocationCode(
"Region",
company.region,
context.buyerRegionCodes,
"EIMS_BUYER_REGION_CODES",
invoice.invoiceNumber,
),
Country: context.buyerCountryCode ?? null,
Zone: company.zone ?? null,
Kebele: company.kebele ?? null,
VatNumber: company.vatNumber ?? null,
Wereda: resolveLocationCode(
"Wereda",
company.woreda,
context.buyerWeredaCodes,
"EIMS_BUYER_WEREDA_CODES",
invoice.invoiceNumber,
),
},
DocumentDetails: {
DocumentNumber: context.documentNumber,
Date: (context.formatDate ?? formatEimsDate)(issuedAt),
Type: EIMS_DOCUMENT_TYPE,
},
ItemList,
PaymentDetails: { Mode: context.payment.mode, PaymentTerm: context.payment.term },
ReferenceDetails: {
PreviousIrn: context.previousIrn,
RelatedDocument: context.relatedDocument ?? null,
},
SellerDetails: seller,
SourceSystem: {
CashierName: context.cashierName,
InvoiceCounter: context.invoiceCounter,
SalesPersonName: context.salesPersonName,
SystemNumber: context.systemNumber,
SystemType: context.systemType,
},
TransactionType: context.transactionType,
ValueDetails,
Version: EIMS_VERSION,
};
}

View File

@@ -1,6 +1,7 @@
import { BaseEntity } from "@edr/api-common"; import { BaseEntity } from "@edr/api-common";
import { Freight } from "@edr/types"; import { Freight } from "@edr/types";
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm"; 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 { PaymentEntity } from "../../payment/entities/payment.entity";
import { Company } from "../../companies/entities/company.entity"; import { Company } from "../../companies/entities/company.entity";
import { CompanyProfile } from "../../companies/entities/company-profile.entity"; import { CompanyProfile } from "../../companies/entities/company-profile.entity";
@@ -105,4 +106,31 @@ export class Invoice extends BaseEntity {
@Column({ name: "due_at", type: "timestamptz" }) @Column({ name: "due_at", type: "timestamptz" })
dueAt!: Date; 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 numeric `DocumentDetails.DocumentNumber` filed for this invoice. */
@Column({ name: "eims_document_number", type: "varchar", length: 16, nullable: true })
eimsDocumentNumber?: string | null;
/** The `SourceSystem.InvoiceCounter` this invoice consumed. */
@Column({ name: "eims_invoice_counter", type: "bigint", nullable: true })
eimsInvoiceCounter?: number | null;
@Column({ name: "eims_submitted_at", type: "timestamptz", nullable: true })
eimsSubmittedAt?: Date | null;
/** EIMS acknowledgement timestamp, stored verbatim — it is a Java ZonedDateTime string. */
@Column({ name: "eims_ack_date", type: "varchar", length: 64, nullable: true })
eimsAckDate?: string | null;
/** Sanitized last failure: the gateway's own error fields only, never our signed envelope. */
@Column({ name: "eims_last_error", type: "jsonb", nullable: true })
eimsLastError?: EimsInvoiceError | null;
} }

View File

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

View File

@@ -11,6 +11,16 @@ import { Booking } from './entities/booking.entity';
import { NotificationsService } from '../notifications/notifications.service'; import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util'; 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 * 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( private inAppStaff(
b: Booking, b: Booking,
title: string, title: string,
@@ -97,7 +111,7 @@ export class BookingLifecycleNotifierService {
overrides: Partial<NotifyInput> = {}, overrides: Partial<NotifyInput> = {},
): void { ): void {
void this.inbox.notify({ void this.inbox.notify({
recipients: { allBackoffice: true }, recipients: { permissionKeys: [FREIGHT_PERMS.bookings.getNotification] },
audience: NotificationAudience.BACKOFFICE, audience: NotificationAudience.BACKOFFICE,
type: NotificationType.REQUEST_SUBMITTED, type: NotificationType.REQUEST_SUBMITTED,
title, title,
@@ -274,6 +288,7 @@ export class BookingLifecycleNotifierService {
`the customs declaration can be filed.${note ? ` Note: "${note}"` : ''}`; `the customs declaration can be filed.${note ? ` Note: "${note}"` : ''}`;
this.logger.log(`TRANSIT ASSIGNEE REQUESTED — ${this.ref(b)}`); this.logger.log(`TRANSIT ASSIGNEE REQUESTED — ${this.ref(b)}`);
this.inAppStaff(b, `Transit assignee needed — ${b.reference}`, msg, { this.inAppStaff(b, `Transit assignee needed — ${b.reference}`, msg, {
recipients: CLEARANCE_DESK,
type: NotificationType.CLEARANCE_REVIEW, type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/gl-djibouti/clearance/${b.id}`, link: `/dashboard/gl-djibouti/clearance/${b.id}`,
}); });
@@ -288,6 +303,7 @@ export class BookingLifecycleNotifierService {
`The customs declaration can now be filed.`; `The customs declaration can now be filed.`;
this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${this.ref(b)}`); this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${this.ref(b)}`);
this.inAppStaff(b, `Transit assignee set — ${b.reference}`, msg, { this.inAppStaff(b, `Transit assignee set — ${b.reference}`, msg, {
recipients: CLEARANCE_DESK,
type: NotificationType.CLEARANCE_REVIEW, type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/bookings/${b.id}/clearance`, link: `/dashboard/bookings/${b.id}/clearance`,
}); });
@@ -395,6 +411,7 @@ export class BookingLifecycleNotifierService {
'Clearance documents uploaded', 'Clearance documents uploaded',
`Customer uploaded clearance documents for booking ${this.ref(b)} — review them in the clearance queue.`, `Customer uploaded clearance documents for booking ${this.ref(b)} — review them in the clearance queue.`,
{ {
recipients: CLEARANCE_DESK,
type: NotificationType.CLEARANCE_REVIEW, type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/bookings/${b.id}/clearance`, 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)}: ` + `The customer requested a change to the draft declaration on booking ${this.ref(b)}: ` +
`"${note}". Send a corrected draft from the clearance page.`; `"${note}". Send a corrected draft from the clearance page.`;
this.inAppStaff(b, `Draft declaration change requested — ${this.ref(b)}`, msg, { this.inAppStaff(b, `Draft declaration change requested — ${this.ref(b)}`, msg, {
recipients: CLEARANCE_DESK,
type: NotificationType.CLEARANCE_REVIEW, type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/bookings/${b.id}/clearance`, link: `/dashboard/bookings/${b.id}/clearance`,
}); });
@@ -440,6 +458,7 @@ export class BookingLifecycleNotifierService {
'Payment slip uploaded', 'Payment slip uploaded',
`Customer uploaded the ${label} payment slip for booking ${this.ref(b)}.`, `Customer uploaded the ${label} payment slip for booking ${this.ref(b)}.`,
{ {
recipients: CLEARANCE_DESK,
type: NotificationType.PAYMENT_RECEIVED, type: NotificationType.PAYMENT_RECEIVED,
link: `/dashboard/bookings/${b.id}/clearance`, link: `/dashboard/bookings/${b.id}/clearance`,
}, },

View File

@@ -20,7 +20,7 @@ import { NotificationInboxService } from '../notification-inbox/notification-inb
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
import { Rate } from '../rule-engine/entities/rate.entity'; import { Rate } from '../rule-engine/entities/rate.entity';
import { BookingBatchService } from '../train-scheduling/booking-batch.service'; import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
import { WagonAllocationBulkLoad } from '../train-schedules/entities/wagon-allocation-bulk-load.entity'; import { WagonAllocationBulkLoad } from '../train-schedules/entities/wagon-allocation-bulk-load.entity';
import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity'; import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity';
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity'; import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
@@ -1112,12 +1112,14 @@ export class BookingWagonCancellationService {
private notifyStaff(booking: Booking, title: string, body: string): void { private notifyStaff(booking: Booking, title: string, body: string): void {
void this.inbox.notify({ void this.inbox.notify({
recipients: { allBackoffice: true }, recipients: { permissionKeys: [FREIGHT_PERMS.bookings.getNotification] },
audience: NotificationAudience.BACKOFFICE, audience: NotificationAudience.BACKOFFICE,
type: NotificationType.BOOKING_STATUS, type: NotificationType.BOOKING_STATUS,
title, title,
body, 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 }, data: { bookingId: booking.id, reference: booking.reference },
}); });
} }

View File

@@ -77,6 +77,7 @@ import { LastMileService } from '../last-mile/last-mile.service';
import { GenerateGrnDto } from './dto/generate-grn.dto'; import { GenerateGrnDto } from './dto/generate-grn.dto';
import { ContainerReceiptService } from './container-receipt.service'; import { ContainerReceiptService } from './container-receipt.service';
import { SignContractDto } from './dto/sign-contract.dto'; import { SignContractDto } from './dto/sign-contract.dto';
import { SetExportHandoverModeDto } from './dto/set-export-handover-mode.dto';
import { UpdateBookingDto } from './dto/update-booking.dto'; import { UpdateBookingDto } from './dto/update-booking.dto';
import { BookingWagonCancellationService } from './booking-wagon-cancellation.service'; import { BookingWagonCancellationService } from './booking-wagon-cancellation.service';
import { import {
@@ -757,6 +758,18 @@ export class BookingsController {
return this.customerTruckService.getLoadableContainers(id); return this.customerTruckService.getLoadableContainers(id);
} }
@Patch(':id/export-handover-mode')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({
summary: 'Export only: choose direct truck-to-train (no warehouse, no GRN) or warehouse first',
})
setExportHandoverMode(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: SetExportHandoverModeDto,
) {
return this.bookingsService.setExportHandoverMode(id, dto.exportHandoverMode);
}
@Post(':id/customer-trucks/:assignmentId/load') @Post(':id/customer-trucks/:assignmentId/load')
@BookingStaff(FREIGHT_PERMS.bookings.operations) @BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Truck_dispatch: load selected containers onto a truck (staff)' }) @ApiOperation({ summary: 'Truck_dispatch: load selected containers onto a truck (staff)' })

View File

@@ -13,7 +13,7 @@ import { insertWithGeneratedReference } from '@edr/api-common';
// import { CustomersService } from '../customers/customers.service'; // import { CustomersService } from '../customers/customers.service';
import { CompaniesService } from '../companies/companies.service'; import { CompaniesService } from '../companies/companies.service';
import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity'; import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
import { eatDay } from '../train-scheduling/batch-window.util'; import { eatDay } from '../train-scheduling/batch-window.util';
import { FilesService } from '../files/files.service'; import { FilesService } from '../files/files.service';
import { MinioService } from '../minio/minio.service'; import { MinioService } from '../minio/minio.service';
@@ -28,7 +28,7 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
import { DataSource, In } from 'typeorm'; import { DataSource, In } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { assertExportReceivedWithGrn } from '../../common/export-received-gate'; import { assertExportReceivedWithGrn, DIRECT_TO_TRAIN } from '../../common/export-received-gate';
import { Yard } from '../rule-engine/entities/yard.entity'; import { Yard } from '../rule-engine/entities/yard.entity';
import { ServiceType } from '../rule-engine/entities/service-type.entity'; import { ServiceType } from '../rule-engine/entities/service-type.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
@@ -285,10 +285,24 @@ export class BookingsService {
// Receipt is proven by the warehouse GRN, but the GRN is warehouse paperwork // Receipt is proven by the warehouse GRN, but the GRN is warehouse paperwork
// and never appears on this sheet — it is only the signal that EDR has taken // and never appears on this sheet — it is only the signal that EDR has taken
// the cargo, which is what the customer's sheet attests to. // the cargo, which is what the customer's sheet attests to.
const isDirectExport =
booking.tradeDirection === 'EXPORT' && booking.exportHandoverMode === DIRECT_TO_TRAIN;
const pendingWagons = wagons.length === 0; const pendingWagons = wagons.length === 0;
if (pendingWagons) { if (pendingWagons) {
const receivedLines: CarriageAcceptanceReceivedRow[] = // Direct truck-to-train cargo never enters the warehouse, so there is no
booking.tradeDirection === 'EXPORT' // GRN'd inventory to build the sheet from. Choosing direct handover is
// itself the acceptance, so the sheet issues off the booking's own
// containers (or its VGM weight when the cargo is bulk).
const receivedLines: CarriageAcceptanceReceivedRow[] = isDirectExport
? await this.dataSource.query(
`SELECT NULL::numeric AS "allocatedWeightTons",
c.container_number AS "containerNumbers"
FROM freight.containers c
WHERE c.booking_id = $1 AND c.deleted_at IS NULL
ORDER BY c.container_number`,
[bookingId],
)
: booking.tradeDirection === 'EXPORT'
? await this.dataSource.query( ? await this.dataSource.query(
`SELECT inv.weight AS "allocatedWeightTons", `SELECT inv.weight AS "allocatedWeightTons",
c.container_number AS "containerNumbers" c.container_number AS "containerNumbers"
@@ -304,6 +318,15 @@ export class BookingsService {
[bookingId], [bookingId],
) )
: []; : [];
// Bulk direct cargo has no containers — one line carrying the booking's
// declared weight still makes a valid sheet.
if (isDirectExport && receivedLines.length === 0) {
receivedLines.push({
allocatedWeightTons:
booking.bulkTotalWeightTons == null ? null : String(booking.bulkTotalWeightTons),
containerNumbers: null,
});
}
if (receivedLines.length === 0) { if (receivedLines.length === 0) {
throw new BadRequestException( throw new BadRequestException(
booking.tradeDirection === 'EXPORT' booking.tradeDirection === 'EXPORT'
@@ -424,7 +447,10 @@ export class BookingsService {
const departureStation = booking.originYard?.label ?? booking.originYard?.code ?? '-'; const departureStation = booking.originYard?.label ?? booking.originYard?.code ?? '-';
const arrivalStation = booking.destinationYard?.label ?? booking.destinationYard?.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 currency = booking.paymentCurrency ?? 'ETB';
const totalAmount = Number(booking.adjustedTotalAmount ?? booking.totalAmount) || 0; const totalAmount = Number(booking.adjustedTotalAmount ?? booking.totalAmount) || 0;
const prices = this.splitAmountAcrossWagons( const prices = this.splitAmountAcrossWagons(
@@ -467,6 +493,28 @@ export class BookingsService {
) )
.join(''); .join('');
// The totals belong in <tbody>, not <tfoot>: the Chromium-less fallback
// renderer only parses tbody rows, so a <tfoot> silently drops every footer
// figure from the printed sheet.
const totalsRow = `<tr class="totals">
<td>TOT</td>
<td>${wagons.length} ${pendingWagons ? 'received lines' : 'wagons'}</td>
<td>${
pendingWagons
? 'pending marshalling'
: `full ${fullWagons} / empty ${wagons.length - fullWagons}`
}</td>
<td class="num">${num(totals.tare, 2)}</td>
<td class="num">${num(totals.length)}</td>
<td class="num">${num(totals.capacity)}</td>
<td></td>
<td>Gross ${num(totals.tare + totals.load)} T</td>
<td></td>
<td></td>
<td></td>
<td class="num">${money(totalAmount)}</td>
</tr>`;
return `<!doctype html> return `<!doctype html>
<html> <html>
<head> <head>
@@ -490,7 +538,7 @@ export class BookingsService {
th { background: #f8fafc; color: #475569; text-align: left; } th { background: #f8fafc; color: #475569; text-align: left; }
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; } th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
.num { text-align: right; } .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; } .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; } .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; } .line { border-top: 1px solid #334155; padding-top: 7px; font-size: 9px; color: #475569; min-height: 34px; }
@@ -538,21 +586,8 @@ export class BookingsService {
</thead> </thead>
<tbody> <tbody>
${rows} ${rows}
${totalsRow}
</tbody> </tbody>
<tfoot>
<tr>
<td colspan="3">${
pendingWagons
? `Received lines: ${wagons.length} — wagons pending marshalling`
: `Total wagons: ${wagons.length} (full ${fullWagons} / empty ${wagons.length - fullWagons})`
}</td>
<td class="num">${num(totals.tare, 2)}</td>
<td class="num">${num(totals.length)}</td>
<td class="num">${num(totals.capacity)}</td>
<td colspan="5">Gross weight (tare + load): ${num(totals.tare + totals.load)} T</td>
<td class="num">${money(totalAmount)}</td>
</tr>
</tfoot>
</table> </table>
<div class="notice"> <div class="notice">
@@ -1985,6 +2020,47 @@ export class BookingsService {
} }
/** Get a single booking by ID with files. */ /** Get a single booking by ID with files. */
/**
* EXPORT only. Choose how the cargo reaches the train. DIRECT_TO_TRAIN takes
* the booking out of the warehouse flow entirely — no receipt, no GRN, and the
* carriage acceptance sheet becomes issuable straight away.
*
* Switching to direct is refused once the goods are already in the shed:
* inventory exists, so the cargo demonstrably went the warehouse route and its
* GRN paperwork must stand.
*/
async setExportHandoverMode(
bookingId: string,
mode: string,
): Promise<{ bookingId: string; exportHandoverMode: string }> {
const booking = await this.bookingsRepository.findById(bookingId);
if (!booking) {
throw new NotFoundException(`Booking ${bookingId} not found`);
}
if ((booking.tradeDirection ?? '').toUpperCase() !== 'EXPORT') {
throw new BadRequestException('Handover mode applies to export bookings only');
}
if (mode === DIRECT_TO_TRAIN) {
const [stored]: Array<{ one: number }> = await this.dataSource.query(
`SELECT 1 AS one
FROM freight.warehouse_inventory
WHERE booking_id = $1 AND deleted_at IS NULL
LIMIT 1`,
[bookingId],
);
if (stored) {
throw new BadRequestException(
'This booking already has cargo in the warehouse, so it cannot be switched to direct truck-to-train',
);
}
}
await this.dataSource.query(
`UPDATE freight.bookings SET export_handover_mode = $2, updated_at = NOW() WHERE id = $1`,
[bookingId, mode],
);
return { bookingId, exportHandoverMode: mode };
}
async findById(id: string): Promise<Booking> { async findById(id: string): Promise<Booking> {
const booking = await this.bookingsRepository.findByIdWithFiles(id); const booking = await this.bookingsRepository.findByIdWithFiles(id);
if (!booking) { if (!booking) {

View File

@@ -0,0 +1,15 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsIn } from 'class-validator';
import { DIRECT_TO_TRAIN, WAREHOUSE } from '../../../common/export-received-gate';
export class SetExportHandoverModeDto {
@ApiProperty({
enum: [DIRECT_TO_TRAIN, WAREHOUSE],
description:
'DIRECT_TO_TRAIN — the customer truck loads straight onto the wagon (no warehouse, no GRN). ' +
'WAREHOUSE — received at the warehouse and issued a GRN first.',
})
@IsIn([DIRECT_TO_TRAIN, WAREHOUSE])
exportHandoverMode!: string;
}

View File

@@ -302,6 +302,18 @@ export class Booking extends BaseEntity {
@Column({ name: 'customer_truck_arrived_at', type: 'timestamptz', nullable: true }) @Column({ name: 'customer_truck_arrived_at', type: 'timestamptz', nullable: true })
customerTruckArrivedAt?: Date | null; customerTruckArrivedAt?: Date | null;
/**
* EXPORT only. How the cargo reaches the train:
* - DIRECT_TO_TRAIN — the customer's truck loads straight onto the wagon. No
* warehouse, so no GRN is ever raised and the carriage acceptance sheet is
* the only document handed over.
* - WAREHOUSE (also null) — received into the warehouse and GRN'd first.
*
* Null is treated as WAREHOUSE so existing bookings keep the GRN gate.
*/
@Column({ name: 'export_handover_mode', type: 'varchar', length: 20, nullable: true })
exportHandoverMode?: string | null;
/** /**
* Did the goods need re-handling in the warehouse? Recorded by warehouse * Did the goods need re-handling in the warehouse? Recorded by warehouse
* staff after unloading. Only `true` bills the DOUBLE_HANDLING_FEE rule; * staff after unloading. Only `true` bills the DOUBLE_HANDLING_FEE rule;

View File

@@ -20,7 +20,14 @@ import { CargoesService } from './cargoes.service';
@ApiTags('cargoes') @ApiTags('cargoes')
@Controller('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 { export class CargoesController {
constructor(private readonly cargoesService: CargoesService) {} constructor(private readonly cargoesService: CargoesService) {}

View File

@@ -421,7 +421,10 @@ export class CompaniesController {
@CurrentUser() user: CurrentIamUser, @CurrentUser() user: CurrentIamUser,
@Body() dto: CompleteIdentityVerificationDto, @Body() dto: CompleteIdentityVerificationDto,
): Promise<CompanyIdentityStateDto> { ): Promise<CompanyIdentityStateDto> {
return this.companiesService.completeIdentityVerification(user.id, dto); return this.companiesService.completeIdentityVerification(user.id, dto, {
email: user.email,
phoneNumber: user.phoneNumber,
});
} }
@Post("identity/gm/same-as-owner") @Post("identity/gm/same-as-owner")
@@ -434,7 +437,10 @@ export class CompaniesController {
async setGmSameAsOwner( async setGmSameAsOwner(
@CurrentUser() user: CurrentIamUser, @CurrentUser() user: CurrentIamUser,
): Promise<CompanyIdentityStateDto> { ): Promise<CompanyIdentityStateDto> {
return this.companiesService.setGmSameAsOwner(user.id); return this.companiesService.setGmSameAsOwner(user.id, {
email: user.email,
phoneNumber: user.phoneNumber,
});
} }
@Delete("identity/gm") @Delete("identity/gm")

View File

@@ -322,41 +322,15 @@ describe("Fayda identity verification binds a person to the company", () => {
// registered phone) with nothing at all. OWNER_VERIFIED is exactly that // registered phone) with nothing at all. OWNER_VERIFIED is exactly that
// shape: a sub, no contact details. // shape: a sub, no contact details.
it("keeps company contact details a Fayda verification never supplied", async () => { it("keeps company contact details a Fayda verification never supplied", async () => {
const { service, deps } = makeService({ const { deps } = makeService({
attributes: { ...OWNER_VERIFIED }, attributes: { ...OWNER_VERIFIED },
}); });
await expect(
service.updateProfile("user-1", {
companyEmail: "account@example.com",
companyPhone: "+251911777777",
} as never),
).resolves.toBeDefined();
const [, patch] = deps.companiesRepo.update.mock.calls.at(-1)!; const [, patch] = deps.companiesRepo.update.mock.calls.at(-1)!;
expect(patch.email).toBe("account@example.com"); expect(patch.email).toBe("account@example.com");
expect(patch.phone).toBe("+251911777777"); expect(patch.phone).toBe("+251911777777");
}); });
it("overwrites company contact details the verification did supply", async () => {
const { service, deps } = makeService({
attributes: {
...OWNER_VERIFIED,
ownerEmail: "abebe@example.com",
ownerPhone: "+251911000000",
},
});
await expect(
service.updateProfile("user-1", {
companyEmail: "someone-else@example.com",
companyPhone: "+251911999999",
} as never),
).resolves.toBeDefined();
const [, patch] = deps.companiesRepo.update.mock.calls.at(-1)!;
expect(patch.email).toBe("abebe@example.com");
expect(patch.phone).toBe("+251911000000");
});
// "Same as owner" copies `ownerEmail ?? null` onto the GM while setting // "Same as owner" copies `ownerEmail ?? null` onto the GM while setting
// `gmFaydaSub`. Locking that null made generalManagerEmail required by // `gmFaydaSub`. Locking that null made generalManagerEmail required by
// onboarding, hidden by the portal's link card and unwritable at once. // onboarding, hidden by the portal's link card and unwritable at once.

View File

@@ -231,41 +231,39 @@ export class CompaniesService {
label: string; label: string;
get: (company: Company) => unknown; get: (company: Company) => unknown;
}[] = [ }[] = [
{ {
key: "tinNumber", key: "tinNumber",
label: "Company TIN", label: "Company TIN",
get: (c) => (c.tin && !c.tin.startsWith("D") ? c.tin : null), get: (c) => (c.tin && !c.tin.startsWith("D") ? c.tin : null),
}, },
{ key: "companyEmail", label: "Company email", get: (c) => c.email }, { key: "companyAddress", label: "Company address", get: (c) => c.address },
{ key: "companyPhone", label: "Company phone", get: (c) => c.phone }, { key: "fanNumber", label: "FAN number", get: (c) => c.fanNumber },
{ key: "companyAddress", label: "Company address", get: (c) => c.address }, {
{ key: "fanNumber", label: "FAN number", get: (c) => c.fanNumber }, key: "contactPersonName",
{ label: "Contact person name",
key: "contactPersonName", get: (c) => c.attributes?.contactPersonName,
label: "Contact person name", },
get: (c) => c.attributes?.contactPersonName, {
}, key: "contactPersonPhone",
{ label: "Contact person phone",
key: "contactPersonPhone", get: (c) => c.attributes?.contactPersonPhone,
label: "Contact person phone", },
get: (c) => c.attributes?.contactPersonPhone, {
}, key: "generalManagerName",
{ label: "General manager name",
key: "generalManagerName", get: (c) => c.attributes?.generalManagerName,
label: "General manager name", },
get: (c) => c.attributes?.generalManagerName, {
}, key: "generalManagerEmail",
{ label: "General manager email",
key: "generalManagerEmail", get: (c) => c.attributes?.generalManagerEmail,
label: "General manager email", },
get: (c) => c.attributes?.generalManagerEmail, {
}, key: "generalManagerPhone",
{ label: "General manager phone",
key: "generalManagerPhone", get: (c) => c.attributes?.generalManagerPhone,
label: "General manager phone", },
get: (c) => c.attributes?.generalManagerPhone, ];
},
];
/** The nationality-based document setting code for a company. */ /** The nationality-based document setting code for a company. */
private documentSettingCodeFor( private documentSettingCodeFor(
@@ -314,8 +312,6 @@ export class CompaniesService {
fanNumber: dto.fanNumber ?? null, fanNumber: dto.fanNumber ?? null,
country: dto.companyLocation ?? "Ethiopia", country: dto.companyLocation ?? "Ethiopia",
address: dto.companyAddress ?? null, address: dto.companyAddress ?? null,
phone: normalizeE164(dto.companyPhone) ?? null,
email: dto.companyEmail ?? null,
attributes: dto.attributes ?? null, attributes: dto.attributes ?? null,
}); });
@@ -492,7 +488,8 @@ export class CompaniesService {
async findCompanyById(id: string): Promise<Company> { async findCompanyById(id: string): Promise<Company> {
const company = await this.companiesRepo.findById(id); const company = await this.companiesRepo.findById(id);
if (!company) throw new NotFoundException(`Company ${id} not found`); if (!company) throw new NotFoundException(`Company ${id} not found`);
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(id); company.companyProfiles =
await this.companyProfilesRepo.findByCompanyId(id);
// External profiles carry the onboarding flag the backoffice gates // External profiles carry the onboarding flag the backoffice gates
// approval decisions on (see ResponseCompanyDto.onboardingCompleted). // approval decisions on (see ResponseCompanyDto.onboardingCompleted).
company.profiles = await this.profilesRepo.findByCompanyId(id); company.profiles = await this.profilesRepo.findByCompanyId(id);
@@ -515,9 +512,7 @@ export class CompaniesService {
); );
} }
if (profile.status !== ProfileStatus.Active) { if (profile.status !== ProfileStatus.Active) {
throw new BadRequestException( throw new BadRequestException("Selected company profile is not active");
"Selected company profile is not active",
);
} }
return profile; return profile;
} }
@@ -760,11 +755,6 @@ export class CompaniesService {
}; };
const keys: string[] = []; const keys: string[] = [];
if (attrs.ownerFaydaSub) {
// The Company-column mirrors of the owner's verified contact details.
if (held("ownerEmail")) keys.push("companyEmail");
if (held("ownerPhone")) keys.push("companyPhone");
}
for (const subject of IDENTITY_SUBJECTS) { for (const subject of IDENTITY_SUBJECTS) {
if (!attrs[`${IDENTITY_PREFIX[subject]}FaydaSub`]) continue; if (!attrs[`${IDENTITY_PREFIX[subject]}FaydaSub`]) continue;
keys.push(...IDENTITY_OWNED_FIELDS[subject].filter(held)); keys.push(...IDENTITY_OWNED_FIELDS[subject].filter(held));
@@ -789,9 +779,6 @@ export class CompaniesService {
if (dto.nationality !== undefined) if (dto.nationality !== undefined)
companyUpdates.nationality = dto.nationality; companyUpdates.nationality = dto.nationality;
if (dto.companyName !== undefined) companyUpdates.name = dto.companyName; if (dto.companyName !== undefined) companyUpdates.name = dto.companyName;
if (dto.companyEmail !== undefined) companyUpdates.email = dto.companyEmail;
if (dto.companyPhone !== undefined)
companyUpdates.phone = normalizeE164(dto.companyPhone);
if (dto.companyLocation !== undefined) if (dto.companyLocation !== undefined)
companyUpdates.country = dto.companyLocation; companyUpdates.country = dto.companyLocation;
if (dto.companyAddress !== undefined) if (dto.companyAddress !== undefined)
@@ -809,7 +796,9 @@ export class CompaniesService {
if (dto.contactPersonPhone !== undefined) if (dto.contactPersonPhone !== undefined)
attrUpdates.contactPersonPhone = normalizeE164(dto.contactPersonPhone); attrUpdates.contactPersonPhone = normalizeE164(dto.contactPersonPhone);
if (dto.contactVerifiedPhone !== undefined) if (dto.contactVerifiedPhone !== undefined)
attrUpdates.contactVerifiedPhone = normalizeE164(dto.contactVerifiedPhone); attrUpdates.contactVerifiedPhone = normalizeE164(
dto.contactVerifiedPhone,
);
if (dto.generalManagerName !== undefined) if (dto.generalManagerName !== undefined)
attrUpdates.generalManagerName = dto.generalManagerName; attrUpdates.generalManagerName = dto.generalManagerName;
if (dto.generalManagerEmail !== undefined) if (dto.generalManagerEmail !== undefined)
@@ -820,7 +809,8 @@ export class CompaniesService {
if (dto.poaPhone !== undefined) if (dto.poaPhone !== undefined)
attrUpdates.poaPhone = normalizeE164(dto.poaPhone); attrUpdates.poaPhone = normalizeE164(dto.poaPhone);
if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail; if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail;
if (dto.poaLocation !== undefined) attrUpdates.poaLocation = dto.poaLocation; if (dto.poaLocation !== undefined)
attrUpdates.poaLocation = dto.poaLocation;
if (dto.poaAddress !== undefined) attrUpdates.poaAddress = dto.poaAddress; if (dto.poaAddress !== undefined) attrUpdates.poaAddress = dto.poaAddress;
if (dto.licenceNumber !== undefined) if (dto.licenceNumber !== undefined)
@@ -857,12 +847,6 @@ export class CompaniesService {
Object.assign(attrUpdates, dto.faydaIdentity); Object.assign(attrUpdates, dto.faydaIdentity);
} }
// companyEmail/companyPhone are the Company-column mirrors of the owner's
// verified contact details (the portal derives and submits them, it never
// lets the customer type them once verified) — lock them the same way
// ownerEmail/ownerPhone themselves are locked below, once there is a
// verified owner to lock them to.
//
// Keyed on the verified VALUE, not on `ownerFaydaSub`: Fayda's email and // Keyed on the verified VALUE, not on `ownerFaydaSub`: Fayda's email and
// phone claims are optional, so a verification can prove the person while // phone claims are optional, so a verification can prove the person while
// supplying neither (see completeIdentityVerification's conditional // supplying neither (see completeIdentityVerification's conditional
@@ -872,9 +856,8 @@ export class CompaniesService {
// forever, and re-verifying could never clear it because Fayda still has // forever, and re-verifying could never clear it because Fayda still has
// nothing to return. // nothing to return.
if (attrUpdates.ownerFaydaSub) { if (attrUpdates.ownerFaydaSub) {
if (attrUpdates.ownerEmail && dto.companyEmail !== undefined) if (attrUpdates.ownerEmail) companyUpdates.email = attrUpdates.ownerEmail;
companyUpdates.email = attrUpdates.ownerEmail; if (attrUpdates.ownerPhone)
if (attrUpdates.ownerPhone && dto.companyPhone !== undefined)
companyUpdates.phone = normalizeE164(String(attrUpdates.ownerPhone)); companyUpdates.phone = normalizeE164(String(attrUpdates.ownerPhone));
} }
@@ -1086,9 +1069,7 @@ export class CompaniesService {
} }
/** List a company's change requests, newest first (backoffice review). */ /** List a company's change requests, newest first (backoffice review). */
async listChangeRequests( async listChangeRequests(companyId: string): Promise<CompanyChangeRequest[]> {
companyId: string,
): Promise<CompanyChangeRequest[]> {
await this.findCompanyById(companyId); await this.findCompanyById(companyId);
return this.changeRequestRepo.findByCompanyId(companyId); return this.changeRequestRepo.findByCompanyId(companyId);
} }
@@ -1150,8 +1131,7 @@ export class CompaniesService {
reviewerId?: string, reviewerId?: string,
): Promise<CompanyChangeRequest> { ): Promise<CompanyChangeRequest> {
const request = await this.changeRequestRepo.findById(id); const request = await this.changeRequestRepo.findById(id);
if (!request) if (!request) throw new NotFoundException(`Change request ${id} not found`);
throw new NotFoundException(`Change request ${id} not found`);
if (request.status !== ChangeRequestStatus.Pending) { if (request.status !== ChangeRequestStatus.Pending) {
throw new BadRequestException( throw new BadRequestException(
`Change request ${id} is already ${request.status}`, `Change request ${id} is already ${request.status}`,
@@ -1164,7 +1144,10 @@ export class CompaniesService {
const snapshot = (request.snapshot ?? {}) as Partial<UpdateProfileDto>; const snapshot = (request.snapshot ?? {}) as Partial<UpdateProfileDto>;
await this.assertTinAvailable(company, snapshot.tin); await this.assertTinAvailable(company, snapshot.tin);
const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, snapshot); const companyUpdates = this.mapProfileDtoToCompanyUpdates(
company,
snapshot,
);
await this.companiesRepo.update(company.id, companyUpdates); await this.companiesRepo.update(company.id, companyUpdates);
await this.applyLicenseChanges(request); await this.applyLicenseChanges(request);
await this.applyDocumentChanges(request); await this.applyDocumentChanges(request);
@@ -1294,7 +1277,12 @@ export class CompaniesService {
); );
} }
if (documentChanges.length > 0) { if (documentChanges.length > 0) {
await this.recordCompanyRevision(company, {}, submittedBy, documentChanges); await this.recordCompanyRevision(
company,
{},
submittedBy,
documentChanges,
);
} }
return uploaded; return uploaded;
} }
@@ -1414,7 +1402,11 @@ export class CompaniesService {
status: ChangeRequestStatus.Pending, status: ChangeRequestStatus.Pending,
}); });
if (company) { if (company) {
this.companyNotifier.changeRequestSubmitted(company, existing.id, false); this.companyNotifier.changeRequestSubmitted(
company,
existing.id,
false,
);
} }
} else { } else {
const history = await this.changeRequestRepo.findByCompanyId(companyId); const history = await this.changeRequestRepo.findByCompanyId(companyId);
@@ -1446,8 +1438,7 @@ export class CompaniesService {
reviewerId?: string, reviewerId?: string,
): Promise<CompanyChangeRequest> { ): Promise<CompanyChangeRequest> {
const request = await this.changeRequestRepo.findById(id); const request = await this.changeRequestRepo.findById(id);
if (!request) if (!request) throw new NotFoundException(`Change request ${id} not found`);
throw new NotFoundException(`Change request ${id} not found`);
if (request.status !== ChangeRequestStatus.Pending) { if (request.status !== ChangeRequestStatus.Pending) {
throw new BadRequestException( throw new BadRequestException(
`Change request ${id} is already ${request.status}`, `Change request ${id} is already ${request.status}`,
@@ -1486,8 +1477,7 @@ export class CompaniesService {
reviewerId?: string, reviewerId?: string,
): Promise<CompanyChangeRequest> { ): Promise<CompanyChangeRequest> {
const request = await this.changeRequestRepo.findById(id); const request = await this.changeRequestRepo.findById(id);
if (!request) if (!request) throw new NotFoundException(`Change request ${id} not found`);
throw new NotFoundException(`Change request ${id} not found`);
if (request.status !== ChangeRequestStatus.Pending) { if (request.status !== ChangeRequestStatus.Pending) {
throw new BadRequestException( throw new BadRequestException(
`Change request ${id} is already ${request.status}`, `Change request ${id} is already ${request.status}`,
@@ -1569,10 +1559,7 @@ export class CompaniesService {
const reactivating = const reactivating =
status === ProfileStatus.Active && status === ProfileStatus.Active &&
existing.status === ProfileStatus.Suspended; existing.status === ProfileStatus.Suspended;
if ( if ((status === ProfileStatus.Suspended || reactivating) && !note?.trim()) {
(status === ProfileStatus.Suspended || reactivating) &&
!note?.trim()
) {
throw new BadRequestException( throw new BadRequestException(
status === ProfileStatus.Suspended status === ProfileStatus.Suspended
? "A message explaining the suspension is required — the customer will see it." ? "A message explaining the suspension is required — the customer will see it."
@@ -1594,7 +1581,9 @@ export class CompaniesService {
existing.status === ProfileStatus.Pending || existing.status === ProfileStatus.Pending ||
existing.status === ProfileStatus.Rejected; existing.status === ProfileStatus.Rejected;
if (awaitingReview) { if (awaitingReview) {
const owners = await this.profilesRepo.findByCompanyId(existing.companyId); const owners = await this.profilesRepo.findByCompanyId(
existing.companyId,
);
if (owners.length > 0 && !owners.some((o) => o.onboardingCompleted)) { if (owners.length > 0 && !owners.some((o) => o.onboardingCompleted)) {
throw new BadRequestException( throw new BadRequestException(
"This customer hasn't finished onboarding yet. Their roles can be reviewed once they submit their application.", "This customer hasn't finished onboarding yet. Their roles can be reviewed once they submit their application.",
@@ -1652,11 +1641,17 @@ export class CompaniesService {
const names = pending.map((f) => f.name).join(", "); const names = pending.map((f) => f.name).join(", ");
throw new BadRequestException( throw new BadRequestException(
`This role has ${pending.length} document(s) awaiting customer correction (${names}). ` + `This role has ${pending.length} document(s) awaiting customer correction (${names}). ` +
`Approve it once the customer has re-uploaded them, or withdraw the change request first.`, `Approve it once the customer has re-uploaded them, or withdraw the change request first.`,
); );
} }
return this.applyProfileStatus(manager, existing, status, note, reviewerId); return this.applyProfileStatus(
manager,
existing,
status,
note,
reviewerId,
);
}); });
} }
@@ -1992,7 +1987,9 @@ export class CompaniesService {
.map((f) => ({ key: f.key, label: f.label })); .map((f) => ({ key: f.key, label: f.label }));
// 2. Nationality-based company documents + which are already uploaded. // 2. Nationality-based company documents + which are already uploaded.
const documentSettingCode = this.documentSettingCodeFor(company.nationality); const documentSettingCode = this.documentSettingCodeFor(
company.nationality,
);
const [setting, uploadedFiles] = await Promise.all([ const [setting, uploadedFiles] = await Promise.all([
this.fileUploadSettingsService this.fileUploadSettingsService
.getByCode(documentSettingCode) .getByCode(documentSettingCode)
@@ -2074,7 +2071,9 @@ export class CompaniesService {
? [`Upload the ${POA_DELEGATION_LABEL} for your Power of Attorney`] ? [`Upload the ${POA_DELEGATION_LABEL} for your Power of Attorney`]
: []), : []),
...(flaggedDelegation ...(flaggedDelegation
? [`Re-upload your ${POA_DELEGATION_LABEL} — EDR asked for a correction`] ? [
`Re-upload your ${POA_DELEGATION_LABEL} — EDR asked for a correction`,
]
: []), : []),
...(identity.faydaRequired && !identity.owner.verified ...(identity.faydaRequired && !identity.owner.verified
? ["Verify the company owner's identity with Fayda"] ? ["Verify the company owner's identity with Fayda"]
@@ -2088,10 +2087,10 @@ export class CompaniesService {
// verification its representative may have no way to obtain. // verification its representative may have no way to obtain.
...((poaRequired || poaProvided) && !poaProven ...((poaRequired || poaProvided) && !poaProven
? [ ? [
identity.faydaRequired identity.faydaRequired
? "Verify your Power of Attorney's identity with Fayda" ? "Verify your Power of Attorney's identity with Fayda"
: "Name your Power of Attorney, or verify them with Fayda", : "Name your Power of Attorney, or verify them with Fayda",
] ]
: []), : []),
...(identity.passportRequired && !identity.owner.passportNumber ...(identity.passportRequired && !identity.owner.passportNumber
? ["Add the company owner's passport number"] ? ["Add the company owner's passport number"]
@@ -2137,7 +2136,10 @@ export class CompaniesService {
return new OnboardingRequirementsResponseDto({ return new OnboardingRequirementsResponseDto({
documentSettingCode, documentSettingCode,
nationality: company.nationality ?? CompanyNationality.Ethiopian, nationality: company.nationality ?? CompanyNationality.Ethiopian,
companyInfo: { complete: missingInfo.length === 0, missingFields: missingInfo }, companyInfo: {
complete: missingInfo.length === 0,
missingFields: missingInfo,
},
documents, documents,
licenseProfiles, licenseProfiles,
poa: { poa: {
@@ -2179,7 +2181,7 @@ export class CompaniesService {
if (!requirements.isComplete) { if (!requirements.isComplete) {
throw new BadRequestException( throw new BadRequestException(
requirements.outstanding[0] ?? requirements.outstanding[0] ??
"Your onboarding is incomplete. Please complete all required steps before submitting.", "Your onboarding is incomplete. Please complete all required steps before submitting.",
); );
} }
@@ -2188,7 +2190,10 @@ export class CompaniesService {
const profiles = await this.companyProfilesRepo.findByCompanyId(companyId); const profiles = await this.companyProfilesRepo.findByCompanyId(companyId);
for (const cp of profiles) { for (const cp of profiles) {
if (cp.status !== ProfileStatus.Pending) { if (cp.status !== ProfileStatus.Pending) {
await this.companyProfilesRepo.updateStatus(cp.id, ProfileStatus.Pending); await this.companyProfilesRepo.updateStatus(
cp.id,
ProfileStatus.Pending,
);
} }
} }
@@ -2214,12 +2219,12 @@ export class CompaniesService {
case CompanyStatus.Suspended: case CompanyStatus.Suspended:
throw new ForbiddenException( throw new ForbiddenException(
`Your company account is suspended — you can't create ${action} right now. ` + `Your company account is suspended — you can't create ${action} right now. ` +
`Please contact EDR support for details.`, `Please contact EDR support for details.`,
); );
case CompanyStatus.Blacklisted: case CompanyStatus.Blacklisted:
throw new ForbiddenException( throw new ForbiddenException(
`Your company account is blacklisted — you can't create ${action}. ` + `Your company account is blacklisted — you can't create ${action}. ` +
`Please contact EDR support.`, `Please contact EDR support.`,
); );
default: default:
throw new ForbiddenException( throw new ForbiddenException(
@@ -2248,8 +2253,7 @@ export class CompaniesService {
switch (profile.status) { switch (profile.status) {
case ProfileStatus.Suspended: case ProfileStatus.Suspended:
throw new ForbiddenException( throw new ForbiddenException(
`Your ${role} role is suspended${ `Your ${role} role is suspended${profile.reviewNote ? `${profile.reviewNote}` : ""
profile.reviewNote ? `${profile.reviewNote}` : ""
}. Your other roles are unaffected. Please contact EDR support to resolve this.`, }. Your other roles are unaffected. Please contact EDR support to resolve this.`,
); );
case ProfileStatus.Blacklisted: case ProfileStatus.Blacklisted:
@@ -2258,8 +2262,7 @@ export class CompaniesService {
); );
case ProfileStatus.Rejected: case ProfileStatus.Rejected:
throw new ForbiddenException( throw new ForbiddenException(
`Your ${role} role was rejected${ `Your ${role} role was rejected${profile.reviewNote ? `${profile.reviewNote}` : ""
profile.reviewNote ? `${profile.reviewNote}` : ""
}. Amend and resubmit it from your settings page.`, }. Amend and resubmit it from your settings page.`,
); );
default: default:
@@ -2518,9 +2521,7 @@ export class CompaniesService {
LICENSE_RESOURCE, LICENSE_RESOURCE,
); );
return records return records
.filter( .filter((r) => r.code === LICENSE_CODE || r.code === LICENSE_PENDING_CODE)
(r) => r.code === LICENSE_CODE || r.code === LICENSE_PENDING_CODE,
)
.map((r) => ({ .map((r) => ({
id: r.id, id: r.id,
name: r.name, name: r.name,
@@ -2687,7 +2688,7 @@ export class CompaniesService {
if (missing.length > 0) { if (missing.length > 0) {
throw new BadRequestException( throw new BadRequestException(
`A freight forwarder acts on other companies' behalf, so a Power of Attorney is required. ` + `A freight forwarder acts on other companies' behalf, so a Power of Attorney is required. ` +
`Add the ${missing.map((f) => f.label.toLowerCase()).join(", ")} first.`, `Add the ${missing.map((f) => f.label.toLowerCase()).join(", ")} first.`,
); );
} }
} }
@@ -2699,13 +2700,13 @@ export class CompaniesService {
if (!onFile) { if (!onFile) {
throw new BadRequestException( throw new BadRequestException(
`Upload the ${POA_DELEGATION_LABEL} for the Power of Attorney` + `Upload the ${POA_DELEGATION_LABEL} for the Power of Attorney` +
(opts.requirePoa ? " — it is required for freight forwarders." : "."), (opts.requirePoa ? " — it is required for freight forwarders." : "."),
); );
} }
if (flagged) { if (flagged) {
throw new BadRequestException( throw new BadRequestException(
`The ${POA_DELEGATION_LABEL} on file needs to be corrected. ` + `The ${POA_DELEGATION_LABEL} on file needs to be corrected. ` +
`Re-upload it before continuing.`, `Re-upload it before continuing.`,
); );
} }
} }
@@ -2748,6 +2749,12 @@ export class CompaniesService {
async completeIdentityVerification( async completeIdentityVerification(
userId: string, userId: string,
dto: CompleteIdentityVerificationDto, dto: CompleteIdentityVerificationDto,
/**
* The signed-in account, used as the owner's fallback contact details.
* Optional so the callers that only have a user id keep compiling — they
* simply get no fallback.
*/
account?: { email?: string; phoneNumber?: string },
): Promise<CompanyIdentityStateDto> { ): Promise<CompanyIdentityStateDto> {
const { company } = await this.getCompanyInfoByUserId(userId); const { company } = await this.getCompanyInfoByUserId(userId);
const prefix = IDENTITY_PREFIX[dto.subject]; const prefix = IDENTITY_PREFIX[dto.subject];
@@ -2769,7 +2776,8 @@ export class CompaniesService {
// of this check entirely. // of this check entirely.
if (dto.subject === "owner" || dto.subject === "poa") { if (dto.subject === "owner" || dto.subject === "poa") {
const other: IdentitySubject = dto.subject === "poa" ? "owner" : "poa"; const other: IdentitySubject = dto.subject === "poa" ? "owner" : "poa";
const otherSub = company.attributes?.[`${IDENTITY_PREFIX[other]}FaydaSub`]; const otherSub =
company.attributes?.[`${IDENTITY_PREFIX[other]}FaydaSub`];
if (otherSub && otherSub === result.sub) { if (otherSub && otherSub === result.sub) {
throw new BadRequestException( throw new BadRequestException(
`This identity is already registered as the company's ${IDENTITY_LABEL[other]}. The Power of Attorney must be a different person from the owner.`, `This identity is already registered as the company's ${IDENTITY_LABEL[other]}. The Power of Attorney must be a different person from the owner.`,
@@ -2778,6 +2786,22 @@ export class CompaniesService {
} }
const now = new Date().toISOString(); const now = new Date().toISOString();
// Fayda's email and phone claims are optional and routinely come back empty.
// For the owner that leaves the company with no contact details at all: the
// step renders no input for them (they are the verification's output), and
// "same as owner" then copies those blanks onto `generalManagerEmail` /
// `generalManagerPhone`, which `REQUIRED_COMPANY_INFO` demands at submit —
// an unfixable dead end. The account doing the onboarding is the one contact
// we always have, and it is already OTP-proven, so it stands in.
//
// Owner only: the PoA and the GM are other people, and the registering
// account's address is not theirs to wear.
const isOwner = dto.subject === "owner";
const email = result.email || (isOwner ? account?.email : undefined);
const phone =
result.phoneNumber || (isOwner ? account?.phoneNumber : undefined);
const identity: VerifiedIdentityAttributes = { const identity: VerifiedIdentityAttributes = {
[`${prefix}FaydaSub`]: result.sub, [`${prefix}FaydaSub`]: result.sub,
[`${prefix}FaydaVerifiedAt`]: now, [`${prefix}FaydaVerifiedAt`]: now,
@@ -2785,14 +2809,12 @@ export class CompaniesService {
[`${prefix}Gender`]: result.gender ?? null, [`${prefix}Gender`]: result.gender ?? null,
// The verified payload owns the person's details from here on. // The verified payload owns the person's details from here on.
...(result.fullName ? { [`${prefix}Name`]: result.fullName } : {}), ...(result.fullName ? { [`${prefix}Name`]: result.fullName } : {}),
...(result.email ? { [`${prefix}Email`]: result.email } : {}), ...(email ? { [`${prefix}Email`]: email } : {}),
// Fayda returns whatever the national registry holds, which is routinely a // Fayda returns whatever the national registry holds, which is routinely a
// local number ("0911223344"). Every typed phone in this service is stored // local number ("0911223344"). Every typed phone in this service is stored
// E.164, and `@IsValidPhone()` rejects anything else — so a raw claim here // E.164, and `@IsValidPhone()` rejects anything else — so a raw claim here
// becomes a value the portal reads back and cannot resubmit. // becomes a value the portal reads back and cannot resubmit.
...(result.phoneNumber ...(phone ? { [`${prefix}Phone`]: normalizeE164(phone) } : {}),
? { [`${prefix}Phone`]: normalizeE164(result.phoneNumber) }
: {}),
...(result.address ? { [`${prefix}Address`]: result.address } : {}), ...(result.address ? { [`${prefix}Address`]: result.address } : {}),
}; };
@@ -2842,7 +2864,13 @@ export class CompaniesService {
* proven identity to copy, only typed text that would arrive wearing a * proven identity to copy, only typed text that would arrive wearing a
* verified badge. * verified badge.
*/ */
async setGmSameAsOwner(userId: string): Promise<CompanyIdentityStateDto> { async setGmSameAsOwner(
userId: string,
/** Same fallback as {@link completeIdentityVerification}, for owners
* verified before that fallback existed — their stored contact details are
* blank, and copying blanks here would block the submit. */
account?: { email?: string; phoneNumber?: string },
): Promise<CompanyIdentityStateDto> {
const { company } = await this.getCompanyInfoByUserId(userId); const { company } = await this.getCompanyInfoByUserId(userId);
const attrs = company.attributes ?? {}; const attrs = company.attributes ?? {};
const ownerSub = attrs.ownerFaydaSub as string | undefined; const ownerSub = attrs.ownerFaydaSub as string | undefined;
@@ -2852,20 +2880,23 @@ export class CompaniesService {
); );
} }
const ownerEmail = (attrs.ownerEmail as string | undefined) || account?.email || null;
const ownerPhone = (attrs.ownerPhone as string | undefined) || account?.phoneNumber || null;
const copied: Record<string, unknown> = { const copied: Record<string, unknown> = {
gmSameAsOwner: true, gmSameAsOwner: true,
gmFaydaSub: ownerSub, gmFaydaSub: ownerSub,
gmFaydaVerifiedAt: attrs.ownerFaydaVerifiedAt ?? new Date().toISOString(), gmFaydaVerifiedAt: attrs.ownerFaydaVerifiedAt ?? new Date().toISOString(),
gmName: attrs.ownerName ?? null, gmName: attrs.ownerName ?? null,
gmEmail: attrs.ownerEmail ?? null, gmEmail: ownerEmail,
gmPhone: attrs.ownerPhone ?? null, gmPhone: ownerPhone ? normalizeE164(ownerPhone) : null,
gmAddress: attrs.ownerAddress ?? null, gmAddress: attrs.ownerAddress ?? null,
gmBirthdate: attrs.ownerBirthdate ?? null, gmBirthdate: attrs.ownerBirthdate ?? null,
gmGender: attrs.ownerGender ?? null, gmGender: attrs.ownerGender ?? null,
// Kept in step for the notifiers, same as a GM verification does. // Kept in step for the notifiers, same as a GM verification does.
generalManagerName: attrs.ownerName ?? null, generalManagerName: attrs.ownerName ?? null,
generalManagerEmail: attrs.ownerEmail ?? null, generalManagerEmail: ownerEmail,
generalManagerPhone: attrs.ownerPhone ?? null, generalManagerPhone: ownerPhone ? normalizeE164(ownerPhone) : null,
}; };
const updated = await this.companiesRepo.update(company.id, { const updated = await this.companiesRepo.update(company.id, {
@@ -2976,8 +3007,8 @@ export class CompaniesService {
const snapshot = { const snapshot = {
...(existing?.snapshot ?? {}), ...(existing?.snapshot ?? {}),
faydaIdentity: { faydaIdentity: {
...(((existing?.snapshot ?? {}) as Record<string, any>) ...(((existing?.snapshot ?? {}) as Record<string, any>).faydaIdentity ??
.faydaIdentity ?? {}), {}),
...identity, ...identity,
}, },
}; };
@@ -3389,7 +3420,10 @@ export class CompaniesService {
"We couldn't find a business license for this TIN with eTrade. Please double-check the number and try again.", "We couldn't find a business license for this TIN with eTrade. Please double-check the number and try again.",
); );
} }
return this.etradeService.extractRegistrationData(businessInfo, companyInfo); return this.etradeService.extractRegistrationData(
businessInfo,
companyInfo,
);
} }
async fetchETradeData(tin: string, excludeCompanyId?: string) { async fetchETradeData(tin: string, excludeCompanyId?: string) {
@@ -3421,7 +3455,9 @@ export class CompaniesService {
const tin = dto.tin ?? company.tin; const tin = dto.tin ?? company.tin;
const registration = await this.resolveEtradeRegistration(tin); const registration = await this.resolveEtradeRegistration(tin);
const fresh: Partial<Record<(typeof ETRADE_SOURCED_FIELDS)[number], string>> = { const fresh: Partial<
Record<(typeof ETRADE_SOURCED_FIELDS)[number], string>
> = {
companyName: registration.companyName, companyName: registration.companyName,
licenceNumber: registration.licenceNumber, licenceNumber: registration.licenceNumber,
statusDescription: registration.statusDescription, statusDescription: registration.statusDescription,

View File

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

View File

@@ -1,9 +1,18 @@
import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsEnum, IsArray, ValidateNested, ArrayMinSize } from 'class-validator'; import {
import { Type } from 'class-transformer'; IsString,
import { CompanyType } from '../entities/company.entity'; IsNotEmpty,
import { ProfileType } from '../entities/company-profile.entity'; IsOptional,
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; MaxLength,
import { IsTin } from '../../../common/validators/is-tin.validator'; IsBoolean,
IsEnum,
IsArray,
ValidateNested,
ArrayMinSize,
} from "class-validator";
import { Type } from "class-transformer";
import { CompanyType } from "../entities/company.entity";
import { ProfileType } from "../entities/company-profile.entity";
import { IsTin } from "../../../common/validators/is-tin.validator";
export class CompanyProfileInputDto { export class CompanyProfileInputDto {
@IsEnum(ProfileType) @IsEnum(ProfileType)
@@ -24,17 +33,6 @@ export class CreateCompanyWithProfileDto {
@MaxLength(200) @MaxLength(200)
companyName!: string; companyName!: string;
@IsOptional()
@IsEmail()
@MaxLength(150)
companyEmail?: string;
@IsOptional()
@IsString()
@MaxLength(20)
@IsValidPhone()
companyPhone?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(32) @MaxLength(32)
@@ -46,7 +44,7 @@ export class CreateCompanyWithProfileDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@IsTin({ message: 'TIN must be exactly 10 digits' }) @IsTin({ message: "TIN must be exactly 10 digits" })
tin?: string; tin?: string;
@IsOptional() @IsOptional()

View File

@@ -2,21 +2,19 @@ import {
buildCompanyIdentityState, buildCompanyIdentityState,
CompanyIdentityStateDto, CompanyIdentityStateDto,
} from "./complete-identity-verification.dto"; } from "./complete-identity-verification.dto";
import { Company } from '../entities/company.entity'; import { Company } from "../entities/company.entity";
import { ExternalProfile } from '../entities/external-profile.entity'; import { ExternalProfile } from "../entities/external-profile.entity";
import { import {
ChangeRequestStatus, ChangeRequestStatus,
CompanyChangeRequest, CompanyChangeRequest,
} from '../entities/company-change-request.entity'; } from "../entities/company-change-request.entity";
import { ResponseCompanyProfileDto } from './response-company.dto'; import { ResponseCompanyProfileDto } from "./response-company.dto";
export class ProfileResponseDto { export class ProfileResponseDto {
companyId: string; companyId: string;
companyName: string; companyName: string;
companyType: string; companyType: string;
nationality: string | null; nationality: string | null;
companyEmail: string | null;
companyPhone: string | null;
companyLocation: string; companyLocation: string;
companyAddress: string | null; companyAddress: string | null;
tinNumber: string; tinNumber: string;
@@ -89,8 +87,6 @@ export class ProfileResponseDto {
this.companyProfiles = this.companyProfiles =
company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)) ?? company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)) ??
[]; [];
this.companyEmail = company.email ?? null;
this.companyPhone = company.phone ?? null;
this.companyLocation = company.country; this.companyLocation = company.country;
this.companyAddress = company.address ?? null; this.companyAddress = company.address ?? null;
this.tinNumber = company.tin; this.tinNumber = company.tin;
@@ -128,9 +124,9 @@ export class ProfileResponseDto {
const openReview = const openReview =
changeRequest && changeRequest &&
(changeRequest.status === ChangeRequestStatus.Pending || (changeRequest.status === ChangeRequestStatus.Pending ||
changeRequest.status === ChangeRequestStatus.Rejected || changeRequest.status === ChangeRequestStatus.Rejected ||
changeRequest.status === ChangeRequestStatus.ChangesRequested) changeRequest.status === ChangeRequestStatus.ChangesRequested)
? changeRequest ? changeRequest
: null; : null;
this.reviewStatus = this.reviewStatus =

View File

@@ -6,11 +6,11 @@ import {
IsEnum, IsEnum,
IsIn, IsIn,
Matches, Matches,
} from 'class-validator'; } from "class-validator";
import { ETHIOPIAN_REGIONS, type EthiopianRegion } from '@edr/types'; import { ETHIOPIAN_REGIONS, type EthiopianRegion } from "@edr/types";
import { CompanyNationality } from '../entities/company.entity'; import { CompanyNationality } from "../entities/company.entity";
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; import { IsValidPhone } from "../../../common/validators/is-phone-number.validator";
import { IsTin } from '../../../common/validators/is-tin.validator'; import { IsTin } from "../../../common/validators/is-tin.validator";
export class UpdateProfileDto { export class UpdateProfileDto {
@IsOptional() @IsOptional()
@@ -22,17 +22,6 @@ export class UpdateProfileDto {
@MaxLength(200) @MaxLength(200)
companyName?: string; companyName?: string;
@IsOptional()
@IsEmail()
@MaxLength(150)
companyEmail?: string;
@IsOptional()
@IsString()
@MaxLength(20)
@IsValidPhone()
companyPhone?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(32) @MaxLength(32)
@@ -44,7 +33,7 @@ export class UpdateProfileDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@IsTin({ message: 'TIN must be exactly 10 digits' }) @IsTin({ message: "TIN must be exactly 10 digits" })
tin?: string; tin?: string;
// Ethiopian VAT registration numbers are 10 digits, the same shape as the // Ethiopian VAT registration numbers are 10 digits, the same shape as the
@@ -53,7 +42,7 @@ export class UpdateProfileDto {
// column may hold. // column may hold.
@IsOptional() @IsOptional()
@IsString() @IsString()
@Matches(/^\d{10}$/, { message: 'VAT number must be exactly 10 digits' }) @Matches(/^\d{10}$/, { message: "VAT number must be exactly 10 digits" })
vatNumber?: string; vatNumber?: string;
// `fanNumber` is deliberately absent: the FAN is the Fayda number of the // `fanNumber` is deliberately absent: the FAN is the Fayda number of the

View File

@@ -11,7 +11,12 @@ import { ComplianceType } from './entities/compliance-record.entity';
@ApiTags('Vehicle Compliance') @ApiTags('Vehicle Compliance')
@Controller('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 { export class ComplianceController {
constructor(private readonly complianceService: ComplianceService) {} constructor(private readonly complianceService: ComplianceService) {}

View File

@@ -17,7 +17,12 @@ import { FilterConsignmentDto } from "./dto/filter-consignment.dto";
@ApiTags("consignments") @ApiTags("consignments")
@Controller("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 { export class ConsignmentsController {
constructor(private readonly consignmentsService: ConsignmentsService) {} constructor(private readonly consignmentsService: ConsignmentsService) {}

View File

@@ -19,7 +19,14 @@ import { ContainersService } from './containers.service';
@ApiTags('containers') @ApiTags('containers')
@Controller('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 { export class ContainersController {
constructor(private readonly containersService: ContainersService) {} constructor(private readonly containersService: ContainersService) {}

View File

@@ -32,7 +32,7 @@ export class Container extends BaseEntity {
type: 'varchar', type: 'varchar',
nullable: true, nullable: true,
}) })
sealNumber!: string | null; sealNumber!: string | null;
@Column({ type: 'varchar', default: 'AVAILABLE' }) @Column({ type: 'varchar', default: 'AVAILABLE' })
status!: string; // AVAILABLE, LOADED, IN_TRANSIT, MAINTENANCE, DAMAGED status!: string; // AVAILABLE, LOADED, IN_TRANSIT, MAINTENANCE, DAMAGED

View File

@@ -3,6 +3,8 @@ import {
Controller, Controller,
Delete, Delete,
Get, Get,
HttpCode,
HttpStatus,
Param, Param,
Patch, Patch,
Post, Post,
@@ -15,55 +17,85 @@ import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { ContractTemplatesService } from "./contract-templates.service"; import { ContractTemplatesService } from "./contract-templates.service";
import { import {
CreateArticleDto, CreateArticleDto,
CreateContractTemplateDto,
PreviewContractTemplateDto, PreviewContractTemplateDto,
ReplaceArticlesDto, ReplaceArticlesDto,
UpdateArticleDto, UpdateArticleDto,
UpdateContractTemplateDto, UpdateContractTemplateDto,
} from "./dto/contract-template.dto"; } from "./dto/contract-template.dto";
// `view` opens the Templates page; `read` is API-read-only for other pages
// that show template data; create/update/delete gate each write. `manage` is
// the legacy write key and keeps working for roles that already hold it.
const TEMPLATE_READ = [
FREIGHT_PERMS.settings.contractTemplates.view,
FREIGHT_PERMS.settings.contractTemplates.read,
FREIGHT_PERMS.settings.contractTemplates.update,
FREIGHT_PERMS.settings.contractTemplates.manage,
FREIGHT_PERMS.admin,
];
const TEMPLATE_UPDATE = [
FREIGHT_PERMS.settings.contractTemplates.update,
FREIGHT_PERMS.settings.contractTemplates.manage,
FREIGHT_PERMS.admin,
];
@ApiTags("contract-templates") @ApiTags("contract-templates")
@Controller("contract-templates") @Controller("contract-templates")
export class ContractTemplatesController { export class ContractTemplatesController {
constructor(private readonly service: ContractTemplatesService) {} constructor(private readonly service: ContractTemplatesService) {}
// Reads are staff-only (the backoffice Templates tab is the only consumer);
// writes are admin-guarded like other freight configuration resources.
@Get() @Get()
@BookingStaff([ @BookingStaff(TEMPLATE_READ)
FREIGHT_PERMS.settings.contractTemplates.view, @ApiOperation({ summary: "List contract templates (system container + staff-created bulk)" })
FREIGHT_PERMS.settings.contractTemplates.manage,
FREIGHT_PERMS.admin,
])
@ApiOperation({ summary: "List the six contract document templates" })
list() { list() {
return this.service.list(); return this.service.list();
} }
@Get(":code") @Post()
@BookingStaff([ @BookingStaff([
FREIGHT_PERMS.settings.contractTemplates.view, FREIGHT_PERMS.settings.contractTemplates.create,
FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.settings.contractTemplates.manage,
FREIGHT_PERMS.admin, FREIGHT_PERMS.admin,
]) ])
@ApiOperation({
summary:
"Create a bulk contract template for a (cargo type, customs option) pair",
})
create(@Body() dto: CreateContractTemplateDto) {
return this.service.create(dto);
}
@Get(":code")
@BookingStaff(TEMPLATE_READ)
@ApiOperation({ summary: "Get one contract template by code" }) @ApiOperation({ summary: "Get one contract template by code" })
getByCode(@Param("code") code: string) { getByCode(@Param("code") code: string) {
return this.service.getByCode(code); return this.service.getByCode(code);
} }
@Patch(":code") @Patch(":code")
@BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin]) @BookingStaff(TEMPLATE_UPDATE)
@ApiOperation({ summary: "Update template metadata (name, title, recitals, active flag)" }) @ApiOperation({ summary: "Update template metadata (name, title, recitals, active flag)" })
update(@Param("code") code: string, @Body() dto: UpdateContractTemplateDto) { update(@Param("code") code: string, @Body() dto: UpdateContractTemplateDto) {
return this.service.update(code, dto); return this.service.update(code, dto);
} }
@Post(":code/preview") @Delete(":code")
@BookingStaff([ @BookingStaff([
FREIGHT_PERMS.settings.contractTemplates.view, FREIGHT_PERMS.settings.contractTemplates.delete,
FREIGHT_PERMS.settings.contractTemplates.manage,
FREIGHT_PERMS.admin, FREIGHT_PERMS.admin,
]) ])
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({
summary: "Delete a staff-created bulk template (system templates refuse)",
})
remove(@Param("code") code: string) {
return this.service.remove(code);
}
@Post(":code/preview")
@BookingStaff(TEMPLATE_READ)
@ApiOperation({ @ApiOperation({
summary: "Render an HTML preview of the template against mock contract data", summary: "Render an HTML preview of the template against mock contract data",
}) })
@@ -77,21 +109,21 @@ export class ContractTemplatesController {
/* ------------------------- article routes ------------------------- */ /* ------------------------- article routes ------------------------- */
@Put(":code/articles") @Put(":code/articles")
@BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin]) @BookingStaff(TEMPLATE_UPDATE)
@ApiOperation({ summary: "Replace the full ordered article list (used for reorder)" }) @ApiOperation({ summary: "Replace the full ordered article list (used for reorder)" })
replaceArticles(@Param("code") code: string, @Body() dto: ReplaceArticlesDto) { replaceArticles(@Param("code") code: string, @Body() dto: ReplaceArticlesDto) {
return this.service.replaceArticles(code, dto.articles); return this.service.replaceArticles(code, dto.articles);
} }
@Post(":code/articles") @Post(":code/articles")
@BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin]) @BookingStaff(TEMPLATE_UPDATE)
@ApiOperation({ summary: "Add an article to the template" }) @ApiOperation({ summary: "Add an article to the template" })
addArticle(@Param("code") code: string, @Body() dto: CreateArticleDto) { addArticle(@Param("code") code: string, @Body() dto: CreateArticleDto) {
return this.service.addArticle(code, dto); return this.service.addArticle(code, dto);
} }
@Patch(":code/articles/:articleId") @Patch(":code/articles/:articleId")
@BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin]) @BookingStaff(TEMPLATE_UPDATE)
@ApiOperation({ summary: "Update an article's title or body" }) @ApiOperation({ summary: "Update an article's title or body" })
updateArticle( updateArticle(
@Param("code") code: string, @Param("code") code: string,
@@ -102,7 +134,7 @@ export class ContractTemplatesController {
} }
@Delete(":code/articles/:articleId") @Delete(":code/articles/:articleId")
@BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin]) @BookingStaff(TEMPLATE_UPDATE)
@ApiOperation({ summary: "Remove an article from the template" }) @ApiOperation({ summary: "Remove an article from the template" })
removeArticle( removeArticle(
@Param("code") code: string, @Param("code") code: string,

View File

@@ -3,10 +3,8 @@ import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm"; import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm"; import { Repository } from "typeorm";
import { import { CargoType } from "../rule-engine/entities/cargo-type.entity";
ContractTemplate, import { ContractTemplate } from "./entities/contract-template.entity";
ContractTemplateCode,
} from "./entities/contract-template.entity";
@Injectable() @Injectable()
export class ContractTemplatesRepository extends BaseRepository<ContractTemplate> { export class ContractTemplatesRepository extends BaseRepository<ContractTemplate> {
@@ -17,12 +15,51 @@ export class ContractTemplatesRepository extends BaseRepository<ContractTemplate
super(repository); super(repository);
} }
findByCode(code: ContractTemplateCode): Promise<ContractTemplate | null> { findByCode(code: string): Promise<ContractTemplate | null> {
return this.repository.findOne({ where: { code } }); return this.repository.findOne({ where: { code } });
} }
override findAll(): Promise<ContractTemplate[]> { override findAll(): Promise<ContractTemplate[]> {
return this.repository.find({ order: { code: "ASC" } }); return this.repository.find({
relations: { cargoType: true },
order: { code: "ASC" },
});
}
findByCargoCombo(
cargoTypeId: string,
withCustoms: boolean,
): Promise<ContractTemplate | null> {
return this.repository.findOne({ where: { cargoTypeId, withCustoms } });
}
/**
* The active bulk template covering this cargo type: written against the
* cargo type itself or against its parent group (the two are mutually
* exclusive, so at most one row matches).
*/
findActiveBulkTemplate(
cargoTypeId: string,
withCustoms: boolean,
): Promise<ContractTemplate | null> {
return this.repository
.createQueryBuilder("t")
.where("t.is_active = true")
.andWhere("t.with_customs = :withCustoms", { withCustoms })
.andWhere(
`(t.cargo_type_id = :cargoTypeId OR t.cargo_type_id = (
SELECT c.parent_group_id FROM freight.cargo_types c
WHERE c.id = :cargoTypeId AND c.deleted_at IS NULL
))`,
{ cargoTypeId },
)
.getOne();
}
findCargoType(id: string): Promise<CargoType | null> {
return this.repository.manager
.getRepository(CargoType)
.findOne({ where: { id } });
} }
async saveTemplate(template: ContractTemplate): Promise<ContractTemplate> { async saveTemplate(template: ContractTemplate): Promise<ContractTemplate> {

View File

@@ -1,4 +1,9 @@
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { ContractRendererService } from "../../contracts/contract-renderer.service"; import { ContractRendererService } from "../../contracts/contract-renderer.service";
@@ -11,6 +16,7 @@ import {
import { ContractTemplatesRepository } from "./contract-templates.repository"; import { ContractTemplatesRepository } from "./contract-templates.repository";
import { import {
CreateArticleDto, CreateArticleDto,
CreateContractTemplateDto,
PreviewContractTemplateDto, PreviewContractTemplateDto,
ReplaceArticleDto, ReplaceArticleDto,
UpdateArticleDto, UpdateArticleDto,
@@ -53,13 +59,17 @@ export class ContractTemplatesService {
async list(): Promise<ContractTemplate[]> { async list(): Promise<ContractTemplate[]> {
const templates = await this.repository.findAll(); const templates = await this.repository.findAll();
const rank = new Map(CONTRACT_TEMPLATE_CODES.map((code, i) => [code, i] as const)); const rank = new Map(CONTRACT_TEMPLATE_CODES.map((code, i) => [code, i] as const));
return templates.sort( // Seeded container templates first in canonical order, then staff-created
(a, b) => (rank.get(a.code) ?? 99) - (rank.get(b.code) ?? 99), // bulk templates alphabetically.
); return templates.sort((a, b) => {
const ra = rank.get(a.code as ContractTemplateCode) ?? 99;
const rb = rank.get(b.code as ContractTemplateCode) ?? 99;
return ra !== rb ? ra - rb : a.name.localeCompare(b.name);
});
} }
async getByCode(code: string): Promise<ContractTemplate> { async getByCode(code: string): Promise<ContractTemplate> {
const template = await this.repository.findByCode(this.assertCode(code)); const template = await this.repository.findByCode(code?.toUpperCase() ?? "");
if (!template) { if (!template) {
throw new NotFoundException(`Contract template ${code} not found`); throw new NotFoundException(`Contract template ${code} not found`);
} }
@@ -67,15 +77,93 @@ export class ContractTemplatesService {
} }
/** /**
* The active template used when generating a contract document for the given * Staff-created bulk template for one (cargo type, customs option) pair.
* direction/freight/customs triple; null when missing or deactivated (the * The cargo type must have hasContractTemplate enabled and the combination
* renderer then falls back to the built-in generic layout). * must not already exist — the same commodity + customs pairing is edited,
* never duplicated.
*/
async create(dto: CreateContractTemplateDto): Promise<ContractTemplate> {
const cargoType = await this.repository.findCargoType(dto.cargoTypeId);
if (!cargoType) {
throw new NotFoundException(`Cargo type ${dto.cargoTypeId} not found`);
}
if (!cargoType.hasContractTemplate) {
throw new BadRequestException(
`"${cargoType.cargoTypeName}" does not allow contract templates — enable "has contract template" on the cargo type first`,
);
}
const variant = dto.withCustoms ? "with" : "without";
const existing = await this.repository.findByCargoCombo(
dto.cargoTypeId,
dto.withCustoms,
);
if (existing) {
throw new ConflictException(
`A "${cargoType.cargoTypeName}" template ${variant} customs clearing already exists — edit that template instead`,
);
}
const template = new ContractTemplate();
template.code = `BULK_${cargoType.code}_${dto.withCustoms ? "CUSTOMS" : "NO_CUSTOMS"}`.toUpperCase();
template.name =
dto.name ??
`${cargoType.cargoTypeName} Bulk Contract (${variant} customs clearing)`;
template.description = dto.description ?? null;
template.documentTitle = dto.withCustoms
? `${cargoType.cargoTypeName} Transportation and Customs Clearance Services`
: `${cargoType.cargoTypeName} Transportation Services`;
template.whereasClauses = [];
template.articles = [];
template.isActive = true;
template.cargoTypeId = cargoType.id;
template.withCustoms = dto.withCustoms;
template.isSystem = false;
try {
return await this.repository.saveTemplate(template);
} catch (error) {
// Partial unique index backstop for concurrent creates of the same combo.
if ((error as { code?: string })?.code === "23505") {
throw new ConflictException(
`A "${cargoType.cargoTypeName}" template ${variant} customs clearing already exists — edit that template instead`,
);
}
throw error;
}
}
/** Bulk templates only — the five seeded container templates are permanent. */
async remove(code: string): Promise<void> {
const template = await this.getByCode(code);
if (template.isSystem) {
throw new BadRequestException(
"System container templates cannot be deleted",
);
}
await this.repository.softDelete(template.id);
}
/**
* The active template used when generating a contract document. Container
* contracts resolve through the fixed direction/customs codes; bulk contracts
* resolve through the staff-created template for the contract's cargo type
* (or its parent group) and customs option. Null when nothing matches or the
* match is deactivated (the renderer then falls back to the built-in generic
* layout).
*/ */
async findActiveForContract( async findActiveForContract(
tradeDirection?: string | null, tradeDirection?: string | null,
freightType?: string | null, freightType?: string | null,
customsClearingEnabled?: boolean | null, customsClearingEnabled?: boolean | null,
cargoTypeId?: string | null,
): Promise<ContractTemplate | null> { ): Promise<ContractTemplate | null> {
const isBulk = (freightType ?? "").toUpperCase().includes("BULK");
if (isBulk) {
if (!cargoTypeId) return null;
return this.repository.findActiveBulkTemplate(
cargoTypeId,
Boolean(customsClearingEnabled),
);
}
const code = contractTemplateCodeFor( const code = contractTemplateCodeFor(
tradeDirection, tradeDirection,
freightType, freightType,
@@ -180,16 +268,32 @@ export class ContractTemplatesService {
: this.sorted(template.articles), : this.sorted(template.articles),
}; };
const view = this.buildMockView(template.code, dynamicTemplate); const view = this.buildMockView(template, dynamicTemplate);
return { html: this.renderer.render(view) }; return { html: this.renderer.render(view) };
} }
/**
* Registry key the mock preview renders against. Staff-created bulk
* templates aren't in the fixed code map — they preview against the
* representative bulk import pack matching their customs option.
*/
private previewKeyFor(template: ContractTemplate): string {
if (template.cargoTypeId) {
return template.withCustoms
? "IMP_BULK_USD_FORWARDING"
: "IMP_BULK_USD_TRANSPORT_ONLY";
}
return PREVIEW_TEMPLATE_KEYS[template.code as ContractTemplateCode];
}
private buildMockView( private buildMockView(
code: ContractTemplateCode, template: ContractTemplate,
dynamicTemplate: ContractDynamicTemplateView, dynamicTemplate: ContractDynamicTemplateView,
): ContractViewModel { ): ContractViewModel {
const meta = getTemplateMeta(PREVIEW_TEMPLATE_KEYS[code]); const code = template.code;
const isBulk = code.endsWith("_BULK"); const previewKey = this.previewKeyFor(template);
const meta = getTemplateMeta(previewKey);
const isBulk = Boolean(template.cargoTypeId) || code.includes("BULK");
const now = new Date(); const now = new Date();
// Representative rate schedule so the admin preview shows the live-rate // Representative rate schedule so the admin preview shows the live-rate
@@ -200,7 +304,7 @@ export class ContractTemplatesService {
bookingId: "00000000-0000-0000-0000-000000000000", bookingId: "00000000-0000-0000-0000-000000000000",
reference: "EDR/CT/2026/0042", reference: "EDR/CT/2026/0042",
status: "CONTRACT_READY", status: "CONTRACT_READY",
templateKey: PREVIEW_TEMPLATE_KEYS[code], templateKey: previewKey,
template: { ...meta, title: dynamicTemplate.name, templateFile: "edr-dynamic.hbs" }, template: { ...meta, title: dynamicTemplate.name, templateFile: "edr-dynamic.hbs" },
contractDate: now.toLocaleDateString("en-GB", { contractDate: now.toLocaleDateString("en-GB", {
day: "numeric", day: "numeric",
@@ -275,7 +379,7 @@ export class ContractTemplatesService {
} }
/** Static, representative rate schedule for the admin preview only. */ /** Static, representative rate schedule for the admin preview only. */
private mockRateSchedule(code: ContractTemplateCode, isBulk: boolean): RateSchedule { private mockRateSchedule(code: string, isBulk: boolean): RateSchedule {
const dir = code.startsWith("IMPORT") const dir = code.startsWith("IMPORT")
? "import" ? "import"
: code.startsWith("EXPORT") : code.startsWith("EXPORT")
@@ -311,16 +415,6 @@ export class ContractTemplatesService {
}; };
} }
private assertCode(code: string): ContractTemplateCode {
const upper = code?.toUpperCase() as ContractTemplateCode;
if (!CONTRACT_TEMPLATE_CODES.includes(upper)) {
throw new BadRequestException(
`Unknown contract template code "${code}". Valid codes: ${CONTRACT_TEMPLATE_CODES.join(", ")}`,
);
}
return upper;
}
private sorted(articles: ContractTemplateArticle[]): ContractTemplateArticle[] { private sorted(articles: ContractTemplateArticle[]): ContractTemplateArticle[] {
return [...(articles ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); return [...(articles ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
} }

View File

@@ -6,12 +6,41 @@ import {
IsInt, IsInt,
IsOptional, IsOptional,
IsString, IsString,
IsUUID,
MaxLength, MaxLength,
Min, Min,
MinLength, MinLength,
ValidateNested, ValidateNested,
} from "class-validator"; } from "class-validator";
export class CreateContractTemplateDto {
@ApiProperty({
description:
"Bulk cargo type this template is written for (must have hasContractTemplate enabled)",
format: "uuid",
})
@IsUUID()
cargoTypeId!: string;
@ApiProperty({
description: "Whether this is the with-customs-clearing variant",
})
@IsBoolean()
withCustoms!: boolean;
@ApiPropertyOptional({ description: "Display name (derived from the cargo type when omitted)" })
@IsOptional()
@IsString()
@MinLength(3)
@MaxLength(200)
name?: string;
@ApiPropertyOptional({ description: "Short description shown on the template card" })
@IsOptional()
@IsString()
description?: string;
}
export class UpdateContractTemplateDto { export class UpdateContractTemplateDto {
@ApiPropertyOptional({ description: "Display name of the template" }) @ApiPropertyOptional({ description: "Display name of the template" })
@IsOptional() @IsOptional()

View File

@@ -1,11 +1,18 @@
import { BaseEntity } from "@edr/api-common"; import { BaseEntity } from "@edr/api-common";
import { Column, Entity, Index } from "typeorm"; import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
import { CargoType } from "../../rule-engine/entities/cargo-type.entity";
/** /**
* The ten canonical contract document templates. Import and export split by * The five seeded container templates (import/export split by customs
* customs clearing (× freight type = 8); intercity does not, because it is a * clearing; intercity is domestic, crosses no border, so it has a single
* purely domestic Ethiopian movement that crosses no border and therefore has * template). These are system rows: always present, never deletable.
* no customs leg at all (× freight type = 2). *
* Bulk templates are NOT seeded — staff create them per bulk cargo type
* (`cargoTypeId`) and customs option (`withCustoms`), one template per
* combination. Their codes are generated as BULK_<cargo code>_(NO_)CUSTOMS.
* The retired direction-keyed bulk codes remain listed so old frozen document
* snapshots still label correctly.
* *
* Contracts store DOMESTIC for intercity movements; the template layer labels * Contracts store DOMESTIC for intercity movements; the template layer labels
* those INTERCITY to match the commercial vocabulary used on the printed * those INTERCITY to match the commercial vocabulary used on the printed
@@ -76,10 +83,12 @@ export function contractTemplateCodeFor(
} }
@Entity({ schema: "freight", name: "contract_templates" }) @Entity({ schema: "freight", name: "contract_templates" })
@Index(["code"], { unique: true }) // Uniqueness lives in partial DB indexes (live rows only): code, and
// (cargo_type_id, with_customs) for staff-created bulk templates.
@Index(["code"])
export class ContractTemplate extends BaseEntity { export class ContractTemplate extends BaseEntity {
@Column({ name: "code", type: "varchar", length: 40, unique: true }) @Column({ name: "code", type: "varchar", length: 80 })
code!: ContractTemplateCode; code!: string;
@Column({ name: "name", type: "varchar", length: 200 }) @Column({ name: "name", type: "varchar", length: 200 })
name!: string; name!: string;
@@ -100,4 +109,20 @@ export class ContractTemplate extends BaseEntity {
@Column({ name: "is_active", type: "boolean", default: true }) @Column({ name: "is_active", type: "boolean", default: true })
isActive!: boolean; isActive!: boolean;
/** Bulk templates only: the cargo type this template is written for. */
@Column({ name: "cargo_type_id", type: "uuid", nullable: true })
cargoTypeId?: string | null;
@ManyToOne(() => CargoType, { nullable: true })
@JoinColumn({ name: "cargo_type_id" })
cargoType?: CargoType | null;
/** Bulk templates only: whether this is the with-customs-clearing variant. */
@Column({ name: "with_customs", type: "boolean", nullable: true })
withCustoms?: boolean | null;
/** The five seeded container templates — cannot be deleted. */
@Column({ name: "is_system", type: "boolean", default: false })
isSystem!: boolean;
} }

View File

@@ -24,7 +24,7 @@ import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
import { BookingInvoiceService } from '../bookings/booking-invoice.service'; import { BookingInvoiceService } from '../bookings/booking-invoice.service';
import { validate20ftWeightPairing } from '../bookings/container-pairing.util'; import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity'; import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
import { BookingBatchService } from '../train-scheduling/booking-batch.service'; import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { eatDay } from '../train-scheduling/batch-window.util'; import { eatDay } from '../train-scheduling/batch-window.util';
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';

View File

@@ -1,5 +1,6 @@
import { ContractExpiryService } from './contract-expiry.service'; import { ContractExpiryService } from './contract-expiry.service';
import type { Contract } from './entities/contract.entity'; 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 * 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')); inbox.notify.mockRejectedValue(new Error('inbox down'));
await expect(service.remindExpiringContracts()).resolves.toBeUndefined(); await expect(service.remindExpiringContracts()).resolves.toBeUndefined();
}); });
// The sweep-failure alert is staff-facing. It used to go to every employee;
// it belongs to the people who would notice expired contracts still listed
// as active, i.e. the contract desk.
it('alerts the contract desk when the sweep itself fails', async () => {
repo.expireLapsedContracts.mockRejectedValue(new Error('deadlock'));
await service.expireLapsedContracts();
expect(inbox.notify).toHaveBeenCalledTimes(1);
expect(inbox.notify.mock.calls[0][0].recipients).toEqual({
permissionKeys: [FREIGHT_PERMS.contracts.getNotification],
});
});
}); });

View File

@@ -4,6 +4,7 @@ import { NotificationAudience, NotificationType } from '@edr/types';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { ContractsRepository } from './contracts.repository'; 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 * How many days before a contract lapses the customer is reminded. Mirrored by
@@ -79,7 +80,11 @@ export class ContractExpiryService {
); );
try { try {
await this.inbox.notify({ 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, audience: NotificationAudience.BACKOFFICE,
type: NotificationType.GENERIC, type: NotificationType.GENERIC,
title: 'Contract expiry sweep failed', title: 'Contract expiry sweep failed',

View File

@@ -11,6 +11,16 @@ import { Contract } from './entities/contract.entity';
import { NotificationsService } from '../notifications/notifications.service'; import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util'; 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 * 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( private inAppStaff(
c: Contract, c: Contract,
title: string, title: string,
@@ -94,7 +108,7 @@ export class ContractNotifierService {
overrides: Partial<NotifyInput> = {}, overrides: Partial<NotifyInput> = {},
): void { ): void {
void this.inbox.notify({ void this.inbox.notify({
recipients: { allBackoffice: true }, recipients: { permissionKeys: [FREIGHT_PERMS.contracts.getNotification] },
audience: NotificationAudience.BACKOFFICE, audience: NotificationAudience.BACKOFFICE,
type: NotificationType.REQUEST_SUBMITTED, type: NotificationType.REQUEST_SUBMITTED,
title, title,
@@ -230,6 +244,7 @@ export class ContractNotifierService {
`the customs declaration can be filed.${note ? ` Note: "${note}"` : ''}`; `the customs declaration can be filed.${note ? ` Note: "${note}"` : ''}`;
this.logger.log(`TRANSIT ASSIGNEE REQUESTED — ${c.reference}`); this.logger.log(`TRANSIT ASSIGNEE REQUESTED — ${c.reference}`);
this.inAppStaff(c, `Transit assignee needed — ${c.reference}`, msg, { this.inAppStaff(c, `Transit assignee needed — ${c.reference}`, msg, {
recipients: CLEARANCE_DESK,
type: NotificationType.CLEARANCE_REVIEW, type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/gl-djibouti/clearance/${c.id}`, link: `/dashboard/gl-djibouti/clearance/${c.id}`,
}); });
@@ -248,6 +263,7 @@ export class ContractNotifierService {
`The customs declaration can now be filed.`; `The customs declaration can now be filed.`;
this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${c.reference}`); this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${c.reference}`);
this.inAppStaff(c, `Transit assignee set — ${c.reference}`, msg, { this.inAppStaff(c, `Transit assignee set — ${c.reference}`, msg, {
recipients: CLEARANCE_DESK,
type: NotificationType.CLEARANCE_REVIEW, type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/contracts/clearance/${c.id}`, link: `/dashboard/contracts/clearance/${c.id}`,
}); });
@@ -264,6 +280,7 @@ export class ContractNotifierService {
`"${note}". Review and re-advise the amount on the clearance page.`; `"${note}". Review and re-advise the amount on the clearance page.`;
this.logger.log(`DUTY DISPUTED — ${c.reference}`); this.logger.log(`DUTY DISPUTED — ${c.reference}`);
this.inAppStaff(c, `Duty disputed on ${c.reference}`, msg, { this.inAppStaff(c, `Duty disputed on ${c.reference}`, msg, {
recipients: CLEARANCE_DESK,
type: NotificationType.CLEARANCE_REVIEW, type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/contracts/clearance/${c.id}`, link: `/dashboard/contracts/clearance/${c.id}`,
}); });
@@ -321,6 +338,7 @@ export class ContractNotifierService {
'Clearance documents uploaded', 'Clearance documents uploaded',
`Customer uploaded clearance documents for contract ${this.ref(c)} — review them in the clearance queue.`, `Customer uploaded clearance documents for contract ${this.ref(c)} — review them in the clearance queue.`,
{ {
recipients: CLEARANCE_DESK,
type: NotificationType.CLEARANCE_REVIEW, type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/contracts/clearance/${c.id}`, link: `/dashboard/contracts/clearance/${c.id}`,
}, },
@@ -334,6 +352,7 @@ export class ContractNotifierService {
'Duty slip uploaded', 'Duty slip uploaded',
`Customer uploaded the duty & tax payment slip for contract ${this.ref(c)}.`, `Customer uploaded the duty & tax payment slip for contract ${this.ref(c)}.`,
{ {
recipients: CLEARANCE_DESK,
type: NotificationType.PAYMENT_RECEIVED, type: NotificationType.PAYMENT_RECEIVED,
link: `/dashboard/contracts/clearance/${c.id}`, link: `/dashboard/contracts/clearance/${c.id}`,
}, },
@@ -347,6 +366,9 @@ export class ContractNotifierService {
'New shipment request', 'New shipment request',
`Shipment request ${requestRef} was filed under contract ${this.ref(c)} and awaits GL review.`, `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}`, link: `/dashboard/shipment-requests/${requestId}`,
data: { contractId: c.id, requestId, reference: requestRef }, data: { contractId: c.id, requestId, reference: requestRef },
}, },

View File

@@ -0,0 +1,38 @@
import { ContractsRepository } from './contracts.repository';
/**
* A ONE_TIME contract stops blocking a duplicate request only once its booking
* is PAID. The existing duplicate-guard spec stubs the repository out, so the
* candidate SQL itself is unchecked there — this pins the predicate.
*/
describe('findDuplicateCandidates ONE_TIME paid gate', () => {
const candidateSql = (): string => {
const conditions: string[] = [];
const qb = {
leftJoinAndSelect: () => qb,
where: () => qb,
andWhere: (condition: string) => {
if (typeof condition === 'string') conditions.push(condition);
return qb;
},
getMany: async () => [],
};
const repository = new ContractsRepository(
{ createQueryBuilder: () => qb } as never,
{} as never,
);
void repository.findDuplicateCandidates('company-1', 'svc-1');
return conditions.join(' AND ');
};
it('spends the contract on payment, not on the booking row existing', () => {
const sql = candidateSql();
expect(sql).toContain("contract.contract_kind <> 'ONE_TIME'");
// The gate: an unpaid booking must NOT free the lane.
expect(sql).toContain("b.payment_status = 'PAID'");
expect(sql).toContain('b.deleted_at IS NULL');
});
});

View File

@@ -424,6 +424,8 @@ export class ContractTransitionService {
contract.tradeDirection, contract.tradeDirection,
contract.freightType, contract.freightType,
contract.customsClearingEnabled, contract.customsClearingEnabled,
// Bulk templates are keyed by the contract's cargo type.
(contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId,
); );
if (!active) return null; if (!active) return null;
return { return {

View File

@@ -1074,16 +1074,23 @@ export class ContractsController {
// ── Booking under contract (Path A customer / Path B GL ET) ──────────────── // ── Booking under contract (Path A customer / Path B GL ET) ────────────────
@Post(':id/bookings') @Post(':id/bookings')
@BookingStaff(FREIGHT_PERMS.contracts.createBooking) // Path A is a customer flow — both audiences must reach the service, whose
// assertGate decides per role. Staff still need contracts:create_booking.
@MixedAudience(FREIGHT_PERMS.contracts.createBooking)
@ApiOperation({ @ApiOperation({
summary: summary:
'Create a shipment booking under a contract — Path A (customer) or Path B (GL Ethiopia).', 'Create a shipment booking under a contract — Path A (customer) or Path B (GL Ethiopia).',
}) })
createBooking( async createBooking(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@Body() dto: CreateBookingUnderContractDto, @Body() dto: CreateBookingUnderContractDto,
@CurrentUser() user: AuthUserPayload, @CurrentUser() user: TCurrentUser & { sub?: string },
) { ) {
// Customer callers may only book on their own contract.
if (!hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking)) {
const contract = await this.contractsService.findById(id);
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
}
// The service decides the execution path from the contract: // The service decides the execution path from the contract:
// Path A (customs disabled) → customer/staff create; status checks apply. // Path A (customs disabled) → customer/staff create; status checks apply.
// Path B (customs enabled) → GL Ethiopia only, once clearance is ready. // Path B (customs enabled) → GL Ethiopia only, once clearance is ready.
@@ -1096,16 +1103,25 @@ export class ContractsController {
} }
@Post(':id/bookings/initiate') @Post(':id/bookings/initiate')
@BookingStaff(FREIGHT_PERMS.contracts.createBooking) // Customer initiates their own ONE_TIME instance; GL initiates on customs
// contracts — the service's assertGate decides per role, so both audiences
// must reach it. Staff still need contracts:create_booking.
@MixedAudience(FREIGHT_PERMS.contracts.createBooking)
@ApiOperation({ @ApiOperation({
summary: summary:
'Initiate a bare booking instance under an import/export contract (ONE_TIME or GENERAL) — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS). ONE_TIME customs instances are opened by the customer (or GL); GENERAL customs comes from a shipment request.', 'Initiate a bare booking instance under an import/export contract (ONE_TIME or GENERAL) — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS). ONE_TIME customs instances are opened by the customer (or GL); GENERAL customs comes from a shipment request.',
}) })
initiateBooking( async initiateBooking(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@Body() dto: CreateBookingUnderContractDto, @Body() dto: CreateBookingUnderContractDto,
@CurrentUser() user: AuthUserPayload, @CurrentUser() user: TCurrentUser & { sub?: string },
) { ) {
// Customer callers may only initiate on their own contract; the service's
// assertGate then decides what a customer may do on it.
if (!hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking)) {
const contract = await this.contractsService.findById(id);
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
}
return this.contractBookingService.initiateUnderContract( return this.contractBookingService.initiateUnderContract(
id, id,
{ contractRouteId: dto?.contractRouteId }, { contractRouteId: dto?.contractRouteId },
@@ -1115,17 +1131,24 @@ export class ContractsController {
} }
@Post(':id/bookings/:bookingId/complete') @Post(':id/bookings/:bookingId/complete')
@BookingStaff(FREIGHT_PERMS.contracts.createBooking) // Customers complete their own initiated (non-customs) instances; the
// service keeps customs completion GL-only via the actor's permissions.
@MixedAudience(FREIGHT_PERMS.contracts.createBooking)
@ApiOperation({ @ApiOperation({
summary: summary:
'Complete an initiated booking after Operations finalized its clearance — cargo + binding day, window and departure checks, pricing and invoicing.', 'Complete an initiated booking after Operations finalized its clearance — cargo + binding day, window and departure checks, pricing and invoicing.',
}) })
completeBooking( async completeBooking(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@Param('bookingId', ParseUUIDPipe) bookingId: string, @Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: CreateBookingUnderContractDto, @Body() dto: CreateBookingUnderContractDto,
@CurrentUser() user: AuthUserPayload, @CurrentUser() user: TCurrentUser & { sub?: string },
) { ) {
// Customer callers may only complete bookings on their own contract.
if (!hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking)) {
const contract = await this.contractsService.findById(id);
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
}
// Customs (Path B) instances may only be completed by GL Ethiopia — the // Customs (Path B) instances may only be completed by GL Ethiopia — the
// service checks the actor's contracts:create_booking permission. // service checks the actor's contracts:create_booking permission.
return this.contractBookingService.completeUnderContract( return this.contractBookingService.completeUnderContract(

View File

@@ -104,15 +104,19 @@ export class ContractsRepository extends BaseRepository<Contract> {
.andWhere('contract.status NOT IN (:...terminal)', { .andWhere('contract.status NOT IN (:...terminal)', {
terminal: TERMINAL_CONTRACT_STATUSES, terminal: TERMINAL_CONTRACT_STATUSES,
}) })
// A ONE_TIME contract allows a single booking, so once that booking // A ONE_TIME contract allows a single booking, so once that booking is
// exists the contract is spent and can never carry another shipment. // PAID the contract is spent and can never carry another shipment.
// Without this it kept blocking new requests on the same service type + // Without this it kept blocking new requests on the same service type +
// route until its validity lapsed — locking a customer out of a lane for // route until its validity lapsed — locking a customer out of a lane for
// the rest of the term after one completed shipment. // the rest of the term after one completed shipment.
// Payment is the gate, not the booking row: a DRAFT or abandoned unpaid
// booking must keep the contract blocking, otherwise a customer holds an
// unpaid booking and requests an identical contract alongside it.
.andWhere( .andWhere(
`(contract.contract_kind <> 'ONE_TIME' OR NOT EXISTS ( `(contract.contract_kind <> 'ONE_TIME' OR NOT EXISTS (
SELECT 1 FROM freight.bookings b SELECT 1 FROM freight.bookings b
WHERE b.contract_id = contract.id AND b.deleted_at IS NULL WHERE b.contract_id = contract.id AND b.deleted_at IS NULL
AND b.payment_status = 'PAID'
))`, ))`,
) )
.getMany(); .getMany();

View File

@@ -24,7 +24,14 @@ import { FleetHistoryService } from '../fleet-history/fleet-history.service';
@ApiTags('drivers') @ApiTags('drivers')
@ApiBearerAuth() @ApiBearerAuth()
@Controller('drivers') @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 { export class DriversController {
constructor( constructor(
private readonly driversService: DriversService, private readonly driversService: DriversService,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,588 @@
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 { NotificationAudience, NotificationPriority, NotificationType } from "@edr/types";
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { EimsAuthService } from "./eims-auth.service";
import { EimsClientService } from "./eims-client.service";
import { EimsApiException } from "./eims.errors";
import { 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;
/** MoR requires a plain integer here, so it cannot be our own `invoiceNumber`. */
documentNumber: string;
previousIrn: string;
}
/**
* Registers a single invoice with MoR EIMS.
*
* Sequencing is a **durable reservation**: the counter is consumed and the holder recorded in a
* committed transaction *before* the request leaves the process, and the network call happens
* outside any transaction. That gives three properties the naive design could not:
*
* - a counter is never reused once an attempt has begun, even across a crash;
* - a crash mid-flight leaves the reservation standing, so nothing blindly resubmits a document
* that may already have reached MoR;
* - an ambiguous result blocks every invoice for the system number, not just its own, because
* `PreviousIrn` is unknown and any later document would chain to a stale IRN.
*
* Signing, authentication and error normalisation belong to `EimsClientService`. Manual only —
* nothing in invoice creation calls this.
*/
@Injectable()
export class EimsInvoiceRegistrationService {
private readonly logger = new Logger(EimsInvoiceRegistrationService.name);
constructor(
@InjectDataSource() private readonly dataSource: DataSource,
private readonly config: ConfigService,
private readonly client: EimsClientService,
private readonly auth: EimsAuthService,
private readonly inbox: NotificationInboxService,
) {}
private get cfg(): EimsConfig {
return this.config.get<EimsConfig>("eims")!;
}
async registerInvoiceWithEims(invoiceId: string): Promise<EimsInvoiceStatusView> {
const cfg = this.cfg;
// Static seller/tax configuration is validated before anything is locked, allocated or sent.
assertEimsInvoiceConfig(cfg);
const invoice = await this.loadInvoiceForMapping(invoiceId);
if (invoice.eimsIrn) return this.toView(invoice);
// Authenticate before reserving: the source system comes from the token, and the state row is
// keyed by it. A login failure here costs nothing — no counter has been consumed yet.
const session = await this.auth.getSessionContext();
const reservation = await this.reserve(invoiceId, session.systemNumber);
if (!reservation) return this.getEimsStatus(invoiceId);
// The request can only be built now: InvoiceCounter and PreviousIrn come from the reservation.
const request = toEimsInvoice(
invoice,
buildEimsSeller(cfg),
buildEimsContext(cfg, {
// Allocated from the system state, not our invoiceNumber: MoR validates DocumentNumber
// against ^(0|[1-9][0-9]{0,8})$, which "INV-20260807-00006" can never satisfy.
documentNumber: reservation.documentNumber,
invoiceCounter: reservation.invoiceCounter,
previousIrn: reservation.previousIrn,
session,
}),
);
let irn: string;
let ackDate: string | undefined;
try {
// Deliberately outside every transaction — no DB lock is held across the wire.
const result = await this.submit(request);
irn = result.irn;
ackDate = result.ackDate;
} catch (err) {
await this.settleFailure(invoiceId, reservation, err);
throw err;
}
await this.settleSuccess(invoiceId, reservation, irn, ackDate);
this.logger.log(
`Invoice ${invoice.invoiceNumber} registered with EIMS (counter ${reservation.invoiceCounter})`,
);
return this.getEimsStatus(invoiceId);
}
/**
* Verify a registered invoice at `POST /v1/verify`.
*
* Requires a stored IRN. An invoice whose submission was never acknowledged cannot be reconciled
* here — the gateway offers no lookup by document number — so it must be resolved with MoR and
* recorded through `resolveEimsRegistration`.
*/
async verifyInvoiceWithEims(invoiceId: string): Promise<EimsVerifyResponse> {
const invoice = await this.loadInvoiceRow(this.dataSource.manager, invoiceId);
if (!invoice.eimsIrn) {
throw new BadRequestException({
code: "EIMS_NO_IRN",
message:
`Invoice ${invoice.invoiceNumber} has no EIMS IRN to verify (status ${invoice.eimsStatus}). ` +
"EIMS can only be queried by IRN, so an unacknowledged submission must be resolved with MoR first.",
});
}
return this.queryVerify(invoice.eimsIrn);
}
/**
* `POST /v1/verify` for one IRN, with the one check that always applies: the gateway must echo
* an `Irn` back. A 200 without it is not a confirmation of anything.
*
* The request property is lowercase `irn`; the response spells it `Irn`. The two are never
* compared — the supplied collection's own fixture uses different example values on each side,
* so equality there would assert a property of the mock rather than of the gateway.
*
* Bearer-authenticated but unsigned, via `postBearer` — see that method for why.
*/
private async queryVerify(irn: string): Promise<EimsVerifyResponse> {
const response = await this.client.postBearer<EimsVerifyRequest, EimsVerifyResponse>(
"/v1/verify",
{ irn },
);
if (!response?.body?.Irn?.trim()) {
throw new EimsApiException(
"SCHEMA_VALIDATION",
"EIMS verify returned no Irn in its response body",
response?.statusCode,
);
}
return response;
}
/**
* Refuse a manual resolution unless the gateway confirms *both* halves of the claim: that this
* IRN is the one it holds, and that it belongs to this invoice.
*
* The document-number check is against `DocumentDetails.DocumentNumber`, which registration
* allocated and stored on the invoice as `eimsDocumentNumber` — the only field tying an IRN back
* to a row in this database.
*
* Recording a wrong IRN is not a local mistake: it marks an unregistered invoice as filed and
* chains every later document to a stranger's reference, so both checks are refusals rather
* than warnings.
*/
private async assertIrnBelongsToInvoice(
irn: string,
expectedDocumentNumber: string,
): Promise<void> {
const response = await this.queryVerify(irn);
const returnedIrn = response.body?.Irn?.trim();
const documentNumber = response.body?.DocumentDetails?.DocumentNumber?.trim();
if (returnedIrn !== irn) {
throw new ConflictException({
code: "EIMS_RESOLVE_IRN_MISMATCH",
message:
`EIMS answered the lookup for IRN ${irn} with ${returnedIrn ?? "(none)"}. ` +
"Refusing to record it — recheck the IRN in the MoR portal.",
});
}
if (documentNumber !== expectedDocumentNumber) {
throw new ConflictException({
code: "EIMS_RESOLVE_DOCUMENT_MISMATCH",
message:
`EIMS reports IRN ${irn} against document ${documentNumber ?? "(none)"}, not ` +
`${expectedDocumentNumber}. Refusing to record it — recheck the IRN in the MoR portal.`,
});
}
}
/**
* Manual reconciliation of a blocked system number.
*
* With an `irn` (found in the MoR portal) the invoice is recorded as registered and the chain
* resumes from it. With `discard` the invoice is marked failed and the chain resumes from the
* previous IRN. Either way the block is cleared — this is the only exit from an ambiguous result.
*
* An IRN is never taken on trust: it is verified at the gateway first, and the document it
* belongs to must be *this* invoice. A transposed digit would otherwise chain every later
* document to a stranger's IRN and mark this invoice registered when it is not.
*/
async resolveEimsRegistration(
invoiceId: string,
input: { irn?: string; discard?: boolean },
): Promise<EimsInvoiceStatusView> {
const irn = input.irn?.trim();
if (!irn && !input.discard) {
throw new BadRequestException({
code: "EIMS_RESOLVE_INPUT_REQUIRED",
message: "Provide the IRN confirmed with MoR, or discard: true to abandon the submission",
});
}
// Cheap ownership check before touching the gateway: resolving an invoice that does not hold
// the reservation is a caller mistake, not something to spend a MoR round trip on. The
// authoritative re-check happens under lock in the transaction below.
const [preState]: { in_flight_invoice_id: string | null }[] = await this.dataSource.query(
`SELECT in_flight_invoice_id FROM freight.eims_system_state
WHERE system_number = $1 AND deleted_at IS NULL LIMIT 1`,
[(await this.auth.getSessionContext()).systemNumber],
);
if (preState?.in_flight_invoice_id && preState.in_flight_invoice_id !== invoiceId) {
throw new ConflictException({
code: "EIMS_RESOLVE_WRONG_INVOICE",
message: `The in-flight EIMS submission is invoice ${preState.in_flight_invoice_id}, not ${invoiceId}`,
});
}
// 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);
if (!invoice.eimsDocumentNumber) {
throw new BadRequestException({
code: "EIMS_NO_DOCUMENT_NUMBER",
message:
`Invoice ${invoice.invoiceNumber} was never allocated an EIMS document number, so a ` +
"returned IRN cannot be tied back to it.",
});
}
await this.assertIrnBelongsToInvoice(irn, invoice.eimsDocumentNumber);
}
// Same source of truth as registration: the state row is keyed by the token's system number.
const session = await this.auth.getSessionContext();
await this.dataSource.transaction(async (manager) => {
const state = await this.lockSystemState(manager, session.systemNumber);
if (state.inFlightInvoiceId && state.inFlightInvoiceId !== invoiceId) {
throw new ConflictException({
code: "EIMS_RESOLVE_WRONG_INVOICE",
message: `The in-flight EIMS submission is invoice ${state.inFlightInvoiceId}, not ${invoiceId}`,
});
}
const invoice = await this.lockInvoice(manager, invoiceId);
if (invoice.eimsIrn) {
throw new ConflictException({
code: "EIMS_ALREADY_REGISTERED",
message: `Invoice ${invoice.invoiceNumber} already has IRN ${invoice.eimsIrn}`,
});
}
await manager.update(Invoice, invoiceId, {
eimsStatus: irn ? EimsInvoiceStatus.Registered : EimsInvoiceStatus.Failed,
eimsIrn: irn ?? null,
});
await manager.update(EimsSystemState, state.id, {
// Only a confirmed IRN may advance the chain; a discard leaves it where it was.
...(irn ? { previousIrn: irn } : {}),
inFlightInvoiceId: null,
inFlightCounter: null,
inFlightDocumentNumber: null,
blockedReason: null,
});
});
this.logger.warn(
`EIMS block on invoice ${invoiceId} resolved manually (${irn ? "IRN recorded" : "discarded"})`,
);
return this.getEimsStatus(invoiceId);
}
async getEimsStatus(invoiceId: string): Promise<EimsInvoiceStatusView> {
return this.toView(await this.loadInvoiceRow(this.dataSource.manager, invoiceId));
}
// ── transactions ─────────────────────────────────────────────────────────────────────────────
/**
* TX1. Consume a counter and record the holder, committed before any HTTP call. Returns `null`
* when the invoice turned out to be registered already (checked under the lock).
*/
private async reserve(invoiceId: string, systemNumber: string): Promise<Reservation | null> {
return this.dataSource.transaction(async (manager) => {
const state = await this.lockSystemState(manager, systemNumber);
if (state.blockedReason) {
throw new ConflictException({
code: "EIMS_SYSTEM_BLOCKED",
message:
`EIMS registration is blocked for system ${systemNumber}: ${state.blockedReason}. ` +
"Resolve the affected invoice before registering anything else.",
});
}
if (state.inFlightInvoiceId) {
throw new ConflictException({
code: "EIMS_SUBMISSION_IN_FLIGHT",
message:
`A submission for invoice ${state.inFlightInvoiceId} is already in flight on system ` +
`${systemNumber}. Wait for it to settle, or resolve it if the process was interrupted.`,
});
}
const invoice = await this.lockInvoice(manager, invoiceId);
if (invoice.eimsIrn) return null;
const invoiceCounter = Number(state.nextInvoiceCounter);
const documentNumber = String(Number(state.nextDocumentNumber));
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,
nextDocumentNumber: Number(documentNumber) + 1,
inFlightInvoiceId: invoiceId,
inFlightCounter: invoiceCounter,
inFlightDocumentNumber: Number(documentNumber),
});
await manager.update(Invoice, invoiceId, {
eimsStatus: EimsInvoiceStatus.Submitting,
eimsInvoiceCounter: invoiceCounter,
eimsDocumentNumber: documentNumber,
eimsSubmittedAt: new Date(),
eimsLastError: null,
});
return { stateId: state.id, invoiceCounter, documentNumber, previousIrn };
});
}
/** TX2a. Record the IRN, advance the chain, release the reservation. */
private async settleSuccess(
invoiceId: string,
reservation: Reservation,
irn: string,
ackDate?: string,
): Promise<void> {
await this.dataSource.transaction(async (manager) => {
await this.lockInvoice(manager, invoiceId);
await manager.update(Invoice, invoiceId, {
eimsStatus: EimsInvoiceStatus.Registered,
eimsIrn: irn,
eimsAckDate: ackDate ?? null,
eimsLastError: null,
});
await manager.update(EimsSystemState, reservation.stateId, {
previousIrn: irn,
inFlightInvoiceId: null,
inFlightCounter: null,
inFlightDocumentNumber: null,
blockedReason: null,
});
});
}
/**
* TX2b. A deterministic rejection releases the reservation **and returns the counter**; an
* ambiguous result keeps both and blocks the system number, because `PreviousIrn` is now unknown
* for every later document.
*
* The two numbers move differently, because MoR constrains them differently:
*
* - `InvoiceCounter` must not **skip** — "Invoice counter is not correct. expected : 1". A
* document MoR definitively refused was never counted there, so ours must not advance either.
* - `DocumentNumber` must not **repeat** — the documented rule is "Document number is not
* unique". It is therefore spent by the attempt itself and never handed back, even for a
* refusal.
*
* An ambiguous result keeps both: MoR may have counted and stored the document.
*/
private async settleFailure(
invoiceId: string,
reservation: Reservation,
err: unknown,
): Promise<void> {
const api = err instanceof EimsApiException ? err : null;
const deterministic = api ? DETERMINISTIC_KINDS.has(api.kind) : false;
const status = deterministic ? EimsInvoiceStatus.Failed : EimsInvoiceStatus.Unknown;
const lastError: EimsInvoiceError = {
kind: api?.kind ?? "UNKNOWN",
message: (err as Error)?.message ?? "unknown error",
httpStatus: api?.httpStatus,
details: api?.details,
at: new Date().toISOString(),
};
await this.dataSource.transaction(async (manager) => {
await manager.update(Invoice, invoiceId, {
eimsStatus: status,
eimsLastError: lastError,
} as QueryDeepPartialEntity<Invoice>);
await manager.update(
EimsSystemState,
reservation.stateId,
deterministic
? {
// Counter returns (MoR never counted a refused document); the document number does
// not (MoR requires it to be unique, so it is burned by the attempt).
nextInvoiceCounter: reservation.invoiceCounter,
inFlightInvoiceId: null,
inFlightCounter: null,
inFlightDocumentNumber: 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}`);
await this.alertStaff(invoiceId, status, lastError, deterministic);
}
/**
* Tell the people who can act about a failed filing.
*
* An ambiguous result is the urgent one: it blocks *every* further invoice for this system
* number until a human resolves it, and nothing else in the system would surface that — the
* sweep just goes quiet. A deterministic rejection affects one invoice, so it is normal
* priority. Never throws: an alert that fails must not mask the filing outcome.
*/
private async alertStaff(
invoiceId: string,
status: EimsInvoiceStatus,
error: EimsInvoiceError,
deterministic: boolean,
): Promise<void> {
try {
await this.inbox.notify({
recipients: { permissionKeys: [FREIGHT_PERMS.invoices.eimsResolve] },
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.GENERIC,
priority: deterministic ? NotificationPriority.NORMAL : NotificationPriority.HIGH,
title: deterministic
? "EIMS rejected an invoice"
: "EIMS filing unresolved — all further filing is blocked",
body: deterministic
? `MoR rejected the filing (${error.kind}): ${error.message}. The invoice is marked FAILED; correct it and file again.`
: `A submission was sent but never acknowledged (${error.kind}). Its IRN is unknown, so no further invoice can be filed until it is resolved with MoR.`,
link: `/dashboard/invoices/${invoiceId}`,
data: { invoiceId, eimsStatus: status, kind: error.kind, action: "EIMS_FILING_FAILED" },
});
} catch (err) {
this.logger.warn(`EIMS staff alert failed for invoice ${invoiceId}: ${(err as Error).message}`);
}
}
// ── internals ────────────────────────────────────────────────────────────────────────────────
/** A non-empty IRN is the only success signal; anything else is a failed registration. */
private async submit(request: EimsInvoiceRequest): Promise<{ irn: string; ackDate?: string }> {
const response = await this.client.postSigned<EimsInvoiceRequest, EimsRegisterResponse>(
"/v1/register",
request,
);
const irn = response?.body?.irn;
if (!irn) {
// The gateway answered, so this is deterministic: the document is not registered.
throw new EimsApiException(
"SCHEMA_VALIDATION",
`EIMS register returned no IRN${response?.body?.errorMessage ? `: ${response.body.errorMessage}` : ""}`,
response?.statusCode,
);
}
return { irn, ackDate: response.body?.ackDate };
}
private async lockInvoice(manager: EntityManager, invoiceId: string): Promise<Invoice> {
const invoice = await manager
.createQueryBuilder(Invoice, "invoice")
.setLock("pessimistic_write")
.where("invoice.id = :invoiceId", { invoiceId })
.getOne();
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
return invoice;
}
/** Locks the system-state row, creating it on first use. */
private async lockSystemState(
manager: EntityManager,
systemNumber: string,
): Promise<EimsSystemState> {
const select = () =>
manager
.createQueryBuilder(EimsSystemState, "state")
.setLock("pessimistic_write")
.where("state.system_number = :systemNumber", { systemNumber })
.getOne();
const existing = await select();
if (existing) return existing;
await manager.query(
`INSERT INTO freight.eims_system_state (system_number) VALUES ($1)
ON CONFLICT (system_number) DO NOTHING`,
[systemNumber],
);
const created = await select();
if (!created) throw new Error(`Could not initialise EIMS system state for ${systemNumber}`);
return created;
}
/** Header + buyer + lines — everything the mapper needs. */
private async loadInvoiceForMapping(
invoiceId: string,
): Promise<Invoice & { lines: EimsMapperLine[] }> {
const invoice = await this.dataSource.getRepository(Invoice).findOne({
where: { id: invoiceId },
relations: { company: true, companyProfile: true },
});
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
const lines: EimsMapperLine[] = await this.dataSource.query(
`SELECT charge_type AS "chargeType", description, quantity, unit_rate AS "unitRate",
amount, currency, metadata
FROM freight.invoice_lines
WHERE invoice_id = $1 AND deleted_at IS NULL
ORDER BY created_at ASC`,
[invoiceId],
);
return Object.assign(invoice, { lines });
}
private async loadInvoiceRow(manager: EntityManager, invoiceId: string): Promise<Invoice> {
const invoice = await manager.findOne(Invoice, { where: { id: invoiceId } });
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
return invoice;
}
private toView(invoice: Invoice): EimsInvoiceStatusView {
const counter = invoice.eimsInvoiceCounter;
return {
invoiceId: invoice.id,
invoiceNumber: invoice.invoiceNumber,
eimsStatus: invoice.eimsStatus ?? EimsInvoiceStatus.NotSubmitted,
eimsIrn: invoice.eimsIrn ?? null,
eimsDocumentNumber: invoice.eimsDocumentNumber ?? null,
eimsInvoiceCounter: counter === null || counter === undefined ? null : Number(counter),
eimsSubmittedAt: invoice.eimsSubmittedAt ?? null,
eimsAckDate: invoice.eimsAckDate ?? null,
eimsLastError: invoice.eimsLastError ?? null,
};
}
}

View File

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

View File

@@ -0,0 +1,89 @@
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;
/** The numeric DocumentNumber filed with MoR; not our own invoiceNumber. */
eimsDocumentNumber: string | null;
eimsInvoiceCounter: number | null;
eimsSubmittedAt: Date | null;
eimsAckDate: string | null;
eimsLastError: EimsInvoiceError | null;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,54 @@
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;
/**
* `DocumentDetails.DocumentNumber` for the next registration.
*
* Separate from our own `invoiceNumber`, which MoR cannot accept: it validates the field against
* `^(0|[1-9][0-9]{0,8})$`, a plain integer.
*/
@Column({ name: "next_document_number", type: "bigint", default: 1 })
nextDocumentNumber!: number;
@Column({ name: "in_flight_document_number", type: "bigint", nullable: true })
inFlightDocumentNumber?: number | null;
/** IRN of the last successful registration; null until the first one succeeds. */
@Column({ name: "previous_irn", type: "varchar", length: 64, nullable: true })
previousIrn?: string | null;
/**
* Invoice holding the current reservation. Committed before the HTTP call, so it survives a
* crash and blocks a blind resubmission of a document that may already have reached MoR.
*/
@Column({ name: "in_flight_invoice_id", type: "uuid", nullable: true })
inFlightInvoiceId?: string | null;
/** Counter handed to the in-flight submission. */
@Column({ name: "in_flight_counter", type: "bigint", nullable: true })
inFlightCounter?: number | null;
/**
* Why registration is blocked for this system number. Set when a submission ends ambiguously:
* the IRN is unknown, so no further document can chain correctly until it is resolved.
*/
@Column({ name: "blocked_reason", type: "text", nullable: true })
blockedReason?: string | null;
}

View File

@@ -10,7 +10,12 @@ import { FacilitiesService } from './facilities.service';
@ApiTags('Facilities') @ApiTags('Facilities')
@Controller('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 { export class FacilitiesController {
constructor(private readonly facilitiesService: FacilitiesService) {} constructor(private readonly facilitiesService: FacilitiesService) {}

View File

@@ -19,7 +19,12 @@ import { RegisterDeviceDto, UpdateDeviceDto } from './dto/gps-device.dto';
@ApiTags('gps-tracking') @ApiTags('gps-tracking')
@ApiBearerAuth() @ApiBearerAuth()
@Controller('gps') @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 { export class GpsTrackingController {
constructor(private readonly gps: GpsTrackingService) {} constructor(private readonly gps: GpsTrackingService) {}

View File

@@ -22,7 +22,14 @@ import { IncidentStatus, IncidentType } from './entities/incident.entity';
// No incidents-specific permission exists in the registry, so this reuses the // No incidents-specific permission exists in the registry, so this reuses the
// (real) drivers.* fleet-road keys — incident records are driver-safety data // (real) drivers.* fleet-road keys — incident records are driver-safety data
// (driver stats / incident history). TODO: add a dedicated incidents:* key. // (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 { export class IncidentsController {
constructor(private readonly incidentsService: IncidentsService) {} constructor(private readonly incidentsService: IncidentsService) {}

View File

@@ -15,7 +15,14 @@ import { InterchangeDocumentsService } from './interchange-documents.service';
@ApiBearerAuth() @ApiBearerAuth()
@Controller('interchange-documents') @Controller('interchange-documents')
// Class-level view guard; each write route adds its own manage permission below. // 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 { export class InterchangeDocumentsController {
constructor(private readonly service: InterchangeDocumentsService) {} constructor(private readonly service: InterchangeDocumentsService) {}

View File

@@ -1,13 +1,19 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { IsNumber, Min } from 'class-validator'; import { IsNumber, IsOptional, Min } from 'class-validator';
export class ApproveLastMileRequestDto { export class ApproveLastMileRequestDto {
// The approve dialog prefills this from GET :id/price-estimate (rule-based), // Omitted = the rule-based last-mile rate estimate is the advance. The chief
// but the chief can still override — the typed value is what's invoiced. // can still override with an explicit amount (required when no rate covers
@ApiProperty({ description: 'Advance amount the customer must pay before execution proceeds', example: 3000 }) // the job).
@Transform(({ value }) => Number(value)) @ApiPropertyOptional({
description:
'Advance override. Omitted = the amount comes from the live last-mile rates (km × rate).',
example: 3000,
})
@IsOptional()
@Transform(({ value }) => (value === null || value === undefined || value === '' ? undefined : Number(value)))
@IsNumber() @IsNumber()
@Min(0.01) @Min(0.01)
advanceAmount!: number; advanceAmount?: number;
} }

View File

@@ -114,7 +114,7 @@ export class LastMileRequestsController {
@Post(':id/approve') @Post(':id/approve')
@BookingStaff(FREIGHT_PERMS.lastMile.requestApprove) @BookingStaff(FREIGHT_PERMS.lastMile.requestApprove)
@ApiOperation({ summary: 'Truck & Machinery chief approves the request — LM contract becomes signable; the advance invoice follows the customer signature' }) @ApiOperation({ summary: 'Truck & Machinery chief approves the request — the advance defaults to the live last-mile rate; LM contract becomes signable and the advance invoice follows the customer signature' })
approve( approve(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@Body() dto: ApproveLastMileRequestDto, @Body() dto: ApproveLastMileRequestDto,

View File

@@ -299,7 +299,11 @@ export class LastMileRequestsService {
return this.findById(id); return this.findById(id);
} }
async approve(id: string, staffId: string | null, advanceAmount: number): Promise<LastMileRequest> { async approve(
id: string,
staffId: string | null,
advanceOverride?: number | null,
): Promise<LastMileRequest> {
const request = await this.findById(id); const request = await this.findById(id);
if (request.status !== LastMileRequestStatus.Submitted) { if (request.status !== LastMileRequestStatus.Submitted) {
throw new BadRequestException(`Only a submitted request can be approved (current status: ${request.status})`); throw new BadRequestException(`Only a submitted request can be approved (current status: ${request.status})`);
@@ -307,6 +311,18 @@ export class LastMileRequestsService {
const booking = request.booking ?? (await this.bookingsRepository.findById(request.bookingId)); const booking = request.booking ?? (await this.bookingsRepository.findById(request.bookingId));
if (!booking) throw new NotFoundException(`Booking ${request.bookingId} not found`); if (!booking) throw new NotFoundException(`Booking ${request.bookingId} not found`);
// The live last-mile rates are the authority on the advance (km × rate);
// the chief's typed amount is only an override — and the only path when no
// rate covers the job. Snapshotted so the contract and invoice stay immune
// to later rate edits.
const estimate = await this.priceEstimate(id);
const advanceAmount = advanceOverride ?? estimate.total;
if (!advanceAmount || advanceAmount <= 0) {
throw new BadRequestException(
'No live last-mile rate covers this job — enter the advance amount manually.',
);
}
// Idempotent per booking — reuses the record if one already exists. // Idempotent per booking — reuses the record if one already exists.
const lastMile = await this.lastMileService.create({ const lastMile = await this.lastMileService.create({
bookingId: request.bookingId, bookingId: request.bookingId,
@@ -316,10 +332,6 @@ export class LastMileRequestsService {
// No invoice yet: the advance is invoiced by LastMileContractService.sign() // No invoice yet: the advance is invoiced by LastMileContractService.sign()
// once the customer has signed the LM contract — doc first, then payment. // once the customer has signed the LM contract — doc first, then payment.
// Snapshot the rate estimate now so the contract shows the numbers the
// chief actually approved against, immune to later rate edits.
const estimate = await this.priceEstimate(id);
await this.requestsRepository.update(id, { await this.requestsRepository.update(id, {
status: LastMileRequestStatus.Approved, status: LastMileRequestStatus.Approved,
reviewedByStaffId: staffId, reviewedByStaffId: staffId,
@@ -364,7 +376,10 @@ export class LastMileRequestsService {
type: 'LAST_MILE_ADVANCE', type: 'LAST_MILE_ADVANCE',
companyId: booking.companyId, companyId: booking.companyId,
companyProfileId: booking.companyProfileId || '', companyProfileId: booking.companyProfileId || '',
currency: booking.paymentCurrency || 'ETB', // The advance is priced by the last-mile rate, so it bills in that
// rate's currency (birr for domestic trucking) — the booking's payment
// currency is only the fallback when the amount was a manual override.
currency: request.contractSummary?.currency || booking.paymentCurrency || 'ETB',
lines: [ lines: [
{ {
chargeType: 'LAST_MILE_ADVANCE', chargeType: 'LAST_MILE_ADVANCE',

View File

@@ -1,6 +1,7 @@
import { NotificationAudience } from '@edr/types'; import { NotificationAudience } from '@edr/types';
import { MaintenanceService } from './maintenance.service'; 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 * 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(notify).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
audience: NotificationAudience.BACKOFFICE, audience: NotificationAudience.BACKOFFICE,
// The fleet desk, not every employee in the company.
recipients: {
permissionKeys: [FREIGHT_PERMS.maintenance.getNotification],
},
title: 'Maintenance due — ET-9875', title: 'Maintenance due — ET-9875',
body: expect.stringContaining('driven 50200 km (due at 50000 km)'), body: expect.stringContaining('driven 50200 km (due at 50000 km)'),
}), }),

View File

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

View File

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

View File

@@ -60,14 +60,38 @@ export class RefundDto {
} }
export class ClientActionDto { export class ClientActionDto {
// INVOKE_BRIDGE (SuperApp mini-app payload) is part of the shared ClientAction union and so
// must be assignable here, but freight never requests platform=inapp and therefore never
// receives one. Passenger owns that flow — see docs/telebirr-miniapp/.
@ApiProperty({ @ApiProperty({
enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP", "SHOW_BILL_REFERENCE"], enum: [
"REDIRECT",
"LAUNCH_APP",
"INVOKE_BRIDGE",
"COLLECT_OTP",
"SHOW_BILL_REFERENCE",
],
}) })
type!: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP" | "SHOW_BILL_REFERENCE"; type!:
| "REDIRECT"
| "LAUNCH_APP"
| "INVOKE_BRIDGE"
| "COLLECT_OTP"
| "SHOW_BILL_REFERENCE";
@ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" }) @ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" })
url?: string; url?: string;
@ApiPropertyOptional({
description: "Set when type=INVOKE_BRIDGE (SuperApp mini app) — not used by freight",
})
bridge?: "TELEBIRR";
@ApiPropertyOptional({
description: "Set when type=INVOKE_BRIDGE (SuperApp mini app) — not used by freight",
})
rawRequest?: string;
@ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" }) @ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" })
appId?: string; appId?: string;

View File

@@ -13,7 +13,14 @@ import {
@ApiTags('Procurement & Asset Lifecycle') @ApiTags('Procurement & Asset Lifecycle')
@Controller('procurement') @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 { export class ProcurementController {
constructor(private readonly procurementService: ProcurementService) {} constructor(private readonly procurementService: ProcurementService) {}

View File

@@ -27,7 +27,15 @@ import { RoutesService } from './routes.service';
@ApiTags('routes') @ApiTags('routes')
@ApiBearerAuth() @ApiBearerAuth()
@Controller('routes') @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 { export class RoutesController {
constructor(private readonly routesService: RoutesService) {} constructor(private readonly routesService: RoutesService) {}

View File

@@ -70,6 +70,16 @@ export class CreateCargoTypeDto {
@IsBoolean() @IsBoolean()
hasLashing?: boolean; hasLashing?: boolean;
@ApiPropertyOptional({
default: false,
description:
'Allow staff to write bulk contract templates for this cargo type. ' +
'Mutually exclusive with the parent group / children having it.',
})
@IsOptional()
@IsBoolean()
hasContractTemplate?: boolean;
@ApiPropertyOptional({ default: true }) @ApiPropertyOptional({ default: true })
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()

View File

@@ -82,6 +82,14 @@ export class CargoType extends BaseEntity {
@Column({ name: 'has_lashing', type: 'boolean', default: false }) @Column({ name: 'has_lashing', type: 'boolean', default: false })
hasLashing!: boolean; hasLashing!: boolean;
/**
* Whether staff may write bulk contract templates against this cargo type.
* Mutually exclusive between a parent group and its children: if the parent
* provides the template, no child may, and vice versa.
*/
@Column({ name: 'has_contract_template', type: 'boolean', default: false })
hasContractTemplate!: boolean;
@Column({ name: 'is_active', type: 'boolean', default: true }) @Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean; isActive!: boolean;

View File

@@ -135,6 +135,35 @@ export class CargoTypesService {
return map; return map;
} }
/**
* A cargo type and its parent group may not BOTH offer a contract template —
* the template would be ambiguous for bookings of the child. To enable the
* child, the parent must be turned off first (and vice versa).
*/
private async assertContractTemplateExclusive(input: {
id?: string;
parentGroupId?: string | null;
}): Promise<void> {
if (input.parentGroupId) {
const parent = await this.repository.findById(input.parentGroupId);
if (parent?.hasContractTemplate) {
throw new BadRequestException(
`Parent group "${parent.cargoTypeName}" already has a contract template — turn it off there first`,
);
}
}
if (input.id) {
const children = await this.repository.findAll({
where: { parentGroupId: input.id, hasContractTemplate: true },
});
if (children.length) {
throw new BadRequestException(
`Child cargo type(s) ${children.map((c) => `"${c.cargoTypeName}"`).join(', ')} already have their own contract template — turn those off first`,
);
}
}
}
/** Create a new cargo type. */ /** Create a new cargo type. */
async create(dto: CreateCargoTypeDto): Promise<CargoType> { async create(dto: CreateCargoTypeDto): Promise<CargoType> {
const code = generateCode(dto.cargoTypeName); const code = generateCode(dto.cargoTypeName);
@@ -144,6 +173,9 @@ export class CargoTypesService {
const parent = await this.repository.findById(dto.parentGroupId); const parent = await this.repository.findById(dto.parentGroupId);
if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`); if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`);
} }
if (dto.hasContractTemplate) {
await this.assertContractTemplateExclusive({ parentGroupId: dto.parentGroupId });
}
const displayOrder = await this.displayOrder.resolveCreateOrder(CargoType, 'displayOrder', { const displayOrder = await this.displayOrder.resolveCreateOrder(CargoType, 'displayOrder', {
explicitOrder: dto.displayOrder, explicitOrder: dto.displayOrder,
@@ -160,6 +192,7 @@ export class CargoTypesService {
code, code,
cargoTypeName: dto.cargoTypeName, cargoTypeName: dto.cargoTypeName,
parentGroupId: dto.parentGroupId ?? null, parentGroupId: dto.parentGroupId ?? null,
hasContractTemplate: dto.hasContractTemplate ?? false,
requiresDirectorApproval: dto.requiresDirectorApproval ?? false, requiresDirectorApproval: dto.requiresDirectorApproval ?? false,
isActive: dto.isActive ?? true, isActive: dto.isActive ?? true,
unitOfMeasure: dto.unitOfMeasure ?? null, unitOfMeasure: dto.unitOfMeasure ?? null,
@@ -183,6 +216,18 @@ export class CargoTypesService {
const parent = await this.repository.findById(dto.parentGroupId); const parent = await this.repository.findById(dto.parentGroupId);
if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`); if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`);
} }
// Re-check the parent/child template exclusivity whenever the flag or the
// parent moves and the row ends up flagged.
const willHaveTemplate = dto.hasContractTemplate ?? existing.hasContractTemplate;
if (
willHaveTemplate &&
(dto.hasContractTemplate !== undefined || dto.parentGroupId !== undefined)
) {
await this.assertContractTemplateExclusive({
id,
parentGroupId: dto.parentGroupId ?? existing.parentGroupId,
});
}
const { const {
wagonTypeIds, wagonTypeIds,
itemsPerWagonMap, itemsPerWagonMap,

View File

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

View File

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

View File

@@ -11,7 +11,7 @@ import { Booking } from '../bookings/entities/booking.entity';
import { BookingsRepository } from '../bookings/bookings.repository'; import { BookingsRepository } from '../bookings/bookings.repository';
import { compareSchedulingPriority } from '../scheduling/compare-scheduling-priority.util'; import { compareSchedulingPriority } from '../scheduling/compare-scheduling-priority.util';
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
import { BookingNotifierService } from '../train-scheduling/booking-notifier.service'; import { BookingNotifierService } from '../train-scheduling/booking-notifier.service';
import { ExecuteRescheduleDto, PreviewRescheduleDto } from './dto/preview-reschedule.dto'; import { ExecuteRescheduleDto, PreviewRescheduleDto } from './dto/preview-reschedule.dto';
import { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto'; import { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto';

View File

@@ -0,0 +1,311 @@
import { SUPPORT_MEDIA_PREFIX, SupportDocSlug } from "@edr/types";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
ArrayMaxSize,
IsArray,
IsIn,
IsObject,
IsOptional,
IsString,
Matches,
MaxLength,
MinLength,
ValidateNested,
} from "class-validator";
/**
* Markdown bodies are safe on read — the portal renders them with
* `react-markdown` and no `rehype-raw`, so any HTML in them is inert. The
* fields worth validating are these: they land in `href`/`src` attributes and
* bypass markdown entirely, which is where a `javascript:` URL would actually
* execute.
*
* Placeholders survive the check because they sit after the scheme
* (`mailto:{{supportEmail}}`, `tel:{{supportPhoneTel}}`).
*/
const LINK_PATTERN = /^(https?:\/\/|mailto:|tel:|\/)/;
const LINK_MESSAGE =
"$property must start with http(s)://, mailto:, tel: or /";
/**
* A media source is either an uploaded MinIO object key, a same-origin path, or
* an https URL. Anything else — notably `javascript:` — is refused, since this
* value lands in an `<img>`/`<video>` src.
*/
const MEDIA_SRC_PATTERN = new RegExp(
`^(https?:\\/\\/|\\/|${SUPPORT_MEDIA_PREFIX.replace("/", "\\/")})`,
);
const MEDIA_SRC_MESSAGE =
`$property must be an uploaded ${SUPPORT_MEDIA_PREFIX} key, a /path, or an http(s):// URL`;
/* ------------------------------- CONTACT ------------------------------- */
export class PortalSupportContactDto {
@ApiProperty()
@IsString()
@MinLength(3)
@MaxLength(200)
email!: string;
@ApiProperty()
@IsString()
@MinLength(3)
@MaxLength(60)
phone!: string;
@ApiProperty()
@IsString()
@MinLength(2)
@MaxLength(200)
office!: string;
@ApiProperty()
@IsString()
@MinLength(2)
@MaxLength(200)
hours!: string;
}
/* ---------------------------- PRIVACY / TERMS --------------------------- */
export class PortalDocSectionDto {
@ApiPropertyOptional({ description: "Stable id; generated when omitted" })
@IsOptional()
@IsString()
@MaxLength(64)
id?: string;
@ApiProperty()
@IsString()
@MinLength(1)
@MaxLength(200)
heading!: string;
@ApiProperty({ description: "Markdown" })
@IsString()
@MaxLength(20_000)
body!: string;
}
export class PortalLegalContentDto {
@ApiProperty()
@IsString()
@MinLength(1)
@MaxLength(200)
title!: string;
@ApiProperty()
@IsString()
@MaxLength(500)
subtitle!: string;
@ApiProperty({ description: 'Free text, e.g. "6 August 2026"' })
@IsString()
@MaxLength(60)
lastUpdated!: string;
@ApiProperty({ type: [PortalDocSectionDto] })
@IsArray()
@ArrayMaxSize(60)
@ValidateNested({ each: true })
@Type(() => PortalDocSectionDto)
sections!: PortalDocSectionDto[];
}
/* --------------------------------- FAQ --------------------------------- */
export class PortalCtaCardDto {
@ApiProperty()
@IsString()
@MinLength(1)
@MaxLength(200)
heading!: string;
@ApiProperty({ description: "Markdown" })
@IsString()
@MaxLength(2_000)
body!: string;
@ApiProperty()
@IsString()
@MinLength(1)
@MaxLength(100)
ctaLabel!: string;
@ApiProperty()
@IsString()
@Matches(LINK_PATTERN, { message: LINK_MESSAGE })
@MaxLength(500)
ctaTo!: string;
}
export class PortalFaqItemDto {
@ApiPropertyOptional({ description: "Stable id; generated when omitted" })
@IsOptional()
@IsString()
@MaxLength(64)
id?: string;
@ApiProperty()
@IsString()
@MinLength(1)
@MaxLength(300)
question!: string;
@ApiProperty({ description: "Markdown" })
@IsString()
@MaxLength(5_000)
answer!: string;
}
export class PortalFaqGroupDto {
@ApiPropertyOptional({ description: "Stable id; generated when omitted" })
@IsOptional()
@IsString()
@MaxLength(64)
id?: string;
@ApiProperty()
@IsString()
@MinLength(1)
@MaxLength(200)
title!: string;
@ApiProperty({ type: [PortalFaqItemDto] })
@IsArray()
@ArrayMaxSize(50)
@ValidateNested({ each: true })
@Type(() => PortalFaqItemDto)
items!: PortalFaqItemDto[];
}
export class PortalFaqContentDto {
@ApiProperty()
@IsString()
@MinLength(1)
@MaxLength(200)
title!: string;
@ApiProperty()
@IsString()
@MaxLength(500)
subtitle!: string;
@ApiProperty({ type: [PortalFaqGroupDto] })
@IsArray()
@ArrayMaxSize(20)
@ValidateNested({ each: true })
@Type(() => PortalFaqGroupDto)
groups!: PortalFaqGroupDto[];
@ApiPropertyOptional({ type: PortalCtaCardDto, nullable: true })
@IsOptional()
@ValidateNested()
@Type(() => PortalCtaCardDto)
footer?: PortalCtaCardDto | null;
}
/* --------------------------------- HELP -------------------------------- */
export class PortalMediaDto {
@ApiPropertyOptional({ description: "Stable id; generated when omitted" })
@IsOptional()
@IsString()
@MaxLength(64)
id?: string;
@ApiProperty({ enum: ["image", "video"] })
@IsIn(["image", "video"])
kind!: "image" | "video";
@ApiProperty({
description: `An uploaded ${SUPPORT_MEDIA_PREFIX} key, a same-origin /path, or an https:// URL`,
})
@IsString()
@Matches(MEDIA_SRC_PATTERN, { message: MEDIA_SRC_MESSAGE })
@MaxLength(500)
src!: string;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@IsString()
@MaxLength(300)
caption?: string | null;
}
export class PortalHelpSectionDto {
@ApiPropertyOptional({ description: "Stable id; generated when omitted" })
@IsOptional()
@IsString()
@MaxLength(64)
id?: string;
@ApiProperty()
@IsString()
@MinLength(1)
@MaxLength(200)
heading!: string;
@ApiProperty({ description: "Markdown" })
@IsString()
@MaxLength(20_000)
body!: string;
@ApiProperty({ type: [PortalMediaDto] })
@IsArray()
@ArrayMaxSize(12)
@ValidateNested({ each: true })
@Type(() => PortalMediaDto)
media!: PortalMediaDto[];
}
export class PortalHelpContentDto {
@ApiProperty()
@IsString()
@MinLength(1)
@MaxLength(200)
title!: string;
@ApiProperty()
@IsString()
@MaxLength(500)
subtitle!: string;
@ApiProperty({ type: [PortalHelpSectionDto] })
@IsArray()
@ArrayMaxSize(40)
@ValidateNested({ each: true })
@Type(() => PortalHelpSectionDto)
sections!: PortalHelpSectionDto[];
}
/* ------------------------------- request ------------------------------- */
export class UpdateSupportDocumentDto {
@ApiProperty({
description:
"The document's whole payload. Validated against the shape for its slug.",
type: Object,
})
@IsObject()
payload!: Record<string, unknown>;
@ApiPropertyOptional({ description: "Why this change was made" })
@IsOptional()
@IsString()
@MaxLength(255)
note?: string;
}
/** Which DTO class a slug's payload is validated against on write. */
export const PAYLOAD_DTO_BY_SLUG: Record<
SupportDocSlug,
new () => object
> = {
CONTACT: PortalSupportContactDto,
HELP: PortalHelpContentDto,
FAQ: PortalFaqContentDto,
PRIVACY: PortalLegalContentDto,
TERMS: PortalLegalContentDto,
};

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