diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f8a1eb68e..b1c797dc6 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -110,6 +110,7 @@ jobs: DEPLOY_USER: tria DOCKER_BUILDKIT: "1" COMPOSE_DOCKER_CLI_BUILD: "1" + ENV_MANAGER_TOKEN: ${{ secrets.ENV_MANAGER_TOKEN }} steps: - name: Checkout @@ -135,10 +136,10 @@ jobs: ;; esac - - name: Sync environment from server + - name: Sync environment from Env manager app run: | chmod +x scripts/deploy/*.sh - ./scripts/deploy/sync-env-from-server.sh "${{ matrix.service }}" + ./scripts/deploy/sync-env-from-env-manager.sh "${{ matrix.service }}" - name: Set compose project name run: | diff --git a/.gitignore b/.gitignore index 63784b865..8fa8bca90 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,12 @@ RUNNING_LOCALLY.md # Generated per-shard compose file for the integration suite (it.mjs). integration/.it-shards.yaml +# private keys / certificates (EIMS INSA credentials and anything like them) — never commit +*.key +*.pem +*.pem.txt +*.p12 +*.pfx +*.crt +secrets/ +certs/ diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 96af26034..2c636eee4 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -76,6 +76,12 @@ SEED_EDR_ORG=true SEED_FREIGHT_STAFF=true 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_ENDPOINT=localhost MINIO_PORT=9000 @@ -126,3 +132,75 @@ EMAIL_QUEUE=email_queue # Shared secret for service-to-service calls (payment microservice <-> freight). # Required at boot; set ALLOW_UNAUTH_INTERNAL=true instead ONLY for local dev. SERVICE_AUTH_TOKEN=change-me + +# ── MoR EIMS e-invoicing (core.mor.gov.et) ───────────────────────────────── +# Disabled by default; every EIMS call fails fast with EIMS_NOT_CONFIGURED until enabled. +EIMS_ENABLED=false +EIMS_BASE_URL=https://core.mor.gov.et +EIMS_CLIENT_ID= +EIMS_CLIENT_SECRET= +EIMS_API_KEY= +EIMS_TIN= +# Source-system identity comes from the access token's systemNumber/systemType claims. +# Setting these turns them into expected-value checks: a mismatch against the token fails +# fast rather than one side silently winning. Leave empty to take the gateway's word. +EIMS_SYSTEM_NUMBER= +EIMS_SYSTEM_TYPE= +# Absolute paths to the INSA-issued credentials. Keep them OUTSIDE the repo; the file +# patterns are gitignored, but a path outside the working tree is safer still. +# The certificate is transmitted as base64 of this file's exact bytes — do not convert it. +EIMS_PRIVATE_KEY_PATH= +EIMS_CERTIFICATE_PATH= +# Optional tuning +EIMS_HTTP_TIMEOUT_MS=30000 +EIMS_TOKEN_SKEW_SECONDS=45 + +# ── EIMS invoice registration (required only to register invoices) ───────── +# Seller identity: EDR's own legal details are not modelled anywhere in the DB. +# Region and Wereda are MoR *codes* (e.g. 13 / 574), not names. +EIMS_SELLER_LEGAL_NAME= +EIMS_SELLER_VAT_NUMBER= +EIMS_SELLER_PHONE= +EIMS_SELLER_EMAIL= +EIMS_SELLER_REGION= +EIMS_SELLER_WEREDA= +# Optional seller address parts; sent as null when unset. +EIMS_SELLER_CITY= +EIMS_SELLER_SUBCITY= +EIMS_SELLER_HOUSE_NUMBER= +EIMS_SELLER_LOCALITY= +# Tax treatment — REQUIRES FINANCE SIGN-OFF. The application models no tax at all +# (invoice.taxAmount is always 0), so nothing here is defaulted: registration fails +# locally, naming the missing variables, until these are set. +# 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 diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index c4837c8a7..fb6a0bd31 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -37,7 +37,8 @@ "iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert", "iam:migration:show": "pnpm run iam:typeorm:cli migration:show", "migration:run": "nest build && node dist/scripts/migrate.js", - "script": "ts-node -r tsconfig-paths/register src/scripts/main.ts" + "script": "ts-node -r tsconfig-paths/register src/scripts/main.ts", + "eims:login": "ts-node -r tsconfig-paths/register src/scripts/eims-login.ts" }, "dependencies": { "@edr/api-common": "workspace:*", diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index c98990d25..1e0908da6 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -23,6 +23,7 @@ import databaseConfig from "./config/database.config"; import telebirrConfig from "./config/telebirr.config"; import rabbitmqConfig from "./config/rabbitmq.config"; import faydaConfig from "./config/fayda.config"; +import eimsConfig from "./config/eims.config"; import { BookingsModule } from "./modules/bookings/bookings.module"; import { ContractsModule } from "./modules/contracts/contracts.module"; @@ -49,6 +50,7 @@ import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-up import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module"; import { ExchangeSettingsModule } from "./modules/exchange-settings/exchange-settings.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 { HealthModule } from "./modules/health/health.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 { PricingDataSeeder } from "./seed/pricing-data.seeder"; import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder"; +import { SupportContentSeeder } from "./seed/support-content.seeder"; // import { YardFacilitiesSeeder } from "./seed/yard-facilities.seeder"; // import { IndodeFacilitySeeder } from "./seed/indode-facility.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 { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder"; import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder"; +import { FreightNotificationPermissionsSeeder } from "./seed/freight-notification-permissions.seeder"; // import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder"; // import { GovCompaniesSeeder } from "./seed/gov-companies.seeder"; import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder"; @@ -83,6 +87,7 @@ import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-d //New Trains, Wagons, Container and Cargo management modules import { TrainsModule } from "./modules/trains/trains.module"; import { VerifaydaModule } from "./modules/verifayda/verifayda.module"; +import { EimsModule } from "./modules/eims/eims.module"; import { FleetHistoryModule } from "./modules/fleet-history/fleet-history.module"; import { WagonsModule } from "./modules/wagons/wagons.module"; import { ContainersModule } from "./modules/container-management/containers.module"; @@ -126,6 +131,7 @@ if (!process.env.APPLICATION_NAME) { telebirrConfig, rabbitmqConfig, faydaConfig, + eimsConfig, ], }), ScheduleModule.forRoot(), @@ -216,6 +222,7 @@ if (!process.env.APPLICATION_NAME) { DropdownSettingsModule, ExchangeSettingsModule, ContractTemplatesModule, + SupportContentModule, OtpModule, HealthModule, RuleEngineModule, @@ -247,6 +254,7 @@ if (!process.env.APPLICATION_NAME) { InterchangeDocumentsModule, ImportOperationsModule, VerifaydaModule, + EimsModule, FleetHistoryModule, AiModule, AuditModule, @@ -255,8 +263,10 @@ if (!process.env.APPLICATION_NAME) { EdrOrgSeeder, FreightPositionsSeeder, FileUploadSettingsSeeder, + SupportContentSeeder, // YardFacilitiesSeeder, FreightPermissionKeyMigrationSeeder, + FreightNotificationPermissionsSeeder, // Disabled seeds — providers commented out (imports/injection/run too): // DemoUsersSeeder, // FreightStaffUsersSeeder, @@ -285,8 +295,10 @@ export class AppModule implements OnApplicationBootstrap { private readonly edrOrgSeeder: EdrOrgSeeder, private readonly freightPositionsSeeder: FreightPositionsSeeder, private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder, + private readonly supportContentSeeder: SupportContentSeeder, // private readonly yardFacilitiesSeeder: YardFacilitiesSeeder, private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder, + private readonly freightNotificationPermissionsSeeder: FreightNotificationPermissionsSeeder, // Disabled seeds — injections commented out (imports/provider/run too): // private readonly demoUsersSeeder: DemoUsersSeeder, // private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder, @@ -328,10 +340,24 @@ export class AppModule implements OnApplicationBootstrap { await this.edrOrgSeeder.run(); await this.iamBaselineSeeder.run(); await this.freightPositionsSeeder.run(); + // freightNotificationPermissions → seeds the :get_notification + // keys and backfills them onto whoever + // already holds each desk's anchor + // permission. Runs LAST in this block so + // it sees a freshly-seeded catalog and + // freshly-seeded positions. Unlike the + // seeders above it is NOT gated behind + // SEED_EDR_ORG — without it every staff + // notification resolves to no one. + await this.freightNotificationPermissionsSeeder.run(); // File upload settings — keep enabled. await this.fileUploadSettingsSeeder.run(); + // 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, // Dire Dawa). Idempotent; creates no yards. // await this.yardFacilitiesSeeder.run(); diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index 49bd31c61..d1b5364c3 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -95,9 +95,14 @@ export const TrainSchedulingRulesManage = () => * wagons:delete, …). The legacy coarse fleet:view / fleet:manage keys remain * valid as a one-of fallback so existing role grants keep working. */ -export const FleetView = (granular?: string) => +export const FleetView = (granular?: string | string[]) => BookingStaff( - granular ? [granular, FREIGHT_PERMS.fleet.view] : FREIGHT_PERMS.fleet.view, + granular + ? [ + ...(Array.isArray(granular) ? granular : [granular]), + FREIGHT_PERMS.fleet.view, + ] + : FREIGHT_PERMS.fleet.view, ); export const FleetManage = (granular?: string) => diff --git a/apps/edr-freight-api/src/common/export-received-gate.spec.ts b/apps/edr-freight-api/src/common/export-received-gate.spec.ts index 6aaa24a26..62e7c758c 100644 --- a/apps/edr-freight-api/src/common/export-received-gate.spec.ts +++ b/apps/edr-freight-api/src/common/export-received-gate.spec.ts @@ -36,4 +36,27 @@ describe('assertExportReceivedWithGrn', () => { assertExportReceivedWithGrn(db([]), { id: 'b-1', tradeDirection: 'DOMESTIC' }), ).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); + }); }); diff --git a/apps/edr-freight-api/src/common/export-received-gate.ts b/apps/edr-freight-api/src/common/export-received-gate.ts index 0e1728800..5fd97480a 100644 --- a/apps/edr-freight-api/src/common/export-received-gate.ts +++ b/apps/edr-freight-api/src/common/export-received-gate.ts @@ -5,8 +5,15 @@ import type { DataSource, EntityManager } from 'typeorm'; export interface ExportLoadGateBooking { id: string; 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 * 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 or any later stage) and carries a GRN, in the column or the notes * 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( db: DataSource | EntityManager, booking: ExportLoadGateBooking, ): Promise { if (booking.tradeDirection !== 'EXPORT') return; + if (booking.exportHandoverMode === DIRECT_TO_TRAIN) return; const [row] = await db.query( `SELECT 1 diff --git a/apps/edr-freight-api/src/common/freight-permission.guard.ts b/apps/edr-freight-api/src/common/freight-permission.guard.ts index db6275c07..9cb002891 100644 --- a/apps/edr-freight-api/src/common/freight-permission.guard.ts +++ b/apps/edr-freight-api/src/common/freight-permission.guard.ts @@ -9,6 +9,7 @@ import { import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { hasFreightPermission, isSuperAdmin } from './freight-permission.util'; +import { readTwinOf } from '../seed/freight-permissions.registry'; // String literals on purpose (same reasoning as login-audience.middleware.ts): // the values are wire-format constants from iam.users.user_type, and importing @@ -22,13 +23,43 @@ const userTypeOf = (user: TCurrentUser): string | undefined => const isEmployee = (user: TCurrentUser): boolean => userTypeOf(user) === 'employee' || isSuperAdmin(user); +const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); + +/** + * Does the caller satisfy a required permission? + * + * Holding the key outright always passes. A required `:view` is ALSO + * satisfied by the weaker `:read` — the key that buys API reads + * without putting the module in the backoffice sidebar — but only on a safe + * HTTP method. + * + * The method restriction is load-bearing, not caution. Nest runs class AND + * method guards, so controllers list every route key on the class gate, + * `:view` among the write keys. Without this check a `:read` holder would + * clear that class gate and then reach any write route that has no method + * gate of its own. Keying on the HTTP verb closes that by construction rather + * than by an audit that goes stale the next time a route is added. + */ +const satisfiedBy = ( + user: TCurrentUser, + required: string, + method: string, +): boolean => { + if (hasFreightPermission(user, required)) return true; + if (!SAFE_METHODS.has(method)) return false; + const readTwin = readTwinOf(required); + return Boolean(readTwin && hasFreightPermission(user, readTwin)); +}; + export function FreightPermissionGuard( permissions: string[], ): Type { @Injectable() class FreightPermissionsGuard implements CanActivate { canActivate(context: ExecutionContext): boolean { - const request = context.switchToHttp().getRequest<{ user?: TCurrentUser }>(); + const request = context + .switchToHttp() + .getRequest<{ user?: TCurrentUser; method: string }>(); const user = request.user; if (!user) { @@ -39,7 +70,7 @@ export function FreightPermissionGuard( } if (!permissions?.length) return true; - if (permissions.some((p) => hasFreightPermission(user, p))) { + if (permissions.some((p) => satisfiedBy(user, p, request.method))) { return true; } @@ -79,7 +110,9 @@ export function MixedAudienceGuard(permissions: string[]): Type { @Injectable() class MixedAudiencesGuard implements CanActivate { canActivate(context: ExecutionContext): boolean { - const request = context.switchToHttp().getRequest<{ user?: TCurrentUser }>(); + const request = context + .switchToHttp() + .getRequest<{ user?: TCurrentUser; method: string }>(); const user = request.user; if (!user) { @@ -93,7 +126,7 @@ export function MixedAudienceGuard(permissions: string[]): Type { } if ( !permissions?.length || - permissions.some((p) => hasFreightPermission(user, p)) + permissions.some((p) => satisfiedBy(user, p, request.method)) ) { return true; } diff --git a/apps/edr-freight-api/src/config/database.config.ts b/apps/edr-freight-api/src/config/database.config.ts index 5de529d15..af4a5b017 100644 --- a/apps/edr-freight-api/src/config/database.config.ts +++ b/apps/edr-freight-api/src/config/database.config.ts @@ -215,8 +215,11 @@ export function buildFreightMigrationDataSourceOptions(): DataSourceOptions { }; } -export default registerAs("database", (): TypeOrmModuleOptions => ({ - ...buildDataSourceOptions(), - autoLoadEntities: true, - migrationsRun: false, -})); +export default registerAs( + "database", + (): TypeOrmModuleOptions => ({ + ...buildDataSourceOptions(), + autoLoadEntities: true, + migrationsRun: false, + }), +); diff --git a/apps/edr-freight-api/src/config/eims.config.ts b/apps/edr-freight-api/src/config/eims.config.ts new file mode 100644 index 000000000..a8a929ca5 --- /dev/null +++ b/apps/edr-freight-api/src/config/eims.config.ts @@ -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; + /** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */ + buyerWeredaCodes: Record; + 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 => { + const map: Record = {}; + 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; +}); diff --git a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts index d4f0c4039..36e4e34d0 100644 --- a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts @@ -120,6 +120,8 @@ export class ContractDocumentViewModelBuilder { contract.tradeDirection, contract.freightType, contract.customsClearingEnabled, + // Bulk templates are keyed by the contract's cargo type. + (contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId, ); dynamicTemplate = dynamicSource ? { diff --git a/apps/edr-freight-api/src/migrations/3310000000000-WagonStatusLogs.ts b/apps/edr-freight-api/src/migrations/3310000000000-WagonStatusLogs.ts new file mode 100644 index 000000000..10c3d4799 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3310000000000-WagonStatusLogs.ts @@ -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 { + 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 { + await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_status_logs`); + } +} diff --git a/apps/edr-freight-api/src/migrations/3320000000000-BulkContractTemplates.ts b/apps/edr-freight-api/src/migrations/3320000000000-BulkContractTemplates.ts new file mode 100644 index 000000000..39a10ca90 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3320000000000-BulkContractTemplates.ts @@ -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 { + 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__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 { + 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 + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3330000000000-EimsInvoiceRegistration.ts b/apps/edr-freight-api/src/migrations/3330000000000-EimsInvoiceRegistration.ts new file mode 100644 index 000000000..1ff9bab35 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3330000000000-EimsInvoiceRegistration.ts @@ -0,0 +1,73 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * EIMS registration state. + * + * `freight.invoices` gains the per-invoice registration outcome: which EIMS counter the invoice + * consumed, the returned IRN, and the last failure. The partial unique index on `eims_irn` is the + * database-level guarantee that one IRN can never be recorded against two invoices, independent of + * application logic. + * + * `freight.eims_system_state` is a single row per MoR system number holding the sequence the + * gateway expects: the next `SourceSystem.InvoiceCounter` and the `ReferenceDetails.PreviousIrn` + * of the last successful registration. Registration takes `FOR UPDATE` on this row, so the counter + * and the IRN chain stay consistent under concurrent submissions. + * + * The `in_flight_*` columns make a submission a *durable reservation*: the counter is consumed and + * the holder recorded in a committed transaction before the HTTP call, so a crash mid-flight leaves + * evidence instead of silently freeing the slot for a blind resubmission. `blocked_reason` is set + * when a submission ends ambiguously (timeout, network, 5xx) — the IRN is unknown, so every later + * document for this system number would chain to a stale `PreviousIrn` and registration stops until + * a human resolves it. + * + * `eims_ack_date` is varchar, not timestamptz: EIMS returns a Java ZonedDateTime string + * ("2025-03-21T08:33:32.707753413Z[Etc/UTC]") that no JS date parser accepts. It is stored + * verbatim so a compliance value is never mangled by a parse. + */ +export class EimsInvoiceRegistration3330000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.invoices + ADD COLUMN IF NOT EXISTS eims_status varchar(20) NOT NULL DEFAULT 'NOT_SUBMITTED', + ADD COLUMN IF NOT EXISTS eims_irn varchar(64), + ADD COLUMN IF NOT EXISTS eims_invoice_counter bigint, + ADD COLUMN IF NOT EXISTS eims_submitted_at timestamptz, + ADD COLUMN IF NOT EXISTS eims_ack_date varchar(64), + ADD COLUMN IF NOT EXISTS eims_last_error jsonb + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS ux_invoices_eims_irn + ON freight.invoices (eims_irn) WHERE eims_irn IS NOT NULL + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.eims_system_state ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + system_number varchar(32) NOT NULL UNIQUE, + next_invoice_counter bigint NOT NULL DEFAULT 1, + previous_irn varchar(64), + in_flight_invoice_id uuid, + in_flight_counter bigint, + blocked_reason text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.eims_system_state`); + await queryRunner.query(`DROP INDEX IF EXISTS freight.ux_invoices_eims_irn`); + await queryRunner.query(` + ALTER TABLE freight.invoices + DROP COLUMN IF EXISTS eims_status, + DROP COLUMN IF EXISTS eims_irn, + DROP COLUMN IF EXISTS eims_invoice_counter, + DROP COLUMN IF EXISTS eims_submitted_at, + DROP COLUMN IF EXISTS eims_ack_date, + DROP COLUMN IF EXISTS eims_last_error + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3340000000000-EimsDocumentNumberSequence.ts b/apps/edr-freight-api/src/migrations/3340000000000-EimsDocumentNumberSequence.ts new file mode 100644 index 000000000..481b7489c --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3340000000000-EimsDocumentNumberSequence.ts @@ -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 { + 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 { + 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 + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3350000000000-SupportContent.ts b/apps/edr-freight-api/src/migrations/3350000000000-SupportContent.ts new file mode 100644 index 000000000..168ee2c6d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3350000000000-SupportContent.ts @@ -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 { + 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 { + await queryRunner.query( + `DROP TABLE IF EXISTS freight.support_document_versions;`, + ); + await queryRunner.query(`DROP TABLE IF EXISTS freight.support_documents;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/3360000000000-SupportHelpSections.ts b/apps/edr-freight-api/src/migrations/3360000000000-SupportHelpSections.ts new file mode 100644 index 000000000..5220d3a6c --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3360000000000-SupportHelpSections.ts @@ -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 { + const rows: { id: string; version: number; payload: Record }[] = + 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 { + // no-op + } +} diff --git a/apps/edr-freight-api/src/migrations/3370000000000-BookingExportHandoverMode.ts b/apps/edr-freight-api/src/migrations/3370000000000-BookingExportHandoverMode.ts new file mode 100644 index 000000000..42acfc8f9 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3370000000000-BookingExportHandoverMode.ts @@ -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 { + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS export_handover_mode varchar(20) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings DROP COLUMN IF EXISTS export_handover_mode + `); + } +} diff --git a/apps/edr-freight-api/src/modules/auth/list-users.service.ts b/apps/edr-freight-api/src/modules/auth/list-users.service.ts index cf7e22be2..c598e105a 100644 --- a/apps/edr-freight-api/src/modules/auth/list-users.service.ts +++ b/apps/edr-freight-api/src/modules/auth/list-users.service.ts @@ -1,4 +1,5 @@ import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { InjectRepository } from '@nestjs/typeorm'; import { PaginatedResponse } from '@edr/types'; 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 { constructor( @InjectRepository(User) private readonly users: Repository, + private readonly config: ConfigService, ) {} findAll(query: ListUsersQueryDto): Promise> { @@ -40,6 +42,29 @@ export class ListUsersService { ]) .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('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) { qb.andWhere('user.userType = :userType', { userType: query.userType }); } diff --git a/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts b/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts index 49384922e..f47b5e8b1 100644 --- a/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts +++ b/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts @@ -46,7 +46,8 @@ export class BackofficeService { /** * IAM user ids of every current employee across all organizations — used by - * the notification recipients resolver's `allBackoffice` selector. + * support chat for staff room membership. Notifications deliberately do NOT + * use this: they target a desk via `getEmployeeUserIdsByPermission`. */ async getAllCurrentEmployeeUserIds(): Promise { const employees = await this.employeeRepository.find({ @@ -63,24 +64,67 @@ export class BackofficeService { * IAM user ids of current employees (any org) holding ANY of the given * permission keys — used by the notification recipients resolver's * `permissionKeys` selector for department/role-scoped targeting. + * + * This MUST agree with the request-time guard (`hasFreightPermission`, + * common/freight-permission.util.ts), which counts four grant carriers plus + * the super_admin bypass. Counting fewer silently drops legitimate + * recipients: an earlier version joined only direct position permissions, on + * which `bookings:view` resolved to 2 users — against 21 through position + * TYPES, which is where admin-created positions actually keep their grants. + * + * Raw SQL rather than QueryBuilder because `Position.positionTypePermissions` + * declares its inverse side against PositionType, so a relation join emits + * `ptp.position_type_id = position.id` and silently matches nothing. Same + * approach as FreightMeService's position-type lookups. */ async getEmployeeUserIdsByPermission( permissionKeys: string[], ): Promise { if (!permissionKeys.length) return []; - const rows: { userId: string | null }[] = await this.employeeRepository - .createQueryBuilder("employee") - .innerJoin("employee.employeePositions", "employeePosition") - .innerJoin("employeePosition.position", "position") - .innerJoin("position.positionPermission", "positionPermission") - .innerJoin("positionPermission.permission", "permission") - .where("employee.isCurrent = :isCurrent", { isCurrent: true }) - .andWhere("permission.key IN (:...permissionKeys)", { permissionKeys }) - .select("DISTINCT employee.user_id", "userId") - .getRawMany(); - return rows - .map((r) => r.userId) - .filter((id): id is string => Boolean(id)); + const rows: { userId: string }[] = await this.dataSource.query( + `WITH target AS (SELECT id FROM iam.permissions WHERE key = ANY($1)) + -- 1. IAM role grants (user_roles -> role_permissions). + SELECT e.user_id AS "userId" + FROM iam.employees e + JOIN iam.user_roles ur ON ur.user_id = e.user_id + JOIN iam.role_permissions rp ON rp.role_id = ur.role_id + WHERE e.is_current AND e.user_id IS NOT NULL + AND rp.permission_id IN (SELECT id FROM target) + UNION + -- 2. Direct position grants. A delegate keeps their own position AND + -- gains the one they stand in for, so both columns count. + SELECT e.user_id + FROM iam.employees e + JOIN iam.employee_positions ep + ON ep.employee_id = e.id AND ep.is_current + JOIN iam.position_permissions pp + ON pp.position_id IN (ep.position_id, ep.delegatee_position_id) + WHERE e.is_current AND e.user_id IS NOT NULL + AND pp.permission_id IN (SELECT id FROM target) + UNION + -- 3. Position TYPE grants — where admin-created positions keep theirs. + SELECT e.user_id + FROM iam.employees e + JOIN iam.employee_positions ep + ON ep.employee_id = e.id AND ep.is_current + JOIN iam.positions p + ON p.id IN (ep.position_id, ep.delegatee_position_id) + JOIN iam.position_type_permissions ptp + ON ptp.position_type_id = p.position_type_id + WHERE e.is_current AND e.user_id IS NOT NULL + AND ptp.permission_id IN (SELECT id FROM target) + UNION + -- 4. super_admin passes every freight permission check, so mirror that + -- here or admins go blind on desks nobody else has been granted yet. + SELECT e.user_id + FROM iam.employees e + JOIN iam.user_roles ur ON ur.user_id = e.user_id + JOIN iam.roles r ON r.id = ur.role_id + WHERE e.is_current AND e.user_id IS NOT NULL + AND r.key = 'super_admin'`, + [permissionKeys], + ); + return rows.map((r) => r.userId); } async createOrganizationUser( diff --git a/apps/edr-freight-api/src/modules/billing/billing.controller.ts b/apps/edr-freight-api/src/modules/billing/billing.controller.ts index d72528310..5bf1448fd 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.controller.ts @@ -1,26 +1,44 @@ import { + Body, Controller, Get, Param, ParseUUIDPipe, + Post, Query, Res, + UploadedFile, + UseInterceptors, } 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 { CurrentUser } from "@edr/api-common"; import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; import { BookingStaff } from "../../common/booking-guards"; +import { resolveAuthUserId } from "../../common/resolve-auth-user-id"; 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 { BillingService } from "./billing.service"; import { FilterInvoiceDto } from "./dto/filter-invoice.dto"; @ApiTags("billing") @Controller("billing") -@BookingStaff(FREIGHT_PERMS.invoices.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@BookingStaff([ + FREIGHT_PERMS.invoices.view, + FREIGHT_PERMS.invoices.export, + FREIGHT_PERMS.invoices.confirmOffline, +]) @ApiBearerAuth() export class BillingController { constructor( @@ -51,6 +69,36 @@ export class BillingController { 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") @BookingStaff(FREIGHT_PERMS.invoices.export) @ApiOperation({ summary: "Download the sealed invoice PDF" }) diff --git a/apps/edr-freight-api/src/modules/billing/billing.module.ts b/apps/edr-freight-api/src/modules/billing/billing.module.ts index a67e6f6ba..7ec5c333b 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.module.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.module.ts @@ -13,6 +13,7 @@ import { InvoiceRepository } from "./invoice.repository"; import { InvoiceLineRepository } from "./invoice-line.repository"; import { PaymentModule } from "../payment/payment.module"; import { CompaniesModule } from "../companies/companies.module"; +import { FilesModule } from "../files/files.module"; @Module({ imports: [ @@ -21,6 +22,7 @@ import { CompaniesModule } from "../companies/companies.module"; CompaniesModule, DocumentsModule, UserTradeAccessModule, + FilesModule, ], controllers: [BillingController, PortalBillingController, PaymentController], providers: [BillingService, InvoiceRepository, InvoiceLineRepository], diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts index 6e25c72aa..9cc5e9315 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts @@ -79,6 +79,7 @@ describe("BillingService.generateInvoice", () => { {} as never, // payment {} as never, // companies {} as never, // invoiceDocuments + {} as never, // files ); }); @@ -140,6 +141,7 @@ describe("BillingService.markInvoiceAsPaid", () => { {} as never, // payment {} as never, // companies {} as never, // invoiceDocuments + {} as never, // files ); await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); @@ -193,6 +195,7 @@ describe("BillingService.markInvoiceAsPaid", () => { {} as never, // payment {} as never, // companies {} as never, // invoiceDocuments + {} as never, // files ); await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); @@ -236,6 +239,7 @@ describe("BillingService.settleByPaymentId", () => { {} as never, // payment {} as never, // companies {} as never, // invoiceDocuments + {} as never, // files ); return { service, mg, events }; } @@ -347,6 +351,7 @@ describe("BillingService.recordPayment", () => { {} as never, // payment {} as never, // companies {} as never, // invoiceDocuments + {} as never, // files ); 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, ); return { service, defaultManager, txManager, transaction }; }; @@ -533,6 +539,7 @@ describe("BillingService.issuePayable", () => { {} as never, {} as never, {} as never, + {} as never, ); return { service, manager }; }; @@ -622,6 +629,7 @@ describe("BillingService — CAC Bank (OTP debit)", () => { payment as never, {} as never, {} as never, + {} as never, ); return { service, repo }; }; @@ -703,6 +711,7 @@ describe("BillingService — CBE bill amounts round UP to whole birr", () => { payment as never, {} as never, {} as never, + {} as never, ); return { service, repo }; }; diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 5051afd6e..ef4b30f8c 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -12,6 +12,7 @@ import { DataSource, EntityManager, In } from "typeorm"; import { Booking } from "../bookings/entities/booking.entity"; import { CompaniesService } from "../companies/companies.service"; +import { FilesService } from "../files/files.service"; import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util"; import { PaymentService } from "../payment/payment.service"; import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto"; @@ -35,6 +36,14 @@ export interface PayInvoiceOptions { 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. */ export interface RecordPaymentInput { /** Amount settled by this payment; must be > 0. */ @@ -150,6 +159,7 @@ export class BillingService { private readonly payment: PaymentService, private readonly companies: CompaniesService, private readonly invoiceDocuments: InvoiceDocumentService, + private readonly files: FilesService, ) { } // ── Reads ────────────────────────────────────────────────────────────────── @@ -214,6 +224,146 @@ export class BillingService { 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 { + 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. */ async findById(id: string): Promise { const invoice = await this.invoices.findById(id, { diff --git a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts new file mode 100644 index 000000000..fa0876310 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts @@ -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 => ({ + invoiceNumber: "INV-20260807-00042", + currency: "ETB", + issuedAt: new Date(2026, 7, 7, 9, 5, 3), + totalAmount: "11000.00", + company: { + name: "ABC Trading PLC", + tin: "0999930000", + vatNumber: "123475885858", + phone: "0912345678", + email: "buyer@abc.et", + region: "13", + zone: "SHA", + woreda: "574", + kebele: "03", + houseNo: "NEW", + country: "Ethiopia", + }, + lines: [ + { chargeType: "RAIL_FREIGHT", description: "Addis → Djibouti", quantity: "1.00", unitRate: "10000.00", amount: "10000.00" }, + { chargeType: "HAZARD_SURCHARGE", description: null, quantity: "2.00", unitRate: "500.00", amount: "1000.00", metadata: { unit: "CTR" } }, + ], + ...over, +}); + +const context = (over: Partial = {}): EimsMapperContext => ({ + systemNumber: "B0360154BA", + systemType: "SYS", + documentNumber: "24", + invoiceCounter: 7, + previousIrn: "", + cashierName: null, + salesPersonName: null, + transactionType: "B2B", + payment: { mode: "CASH", term: "IMMIDIATE" }, + taxForLine: () => ({ code: "VAT15", ratePercent: 15, exciseTaxValue: 0 }), + natureOfSupplies: "Service", + unitDefault: "PCS", + incomeWithholdValue: 0, + transactionWithholdValue: 0, + 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"); + }); +}); diff --git a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts new file mode 100644 index 000000000..0c4b40829 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts @@ -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 | 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; + /** + * 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; + 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, + 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, + }; +} diff --git a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts index 23c332f80..411d15ebe 100644 --- a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts +++ b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts @@ -1,6 +1,7 @@ import { BaseEntity } from "@edr/api-common"; import { Freight } from "@edr/types"; import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm"; +import type { EimsInvoiceError, EimsInvoiceStatus } from "../../eims/eims-registration.types"; import { PaymentEntity } from "../../payment/entities/payment.entity"; import { Company } from "../../companies/entities/company.entity"; import { CompanyProfile } from "../../companies/entities/company-profile.entity"; @@ -105,4 +106,31 @@ export class Invoice extends BaseEntity { @Column({ name: "due_at", type: "timestamptz" }) dueAt!: Date; + + /** MoR EIMS registration state. Set only by the EIMS module; billing never writes these. */ + @Column({ name: "eims_status", type: "varchar", length: 20, default: "NOT_SUBMITTED" }) + eimsStatus!: EimsInvoiceStatus; + + /** Invoice Reference Number returned by EIMS. Unique across invoices (partial index). */ + @Column({ name: "eims_irn", type: "varchar", length: 64, nullable: true }) + eimsIrn?: string | null; + + /** The 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; } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.spec.ts index 2c21d804c..9a5960295 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.spec.ts @@ -1,5 +1,6 @@ import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service'; import type { Booking } from './entities/booking.entity'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; /** * Who hears "Operations wants changes" depends on who owns the booking. A @@ -74,3 +75,39 @@ describe('BookingLifecycleNotifierService — operation changes requested', () = expect(inbox.notify.mock.calls[0][0].recipients).toEqual({ companyId: 'co-1' }); }); }); + +/** + * Staff notifications used to go to every employee in every organization. They + * now target a desk — and the two desks are disjoint: the GL presets hold no + * bookings:view and no intake keys, so intake pings would be noise they cannot + * act on. Both branches run through the same `inAppStaff` helper, which is the + * easy place to lose the distinction again. + */ +describe('BookingLifecycleNotifierService — staff desk targeting', () => { + const booking = () => + ({ id: 'b-1', reference: 'BKG-0001', companyId: 'co-1' }) as Booking; + + let inbox: { notify: jest.Mock }; + let service: BookingLifecycleNotifierService; + + beforeEach(() => { + inbox = { notify: jest.fn().mockResolvedValue(undefined) }; + service = new BookingLifecycleNotifierService( + { directSend: jest.fn().mockResolvedValue(undefined) } as never, + inbox as never, + { query: jest.fn().mockResolvedValue([]) } as never, + ); + }); + + it('routes intake items to the booking desk and clearance items to the clearance desk', () => { + service.submittedToStaff(booking()); + service.clearanceDocsUploadedToStaff(booking()); + + expect(inbox.notify.mock.calls[0][0].recipients).toEqual({ + permissionKeys: [FREIGHT_PERMS.bookings.getNotification], + }); + expect(inbox.notify.mock.calls[1][0].recipients).toEqual({ + permissionKeys: [FREIGHT_PERMS.bookings.clearanceGetNotification], + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts index 6245195eb..90b020472 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts @@ -11,6 +11,16 @@ import { Booking } from './entities/booking.entity'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; + +/** + * Clearance items are worked by the GL desks, which hold no bookings:view and + * no intake keys — so they take their own selector rather than the booking + * desk's. Every override using this deep-links to a clearance page. + */ +const CLEARANCE_DESK = { + permissionKeys: [FREIGHT_PERMS.bookings.clearanceGetNotification], +}; /** * Customer + staff notifications for the booking lifecycle: review, clearance @@ -89,7 +99,11 @@ export class BookingLifecycleNotifierService { }); } - /** Persist + push an in-app item to every backoffice staff user. */ + /** + * Persist + push an in-app item to the booking desk — staff holding + * `bookings:get_notification`. Callers whose item belongs to a different desk + * override `recipients` (see {@link CLEARANCE_DESK}). + */ private inAppStaff( b: Booking, title: string, @@ -97,7 +111,7 @@ export class BookingLifecycleNotifierService { overrides: Partial = {}, ): void { void this.inbox.notify({ - recipients: { allBackoffice: true }, + recipients: { permissionKeys: [FREIGHT_PERMS.bookings.getNotification] }, audience: NotificationAudience.BACKOFFICE, type: NotificationType.REQUEST_SUBMITTED, title, @@ -274,6 +288,7 @@ export class BookingLifecycleNotifierService { `the customs declaration can be filed.${note ? ` Note: "${note}"` : ''}`; this.logger.log(`TRANSIT ASSIGNEE REQUESTED — ${this.ref(b)}`); this.inAppStaff(b, `Transit assignee needed — ${b.reference}`, msg, { + recipients: CLEARANCE_DESK, type: NotificationType.CLEARANCE_REVIEW, link: `/dashboard/gl-djibouti/clearance/${b.id}`, }); @@ -288,6 +303,7 @@ export class BookingLifecycleNotifierService { `The customs declaration can now be filed.`; this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${this.ref(b)}`); this.inAppStaff(b, `Transit assignee set — ${b.reference}`, msg, { + recipients: CLEARANCE_DESK, type: NotificationType.CLEARANCE_REVIEW, link: `/dashboard/bookings/${b.id}/clearance`, }); @@ -395,6 +411,7 @@ export class BookingLifecycleNotifierService { 'Clearance documents uploaded', `Customer uploaded clearance documents for booking ${this.ref(b)} — review them in the clearance queue.`, { + recipients: CLEARANCE_DESK, type: NotificationType.CLEARANCE_REVIEW, link: `/dashboard/bookings/${b.id}/clearance`, }, @@ -422,6 +439,7 @@ export class BookingLifecycleNotifierService { `The customer requested a change to the draft declaration on booking ${this.ref(b)}: ` + `"${note}". Send a corrected draft from the clearance page.`; this.inAppStaff(b, `Draft declaration change requested — ${this.ref(b)}`, msg, { + recipients: CLEARANCE_DESK, type: NotificationType.CLEARANCE_REVIEW, link: `/dashboard/bookings/${b.id}/clearance`, }); @@ -440,6 +458,7 @@ export class BookingLifecycleNotifierService { 'Payment slip uploaded', `Customer uploaded the ${label} payment slip for booking ${this.ref(b)}.`, { + recipients: CLEARANCE_DESK, type: NotificationType.PAYMENT_RECEIVED, link: `/dashboard/bookings/${b.id}/clearance`, }, diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts index c576f5a43..b62fdfca0 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts @@ -20,7 +20,7 @@ import { NotificationInboxService } from '../notification-inbox/notification-inb import { wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { Rate } from '../rule-engine/entities/rate.entity'; 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 { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.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 { void this.inbox.notify({ - recipients: { allBackoffice: true }, + recipients: { permissionKeys: [FREIGHT_PERMS.bookings.getNotification] }, audience: NotificationAudience.BACKOFFICE, type: NotificationType.BOOKING_STATUS, title, body, - link: `/bookings/${booking.id}`, + // The portal path `/bookings/:id` used to be sent here, which 404s in the + // dashboard. The staff view of these lives on the queue page. + link: '/dashboard/wagon-cancellations', data: { bookingId: booking.id, reference: booking.reference }, }); } diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index b7eb526a3..f10b5f021 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -77,6 +77,7 @@ import { LastMileService } from '../last-mile/last-mile.service'; import { GenerateGrnDto } from './dto/generate-grn.dto'; import { ContainerReceiptService } from './container-receipt.service'; import { SignContractDto } from './dto/sign-contract.dto'; +import { SetExportHandoverModeDto } from './dto/set-export-handover-mode.dto'; import { UpdateBookingDto } from './dto/update-booking.dto'; import { BookingWagonCancellationService } from './booking-wagon-cancellation.service'; import { @@ -757,6 +758,18 @@ export class BookingsController { 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') @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'Truck_dispatch: load selected containers onto a truck (staff)' }) diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index e4b902fe1..c96c54f9b 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -13,7 +13,7 @@ import { insertWithGeneratedReference } from '@edr/api-common'; // import { CustomersService } from '../customers/customers.service'; import { CompaniesService } from '../companies/companies.service'; 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 { FilesService } from '../files/files.service'; import { MinioService } from '../minio/minio.service'; @@ -28,7 +28,7 @@ import { EventEmitter2 } from '@nestjs/event-emitter'; import { DataSource, In } from 'typeorm'; 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 { ServiceType } from '../rule-engine/entities/service-type.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 // 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. + const isDirectExport = + booking.tradeDirection === 'EXPORT' && booking.exportHandoverMode === DIRECT_TO_TRAIN; const pendingWagons = wagons.length === 0; if (pendingWagons) { - const receivedLines: CarriageAcceptanceReceivedRow[] = - booking.tradeDirection === 'EXPORT' + // Direct truck-to-train cargo never enters the warehouse, so there is no + // 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( `SELECT inv.weight AS "allocatedWeightTons", c.container_number AS "containerNumbers" @@ -304,6 +318,15 @@ export class BookingsService { [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) { throw new BadRequestException( booking.tradeDirection === 'EXPORT' @@ -424,7 +447,10 @@ export class BookingsService { const departureStation = booking.originYard?.label ?? booking.originYard?.code ?? '-'; const arrivalStation = booking.destinationYard?.label ?? booking.destinationYard?.code ?? '-'; - const cargoName = booking.cargoType?.cargoTypeName ?? booking.cargoFreeText ?? '-'; + // Container bookings carry no cargo type or free text — name the freight type + // rather than printing a dash in the Cargo Name column. + const cargoName = + booking.cargoType?.cargoTypeName ?? booking.cargoFreeText ?? booking.freightType ?? '-'; const currency = booking.paymentCurrency ?? 'ETB'; const totalAmount = Number(booking.adjustedTotalAmount ?? booking.totalAmount) || 0; const prices = this.splitAmountAcrossWagons( @@ -467,6 +493,28 @@ export class BookingsService { ) .join(''); + // The totals belong in , not : the Chromium-less fallback + // renderer only parses tbody rows, so a silently drops every footer + // figure from the printed sheet. + const totalsRow = ` + TOT + ${wagons.length} ${pendingWagons ? 'received lines' : 'wagons'} + ${ + pendingWagons + ? 'pending marshalling' + : `full ${fullWagons} / empty ${wagons.length - fullWagons}` + } + ${num(totals.tare, 2)} + ${num(totals.length)} + ${num(totals.capacity)} + + Gross ${num(totals.tare + totals.load)} T + + + + ${money(totalAmount)} + `; + return ` @@ -490,7 +538,7 @@ export class BookingsService { th { background: #f8fafc; color: #475569; text-align: left; } th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; } .num { text-align: right; } - tfoot td { background: #f8fafc; font-weight: 700; } + tr.totals td { background: #f8fafc; font-weight: 700; } .notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; } .signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; } .line { border-top: 1px solid #334155; padding-top: 7px; font-size: 9px; color: #475569; min-height: 34px; } @@ -538,21 +586,8 @@ export class BookingsService { ${rows} + ${totalsRow} - - - ${ - pendingWagons - ? `Received lines: ${wagons.length} — wagons pending marshalling` - : `Total wagons: ${wagons.length} (full ${fullWagons} / empty ${wagons.length - fullWagons})` - } - ${num(totals.tare, 2)} - ${num(totals.length)} - ${num(totals.capacity)} - Gross weight (tare + load): ${num(totals.tare + totals.load)} T - ${money(totalAmount)} - -
@@ -1985,6 +2020,47 @@ export class BookingsService { } /** 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 { const booking = await this.bookingsRepository.findByIdWithFiles(id); if (!booking) { diff --git a/apps/edr-freight-api/src/modules/bookings/dto/set-export-handover-mode.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/set-export-handover-mode.dto.ts new file mode 100644 index 000000000..c2685cba0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/set-export-handover-mode.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 3d339fa19..dbc306e3b 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -302,6 +302,18 @@ export class Booking extends BaseEntity { @Column({ name: 'customer_truck_arrived_at', type: 'timestamptz', nullable: true }) 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 * staff after unloading. Only `true` bills the DOUBLE_HANDLING_FEE rule; diff --git a/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts b/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts index ac3adb7e8..09c9b677a 100644 --- a/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts +++ b/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts @@ -20,7 +20,14 @@ import { CargoesService } from './cargoes.service'; @ApiTags('cargoes') @Controller('cargoes') -@FleetView(FREIGHT_PERMS.cargoes.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@FleetView([ + FREIGHT_PERMS.cargoes.view, + FREIGHT_PERMS.cargoes.create, + FREIGHT_PERMS.cargoes.update, + FREIGHT_PERMS.cargoes.delete, +]) export class CargoesController { constructor(private readonly cargoesService: CargoesService) {} diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 810d4d46a..1dd28ca9c 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -421,7 +421,10 @@ export class CompaniesController { @CurrentUser() user: CurrentIamUser, @Body() dto: CompleteIdentityVerificationDto, ): Promise { - 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") @@ -434,7 +437,10 @@ export class CompaniesController { async setGmSameAsOwner( @CurrentUser() user: CurrentIamUser, ): Promise { - return this.companiesService.setGmSameAsOwner(user.id); + return this.companiesService.setGmSameAsOwner(user.id, { + email: user.email, + phoneNumber: user.phoneNumber, + }); } @Delete("identity/gm") diff --git a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts index 51dd1d61f..dd198b1c6 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts @@ -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 // shape: a sub, no contact details. it("keeps company contact details a Fayda verification never supplied", async () => { - const { service, deps } = makeService({ + const { deps } = makeService({ 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)!; expect(patch.email).toBe("account@example.com"); 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 // `gmFaydaSub`. Locking that null made generalManagerEmail required by // onboarding, hidden by the portal's link card and unwritable at once. diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index eb6b28b44..2cddb2974 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -231,41 +231,39 @@ export class CompaniesService { label: string; get: (company: Company) => unknown; }[] = [ - { - key: "tinNumber", - label: "Company TIN", - get: (c) => (c.tin && !c.tin.startsWith("D") ? c.tin : null), - }, - { key: "companyEmail", label: "Company email", get: (c) => c.email }, - { key: "companyPhone", label: "Company phone", get: (c) => c.phone }, - { key: "companyAddress", label: "Company address", get: (c) => c.address }, - { key: "fanNumber", label: "FAN number", get: (c) => c.fanNumber }, - { - key: "contactPersonName", - label: "Contact person name", - get: (c) => c.attributes?.contactPersonName, - }, - { - key: "contactPersonPhone", - label: "Contact person phone", - get: (c) => c.attributes?.contactPersonPhone, - }, - { - key: "generalManagerName", - label: "General manager name", - get: (c) => c.attributes?.generalManagerName, - }, - { - key: "generalManagerEmail", - label: "General manager email", - get: (c) => c.attributes?.generalManagerEmail, - }, - { - key: "generalManagerPhone", - label: "General manager phone", - get: (c) => c.attributes?.generalManagerPhone, - }, - ]; + { + key: "tinNumber", + label: "Company TIN", + get: (c) => (c.tin && !c.tin.startsWith("D") ? c.tin : null), + }, + { key: "companyAddress", label: "Company address", get: (c) => c.address }, + { key: "fanNumber", label: "FAN number", get: (c) => c.fanNumber }, + { + key: "contactPersonName", + label: "Contact person name", + get: (c) => c.attributes?.contactPersonName, + }, + { + key: "contactPersonPhone", + label: "Contact person phone", + get: (c) => c.attributes?.contactPersonPhone, + }, + { + key: "generalManagerName", + label: "General manager name", + get: (c) => c.attributes?.generalManagerName, + }, + { + key: "generalManagerEmail", + label: "General manager email", + get: (c) => c.attributes?.generalManagerEmail, + }, + { + key: "generalManagerPhone", + label: "General manager phone", + get: (c) => c.attributes?.generalManagerPhone, + }, + ]; /** The nationality-based document setting code for a company. */ private documentSettingCodeFor( @@ -314,8 +312,6 @@ export class CompaniesService { fanNumber: dto.fanNumber ?? null, country: dto.companyLocation ?? "Ethiopia", address: dto.companyAddress ?? null, - phone: normalizeE164(dto.companyPhone) ?? null, - email: dto.companyEmail ?? null, attributes: dto.attributes ?? null, }); @@ -492,7 +488,8 @@ export class CompaniesService { async findCompanyById(id: string): Promise { const company = await this.companiesRepo.findById(id); 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 // approval decisions on (see ResponseCompanyDto.onboardingCompleted). company.profiles = await this.profilesRepo.findByCompanyId(id); @@ -515,9 +512,7 @@ export class CompaniesService { ); } if (profile.status !== ProfileStatus.Active) { - throw new BadRequestException( - "Selected company profile is not active", - ); + throw new BadRequestException("Selected company profile is not active"); } return profile; } @@ -760,11 +755,6 @@ export class CompaniesService { }; 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) { if (!attrs[`${IDENTITY_PREFIX[subject]}FaydaSub`]) continue; keys.push(...IDENTITY_OWNED_FIELDS[subject].filter(held)); @@ -789,9 +779,6 @@ export class CompaniesService { if (dto.nationality !== undefined) companyUpdates.nationality = dto.nationality; 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) companyUpdates.country = dto.companyLocation; if (dto.companyAddress !== undefined) @@ -809,7 +796,9 @@ export class CompaniesService { if (dto.contactPersonPhone !== undefined) attrUpdates.contactPersonPhone = normalizeE164(dto.contactPersonPhone); if (dto.contactVerifiedPhone !== undefined) - attrUpdates.contactVerifiedPhone = normalizeE164(dto.contactVerifiedPhone); + attrUpdates.contactVerifiedPhone = normalizeE164( + dto.contactVerifiedPhone, + ); if (dto.generalManagerName !== undefined) attrUpdates.generalManagerName = dto.generalManagerName; if (dto.generalManagerEmail !== undefined) @@ -820,7 +809,8 @@ export class CompaniesService { if (dto.poaPhone !== undefined) attrUpdates.poaPhone = normalizeE164(dto.poaPhone); 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.licenceNumber !== undefined) @@ -857,12 +847,6 @@ export class CompaniesService { 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 // phone claims are optional, so a verification can prove the person while // 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 // nothing to return. if (attrUpdates.ownerFaydaSub) { - if (attrUpdates.ownerEmail && dto.companyEmail !== undefined) - companyUpdates.email = attrUpdates.ownerEmail; - if (attrUpdates.ownerPhone && dto.companyPhone !== undefined) + if (attrUpdates.ownerEmail) companyUpdates.email = attrUpdates.ownerEmail; + if (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). */ - async listChangeRequests( - companyId: string, - ): Promise { + async listChangeRequests(companyId: string): Promise { await this.findCompanyById(companyId); return this.changeRequestRepo.findByCompanyId(companyId); } @@ -1150,8 +1131,7 @@ export class CompaniesService { reviewerId?: string, ): Promise { const request = await this.changeRequestRepo.findById(id); - if (!request) - throw new NotFoundException(`Change request ${id} not found`); + if (!request) throw new NotFoundException(`Change request ${id} not found`); if (request.status !== ChangeRequestStatus.Pending) { throw new BadRequestException( `Change request ${id} is already ${request.status}`, @@ -1164,7 +1144,10 @@ export class CompaniesService { const snapshot = (request.snapshot ?? {}) as Partial; 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.applyLicenseChanges(request); await this.applyDocumentChanges(request); @@ -1294,7 +1277,12 @@ export class CompaniesService { ); } if (documentChanges.length > 0) { - await this.recordCompanyRevision(company, {}, submittedBy, documentChanges); + await this.recordCompanyRevision( + company, + {}, + submittedBy, + documentChanges, + ); } return uploaded; } @@ -1414,7 +1402,11 @@ export class CompaniesService { status: ChangeRequestStatus.Pending, }); if (company) { - this.companyNotifier.changeRequestSubmitted(company, existing.id, false); + this.companyNotifier.changeRequestSubmitted( + company, + existing.id, + false, + ); } } else { const history = await this.changeRequestRepo.findByCompanyId(companyId); @@ -1446,8 +1438,7 @@ export class CompaniesService { reviewerId?: string, ): Promise { const request = await this.changeRequestRepo.findById(id); - if (!request) - throw new NotFoundException(`Change request ${id} not found`); + if (!request) throw new NotFoundException(`Change request ${id} not found`); if (request.status !== ChangeRequestStatus.Pending) { throw new BadRequestException( `Change request ${id} is already ${request.status}`, @@ -1486,8 +1477,7 @@ export class CompaniesService { reviewerId?: string, ): Promise { const request = await this.changeRequestRepo.findById(id); - if (!request) - throw new NotFoundException(`Change request ${id} not found`); + if (!request) throw new NotFoundException(`Change request ${id} not found`); if (request.status !== ChangeRequestStatus.Pending) { throw new BadRequestException( `Change request ${id} is already ${request.status}`, @@ -1569,10 +1559,7 @@ export class CompaniesService { const reactivating = status === ProfileStatus.Active && existing.status === ProfileStatus.Suspended; - if ( - (status === ProfileStatus.Suspended || reactivating) && - !note?.trim() - ) { + if ((status === ProfileStatus.Suspended || reactivating) && !note?.trim()) { throw new BadRequestException( status === ProfileStatus.Suspended ? "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.Rejected; 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)) { throw new BadRequestException( "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(", "); throw new BadRequestException( `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 })); // 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([ this.fileUploadSettingsService .getByCode(documentSettingCode) @@ -2074,7 +2071,9 @@ export class CompaniesService { ? [`Upload the ${POA_DELEGATION_LABEL} for your Power of Attorney`] : []), ...(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 ? ["Verify the company owner's identity with Fayda"] @@ -2088,10 +2087,10 @@ export class CompaniesService { // verification its representative may have no way to obtain. ...((poaRequired || poaProvided) && !poaProven ? [ - identity.faydaRequired - ? "Verify your Power of Attorney's identity with Fayda" - : "Name your Power of Attorney, or verify them with Fayda", - ] + identity.faydaRequired + ? "Verify your Power of Attorney's identity with Fayda" + : "Name your Power of Attorney, or verify them with Fayda", + ] : []), ...(identity.passportRequired && !identity.owner.passportNumber ? ["Add the company owner's passport number"] @@ -2137,7 +2136,10 @@ export class CompaniesService { return new OnboardingRequirementsResponseDto({ documentSettingCode, nationality: company.nationality ?? CompanyNationality.Ethiopian, - companyInfo: { complete: missingInfo.length === 0, missingFields: missingInfo }, + companyInfo: { + complete: missingInfo.length === 0, + missingFields: missingInfo, + }, documents, licenseProfiles, poa: { @@ -2179,7 +2181,7 @@ export class CompaniesService { if (!requirements.isComplete) { throw new BadRequestException( 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); for (const cp of profiles) { 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: throw new ForbiddenException( `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: throw new ForbiddenException( `Your company account is blacklisted — you can't create ${action}. ` + - `Please contact EDR support.`, + `Please contact EDR support.`, ); default: throw new ForbiddenException( @@ -2248,8 +2253,7 @@ export class CompaniesService { switch (profile.status) { case ProfileStatus.Suspended: throw new ForbiddenException( - `Your ${role} role is suspended${ - profile.reviewNote ? ` — ${profile.reviewNote}` : "" + `Your ${role} role is suspended${profile.reviewNote ? ` — ${profile.reviewNote}` : "" }. Your other roles are unaffected. Please contact EDR support to resolve this.`, ); case ProfileStatus.Blacklisted: @@ -2258,8 +2262,7 @@ export class CompaniesService { ); case ProfileStatus.Rejected: throw new ForbiddenException( - `Your ${role} role was rejected${ - profile.reviewNote ? ` — ${profile.reviewNote}` : "" + `Your ${role} role was rejected${profile.reviewNote ? ` — ${profile.reviewNote}` : "" }. Amend and resubmit it from your settings page.`, ); default: @@ -2518,9 +2521,7 @@ export class CompaniesService { LICENSE_RESOURCE, ); return records - .filter( - (r) => r.code === LICENSE_CODE || r.code === LICENSE_PENDING_CODE, - ) + .filter((r) => r.code === LICENSE_CODE || r.code === LICENSE_PENDING_CODE) .map((r) => ({ id: r.id, name: r.name, @@ -2687,7 +2688,7 @@ export class CompaniesService { if (missing.length > 0) { throw new BadRequestException( `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) { throw new BadRequestException( `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) { throw new BadRequestException( `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( userId: string, 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 { const { company } = await this.getCompanyInfoByUserId(userId); const prefix = IDENTITY_PREFIX[dto.subject]; @@ -2769,7 +2776,8 @@ export class CompaniesService { // of this check entirely. if (dto.subject === "owner" || dto.subject === "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) { 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.`, @@ -2778,6 +2786,22 @@ export class CompaniesService { } 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 = { [`${prefix}FaydaSub`]: result.sub, [`${prefix}FaydaVerifiedAt`]: now, @@ -2785,14 +2809,12 @@ export class CompaniesService { [`${prefix}Gender`]: result.gender ?? null, // The verified payload owns the person's details from here on. ...(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 // local number ("0911223344"). Every typed phone in this service is stored // E.164, and `@IsValidPhone()` rejects anything else — so a raw claim here // becomes a value the portal reads back and cannot resubmit. - ...(result.phoneNumber - ? { [`${prefix}Phone`]: normalizeE164(result.phoneNumber) } - : {}), + ...(phone ? { [`${prefix}Phone`]: normalizeE164(phone) } : {}), ...(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 * verified badge. */ - async setGmSameAsOwner(userId: string): Promise { + 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 { const { company } = await this.getCompanyInfoByUserId(userId); const attrs = company.attributes ?? {}; 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 = { gmSameAsOwner: true, gmFaydaSub: ownerSub, gmFaydaVerifiedAt: attrs.ownerFaydaVerifiedAt ?? new Date().toISOString(), gmName: attrs.ownerName ?? null, - gmEmail: attrs.ownerEmail ?? null, - gmPhone: attrs.ownerPhone ?? null, + gmEmail: ownerEmail, + gmPhone: ownerPhone ? normalizeE164(ownerPhone) : null, gmAddress: attrs.ownerAddress ?? null, gmBirthdate: attrs.ownerBirthdate ?? null, gmGender: attrs.ownerGender ?? null, // Kept in step for the notifiers, same as a GM verification does. generalManagerName: attrs.ownerName ?? null, - generalManagerEmail: attrs.ownerEmail ?? null, - generalManagerPhone: attrs.ownerPhone ?? null, + generalManagerEmail: ownerEmail, + generalManagerPhone: ownerPhone ? normalizeE164(ownerPhone) : null, }; const updated = await this.companiesRepo.update(company.id, { @@ -2976,8 +3007,8 @@ export class CompaniesService { const snapshot = { ...(existing?.snapshot ?? {}), faydaIdentity: { - ...(((existing?.snapshot ?? {}) as Record) - .faydaIdentity ?? {}), + ...(((existing?.snapshot ?? {}) as Record).faydaIdentity ?? + {}), ...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.", ); } - return this.etradeService.extractRegistrationData(businessInfo, companyInfo); + return this.etradeService.extractRegistrationData( + businessInfo, + companyInfo, + ); } async fetchETradeData(tin: string, excludeCompanyId?: string) { @@ -3421,7 +3455,9 @@ export class CompaniesService { const tin = dto.tin ?? company.tin; const registration = await this.resolveEtradeRegistration(tin); - const fresh: Partial> = { + const fresh: Partial< + Record<(typeof ETRADE_SOURCED_FIELDS)[number], string> + > = { companyName: registration.companyName, licenceNumber: registration.licenceNumber, statusDescription: registration.statusDescription, diff --git a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts index 66f88e9f8..178ace492 100644 --- a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts @@ -11,6 +11,7 @@ import { Company, CompanyStatus } from "./entities/company.entity"; import { NotificationsService } from "../notifications/notifications.service"; import { NotificationInboxService } from "../notification-inbox/notification-inbox.service"; import { resolveCompanyNotifyPhone } from "../notifications/resolve-company-phone.util"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; /** Account statuses that lock the customer out and therefore must be told to them. */ const PUNITIVE_STATUSES: readonly CompanyStatus[] = [ @@ -175,12 +176,9 @@ export class CompanyNotifierService { // ── Backoffice-facing: work has arrived back in the review queue ──────────── /** - * Persist + push an in-app item to every backoffice staff user, deep-linked to - * the customer's detail page. - * - * The recipient resolver has no role/permission targeting (see - * `notification-recipients.service.ts`) — `allBackoffice` is the narrowest - * selector available, so marketing is reached by notifying all staff. + * Persist + push an in-app item to the customer desk — staff holding + * `customers:get_notification` — deep-linked to the customer's detail page, + * which is itself gated on `customers:view`. */ private notifyStaff( company: Company, @@ -189,7 +187,7 @@ export class CompanyNotifierService { data: Record = {}, ): void { void this.inbox.notify({ - recipients: { allBackoffice: true }, + recipients: { permissionKeys: [FREIGHT_PERMS.customers.getNotification] }, audience: NotificationAudience.BACKOFFICE, type: NotificationType.REQUEST_SUBMITTED, title, diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts index 7816572ec..d82093336 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts @@ -1,9 +1,18 @@ -import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, 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 { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; -import { IsTin } from '../../../common/validators/is-tin.validator'; +import { + IsString, + IsNotEmpty, + IsOptional, + MaxLength, + 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 { @IsEnum(ProfileType) @@ -24,17 +33,6 @@ export class CreateCompanyWithProfileDto { @MaxLength(200) companyName!: string; - @IsOptional() - @IsEmail() - @MaxLength(150) - companyEmail?: string; - - @IsOptional() - @IsString() - @MaxLength(20) - @IsValidPhone() - companyPhone?: string; - @IsOptional() @IsString() @MaxLength(32) @@ -46,7 +44,7 @@ export class CreateCompanyWithProfileDto { @IsOptional() @IsString() - @IsTin({ message: 'TIN must be exactly 10 digits' }) + @IsTin({ message: "TIN must be exactly 10 digits" }) tin?: string; @IsOptional() diff --git a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts index f0a19dad7..a70c52b5d 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts @@ -2,21 +2,19 @@ import { buildCompanyIdentityState, CompanyIdentityStateDto, } from "./complete-identity-verification.dto"; -import { Company } from '../entities/company.entity'; -import { ExternalProfile } from '../entities/external-profile.entity'; +import { Company } from "../entities/company.entity"; +import { ExternalProfile } from "../entities/external-profile.entity"; import { ChangeRequestStatus, CompanyChangeRequest, -} from '../entities/company-change-request.entity'; -import { ResponseCompanyProfileDto } from './response-company.dto'; +} from "../entities/company-change-request.entity"; +import { ResponseCompanyProfileDto } from "./response-company.dto"; export class ProfileResponseDto { companyId: string; companyName: string; companyType: string; nationality: string | null; - companyEmail: string | null; - companyPhone: string | null; companyLocation: string; companyAddress: string | null; tinNumber: string; @@ -89,8 +87,6 @@ export class ProfileResponseDto { this.companyProfiles = company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)) ?? []; - this.companyEmail = company.email ?? null; - this.companyPhone = company.phone ?? null; this.companyLocation = company.country; this.companyAddress = company.address ?? null; this.tinNumber = company.tin; @@ -128,9 +124,9 @@ export class ProfileResponseDto { const openReview = changeRequest && - (changeRequest.status === ChangeRequestStatus.Pending || - changeRequest.status === ChangeRequestStatus.Rejected || - changeRequest.status === ChangeRequestStatus.ChangesRequested) + (changeRequest.status === ChangeRequestStatus.Pending || + changeRequest.status === ChangeRequestStatus.Rejected || + changeRequest.status === ChangeRequestStatus.ChangesRequested) ? changeRequest : null; this.reviewStatus = diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts index dc7729479..214c3858a 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts @@ -6,11 +6,11 @@ import { IsEnum, IsIn, Matches, -} from 'class-validator'; -import { ETHIOPIAN_REGIONS, type EthiopianRegion } from '@edr/types'; -import { CompanyNationality } from '../entities/company.entity'; -import { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; -import { IsTin } from '../../../common/validators/is-tin.validator'; +} from "class-validator"; +import { ETHIOPIAN_REGIONS, type EthiopianRegion } from "@edr/types"; +import { CompanyNationality } from "../entities/company.entity"; +import { IsValidPhone } from "../../../common/validators/is-phone-number.validator"; +import { IsTin } from "../../../common/validators/is-tin.validator"; export class UpdateProfileDto { @IsOptional() @@ -22,17 +22,6 @@ export class UpdateProfileDto { @MaxLength(200) companyName?: string; - @IsOptional() - @IsEmail() - @MaxLength(150) - companyEmail?: string; - - @IsOptional() - @IsString() - @MaxLength(20) - @IsValidPhone() - companyPhone?: string; - @IsOptional() @IsString() @MaxLength(32) @@ -44,7 +33,7 @@ export class UpdateProfileDto { @IsOptional() @IsString() - @IsTin({ message: 'TIN must be exactly 10 digits' }) + @IsTin({ message: "TIN must be exactly 10 digits" }) tin?: string; // Ethiopian VAT registration numbers are 10 digits, the same shape as the @@ -53,7 +42,7 @@ export class UpdateProfileDto { // column may hold. @IsOptional() @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; // `fanNumber` is deliberately absent: the FAN is the Fayda number of the diff --git a/apps/edr-freight-api/src/modules/compliance/compliance.controller.ts b/apps/edr-freight-api/src/modules/compliance/compliance.controller.ts index 0e5949300..25d010253 100644 --- a/apps/edr-freight-api/src/modules/compliance/compliance.controller.ts +++ b/apps/edr-freight-api/src/modules/compliance/compliance.controller.ts @@ -11,7 +11,12 @@ import { ComplianceType } from './entities/compliance-record.entity'; @ApiTags('Vehicle Compliance') @Controller('compliance') -@BookingStaff(FREIGHT_PERMS.compliance.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@BookingStaff([ + FREIGHT_PERMS.compliance.view, + FREIGHT_PERMS.compliance.manage, +]) export class ComplianceController { constructor(private readonly complianceService: ComplianceService) {} diff --git a/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts b/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts index 579b9ee26..a9aaa73e3 100644 --- a/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts +++ b/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts @@ -17,7 +17,12 @@ import { FilterConsignmentDto } from "./dto/filter-consignment.dto"; @ApiTags("consignments") @Controller("consignments") -@FleetView(FREIGHT_PERMS.consignments.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@FleetView([ + FREIGHT_PERMS.consignments.view, + FREIGHT_PERMS.consignments.create, +]) export class ConsignmentsController { constructor(private readonly consignmentsService: ConsignmentsService) {} diff --git a/apps/edr-freight-api/src/modules/container-management/containers.controller.ts b/apps/edr-freight-api/src/modules/container-management/containers.controller.ts index 0a5e6bb0f..a209e2609 100644 --- a/apps/edr-freight-api/src/modules/container-management/containers.controller.ts +++ b/apps/edr-freight-api/src/modules/container-management/containers.controller.ts @@ -19,7 +19,14 @@ import { ContainersService } from './containers.service'; @ApiTags('containers') @Controller('containers') -@FleetView(FREIGHT_PERMS.containers.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@FleetView([ + FREIGHT_PERMS.containers.view, + FREIGHT_PERMS.containers.create, + FREIGHT_PERMS.containers.update, + FREIGHT_PERMS.containers.delete, +]) export class ContainersController { constructor(private readonly containersService: ContainersService) {} diff --git a/apps/edr-freight-api/src/modules/container-management/entities/container.entity.ts b/apps/edr-freight-api/src/modules/container-management/entities/container.entity.ts index e6fdd47ee..ec6df69c5 100644 --- a/apps/edr-freight-api/src/modules/container-management/entities/container.entity.ts +++ b/apps/edr-freight-api/src/modules/container-management/entities/container.entity.ts @@ -32,7 +32,7 @@ export class Container extends BaseEntity { type: 'varchar', nullable: true, }) -sealNumber!: string | null; + sealNumber!: string | null; @Column({ type: 'varchar', default: 'AVAILABLE' }) status!: string; // AVAILABLE, LOADED, IN_TRANSIT, MAINTENANCE, DAMAGED diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.controller.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.controller.ts index 6cba17f61..7053d224f 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.controller.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.controller.ts @@ -3,6 +3,8 @@ import { Controller, Delete, Get, + HttpCode, + HttpStatus, Param, Patch, Post, @@ -15,55 +17,85 @@ import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { ContractTemplatesService } from "./contract-templates.service"; import { CreateArticleDto, + CreateContractTemplateDto, PreviewContractTemplateDto, ReplaceArticlesDto, UpdateArticleDto, UpdateContractTemplateDto, } 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") @Controller("contract-templates") export class ContractTemplatesController { 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() - @BookingStaff([ - FREIGHT_PERMS.settings.contractTemplates.view, - FREIGHT_PERMS.settings.contractTemplates.manage, - FREIGHT_PERMS.admin, - ]) - @ApiOperation({ summary: "List the six contract document templates" }) + @BookingStaff(TEMPLATE_READ) + @ApiOperation({ summary: "List contract templates (system container + staff-created bulk)" }) list() { return this.service.list(); } - @Get(":code") + @Post() @BookingStaff([ - FREIGHT_PERMS.settings.contractTemplates.view, + FREIGHT_PERMS.settings.contractTemplates.create, FREIGHT_PERMS.settings.contractTemplates.manage, 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" }) getByCode(@Param("code") code: string) { return this.service.getByCode(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)" }) update(@Param("code") code: string, @Body() dto: UpdateContractTemplateDto) { return this.service.update(code, dto); } - @Post(":code/preview") + @Delete(":code") @BookingStaff([ - FREIGHT_PERMS.settings.contractTemplates.view, - FREIGHT_PERMS.settings.contractTemplates.manage, + FREIGHT_PERMS.settings.contractTemplates.delete, 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({ summary: "Render an HTML preview of the template against mock contract data", }) @@ -77,21 +109,21 @@ export class ContractTemplatesController { /* ------------------------- article routes ------------------------- */ @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)" }) replaceArticles(@Param("code") code: string, @Body() dto: ReplaceArticlesDto) { return this.service.replaceArticles(code, dto.articles); } @Post(":code/articles") - @BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin]) + @BookingStaff(TEMPLATE_UPDATE) @ApiOperation({ summary: "Add an article to the template" }) addArticle(@Param("code") code: string, @Body() dto: CreateArticleDto) { return this.service.addArticle(code, dto); } @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" }) updateArticle( @Param("code") code: string, @@ -102,7 +134,7 @@ export class ContractTemplatesController { } @Delete(":code/articles/:articleId") - @BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin]) + @BookingStaff(TEMPLATE_UPDATE) @ApiOperation({ summary: "Remove an article from the template" }) removeArticle( @Param("code") code: string, diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.repository.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.repository.ts index 2f4fb0117..9d50b1677 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.repository.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.repository.ts @@ -3,10 +3,8 @@ import { Injectable } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; import { Repository } from "typeorm"; -import { - ContractTemplate, - ContractTemplateCode, -} from "./entities/contract-template.entity"; +import { CargoType } from "../rule-engine/entities/cargo-type.entity"; +import { ContractTemplate } from "./entities/contract-template.entity"; @Injectable() export class ContractTemplatesRepository extends BaseRepository { @@ -17,12 +15,51 @@ export class ContractTemplatesRepository extends BaseRepository { + findByCode(code: string): Promise { return this.repository.findOne({ where: { code } }); } override findAll(): Promise { - return this.repository.find({ order: { code: "ASC" } }); + return this.repository.find({ + relations: { cargoType: true }, + order: { code: "ASC" }, + }); + } + + findByCargoCombo( + cargoTypeId: string, + withCustoms: boolean, + ): Promise { + 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 { + 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 { + return this.repository.manager + .getRepository(CargoType) + .findOne({ where: { id } }); } async saveTemplate(template: ContractTemplate): Promise { diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts index ea41b57a2..da9c849f7 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts @@ -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 { ContractRendererService } from "../../contracts/contract-renderer.service"; @@ -11,6 +16,7 @@ import { import { ContractTemplatesRepository } from "./contract-templates.repository"; import { CreateArticleDto, + CreateContractTemplateDto, PreviewContractTemplateDto, ReplaceArticleDto, UpdateArticleDto, @@ -53,13 +59,17 @@ export class ContractTemplatesService { async list(): Promise { const templates = await this.repository.findAll(); const rank = new Map(CONTRACT_TEMPLATE_CODES.map((code, i) => [code, i] as const)); - return templates.sort( - (a, b) => (rank.get(a.code) ?? 99) - (rank.get(b.code) ?? 99), - ); + // Seeded container templates first in canonical order, then staff-created + // 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 { - const template = await this.repository.findByCode(this.assertCode(code)); + const template = await this.repository.findByCode(code?.toUpperCase() ?? ""); if (!template) { 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 - * direction/freight/customs triple; null when missing or deactivated (the - * renderer then falls back to the built-in generic layout). + * Staff-created bulk template for one (cargo type, customs option) pair. + * The cargo type must have hasContractTemplate enabled and the combination + * must not already exist — the same commodity + customs pairing is edited, + * never duplicated. + */ + async create(dto: CreateContractTemplateDto): Promise { + 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 { + 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( tradeDirection?: string | null, freightType?: string | null, customsClearingEnabled?: boolean | null, + cargoTypeId?: string | null, ): Promise { + const isBulk = (freightType ?? "").toUpperCase().includes("BULK"); + if (isBulk) { + if (!cargoTypeId) return null; + return this.repository.findActiveBulkTemplate( + cargoTypeId, + Boolean(customsClearingEnabled), + ); + } const code = contractTemplateCodeFor( tradeDirection, freightType, @@ -180,16 +268,32 @@ export class ContractTemplatesService { : this.sorted(template.articles), }; - const view = this.buildMockView(template.code, dynamicTemplate); + const view = this.buildMockView(template, dynamicTemplate); 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( - code: ContractTemplateCode, + template: ContractTemplate, dynamicTemplate: ContractDynamicTemplateView, ): ContractViewModel { - const meta = getTemplateMeta(PREVIEW_TEMPLATE_KEYS[code]); - const isBulk = code.endsWith("_BULK"); + const code = template.code; + const previewKey = this.previewKeyFor(template); + const meta = getTemplateMeta(previewKey); + const isBulk = Boolean(template.cargoTypeId) || code.includes("BULK"); const now = new Date(); // Representative rate schedule so the admin preview shows the live-rate @@ -200,7 +304,7 @@ export class ContractTemplatesService { bookingId: "00000000-0000-0000-0000-000000000000", reference: "EDR/CT/2026/0042", status: "CONTRACT_READY", - templateKey: PREVIEW_TEMPLATE_KEYS[code], + templateKey: previewKey, template: { ...meta, title: dynamicTemplate.name, templateFile: "edr-dynamic.hbs" }, contractDate: now.toLocaleDateString("en-GB", { day: "numeric", @@ -275,7 +379,7 @@ export class ContractTemplatesService { } /** 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") ? "import" : 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[] { return [...(articles ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); } diff --git a/apps/edr-freight-api/src/modules/contract-templates/dto/contract-template.dto.ts b/apps/edr-freight-api/src/modules/contract-templates/dto/contract-template.dto.ts index 0ea69f262..c8911d8e5 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/dto/contract-template.dto.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/dto/contract-template.dto.ts @@ -6,12 +6,41 @@ import { IsInt, IsOptional, IsString, + IsUUID, MaxLength, Min, MinLength, ValidateNested, } 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 { @ApiPropertyOptional({ description: "Display name of the template" }) @IsOptional() diff --git a/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts b/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts index c322c20a4..af0721572 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts @@ -1,11 +1,18 @@ 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 - * customs clearing (× freight type = 8); intercity does not, because it is a - * purely domestic Ethiopian movement that crosses no border and therefore has - * no customs leg at all (× freight type = 2). + * The five seeded container templates (import/export split by customs + * clearing; intercity is domestic, crosses no border, so it has a single + * template). These are system rows: always present, never deletable. + * + * 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__(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 * those INTERCITY to match the commercial vocabulary used on the printed @@ -76,10 +83,12 @@ export function contractTemplateCodeFor( } @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 { - @Column({ name: "code", type: "varchar", length: 40, unique: true }) - code!: ContractTemplateCode; + @Column({ name: "code", type: "varchar", length: 80 }) + code!: string; @Column({ name: "name", type: "varchar", length: 200 }) name!: string; @@ -100,4 +109,20 @@ export class ContractTemplate extends BaseEntity { @Column({ name: "is_active", type: "boolean", default: true }) 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; } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 59afdb585..441544b39 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -24,7 +24,7 @@ import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto'; import { BookingInvoiceService } from '../bookings/booking-invoice.service'; import { validate20ftWeightPairing } from '../bookings/container-pairing.util'; 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 { eatDay } from '../train-scheduling/batch-window.util'; import { wagonsPerUnitForSize } from '../rule-engine/container-type.util'; diff --git a/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.spec.ts index 0551cfc7a..27cfa5fa1 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.spec.ts @@ -1,5 +1,6 @@ import { ContractExpiryService } from './contract-expiry.service'; import type { Contract } from './entities/contract.entity'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; /** * The reminder must warn each customer once, ten days out, and must never let a @@ -60,4 +61,18 @@ describe('ContractExpiryService — expiry reminder', () => { inbox.notify.mockRejectedValue(new Error('inbox down')); await expect(service.remindExpiringContracts()).resolves.toBeUndefined(); }); + + // The sweep-failure alert is staff-facing. It used to go to every employee; + // it belongs to the people who would notice expired contracts still listed + // as active, i.e. the contract desk. + it('alerts the contract desk when the sweep itself fails', async () => { + repo.expireLapsedContracts.mockRejectedValue(new Error('deadlock')); + + await service.expireLapsedContracts(); + + expect(inbox.notify).toHaveBeenCalledTimes(1); + expect(inbox.notify.mock.calls[0][0].recipients).toEqual({ + permissionKeys: [FREIGHT_PERMS.contracts.getNotification], + }); + }); }); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.ts index 1ef84c141..c570fbc0a 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.ts @@ -4,6 +4,7 @@ import { NotificationAudience, NotificationType } from '@edr/types'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { ContractsRepository } from './contracts.repository'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; /** * How many days before a contract lapses the customer is reminded. Mirrored by @@ -79,7 +80,11 @@ export class ContractExpiryService { ); try { await this.inbox.notify({ - recipients: { allBackoffice: true }, + // The people who would notice expired contracts still listed as + // active are the ones working the contract desk. + recipients: { + permissionKeys: [FREIGHT_PERMS.contracts.getNotification], + }, audience: NotificationAudience.BACKOFFICE, type: NotificationType.GENERIC, title: 'Contract expiry sweep failed', diff --git a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts index 92a313569..e3c79a742 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts @@ -11,6 +11,16 @@ import { Contract } from './entities/contract.entity'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; + +/** + * Clearance items are worked by the GL desks, which hold no intake keys — so + * they take their own selector rather than the contract desk's. Every override + * using this deep-links to a clearance or shipment-request page. + */ +const CLEARANCE_DESK = { + permissionKeys: [FREIGHT_PERMS.contracts.clearanceGetNotification], +}; /** * Customer + staff notifications for the contract lifecycle. Every customer @@ -86,7 +96,11 @@ export class ContractNotifierService { }); } - /** Persist + push an in-app item to every backoffice staff user. */ + /** + * Persist + push an in-app item to the contract desk — staff holding + * `contracts:get_notification`. Callers whose item belongs to a different + * desk override `recipients` (see {@link CLEARANCE_DESK}). + */ private inAppStaff( c: Contract, title: string, @@ -94,7 +108,7 @@ export class ContractNotifierService { overrides: Partial = {}, ): void { void this.inbox.notify({ - recipients: { allBackoffice: true }, + recipients: { permissionKeys: [FREIGHT_PERMS.contracts.getNotification] }, audience: NotificationAudience.BACKOFFICE, type: NotificationType.REQUEST_SUBMITTED, title, @@ -230,6 +244,7 @@ export class ContractNotifierService { `the customs declaration can be filed.${note ? ` Note: "${note}"` : ''}`; this.logger.log(`TRANSIT ASSIGNEE REQUESTED — ${c.reference}`); this.inAppStaff(c, `Transit assignee needed — ${c.reference}`, msg, { + recipients: CLEARANCE_DESK, type: NotificationType.CLEARANCE_REVIEW, link: `/dashboard/gl-djibouti/clearance/${c.id}`, }); @@ -248,6 +263,7 @@ export class ContractNotifierService { `The customs declaration can now be filed.`; this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${c.reference}`); this.inAppStaff(c, `Transit assignee set — ${c.reference}`, msg, { + recipients: CLEARANCE_DESK, type: NotificationType.CLEARANCE_REVIEW, link: `/dashboard/contracts/clearance/${c.id}`, }); @@ -264,6 +280,7 @@ export class ContractNotifierService { `"${note}". Review and re-advise the amount on the clearance page.`; this.logger.log(`DUTY DISPUTED — ${c.reference}`); this.inAppStaff(c, `Duty disputed on ${c.reference}`, msg, { + recipients: CLEARANCE_DESK, type: NotificationType.CLEARANCE_REVIEW, link: `/dashboard/contracts/clearance/${c.id}`, }); @@ -321,6 +338,7 @@ export class ContractNotifierService { 'Clearance documents uploaded', `Customer uploaded clearance documents for contract ${this.ref(c)} — review them in the clearance queue.`, { + recipients: CLEARANCE_DESK, type: NotificationType.CLEARANCE_REVIEW, link: `/dashboard/contracts/clearance/${c.id}`, }, @@ -334,6 +352,7 @@ export class ContractNotifierService { 'Duty slip uploaded', `Customer uploaded the duty & tax payment slip for contract ${this.ref(c)}.`, { + recipients: CLEARANCE_DESK, type: NotificationType.PAYMENT_RECEIVED, link: `/dashboard/contracts/clearance/${c.id}`, }, @@ -347,6 +366,9 @@ export class ContractNotifierService { 'New shipment request', `Shipment request ${requestRef} was filed under contract ${this.ref(c)} and awaits GL review.`, { + // GL reviews these, and the shipment-requests page is gated on + // contracts:create_booking — a key only the GL Ethiopia preset holds. + recipients: CLEARANCE_DESK, link: `/dashboard/shipment-requests/${requestId}`, data: { contractId: c.id, requestId, reference: requestRef }, }, diff --git a/apps/edr-freight-api/src/modules/contracts/contract-onetime-paid-gate.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-onetime-paid-gate.spec.ts new file mode 100644 index 000000000..0b2a16026 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-onetime-paid-gate.spec.ts @@ -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'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index 0a073c9f8..ba3e3fd8b 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -424,6 +424,8 @@ export class ContractTransitionService { contract.tradeDirection, contract.freightType, contract.customsClearingEnabled, + // Bulk templates are keyed by the contract's cargo type. + (contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId, ); if (!active) return null; return { diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 6273be365..0ff9b7c24 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -1074,16 +1074,23 @@ export class ContractsController { // ── Booking under contract (Path A customer / Path B GL ET) ──────────────── @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({ summary: 'Create a shipment booking under a contract — Path A (customer) or Path B (GL Ethiopia).', }) - createBooking( + async createBooking( @Param('id', ParseUUIDPipe) id: string, @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: // Path A (customs disabled) → customer/staff create; status checks apply. // Path B (customs enabled) → GL Ethiopia only, once clearance is ready. @@ -1096,16 +1103,25 @@ export class ContractsController { } @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({ 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.', }) - initiateBooking( + async initiateBooking( @Param('id', ParseUUIDPipe) id: string, @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( id, { contractRouteId: dto?.contractRouteId }, @@ -1115,17 +1131,24 @@ export class ContractsController { } @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({ summary: '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('bookingId', ParseUUIDPipe) bookingId: string, @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 // service checks the actor's contracts:create_booking permission. return this.contractBookingService.completeUnderContract( diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts index a7fddd816..2ab7e3d7e 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -104,15 +104,19 @@ export class ContractsRepository extends BaseRepository { .andWhere('contract.status NOT IN (:...terminal)', { terminal: TERMINAL_CONTRACT_STATUSES, }) - // A ONE_TIME contract allows a single booking, so once that booking - // exists the contract is spent and can never carry another shipment. + // A ONE_TIME contract allows a single booking, so once that booking is + // PAID the contract is spent and can never carry another shipment. // 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 // 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( `(contract.contract_kind <> 'ONE_TIME' OR NOT EXISTS ( SELECT 1 FROM freight.bookings b WHERE b.contract_id = contract.id AND b.deleted_at IS NULL + AND b.payment_status = 'PAID' ))`, ) .getMany(); diff --git a/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts b/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts index e5b6ae146..8d2be55df 100644 --- a/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts +++ b/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts @@ -24,7 +24,14 @@ import { FleetHistoryService } from '../fleet-history/fleet-history.service'; @ApiTags('drivers') @ApiBearerAuth() @Controller('drivers') -@BookingStaff(FREIGHT_PERMS.drivers.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@BookingStaff([ + FREIGHT_PERMS.drivers.view, + FREIGHT_PERMS.drivers.create, + FREIGHT_PERMS.drivers.update, + FREIGHT_PERMS.drivers.delete, +]) export class DriversController { constructor( private readonly driversService: DriversService, diff --git a/apps/edr-freight-api/src/modules/eims/dto/resolve-eims-registration.dto.ts b/apps/edr-freight-api/src/modules/eims/dto/resolve-eims-registration.dto.ts new file mode 100644 index 000000000..66896fd18 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/dto/resolve-eims-registration.dto.ts @@ -0,0 +1,25 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { IsBoolean, IsOptional, IsString, Length } from "class-validator"; + +/** + * Manual reconciliation of a submission that was never acknowledged. Exactly one of the two is + * meaningful: supply the IRN confirmed with MoR, or discard the attempt. + */ +export class ResolveEimsRegistrationDto { + @ApiPropertyOptional({ + description: "IRN confirmed in the MoR portal. Records the registration and resumes the chain.", + example: "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0", + }) + @IsOptional() + @IsString() + @Length(1, 64) + irn?: string; + + @ApiPropertyOptional({ + description: "Abandon the submission: the invoice is marked FAILED and the chain is unchanged.", + example: true, + }) + @IsOptional() + @IsBoolean() + discard?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts new file mode 100644 index 000000000..1bed1fe29 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts @@ -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(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: (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", + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/eims/eims-auth.service.ts b/apps/edr-freight-api/src/modules/eims/eims-auth.service.ts new file mode 100644 index 000000000..9e53723d0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-auth.service.ts @@ -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 { + const payload = accessToken.split(".")[1]; + if (!payload) { + throw new EimsApiException("UNKNOWN", "EIMS access token is not a JWT (no payload segment)"); + } + try { + return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as Record; + } catch (err) { + // The token itself is never included — only that its payload would not parse. + throw new EimsApiException( + "UNKNOWN", + `EIMS access token payload could not be decoded: ${(err as Error).message}`, + ); + } +} + +const claimString = (claims: Record, name: string): string => { + const value = claims[name]; + return typeof value === "string" ? value.trim() : ""; +}; + +/** Used when the gateway omits `expiresIn`; the observed value is 3600. */ +const FALLBACK_EXPIRES_IN_SECONDS = 3600; + +/** + * EIMS authentication: signed `POST /auth/login`, plus an in-memory access-token cache. + * + * Login is the one EIMS call that carries no bearer token, which is why it lives here rather than + * in the generic client. Tokens are held in memory only — never persisted, never logged, never + * returned to a frontend. + */ +@Injectable() +export class EimsAuthService { + private readonly logger = new Logger(EimsAuthService.name); + private cache: TokenCache | null = null; + private loginInFlight: Promise | null = null; + + constructor( + private readonly http: HttpService, + private readonly config: ConfigService, + private readonly signer: EimsSignerService, + ) {} + + private get cfg(): EimsConfig { + return this.config.get("eims")!; + } + + /** + * A non-expired access token, logging in if needed. Concurrent callers share one login: the + * first caller stores the in-flight promise and everyone else awaits it. + */ + async getValidAccessToken(): Promise { + if (this.cache && Date.now() < this.cache.expiresAt) { + return this.cache.accessToken; + } + if (this.loginInFlight) return this.loginInFlight; + + this.loginInFlight = this.login(); + try { + return await this.loginInFlight; + } finally { + this.loginInFlight = null; + } + } + + /** + * The source-system identity MoR issued this session, refreshing the login if needed. + * + * This is the authority for `SourceSystem.SystemNumber` / `SystemType`: the gateway stamps both + * into the access token for the authenticating credentials, so a local env value could only ever + * disagree with it. + */ + async getSessionContext(): Promise { + await this.getValidAccessToken(); + return this.cache!.session; + } + + /** Drop the cached token — called after a 401 so the next request re-authenticates. */ + invalidate(): void { + this.cache = null; + } + + /** + * Read the source-system claims out of the token, and cross-check anything configured locally. + * + * `EIMS_SYSTEM_NUMBER` / `EIMS_SYSTEM_TYPE` are optional expectations, not inputs: when set they + * are compared and a mismatch fails immediately rather than one silently winning. Registering + * under the wrong source system is not something to discover from a rejected invoice. + */ + private readSessionContext(accessToken: string, cfg: EimsConfig): EimsSessionContext { + const claims = decodeTokenClaims(accessToken); + const systemNumber = claimString(claims, "systemNumber"); + const systemType = claimString(claims, "systemType"); + + const missing = [ + !systemNumber && "systemNumber", + !systemType && "systemType", + ].filter(Boolean); + if (missing.length > 0) { + throw new EimsApiException( + "UNKNOWN", + `EIMS access token carries no ${missing.join(" or ")} claim; cannot identify the source system`, + ); + } + + const mismatches = [ + cfg.systemNumber && cfg.systemNumber !== systemNumber + ? `EIMS_SYSTEM_NUMBER=${cfg.systemNumber} but the token says ${systemNumber}` + : null, + cfg.systemType && cfg.systemType !== systemType + ? `EIMS_SYSTEM_TYPE=${cfg.systemType} but the token says ${systemType}` + : null, + ].filter(Boolean); + if (mismatches.length > 0) { + throw new EimsConfigException( + `EIMS source-system configuration disagrees with the issued token: ${mismatches.join("; ")}. ` + + "Correct the environment or the credentials — neither value is assumed to win.", + ); + } + + return { systemNumber, systemType }; + } + + private async login(): Promise { + const cfg = this.cfg; + if (!cfg.enabled) { + throw new EimsConfigException("EIMS integration is disabled; set EIMS_ENABLED=true to use it"); + } + + const request: EimsLoginRequest = { + clientId: cfg.clientId, + clientSecret: cfg.clientSecret, + apikey: cfg.apiKey, + tin: cfg.tin, + }; + const body = toSignedBody(this.signer.signRequest(request)); + + let response: EimsLoginResponse; + try { + const res = await firstValueFrom( + this.http.post(`${cfg.baseUrl}/auth/login`, body, { + headers: { "Content-Type": "application/json" }, + timeout: cfg.httpTimeoutMs, + }), + ); + response = res.data; + } catch (err) { + const mapped = toEimsApiException(err, "login"); + this.logger.error(mapped.message); + throw mapped; + } + + const accessToken = response?.data?.accessToken; + if (!accessToken) { + throw new EimsApiException("UNKNOWN", "EIMS login returned no accessToken"); + } + + const expiresIn = + Number.isFinite(response.data.expiresIn) && response.data.expiresIn > 0 + ? response.data.expiresIn + : FALLBACK_EXPIRES_IN_SECONDS; + + // TODO: implement `POST /auth/refresh-token` and hold `response.data.refreshToken`. The + // collection shows a bare `{refreshToken}` body with no envelope, but it also carries unsigned + // examples of calls that do require signing, so whether refresh must be signed is unconfirmed. + // Until MoR confirms it, an expired token just triggers a fresh login — `expiresIn` is 3600s, + // so that is one extra call an hour. + // 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; + } +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.spec.ts new file mode 100644 index 000000000..6246d5a89 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.spec.ts @@ -0,0 +1,139 @@ +import { ConfigService } from "@nestjs/config"; +import { DataSource } from "typeorm"; + +import { EimsConfig } from "../../config/eims.config"; +import { EimsAutoSubmitService } from "./eims-auto-submit.service"; +import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service"; +import { EimsInvoiceStatus } from "./eims-registration.types"; +import { eimsConfig } from "./eims-test-fixtures"; + +const INVOICE_ID = "11111111-1111-4111-8111-111111111111"; + +/** + * `query` is answered by shape: the first call is the system-state guard, the second is the + * candidate lookup. Keeps the fake honest about the order the service actually asks in. + */ +const build = ( + opts: { + cfg?: Partial; + state?: { in_flight_invoice_id?: string | null; blocked_reason?: string | null }; + candidate?: { id: string; invoiceNumber: string } | null; + register?: jest.Mock; + } = {}, +) => { + const register = + opts.register ?? + jest.fn().mockResolvedValue({ eimsStatus: EimsInvoiceStatus.Registered, eimsIrn: "IRN-1" }); + + const query = jest.fn().mockImplementation((sql: string) => { + if (sql.includes("eims_system_state")) { + return Promise.resolve( + opts.state ? [{ in_flight_invoice_id: null, blocked_reason: null, ...opts.state }] : [], + ); + } + return Promise.resolve(opts.candidate === undefined ? [] : opts.candidate ? [opts.candidate] : []); + }); + + const service = new EimsAutoSubmitService( + { query } as unknown as DataSource, + { get: () => eimsConfig({ autoSubmit: true, ...opts.cfg }) } as unknown as ConfigService, + { registerInvoiceWithEims: register } as unknown as EimsInvoiceRegistrationService, + ); + return { service, register, query }; +}; + +const candidate = { id: INVOICE_ID, invoiceNumber: "INV-20260807-00006" }; + +describe("EimsAutoSubmitService.tick", () => { + it("files the oldest eligible invoice through the registration service", async () => { + const { service, register } = build({ candidate }); + + await service.tick(); + + expect(register).toHaveBeenCalledTimes(1); + expect(register).toHaveBeenCalledWith(INVOICE_ID); + }); + + it("files nothing when EIMS_AUTO_SUBMIT is off", async () => { + const { service, register, query } = build({ cfg: { autoSubmit: false }, candidate }); + + await service.tick(); + + expect(register).not.toHaveBeenCalled(); + expect(query).not.toHaveBeenCalled(); + }); + + it("files nothing when EIMS itself is disabled, even with auto-submit on", async () => { + const { service, register, query } = build({ cfg: { enabled: false }, candidate }); + + await service.tick(); + + expect(register).not.toHaveBeenCalled(); + expect(query).not.toHaveBeenCalled(); + }); + + it("does not submit while another submission is in flight", async () => { + const { service, register } = build({ + state: { in_flight_invoice_id: "22222222-2222-4222-8222-222222222222" }, + candidate, + }); + + await service.tick(); + + expect(register).not.toHaveBeenCalled(); + }); + + it("does not submit while the system number is blocked", async () => { + const { service, register } = build({ + state: { blocked_reason: "never acknowledged" }, + candidate, + }); + + await service.tick(); + + expect(register).not.toHaveBeenCalled(); + }); + + it("does nothing when no invoice is eligible", async () => { + const { service, register } = build({ candidate: null }); + + await service.tick(); + + expect(register).not.toHaveBeenCalled(); + }); + + it("asks only for NOT_SUBMITTED invoices, so UNKNOWN and FAILED are never retried", async () => { + const { service, query } = build({ candidate }); + + await service.tick(); + + const [sql, params] = query.mock.calls.find(([s]: [string]) => s.includes("freight.invoices"))!; + expect(sql).toContain("i.eims_status = $1"); + expect(params[0]).toBe(EimsInvoiceStatus.NotSubmitted); + expect(sql).toContain("i.issued_at IS NOT NULL"); + }); + + it("survives a filing failure so the job keeps running", async () => { + const register = jest.fn().mockRejectedValue(new Error("EIMS register failed (406)")); + const { service } = build({ candidate, register }); + + await expect(service.tick()).resolves.toBeUndefined(); + expect(register).toHaveBeenCalledTimes(1); + }); + + it("does not start a second tick while one is still filing", async () => { + let release: () => void = () => {}; + const register = jest.fn().mockImplementation( + () => new Promise((resolve) => (release = () => resolve({ eimsStatus: "REGISTERED" }))), + ); + const { service } = build({ candidate, register }); + + const first = service.tick(); + await new Promise((r) => setImmediate(r)); + await service.tick(); // overlapping tick, must be a no-op + + expect(register).toHaveBeenCalledTimes(1); + release(); + await first; + }); +}); diff --git a/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.ts b/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.ts new file mode 100644 index 000000000..fb5a0d1f6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.ts @@ -0,0 +1,126 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Cron } from "@nestjs/schedule"; +import { InjectDataSource } from "@nestjs/typeorm"; +import { DataSource } from "typeorm"; + +import { EimsConfig } from "../../config/eims.config"; +import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service"; +import { EimsInvoiceStatus } from "./eims-registration.types"; + +/** + * Files issued invoices with MoR EIMS on a timer. + * + * Invoices are produced by the freight workflow rather than by a person, so this — not the manual + * endpoint — is the production path. It is a sweep rather than a hook on the eleven places an + * invoice can be created or issued, which buys three things: the workflow is untouched, the HTTP + * call is by construction outside the invoice's transaction, and an invoice missed through a crash + * or a restart is picked up on the next tick. + * + * `invoices.eims_status` is the queue — nothing new is persisted. Only `NOT_SUBMITTED` is eligible: + * `UNKNOWN` must never be retried automatically (the document may already be filed), and `FAILED` + * waits for an explicit retry policy rather than a timer's guess. + * + * Off unless **both** `EIMS_ENABLED` and `EIMS_AUTO_SUBMIT` are true. Enabling it starts filing + * real documents with the tax authority, and a registration cannot be undone from this side. + */ +@Injectable() +export class EimsAutoSubmitService { + private readonly logger = new Logger(EimsAutoSubmitService.name); + /** Guards against a tick starting while the previous one is still filing. */ + private running = false; + + constructor( + @InjectDataSource() private readonly dataSource: DataSource, + private readonly config: ConfigService, + private readonly registration: EimsInvoiceRegistrationService, + ) {} + + private get cfg(): EimsConfig { + return this.config.get("eims")!; + } + + /** + * One invoice per tick. + * + * Deliberately not a batch: each filing consumes a counter and advances the IRN chain, an + * ambiguous result blocks the system number until a human resolves it, and a misconfiguration + * should cost one rejected document rather than a burst of them. + */ + @Cron(process.env.EIMS_AUTO_SUBMIT_CRON ?? "0 */5 * * * *", { name: "eims-auto-submit" }) + async tick(): Promise { + const cfg = this.cfg; + if (!cfg.enabled || !cfg.autoSubmit) return; + if (this.running) return; + + this.running = true; + try { + // Rule of the chain: nothing may be filed while a submission is in flight or the system is + // blocked. The reservation would refuse anyway — checking first keeps the log quiet and + // avoids burning a tick on a guaranteed conflict. + const blocked = await this.systemBlockReason(); + if (blocked) { + this.logger.warn(`EIMS auto-submit paused: ${blocked}`); + return; + } + + const candidate = await this.nextCandidate(); + if (!candidate) return; + + const view = await this.registration.registerInvoiceWithEims(candidate.id); + this.logger.log( + `EIMS auto-submit: invoice ${candidate.invoiceNumber} -> ${view.eimsStatus}` + + (view.eimsIrn ? ` (IRN ${view.eimsIrn})` : ""), + ); + } catch (err) { + // Never let a filing failure kill the job. The outcome is already persisted on the invoice + // (FAILED or UNKNOWN with the gateway's own message), and a blocked system number stops the + // next tick at the guard above. + this.logger.error(`EIMS auto-submit tick failed: ${(err as Error).message}`); + } finally { + this.running = false; + } + } + + /** Why filing is currently impossible for this system number, or null when it is free. */ + private async systemBlockReason(): Promise { + const rows: { in_flight_invoice_id: string | null; blocked_reason: string | null }[] = + await this.dataSource.query( + `SELECT in_flight_invoice_id, blocked_reason + FROM freight.eims_system_state + WHERE system_number = $1 AND deleted_at IS NULL + LIMIT 1`, + [this.cfg.systemNumber], + ); + const state = rows[0]; + if (!state) return null; + if (state.blocked_reason) return state.blocked_reason; + if (state.in_flight_invoice_id) { + return `a submission for invoice ${state.in_flight_invoice_id} is still in flight`; + } + return null; + } + + /** + * Oldest never-submitted invoice that is issued, still inside MoR's document-age window, and + * carries at least one line. + */ + private async nextCandidate(): Promise<{ id: string; invoiceNumber: string } | null> { + const rows: { id: string; invoiceNumber: string }[] = await this.dataSource.query( + `SELECT i.id, i.invoice_number AS "invoiceNumber" + FROM freight.invoices i + WHERE i.eims_status = $1 + AND i.issued_at IS NOT NULL + AND i.deleted_at IS NULL + AND i.issued_at > now() - ($2 || ' days')::interval + AND EXISTS ( + SELECT 1 FROM freight.invoice_lines l + WHERE l.invoice_id = i.id AND l.deleted_at IS NULL + ) + ORDER BY i.issued_at ASC + LIMIT 1`, + [EimsInvoiceStatus.NotSubmitted, this.cfg.autoSubmitMaxAgeDays], + ); + return rows[0] ?? null; + } +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-client.service.ts b/apps/edr-freight-api/src/modules/eims/eims-client.service.ts new file mode 100644 index 000000000..610647b1a --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-client.service.ts @@ -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("eims")!; + } + + /** + * Sign `request`, POST it to `path` with a valid bearer token, and return the parsed response. + * A 401 invalidates the cached token and retries exactly once. + */ + async postSigned(path: string, request: TRequest): Promise { + return this.send(path, request, false, true); + } + + /** + * POST `request` verbatim — bearer-authenticated but **not** wrapped in a signed envelope. + * + * `/v1/verify` is the only endpoint observed to work this way: the supplied collection sends a + * raw `{"irn":"…"}` body with no `signature`/`certificate` siblings. Kept as its own entry point + * so that if the live gateway turns out to require signing after all, exactly one call site + * changes — `postSigned` is already the alternative. + */ + async postBearer(path: string, request: TRequest): Promise { + return this.send(path, request, false, false); + } + + private async send( + path: string, + request: TRequest, + isRetry: boolean, + signed: boolean, + ): Promise { + const cfg = this.cfg; + const token = await this.auth.getValidAccessToken(); + const body = signed ? toSignedBody(this.signer.signRequest(request)) : request; + + try { + const res = await firstValueFrom( + this.http.post(`${cfg.baseUrl}${path}`, body, { + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + timeout: cfg.httpTimeoutMs, + }), + ); + return res.data; + } catch (err) { + const mapped = toEimsApiException(err, `POST ${path}`); + if (mapped.kind === "AUTH" && !isRetry) { + this.logger.warn(`EIMS rejected the token on ${path}; re-authenticating once`); + this.auth.invalidate(); + return this.send(path, request, true, signed); + } + this.logger.error(mapped.message); + throw mapped; + } + } +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-credentials.provider.ts b/apps/edr-freight-api/src/modules/eims/eims-credentials.provider.ts new file mode 100644 index 000000000..b68a4a32f --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-credentials.provider.ts @@ -0,0 +1,77 @@ +import { readFileSync } from "node:fs"; +import { KeyObject, createPrivateKey } from "node:crypto"; +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { EimsConfig } from "../../config/eims.config"; +import { EimsConfigException } from "./eims.errors"; + +/** + * Loads the INSA-issued EIMS credentials from disk, once, and keeps them in memory. + * + * The certificate is sent as base64 of the **exact bytes of the issued file** — it is deliberately + * never parsed, re-encoded or re-exported, because that is what produced a working live login. + * The private key never leaves this process: it is only ever used to produce a signature. + */ +@Injectable() +export class EimsCredentialsProvider { + private readonly logger = new Logger(EimsCredentialsProvider.name); + private privateKey: KeyObject | null = null; + private certificateBase64: string | null = null; + + constructor(private readonly config: ConfigService) {} + + private get cfg(): EimsConfig { + return this.config.get("eims")!; + } + + /** RSA private key, parsed once. Throws a config error if the path is missing or unusable. */ + getPrivateKey(): KeyObject { + if (this.privateKey) return this.privateKey; + + const path = this.cfg.privateKeyPath; + if (!path) throw new EimsConfigException("EIMS_PRIVATE_KEY_PATH is not set"); + + let key: KeyObject; + try { + key = createPrivateKey(readFileSync(path)); + } catch (err) { + // The path is operational information, not a secret; the key material never appears. + throw new EimsConfigException( + `EIMS private key at ${path} could not be read or parsed: ${(err as Error).message}`, + ); + } + if (key.asymmetricKeyType !== "rsa") { + throw new EimsConfigException( + `EIMS private key at ${path} is ${key.asymmetricKeyType ?? "of unknown type"}; EIMS requires RSA`, + ); + } + + this.privateKey = key; + this.logger.log(`EIMS private key loaded (RSA-${key.asymmetricKeyDetails?.modulusLength ?? "?"})`); + return key; + } + + /** Base64 of the certificate file's exact bytes. No parsing, no re-encoding. */ + getCertificateBase64(): string { + if (this.certificateBase64) return this.certificateBase64; + + const path = this.cfg.certificatePath; + if (!path) throw new EimsConfigException("EIMS_CERTIFICATE_PATH is not set"); + + let bytes: Buffer; + try { + bytes = readFileSync(path); + } catch (err) { + throw new EimsConfigException( + `EIMS certificate at ${path} could not be read: ${(err as Error).message}`, + ); + } + if (bytes.length === 0) { + throw new EimsConfigException(`EIMS certificate at ${path} is empty`); + } + + this.certificateBase64 = bytes.toString("base64"); + this.logger.log(`EIMS certificate bundle loaded (${bytes.length} bytes)`); + return this.certificateBase64; + } +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts new file mode 100644 index 000000000..4e7a82069 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts @@ -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, + }; +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts new file mode 100644 index 000000000..66e27da6b --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts @@ -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 => + ({ + enabled: true, + baseUrl: "https://core.mor.gov.et", + clientId: "cid", + clientSecret: "secret", + apiKey: "key", + tin: "0000034558", + systemNumber: SYSTEM_NUMBER, + systemType: "SYS", + privateKeyPath: "/dev/null", + certificatePath: "/dev/null", + httpTimeoutMs: 30_000, + tokenSkewMs: 45_000, + invoice: eimsInvoiceConfig(over), + }) as EimsConfig; + +const invoiceRow = (over: Partial = {}): Invoice => + ({ + id: INVOICE_ID, + invoiceNumber: "INV-20260807-00042", + currency: "ETB", + issuedAt: new Date(2026, 7, 7, 9, 5, 3), + totalAmount: "10000.00", + eimsStatus: EimsInvoiceStatus.NotSubmitted, + eimsIrn: null, + eimsInvoiceCounter: null, + eimsSubmittedAt: null, + eimsAckDate: null, + eimsLastError: null, + company: { + name: "ABC Trading PLC", + tin: "0999930000", + vatNumber: "123475885858", + phone: "0912345678", + email: "buyer@abc.et", + region: "13", + zone: "SHA", + woreda: "574", + kebele: "03", + houseNo: "NEW", + country: "Ethiopia", + }, + ...over, + }) as unknown as Invoice; + +const LINES = [ + { + chargeType: "RAIL_FREIGHT", + description: "Addis to Djibouti", + quantity: "1.00", + unitRate: "10000.00", + amount: "10000.00", + }, +]; + +/** + * In-memory stand-in for the two locked rows. `update` merges, `createQueryBuilder(...).getOne()` + * returns the live object — enough to assert ordering, values and the reservation lifecycle without + * a database. + */ +class FakeDb { + invoices = new Map(); + state: EimsSystemState | null = null; + /** Runs before every transaction body, to simulate a concurrent writer. */ + onTransaction: (() => void) | null = null; + + constructor(invoices: Invoice[], state?: Partial) { + for (const inv of invoices) this.invoices.set(inv.id, inv); + this.state = { + id: "state-1", + systemNumber: SYSTEM_NUMBER, + nextInvoiceCounter: 7, + 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) => { + id = params.invoiceId ?? params.systemNumber; + return builder; + }, + getOne: async () => (isInvoice ? (this.invoices.get(id!) ?? null) : this.state), + }; + return builder; + }, + findOne: async (_entity: unknown, options: { where: { id: string } }) => + this.invoices.get(options.where.id) ?? null, + update: async (entity: unknown, id: string, patch: Record) => { + if (entity === Invoice) Object.assign(this.invoices.get(id)!, patch); + else Object.assign(this.state!, patch); + }, + query: async () => [], + getRepository: () => ({ + findOne: async (options: { where: { id: string } }) => + this.invoices.get(options.where.id) ?? null, + }), + }; + + asDataSource(): DataSource { + return { + manager: this.manager, + getRepository: this.manager.getRepository, + query: async (sql: string) => + sql.includes("eims_system_state") + ? [{ in_flight_invoice_id: this.state?.inFlightInvoiceId ?? null }] + : LINES, + transaction: async (body: (m: unknown) => Promise) => { + 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 = {}) => ({ + 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); + }); +}); diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts new file mode 100644 index 000000000..64854bf53 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts @@ -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("eims")!; + } + + async registerInvoiceWithEims(invoiceId: string): Promise { + const cfg = this.cfg; + // Static seller/tax configuration is validated before anything is locked, allocated or sent. + assertEimsInvoiceConfig(cfg); + + const invoice = await this.loadInvoiceForMapping(invoiceId); + if (invoice.eimsIrn) return this.toView(invoice); + + // 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 { + const invoice = await this.loadInvoiceRow(this.dataSource.manager, invoiceId); + if (!invoice.eimsIrn) { + throw new BadRequestException({ + code: "EIMS_NO_IRN", + message: + `Invoice ${invoice.invoiceNumber} has no EIMS IRN to verify (status ${invoice.eimsStatus}). ` + + "EIMS can only be queried by IRN, so an unacknowledged submission must be resolved with MoR first.", + }); + } + return this.queryVerify(invoice.eimsIrn); + } + + /** + * `POST /v1/verify` for one IRN, with the one check that always applies: the gateway must echo + * an `Irn` back. A 200 without it is not a confirmation of anything. + * + * The request property is lowercase `irn`; the response spells it `Irn`. The two are never + * compared — the supplied collection's own fixture uses different example values on each side, + * so equality there would assert a property of the mock rather than of the gateway. + * + * Bearer-authenticated but unsigned, via `postBearer` — see that method for why. + */ + private async queryVerify(irn: string): Promise { + const response = await this.client.postBearer( + "/v1/verify", + { irn }, + ); + if (!response?.body?.Irn?.trim()) { + throw new EimsApiException( + "SCHEMA_VALIDATION", + "EIMS verify returned no Irn in its response body", + response?.statusCode, + ); + } + return response; + } + + /** + * Refuse a manual resolution unless the gateway 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 { + 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 { + 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 { + return this.toView(await this.loadInvoiceRow(this.dataSource.manager, invoiceId)); + } + + // ── transactions ───────────────────────────────────────────────────────────────────────────── + + /** + * TX1. Consume a counter and record the holder, committed before any HTTP call. Returns `null` + * when the invoice turned out to be registered already (checked under the lock). + */ + private async reserve(invoiceId: string, systemNumber: string): Promise { + return this.dataSource.transaction(async (manager) => { + const state = await this.lockSystemState(manager, systemNumber); + + if (state.blockedReason) { + throw new ConflictException({ + code: "EIMS_SYSTEM_BLOCKED", + message: + `EIMS registration is blocked for system ${systemNumber}: ${state.blockedReason}. ` + + "Resolve the affected invoice before registering anything else.", + }); + } + if (state.inFlightInvoiceId) { + throw new ConflictException({ + code: "EIMS_SUBMISSION_IN_FLIGHT", + message: + `A submission for invoice ${state.inFlightInvoiceId} is already in flight on system ` + + `${systemNumber}. Wait for it to settle, or resolve it if the process was interrupted.`, + }); + } + + const invoice = await this.lockInvoice(manager, invoiceId); + if (invoice.eimsIrn) return null; + + const invoiceCounter = Number(state.nextInvoiceCounter); + const 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 { + 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 { + const api = err instanceof EimsApiException ? err : null; + const deterministic = api ? DETERMINISTIC_KINDS.has(api.kind) : false; + const status = deterministic ? EimsInvoiceStatus.Failed : EimsInvoiceStatus.Unknown; + const lastError: EimsInvoiceError = { + kind: api?.kind ?? "UNKNOWN", + message: (err as Error)?.message ?? "unknown error", + httpStatus: api?.httpStatus, + details: api?.details, + at: new Date().toISOString(), + }; + + await this.dataSource.transaction(async (manager) => { + await manager.update(Invoice, invoiceId, { + eimsStatus: status, + eimsLastError: lastError, + } as QueryDeepPartialEntity); + + await manager.update( + EimsSystemState, + reservation.stateId, + deterministic + ? { + // 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 { + 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( + "/v1/register", + request, + ); + const irn = response?.body?.irn; + if (!irn) { + // The gateway answered, so this is deterministic: the document is not registered. + throw new EimsApiException( + "SCHEMA_VALIDATION", + `EIMS register returned no IRN${response?.body?.errorMessage ? `: ${response.body.errorMessage}` : ""}`, + response?.statusCode, + ); + } + return { irn, ackDate: response.body?.ackDate }; + } + + private async lockInvoice(manager: EntityManager, invoiceId: string): Promise { + const invoice = await manager + .createQueryBuilder(Invoice, "invoice") + .setLock("pessimistic_write") + .where("invoice.id = :invoiceId", { invoiceId }) + .getOne(); + if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`); + return invoice; + } + + /** Locks the system-state row, creating it on first use. */ + private async lockSystemState( + manager: EntityManager, + systemNumber: string, + ): Promise { + const select = () => + manager + .createQueryBuilder(EimsSystemState, "state") + .setLock("pessimistic_write") + .where("state.system_number = :systemNumber", { systemNumber }) + .getOne(); + + const existing = await select(); + if (existing) return existing; + + await manager.query( + `INSERT INTO freight.eims_system_state (system_number) VALUES ($1) + ON CONFLICT (system_number) DO NOTHING`, + [systemNumber], + ); + const created = await select(); + if (!created) throw new Error(`Could not initialise EIMS system state for ${systemNumber}`); + return created; + } + + /** Header + buyer + lines — everything the mapper needs. */ + private async loadInvoiceForMapping( + invoiceId: string, + ): Promise { + const invoice = await this.dataSource.getRepository(Invoice).findOne({ + where: { id: invoiceId }, + relations: { company: true, companyProfile: true }, + }); + if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`); + + const lines: EimsMapperLine[] = await this.dataSource.query( + `SELECT charge_type AS "chargeType", description, quantity, unit_rate AS "unitRate", + amount, currency, metadata + FROM freight.invoice_lines + WHERE invoice_id = $1 AND deleted_at IS NULL + ORDER BY created_at ASC`, + [invoiceId], + ); + return Object.assign(invoice, { lines }); + } + + private async loadInvoiceRow(manager: EntityManager, invoiceId: string): Promise { + const invoice = await manager.findOne(Invoice, { where: { id: invoiceId } }); + if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`); + return invoice; + } + + private toView(invoice: Invoice): EimsInvoiceStatusView { + const counter = invoice.eimsInvoiceCounter; + return { + invoiceId: invoice.id, + invoiceNumber: invoice.invoiceNumber, + eimsStatus: invoice.eimsStatus ?? EimsInvoiceStatus.NotSubmitted, + eimsIrn: invoice.eimsIrn ?? null, + eimsDocumentNumber: invoice.eimsDocumentNumber ?? null, + eimsInvoiceCounter: counter === null || counter === undefined ? null : Number(counter), + eimsSubmittedAt: invoice.eimsSubmittedAt ?? null, + eimsAckDate: invoice.eimsAckDate ?? null, + eimsLastError: invoice.eimsLastError ?? null, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts new file mode 100644 index 000000000..f47cfe9d9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts @@ -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); + } +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts b/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts new file mode 100644 index 000000000..c4d9f3842 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-signer.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-signer.service.spec.ts new file mode 100644 index 000000000..5e408ddf7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-signer.service.spec.ts @@ -0,0 +1,118 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createVerify, generateKeyPairSync } from "node:crypto"; +import { ConfigService } from "@nestjs/config"; +import { EimsCredentialsProvider } from "./eims-credentials.provider"; +import { EimsSignerService, toSignedBody } from "./eims-signer.service"; + +/** + * Test-only key material: generated per run, never a production key. The "certificate" fixture is + * an arbitrary byte blob — the point is that its exact bytes survive base64 round-tripping, not + * that it is a valid X.509 chain. + */ +const CERTIFICATE_FIXTURE = "Subject: CN=TEST\n-----BEGIN CERTIFICATE-----\nZm9vYmFy\n-----END CERTIFICATE-----\n"; + +let dir: string; +let keyPath: string; +let certPath: string; +let publicKeyPem: string; +let signer: EimsSignerService; + +beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), "eims-signer-")); + keyPath = join(dir, "private_key.key"); + certPath = join(dir, "certificate.pem.txt"); + + const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + writeFileSync(keyPath, privateKey.export({ type: "pkcs8", format: "pem" })); + writeFileSync(certPath, CERTIFICATE_FIXTURE, "utf8"); + publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString(); + + const config = { + get: () => ({ privateKeyPath: keyPath, certificatePath: certPath }), + } as unknown as ConfigService; + signer = new EimsSignerService(new EimsCredentialsProvider(config)); +}); + +afterAll(() => rmSync(dir, { recursive: true, force: true })); + +const login = () => ({ clientId: "cid", clientSecret: "secret", apikey: "key", tin: "0000000000" }); + +const verify = (payload: string, signature: string): boolean => + createVerify("RSA-SHA512").update(payload, "utf8").verify(publicKeyPem, signature, "base64"); + +describe("EimsSignerService", () => { + it("produces a signature that verifies against the matching public key", () => { + const signed = signer.signRequest(login()); + expect(verify(JSON.stringify(signed.request), signed.signature)).toBe(true); + }); + + it("fails verification when a single request field changes", () => { + const signed = signer.signRequest(login()); + const tampered = JSON.stringify({ ...signed.request, tin: "9999999999" }); + expect(verify(tampered, signed.signature)).toBe(false); + }); + + it("emits a 256-byte signature for an RSA-2048 key", () => { + const signed = signer.signRequest(login()); + expect(Buffer.from(signed.signature, "base64")).toHaveLength(256); + }); + + it("sends the certificate as base64 of the file's exact bytes", () => { + const signed = signer.signRequest(login()); + expect(signed.certificate).toBe(readFileSync(certPath).toString("base64")); + expect(Buffer.from(signed.certificate, "base64").equals(readFileSync(certPath))).toBe(true); + }); + + it("signs the inner request only, and the wire body carries those exact bytes", () => { + const signed = signer.signRequest(login()); + const body = toSignedBody(signed); + + // The signed string appears verbatim inside the transmitted envelope. + expect(body).toContain(`"request":${JSON.stringify(signed.request)}`); + // Compact, never pretty-printed. + expect(body).not.toMatch(/\n/); + expect(JSON.parse(body)).toEqual({ + request: login(), + signature: signed.signature, + certificate: signed.certificate, + }); + }); + + it("does not mutate the request object", () => { + const request = login(); + const signed = signer.signRequest(request); + expect(signed.request).toBe(request); + expect(request).toEqual(login()); + }); + + it("reuses the loaded key and certificate across calls", () => { + const first = signer.signRequest(login()); + const second = signer.signRequest(login()); + // PKCS#1 v1.5 is deterministic: same key + same payload ⇒ identical signature. + expect(second.signature).toBe(first.signature); + expect(second.certificate).toBe(first.certificate); + }); +}); + +describe("EimsCredentialsProvider", () => { + const providerFor = (paths: { privateKeyPath?: string; certificatePath?: string }) => + new EimsCredentialsProvider({ get: () => paths } as unknown as ConfigService); + + it("fails clearly when the key path is unset", () => { + expect(() => providerFor({}).getPrivateKey()).toThrow(/EIMS_PRIVATE_KEY_PATH is not set/); + }); + + it("fails clearly when the key file is missing", () => { + expect(() => providerFor({ privateKeyPath: join(dir, "nope.key") }).getPrivateKey()).toThrow( + /could not be read or parsed/, + ); + }); + + it("fails clearly when the certificate file is empty", () => { + const emptyPath = join(dir, "empty.txt"); + writeFileSync(emptyPath, ""); + expect(() => providerFor({ certificatePath: emptyPath }).getCertificateBase64()).toThrow(/is empty/); + }); +}); diff --git a/apps/edr-freight-api/src/modules/eims/eims-signer.service.ts b/apps/edr-freight-api/src/modules/eims/eims-signer.service.ts new file mode 100644 index 000000000..babec6b44 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-signer.service.ts @@ -0,0 +1,37 @@ +import { createSign } from "node:crypto"; +import { Injectable } from "@nestjs/common"; +import { EimsCredentialsProvider } from "./eims-credentials.provider"; +import { EimsSignedRequest } from "./eims.types"; + +/** + * Signs EIMS request objects, reproducing the process that produced a working live access token: + * + * 1. compact `JSON.stringify` of the **inner** request object only, + * 2. those exact UTF-8 bytes, + * 3. RSA + SHA-512 (`SHA512withRSA`, PKCS#1 v1.5 — Node's default RSA padding), + * 4. base64 of the raw signature bytes (256 bytes for an RSA-2048 key), + * 5. base64 of the certificate file's exact bytes. + * + * The outer `{request, signature, certificate}` envelope is never itself signed, and the request + * object is never mutated after serialization. + */ +@Injectable() +export class EimsSignerService { + constructor(private readonly credentials: EimsCredentialsProvider) {} + + signRequest(request: T): EimsSignedRequest { + const payload = JSON.stringify(request); + const signature = createSign("RSA-SHA512") + .update(payload, "utf8") + .sign(this.credentials.getPrivateKey(), "base64"); + + return { request, signature, certificate: this.credentials.getCertificateBase64() }; + } +} + +/** + * Exact wire body for a signed envelope. Serializing here (rather than handing axios an object) + * keeps one serializer in play: the `request` segment of this string is byte-identical to the + * string that was signed. + */ +export const toSignedBody = (signed: EimsSignedRequest): string => JSON.stringify(signed); diff --git a/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts b/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts new file mode 100644 index 000000000..edd079b51 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts @@ -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 => ({ + 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 => ({ + 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 => { + const payload = { systemNumber: EIMS_SYSTEM_NUMBER, systemType: EIMS_SYSTEM_TYPE, ...claims }; + for (const [key, value] of Object.entries(payload)) { + if (value === undefined) delete (payload as Record)[key]; + } + return [ + "eyJhbGciOiJSUzI1NiJ9", + Buffer.from(JSON.stringify(payload)).toString("base64url"), + "signature", + ].join("."); +}; diff --git a/apps/edr-freight-api/src/modules/eims/eims.errors.ts b/apps/edr-freight-api/src/modules/eims/eims.errors.ts new file mode 100644 index 000000000..3be21fdd3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims.errors.ts @@ -0,0 +1,89 @@ +import { BadGatewayException, ServiceUnavailableException } from "@nestjs/common"; +import { AxiosError } from "axios"; +import { EimsErrorResponse } from "./eims.types"; + +export type EimsFailureKind = + | "NETWORK" + | "TIMEOUT" + | "SCHEMA_VALIDATION" + | "AUTH" + | "FORBIDDEN" + | "RULE_VALIDATION" + | "SERVER" + | "UNKNOWN"; + +/** Raised when EIMS is disabled or its credential files are unusable. */ +export class EimsConfigException extends ServiceUnavailableException { + constructor(message: string) { + super({ code: "EIMS_NOT_CONFIGURED", message }); + } +} + +/** + * A failed EIMS call. Carries only the gateway's own error reporting — never the request body, + * signature, certificate, bearer token or any configured secret. + */ +export class EimsApiException extends BadGatewayException { + constructor( + readonly kind: EimsFailureKind, + message: string, + readonly httpStatus?: number, + readonly details?: EimsErrorResponse, + ) { + super({ code: `EIMS_${kind}`, message }); + } +} + +const SAFE_KEYS = ["message", "statusCode", "code", "details", "body"] as const; + +/** + * Keep only the gateway's error-reporting fields. Anything else a response might carry — an echoed + * request, a token, a signature — is dropped before it can reach a log or an exception payload. + */ +export function redactEimsBody(data: unknown): EimsErrorResponse | undefined { + if (!data || typeof data !== "object") return undefined; + const source = data as Record; + const safe: Record = {}; + for (const key of SAFE_KEYS) { + if (source[key] !== undefined) safe[key] = source[key]; + } + return Object.keys(safe).length > 0 ? (safe as EimsErrorResponse) : undefined; +} + +const kindFor = (status: number): EimsFailureKind => { + if (status === 400) return "SCHEMA_VALIDATION"; + if (status === 401) return "AUTH"; + if (status === 403) return "FORBIDDEN"; + if (status === 406) return "RULE_VALIDATION"; + if (status >= 500) return "SERVER"; + return "UNKNOWN"; +}; + +/** First error line the gateway gives us, whichever shape it used. */ +const describe = (body: EimsErrorResponse | undefined): string => { + if (!body) return "no error body"; + const detail = body.details?.find((d) => d.errorMessage)?.errorMessage; + return [body.message, body.code && `code=${body.code}`, detail].filter(Boolean).join(" ") || "no error body"; +}; + +/** + * Normalise anything thrown by an EIMS HTTP call into an `EimsApiException`. `operation` is a + * short label such as `"login"` or `"POST /v1/register"` — never a payload. + */ +export function toEimsApiException(err: unknown, operation: string): EimsApiException { + if (err instanceof EimsApiException) return err; + + if (err instanceof AxiosError) { + if (err.code === "ECONNABORTED" || err.code === "ETIMEDOUT") { + return new EimsApiException("TIMEOUT", `EIMS ${operation} timed out`); + } + if (!err.response) { + return new EimsApiException("NETWORK", `EIMS ${operation} could not reach the gateway (${err.code ?? "no code"})`); + } + const status = err.response.status; + const body = redactEimsBody(err.response.data); + return new EimsApiException(kindFor(status), `EIMS ${operation} failed (${status}): ${describe(body)}`, status, body); + } + + return new EimsApiException("UNKNOWN", `EIMS ${operation} failed: ${(err as Error)?.message ?? "unknown error"}`); +} diff --git a/apps/edr-freight-api/src/modules/eims/eims.module.ts b/apps/edr-freight-api/src/modules/eims/eims.module.ts new file mode 100644 index 000000000..53d3d4090 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims.module.ts @@ -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 {} diff --git a/apps/edr-freight-api/src/modules/eims/eims.types.ts b/apps/edr-freight-api/src/modules/eims/eims.types.ts new file mode 100644 index 000000000..6e74604bd --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims.types.ts @@ -0,0 +1,46 @@ +/** + * Wire types for the MoR EIMS gateway, taken from the supplied Postman collection. + * + * Every protected payload is the same envelope: the business object under `request`, a base64 + * RSA-SHA512 signature over the *inner* object only, and the base64 certificate bundle. + */ +export interface EimsSignedRequest { + request: T; + signature: string; + certificate: string; +} + +/** Inner request of `POST /auth/login`. Note the lowercase `apikey` — that is the wire name. */ +export interface EimsLoginRequest { + clientId: string; + clientSecret: string; + apikey: string; + tin: string; +} + +export interface EimsLoginData { + accessToken: string; + refreshToken: string; + /** Observed as a UUID on login and `null` on refresh; unused today. */ + encryptionKey: string | null; + /** Seconds. Observed value: 3600. */ + expiresIn: number; +} + +export interface EimsLoginResponse { + data: EimsLoginData; + status: string; +} + +/** + * Error bodies differ per failure mode: gateway errors carry `message`/`code`/`details`, + * schema errors carry a JSON-Schema violation array under `body`, rule errors carry + * `[{portion, errorMessage[]}]` under `body`. Only these fields are ever surfaced or logged. + */ +export interface EimsErrorResponse { + message?: string; + statusCode?: number; + code?: string; + details?: { errorMessage?: string; field?: string }[]; + body?: unknown; +} diff --git a/apps/edr-freight-api/src/modules/eims/entities/eims-system-state.entity.ts b/apps/edr-freight-api/src/modules/eims/entities/eims-system-state.entity.ts new file mode 100644 index 000000000..a21057042 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/entities/eims-system-state.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/facilities/facilities.controller.ts b/apps/edr-freight-api/src/modules/facilities/facilities.controller.ts index 451c4d03b..7db35d432 100644 --- a/apps/edr-freight-api/src/modules/facilities/facilities.controller.ts +++ b/apps/edr-freight-api/src/modules/facilities/facilities.controller.ts @@ -10,7 +10,12 @@ import { FacilitiesService } from './facilities.service'; @ApiTags('Facilities') @Controller('facilities') -@BookingStaff(FREIGHT_PERMS.facilities.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@BookingStaff([ + FREIGHT_PERMS.facilities.view, + FREIGHT_PERMS.facilities.manage, +]) export class FacilitiesController { constructor(private readonly facilitiesService: FacilitiesService) {} diff --git a/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts index 8bd4a31d8..d0cc2d1b4 100644 --- a/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts +++ b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts @@ -19,7 +19,12 @@ import { RegisterDeviceDto, UpdateDeviceDto } from './dto/gps-device.dto'; @ApiTags('gps-tracking') @ApiBearerAuth() @Controller('gps') -@BookingStaff(FREIGHT_PERMS.tracking.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@BookingStaff([ + FREIGHT_PERMS.tracking.view, + FREIGHT_PERMS.tracking.manage, +]) export class GpsTrackingController { constructor(private readonly gps: GpsTrackingService) {} diff --git a/apps/edr-freight-api/src/modules/incidents/incidents.controller.ts b/apps/edr-freight-api/src/modules/incidents/incidents.controller.ts index b9cebd653..2e9dfb169 100644 --- a/apps/edr-freight-api/src/modules/incidents/incidents.controller.ts +++ b/apps/edr-freight-api/src/modules/incidents/incidents.controller.ts @@ -22,7 +22,14 @@ import { IncidentStatus, IncidentType } from './entities/incident.entity'; // No incidents-specific permission exists in the registry, so this reuses the // (real) drivers.* fleet-road keys — incident records are driver-safety data // (driver stats / incident history). TODO: add a dedicated incidents:* key. -@BookingStaff(FREIGHT_PERMS.drivers.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@BookingStaff([ + FREIGHT_PERMS.drivers.view, + FREIGHT_PERMS.drivers.create, + FREIGHT_PERMS.drivers.update, + FREIGHT_PERMS.drivers.delete, +]) export class IncidentsController { constructor(private readonly incidentsService: IncidentsService) {} diff --git a/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.controller.ts b/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.controller.ts index 61a13744e..b5e92ca39 100644 --- a/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.controller.ts +++ b/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.controller.ts @@ -15,7 +15,14 @@ import { InterchangeDocumentsService } from './interchange-documents.service'; @ApiBearerAuth() @Controller('interchange-documents') // Class-level view guard; each write route adds its own manage permission below. -@BookingStaff(FREIGHT_PERMS.interchangeDocuments.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@BookingStaff([ + FREIGHT_PERMS.interchangeDocuments.view, + FREIGHT_PERMS.interchangeDocuments.generate, + FREIGHT_PERMS.interchangeDocuments.acknowledge, + FREIGHT_PERMS.interchangeDocuments.dispute, +]) export class InterchangeDocumentsController { constructor(private readonly service: InterchangeDocumentsService) {} diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/dto/approve-last-mile-request.dto.ts b/apps/edr-freight-api/src/modules/last-mile-requests/dto/approve-last-mile-request.dto.ts index 56be0c545..0fa7457a8 100644 --- a/apps/edr-freight-api/src/modules/last-mile-requests/dto/approve-last-mile-request.dto.ts +++ b/apps/edr-freight-api/src/modules/last-mile-requests/dto/approve-last-mile-request.dto.ts @@ -1,13 +1,19 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsNumber, Min } from 'class-validator'; +import { IsNumber, IsOptional, Min } from 'class-validator'; export class ApproveLastMileRequestDto { - // The approve dialog prefills this from GET :id/price-estimate (rule-based), - // but the chief can still override — the typed value is what's invoiced. - @ApiProperty({ description: 'Advance amount the customer must pay before execution proceeds', example: 3000 }) - @Transform(({ value }) => Number(value)) + // Omitted = the rule-based last-mile rate estimate is the advance. The chief + // can still override with an explicit amount (required when no rate covers + // the job). + @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() @Min(0.01) - advanceAmount!: number; + advanceAmount?: number; } diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts index 10f82ad07..c6910c7a7 100644 --- a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts @@ -114,7 +114,7 @@ export class LastMileRequestsController { @Post(':id/approve') @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( @Param('id', ParseUUIDPipe) id: string, @Body() dto: ApproveLastMileRequestDto, diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts index 901e7b9b5..b89f06479 100644 --- a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts @@ -299,7 +299,11 @@ export class LastMileRequestsService { return this.findById(id); } - async approve(id: string, staffId: string | null, advanceAmount: number): Promise { + async approve( + id: string, + staffId: string | null, + advanceOverride?: number | null, + ): Promise { const request = await this.findById(id); if (request.status !== LastMileRequestStatus.Submitted) { 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)); 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. const lastMile = await this.lastMileService.create({ bookingId: request.bookingId, @@ -316,10 +332,6 @@ export class LastMileRequestsService { // No invoice yet: the advance is invoiced by LastMileContractService.sign() // 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, { status: LastMileRequestStatus.Approved, reviewedByStaffId: staffId, @@ -364,7 +376,10 @@ export class LastMileRequestsService { type: 'LAST_MILE_ADVANCE', companyId: booking.companyId, 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: [ { chargeType: 'LAST_MILE_ADVANCE', diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance-due-alert.spec.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance-due-alert.spec.ts index 7aac5e863..5726123cf 100644 --- a/apps/edr-freight-api/src/modules/maintenance/maintenance-due-alert.spec.ts +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance-due-alert.spec.ts @@ -1,6 +1,7 @@ import { NotificationAudience } from '@edr/types'; import { MaintenanceService } from './maintenance.service'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; /** * The daily due-alert: a SCHEDULED item that crossed its km or date threshold @@ -37,6 +38,10 @@ describe('MaintenanceService.sendDueAlerts', () => { expect(notify).toHaveBeenCalledWith( expect.objectContaining({ audience: NotificationAudience.BACKOFFICE, + // The fleet desk, not every employee in the company. + recipients: { + permissionKeys: [FREIGHT_PERMS.maintenance.getNotification], + }, title: 'Maintenance due — ET-9875', body: expect.stringContaining('driven 50200 km (due at 50000 km)'), }), diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts index dd9da55a8..54aab6e36 100644 --- a/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts @@ -15,6 +15,7 @@ import { UpsertMaintenanceIntervalDto, } from './dto/create-maintenance.dto'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; @Injectable() export class MaintenanceService { @@ -53,7 +54,9 @@ export class MaintenanceService { ? `driven ${item.currentKm} km (due at ${item.nextDueKm} km)` : `due ${new Date(item.nextDueDate as Date).toLocaleDateString()}`; await this.inbox.notify({ - recipients: { allBackoffice: true }, + recipients: { + permissionKeys: [FREIGHT_PERMS.maintenance.getNotification], + }, audience: NotificationAudience.BACKOFFICE, type: NotificationType.GENERIC, title: `Maintenance due — ${item.plateNumber}`, diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts b/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts index e3e711d19..d8e338dbf 100644 --- a/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts +++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts @@ -14,7 +14,9 @@ import { ExternalProfileRepository } from "../companies/external-profile.reposit * - `companyProfileId` → resolved to its company, then to that company's users. * - `organizationId` → all current employees of the org (backoffice staff). * - `permissionKeys` → current employees (any org) holding any of these - * permission keys (e.g. department/role-scoped targeting). + * permission keys — how every staff-facing notification is targeted. There is + * deliberately no "all backoffice" selector: staff notifications belong to a + * desk, and the `:get_notification` keys name which one. */ @Injectable() export class NotificationRecipientsService { @@ -69,18 +71,6 @@ export class NotificationRecipientsService { } } - if (recipients.allBackoffice) { - try { - for (const uid of await this.backoffice.getAllCurrentEmployeeUserIds()) { - ids.add(uid); - } - } catch (err) { - this.logger.warn( - `Failed to resolve allBackoffice recipients: ${(err as Error).message}`, - ); - } - } - if (recipients.permissionKeys?.length) { try { for (const uid of await this.backoffice.getEmployeeUserIdsByPermission( diff --git a/apps/edr-freight-api/src/modules/payment/payments.dto.ts b/apps/edr-freight-api/src/modules/payment/payments.dto.ts index 255da0ea2..ee7b703d2 100644 --- a/apps/edr-freight-api/src/modules/payment/payments.dto.ts +++ b/apps/edr-freight-api/src/modules/payment/payments.dto.ts @@ -60,14 +60,38 @@ export class RefundDto { } 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({ - 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)" }) 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)" }) appId?: string; diff --git a/apps/edr-freight-api/src/modules/procurement/procurement.controller.ts b/apps/edr-freight-api/src/modules/procurement/procurement.controller.ts index 374b2bd53..e96b9dc74 100644 --- a/apps/edr-freight-api/src/modules/procurement/procurement.controller.ts +++ b/apps/edr-freight-api/src/modules/procurement/procurement.controller.ts @@ -13,7 +13,14 @@ import { @ApiTags('Procurement & Asset Lifecycle') @Controller('procurement') -@BookingStaff(FREIGHT_PERMS.procurement.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@BookingStaff([ + FREIGHT_PERMS.procurement.view, + FREIGHT_PERMS.procurement.vendorManage, + FREIGHT_PERMS.procurement.acquisitionManage, + FREIGHT_PERMS.procurement.disposalManage, +]) export class ProcurementController { constructor(private readonly procurementService: ProcurementService) {} diff --git a/apps/edr-freight-api/src/modules/routes/routes.controller.ts b/apps/edr-freight-api/src/modules/routes/routes.controller.ts index ed61f08b0..96812275d 100644 --- a/apps/edr-freight-api/src/modules/routes/routes.controller.ts +++ b/apps/edr-freight-api/src/modules/routes/routes.controller.ts @@ -27,7 +27,15 @@ import { RoutesService } from './routes.service'; @ApiTags('routes') @ApiBearerAuth() @Controller('routes') -@FleetView(FREIGHT_PERMS.routes.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@FleetView([ + FREIGHT_PERMS.routes.view, + FREIGHT_PERMS.routes.create, + FREIGHT_PERMS.routes.update, + FREIGHT_PERMS.routes.hardDelete, + FREIGHT_PERMS.routes.delete, +]) export class RoutesController { constructor(private readonly routesService: RoutesService) {} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts index eefc560e8..d116a4b4a 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts @@ -70,6 +70,16 @@ export class CreateCargoTypeDto { @IsBoolean() 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 }) @IsOptional() @IsBoolean() diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts index e685f3a2d..b7717684a 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts @@ -82,6 +82,14 @@ export class CargoType extends BaseEntity { @Column({ name: 'has_lashing', type: 'boolean', default: false }) 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 }) isActive!: boolean; diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts index 1f25b0823..9c11005e9 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts @@ -135,6 +135,35 @@ export class CargoTypesService { 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 { + 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. */ async create(dto: CreateCargoTypeDto): Promise { const code = generateCode(dto.cargoTypeName); @@ -144,6 +173,9 @@ export class CargoTypesService { const parent = await this.repository.findById(dto.parentGroupId); 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', { explicitOrder: dto.displayOrder, @@ -160,6 +192,7 @@ export class CargoTypesService { code, cargoTypeName: dto.cargoTypeName, parentGroupId: dto.parentGroupId ?? null, + hasContractTemplate: dto.hasContractTemplate ?? false, requiresDirectorApproval: dto.requiresDirectorApproval ?? false, isActive: dto.isActive ?? true, unitOfMeasure: dto.unitOfMeasure ?? null, @@ -183,6 +216,18 @@ export class CargoTypesService { const parent = await this.repository.findById(dto.parentGroupId); 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 { wagonTypeIds, itemsPerWagonMap, diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/priority-rule-change-requests.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/priority-rule-change-requests.service.ts index a80221c68..1526769d3 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/priority-rule-change-requests.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/priority-rule-change-requests.service.ts @@ -22,6 +22,7 @@ import { PriorityRuleChangeStatus, } from '../entities/priority-rule-change-request.entity'; import { PriorityConfigsService } from './priority-configs.service'; +import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry'; /** Backoffice rule-engine page — where both queue and rules live. */ const RULES_LINK = '/dashboard/rules/priority-configs'; @@ -221,7 +222,9 @@ export class PriorityRuleChangeRequestsService { ): void { void this.inbox .notify({ - recipients: { allBackoffice: true }, + recipients: { + permissionKeys: [FREIGHT_PERMS.ruleEngine.getNotification], + }, audience: NotificationAudience.BACKOFFICE, type: NotificationType.REQUEST_SUBMITTED, title, diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts index 6dde40d7d..8cc97f343 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts @@ -19,6 +19,7 @@ import { } from '../entities/rate-change-request.entity'; import { Rate } from '../entities/rate.entity'; import { RatesService } from './rates.service'; +import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry'; /** Backoffice page where both the queue and the rates live. */ const RATES_LINK = '/dashboard/rules/rates'; @@ -234,7 +235,9 @@ export class RateChangeRequestsService { private notifyTeam(title: string, body: string, request: RateChangeRequest): void { void this.inbox .notify({ - recipients: { allBackoffice: true }, + recipients: { + permissionKeys: [FREIGHT_PERMS.ruleEngine.getNotification], + }, audience: NotificationAudience.BACKOFFICE, type: NotificationType.REQUEST_SUBMITTED, title, diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts index fefbbc868..7283c874a 100644 --- a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts +++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts @@ -11,7 +11,7 @@ import { Booking } from '../bookings/entities/booking.entity'; import { BookingsRepository } from '../bookings/bookings.repository'; import { compareSchedulingPriority } from '../scheduling/compare-scheduling-priority.util'; 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 { ExecuteRescheduleDto, PreviewRescheduleDto } from './dto/preview-reschedule.dto'; import { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto'; diff --git a/apps/edr-freight-api/src/modules/support-content/dto/support-content.dto.ts b/apps/edr-freight-api/src/modules/support-content/dto/support-content.dto.ts new file mode 100644 index 000000000..fdfffe8be --- /dev/null +++ b/apps/edr-freight-api/src/modules/support-content/dto/support-content.dto.ts @@ -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 ``/`