mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
5
.github/workflows/deploy.yml
vendored
5
.github/workflows/deploy.yml
vendored
@@ -110,6 +110,7 @@ jobs:
|
|||||||
DEPLOY_USER: tria
|
DEPLOY_USER: tria
|
||||||
DOCKER_BUILDKIT: "1"
|
DOCKER_BUILDKIT: "1"
|
||||||
COMPOSE_DOCKER_CLI_BUILD: "1"
|
COMPOSE_DOCKER_CLI_BUILD: "1"
|
||||||
|
ENV_MANAGER_TOKEN: ${{ secrets.ENV_MANAGER_TOKEN }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -135,10 +136,10 @@ jobs:
|
|||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
- name: Sync environment from server
|
- name: Sync environment from Env manager app
|
||||||
run: |
|
run: |
|
||||||
chmod +x scripts/deploy/*.sh
|
chmod +x scripts/deploy/*.sh
|
||||||
./scripts/deploy/sync-env-from-server.sh "${{ matrix.service }}"
|
./scripts/deploy/sync-env-from-env-manager.sh "${{ matrix.service }}"
|
||||||
|
|
||||||
- name: Set compose project name
|
- name: Set compose project name
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -53,3 +53,12 @@ RUNNING_LOCALLY.md
|
|||||||
# Generated per-shard compose file for the integration suite (it.mjs).
|
# Generated per-shard compose file for the integration suite (it.mjs).
|
||||||
integration/.it-shards.yaml
|
integration/.it-shards.yaml
|
||||||
|
|
||||||
|
# private keys / certificates (EIMS INSA credentials and anything like them) — never commit
|
||||||
|
*.key
|
||||||
|
*.pem
|
||||||
|
*.pem.txt
|
||||||
|
*.p12
|
||||||
|
*.pfx
|
||||||
|
*.crt
|
||||||
|
secrets/
|
||||||
|
certs/
|
||||||
|
|||||||
@@ -76,6 +76,12 @@ SEED_EDR_ORG=true
|
|||||||
SEED_FREIGHT_STAFF=true
|
SEED_FREIGHT_STAFF=true
|
||||||
SEED_EXPORT_DJIBOUTI_INTERCHANGE_DEMO=false
|
SEED_EXPORT_DJIBOUTI_INTERCHANGE_DEMO=false
|
||||||
|
|
||||||
|
# Limits GET /staff/users to employees of this IAM organization (iam.organizations.key).
|
||||||
|
# Unset = every employee. A key matching no organization returns no users.
|
||||||
|
# Dev seed key: edr_freight
|
||||||
|
# Production: ETHIO_DJIBOUTI_STANDARD_GAUGE_RAILWAY_SHARE_COMPANY_001
|
||||||
|
FREIGHT_ORG_KEY=edr_freight
|
||||||
|
|
||||||
# MinIO (used by @tria-plc/iamapi-common for file storage)
|
# MinIO (used by @tria-plc/iamapi-common for file storage)
|
||||||
MINIO_ENDPOINT=localhost
|
MINIO_ENDPOINT=localhost
|
||||||
MINIO_PORT=9000
|
MINIO_PORT=9000
|
||||||
@@ -126,3 +132,75 @@ EMAIL_QUEUE=email_queue
|
|||||||
# Shared secret for service-to-service calls (payment microservice <-> freight).
|
# Shared secret for service-to-service calls (payment microservice <-> freight).
|
||||||
# Required at boot; set ALLOW_UNAUTH_INTERNAL=true instead ONLY for local dev.
|
# Required at boot; set ALLOW_UNAUTH_INTERNAL=true instead ONLY for local dev.
|
||||||
SERVICE_AUTH_TOKEN=change-me
|
SERVICE_AUTH_TOKEN=change-me
|
||||||
|
|
||||||
|
# ── MoR EIMS e-invoicing (core.mor.gov.et) ─────────────────────────────────
|
||||||
|
# Disabled by default; every EIMS call fails fast with EIMS_NOT_CONFIGURED until enabled.
|
||||||
|
EIMS_ENABLED=false
|
||||||
|
EIMS_BASE_URL=https://core.mor.gov.et
|
||||||
|
EIMS_CLIENT_ID=
|
||||||
|
EIMS_CLIENT_SECRET=
|
||||||
|
EIMS_API_KEY=
|
||||||
|
EIMS_TIN=
|
||||||
|
# Source-system identity comes from the access token's systemNumber/systemType claims.
|
||||||
|
# Setting these turns them into expected-value checks: a mismatch against the token fails
|
||||||
|
# fast rather than one side silently winning. Leave empty to take the gateway's word.
|
||||||
|
EIMS_SYSTEM_NUMBER=
|
||||||
|
EIMS_SYSTEM_TYPE=
|
||||||
|
# Absolute paths to the INSA-issued credentials. Keep them OUTSIDE the repo; the file
|
||||||
|
# patterns are gitignored, but a path outside the working tree is safer still.
|
||||||
|
# The certificate is transmitted as base64 of this file's exact bytes — do not convert it.
|
||||||
|
EIMS_PRIVATE_KEY_PATH=
|
||||||
|
EIMS_CERTIFICATE_PATH=
|
||||||
|
# Optional tuning
|
||||||
|
EIMS_HTTP_TIMEOUT_MS=30000
|
||||||
|
EIMS_TOKEN_SKEW_SECONDS=45
|
||||||
|
|
||||||
|
# ── EIMS invoice registration (required only to register invoices) ─────────
|
||||||
|
# Seller identity: EDR's own legal details are not modelled anywhere in the DB.
|
||||||
|
# Region and Wereda are MoR *codes* (e.g. 13 / 574), not names.
|
||||||
|
EIMS_SELLER_LEGAL_NAME=
|
||||||
|
EIMS_SELLER_VAT_NUMBER=
|
||||||
|
EIMS_SELLER_PHONE=
|
||||||
|
EIMS_SELLER_EMAIL=
|
||||||
|
EIMS_SELLER_REGION=
|
||||||
|
EIMS_SELLER_WEREDA=
|
||||||
|
# Optional seller address parts; sent as null when unset.
|
||||||
|
EIMS_SELLER_CITY=
|
||||||
|
EIMS_SELLER_SUBCITY=
|
||||||
|
EIMS_SELLER_HOUSE_NUMBER=
|
||||||
|
EIMS_SELLER_LOCALITY=
|
||||||
|
# Tax treatment — REQUIRES FINANCE SIGN-OFF. The application models no tax at all
|
||||||
|
# (invoice.taxAmount is always 0), so nothing here is defaulted: registration fails
|
||||||
|
# locally, naming the missing variables, until these are set.
|
||||||
|
# Required, and deliberately unset: the choice is a tax position, not a default.
|
||||||
|
# MoR's enum (from its own 400): TOT10 TOT2 VAT15 VWHT TWHT VATEX VATWH WHOP2 WTHOI VAT0 VWTH
|
||||||
|
# Pending finance confirmation of VAT0 (zero-rated) vs VATEX (exempt).
|
||||||
|
EIMS_TAX_CODE=
|
||||||
|
EIMS_TAX_RATE_PERCENT=0
|
||||||
|
EIMS_EXCISE_TAX_VALUE=0
|
||||||
|
EIMS_INCOME_WITHHOLD_VALUE=0
|
||||||
|
EIMS_TRANSACTION_WITHHOLD_VALUE=0
|
||||||
|
# Document classification and payment presentation.
|
||||||
|
EIMS_TRANSACTION_TYPE=B2B
|
||||||
|
# Lowercase constant: MoR's oneOf branches require exactly 'goods' or 'service'.
|
||||||
|
EIMS_NATURE_OF_SUPPLIES=service
|
||||||
|
EIMS_PAYMENT_MODE=CASH
|
||||||
|
EIMS_PAYMENT_TERM=IMMIDIATE
|
||||||
|
EIMS_UNIT_DEFAULT=PCS
|
||||||
|
# MoR numeric country code for the buyer; our companies store the country name.
|
||||||
|
EIMS_BUYER_COUNTRY_CODE=
|
||||||
|
# Buyer region name -> MoR numeric code. companies.region holds names; MoR wants ^[0-9]{1,3}$.
|
||||||
|
# An unmapped region fails locally rather than being filed with a guess.
|
||||||
|
EIMS_BUYER_REGION_CODES=Addis Ababa=13
|
||||||
|
# Same mechanism for Wereda. MoR has never named a Wereda regex in an error (only Region's is
|
||||||
|
# confirmed), so this is precautionary — but an unmapped name still fails locally, not filed as a guess.
|
||||||
|
EIMS_BUYER_WEREDA_CODES=
|
||||||
|
EIMS_CASHIER_NAME=
|
||||||
|
EIMS_SALESPERSON_NAME=
|
||||||
|
# Automatic filing of issued invoices (@Cron sweep, one invoice per tick).
|
||||||
|
# Independent of EIMS_ENABLED on purpose: authentication can be live long before
|
||||||
|
# filing is. Both must be true before anything is submitted automatically.
|
||||||
|
EIMS_AUTO_SUBMIT=false
|
||||||
|
EIMS_AUTO_SUBMIT_CRON=0 */5 * * * *
|
||||||
|
# MoR rejects documents older than 3 days; the sweep will not attempt those.
|
||||||
|
EIMS_AUTO_SUBMIT_MAX_AGE_DAYS=3
|
||||||
|
|||||||
@@ -37,7 +37,8 @@
|
|||||||
"iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert",
|
"iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert",
|
||||||
"iam:migration:show": "pnpm run iam:typeorm:cli migration:show",
|
"iam:migration:show": "pnpm run iam:typeorm:cli migration:show",
|
||||||
"migration:run": "nest build && node dist/scripts/migrate.js",
|
"migration:run": "nest build && node dist/scripts/migrate.js",
|
||||||
"script": "ts-node -r tsconfig-paths/register src/scripts/main.ts"
|
"script": "ts-node -r tsconfig-paths/register src/scripts/main.ts",
|
||||||
|
"eims:login": "ts-node -r tsconfig-paths/register src/scripts/eims-login.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@edr/api-common": "workspace:*",
|
"@edr/api-common": "workspace:*",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import databaseConfig from "./config/database.config";
|
|||||||
import telebirrConfig from "./config/telebirr.config";
|
import telebirrConfig from "./config/telebirr.config";
|
||||||
import rabbitmqConfig from "./config/rabbitmq.config";
|
import rabbitmqConfig from "./config/rabbitmq.config";
|
||||||
import faydaConfig from "./config/fayda.config";
|
import faydaConfig from "./config/fayda.config";
|
||||||
|
import eimsConfig from "./config/eims.config";
|
||||||
|
|
||||||
import { BookingsModule } from "./modules/bookings/bookings.module";
|
import { BookingsModule } from "./modules/bookings/bookings.module";
|
||||||
import { ContractsModule } from "./modules/contracts/contracts.module";
|
import { ContractsModule } from "./modules/contracts/contracts.module";
|
||||||
@@ -49,6 +50,7 @@ import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-up
|
|||||||
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
|
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
|
||||||
import { ExchangeSettingsModule } from "./modules/exchange-settings/exchange-settings.module";
|
import { ExchangeSettingsModule } from "./modules/exchange-settings/exchange-settings.module";
|
||||||
import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module";
|
import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module";
|
||||||
|
import { SupportContentModule } from "./modules/support-content/support-content.module";
|
||||||
import { OtpModule } from "./modules/otp/otp.module";
|
import { OtpModule } from "./modules/otp/otp.module";
|
||||||
import { HealthModule } from "./modules/health/health.module";
|
import { HealthModule } from "./modules/health/health.module";
|
||||||
import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module";
|
import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module";
|
||||||
@@ -66,6 +68,7 @@ import { FreightPositionsSeeder } from "./seed/freight-positions.seeder";
|
|||||||
import { PaymentModule } from "./modules/payment/payment.module";
|
import { PaymentModule } from "./modules/payment/payment.module";
|
||||||
// import { PricingDataSeeder } from "./seed/pricing-data.seeder";
|
// import { PricingDataSeeder } from "./seed/pricing-data.seeder";
|
||||||
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
|
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
|
||||||
|
import { SupportContentSeeder } from "./seed/support-content.seeder";
|
||||||
// import { YardFacilitiesSeeder } from "./seed/yard-facilities.seeder";
|
// import { YardFacilitiesSeeder } from "./seed/yard-facilities.seeder";
|
||||||
// import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder";
|
// import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder";
|
||||||
// import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder";
|
// import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder";
|
||||||
@@ -76,6 +79,7 @@ import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
|
|||||||
// import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder";
|
// import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder";
|
||||||
// import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder";
|
// import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder";
|
||||||
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
|
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
|
||||||
|
import { FreightNotificationPermissionsSeeder } from "./seed/freight-notification-permissions.seeder";
|
||||||
// import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
|
// import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
|
||||||
// import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
|
// import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
|
||||||
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
|
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
|
||||||
@@ -83,6 +87,7 @@ import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-d
|
|||||||
//New Trains, Wagons, Container and Cargo management modules
|
//New Trains, Wagons, Container and Cargo management modules
|
||||||
import { TrainsModule } from "./modules/trains/trains.module";
|
import { TrainsModule } from "./modules/trains/trains.module";
|
||||||
import { VerifaydaModule } from "./modules/verifayda/verifayda.module";
|
import { VerifaydaModule } from "./modules/verifayda/verifayda.module";
|
||||||
|
import { EimsModule } from "./modules/eims/eims.module";
|
||||||
import { FleetHistoryModule } from "./modules/fleet-history/fleet-history.module";
|
import { FleetHistoryModule } from "./modules/fleet-history/fleet-history.module";
|
||||||
import { WagonsModule } from "./modules/wagons/wagons.module";
|
import { WagonsModule } from "./modules/wagons/wagons.module";
|
||||||
import { ContainersModule } from "./modules/container-management/containers.module";
|
import { ContainersModule } from "./modules/container-management/containers.module";
|
||||||
@@ -126,6 +131,7 @@ if (!process.env.APPLICATION_NAME) {
|
|||||||
telebirrConfig,
|
telebirrConfig,
|
||||||
rabbitmqConfig,
|
rabbitmqConfig,
|
||||||
faydaConfig,
|
faydaConfig,
|
||||||
|
eimsConfig,
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
@@ -216,6 +222,7 @@ if (!process.env.APPLICATION_NAME) {
|
|||||||
DropdownSettingsModule,
|
DropdownSettingsModule,
|
||||||
ExchangeSettingsModule,
|
ExchangeSettingsModule,
|
||||||
ContractTemplatesModule,
|
ContractTemplatesModule,
|
||||||
|
SupportContentModule,
|
||||||
OtpModule,
|
OtpModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
RuleEngineModule,
|
RuleEngineModule,
|
||||||
@@ -247,6 +254,7 @@ if (!process.env.APPLICATION_NAME) {
|
|||||||
InterchangeDocumentsModule,
|
InterchangeDocumentsModule,
|
||||||
ImportOperationsModule,
|
ImportOperationsModule,
|
||||||
VerifaydaModule,
|
VerifaydaModule,
|
||||||
|
EimsModule,
|
||||||
FleetHistoryModule,
|
FleetHistoryModule,
|
||||||
AiModule,
|
AiModule,
|
||||||
AuditModule,
|
AuditModule,
|
||||||
@@ -255,8 +263,10 @@ if (!process.env.APPLICATION_NAME) {
|
|||||||
EdrOrgSeeder,
|
EdrOrgSeeder,
|
||||||
FreightPositionsSeeder,
|
FreightPositionsSeeder,
|
||||||
FileUploadSettingsSeeder,
|
FileUploadSettingsSeeder,
|
||||||
|
SupportContentSeeder,
|
||||||
// YardFacilitiesSeeder,
|
// YardFacilitiesSeeder,
|
||||||
FreightPermissionKeyMigrationSeeder,
|
FreightPermissionKeyMigrationSeeder,
|
||||||
|
FreightNotificationPermissionsSeeder,
|
||||||
// Disabled seeds — providers commented out (imports/injection/run too):
|
// Disabled seeds — providers commented out (imports/injection/run too):
|
||||||
// DemoUsersSeeder,
|
// DemoUsersSeeder,
|
||||||
// FreightStaffUsersSeeder,
|
// FreightStaffUsersSeeder,
|
||||||
@@ -285,8 +295,10 @@ export class AppModule implements OnApplicationBootstrap {
|
|||||||
private readonly edrOrgSeeder: EdrOrgSeeder,
|
private readonly edrOrgSeeder: EdrOrgSeeder,
|
||||||
private readonly freightPositionsSeeder: FreightPositionsSeeder,
|
private readonly freightPositionsSeeder: FreightPositionsSeeder,
|
||||||
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
|
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
|
||||||
|
private readonly supportContentSeeder: SupportContentSeeder,
|
||||||
// private readonly yardFacilitiesSeeder: YardFacilitiesSeeder,
|
// private readonly yardFacilitiesSeeder: YardFacilitiesSeeder,
|
||||||
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
|
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
|
||||||
|
private readonly freightNotificationPermissionsSeeder: FreightNotificationPermissionsSeeder,
|
||||||
// Disabled seeds — injections commented out (imports/provider/run too):
|
// Disabled seeds — injections commented out (imports/provider/run too):
|
||||||
// private readonly demoUsersSeeder: DemoUsersSeeder,
|
// private readonly demoUsersSeeder: DemoUsersSeeder,
|
||||||
// private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
|
// private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
|
||||||
@@ -328,10 +340,24 @@ export class AppModule implements OnApplicationBootstrap {
|
|||||||
await this.edrOrgSeeder.run();
|
await this.edrOrgSeeder.run();
|
||||||
await this.iamBaselineSeeder.run();
|
await this.iamBaselineSeeder.run();
|
||||||
await this.freightPositionsSeeder.run();
|
await this.freightPositionsSeeder.run();
|
||||||
|
// freightNotificationPermissions → seeds the <module>:get_notification
|
||||||
|
// keys and backfills them onto whoever
|
||||||
|
// already holds each desk's anchor
|
||||||
|
// permission. Runs LAST in this block so
|
||||||
|
// it sees a freshly-seeded catalog and
|
||||||
|
// freshly-seeded positions. Unlike the
|
||||||
|
// seeders above it is NOT gated behind
|
||||||
|
// SEED_EDR_ORG — without it every staff
|
||||||
|
// notification resolves to no one.
|
||||||
|
await this.freightNotificationPermissionsSeeder.run();
|
||||||
|
|
||||||
// File upload settings — keep enabled.
|
// File upload settings — keep enabled.
|
||||||
await this.fileUploadSettingsSeeder.run();
|
await this.fileUploadSettingsSeeder.run();
|
||||||
|
|
||||||
|
// Portal help/FAQ/legal copy — keep enabled. Idempotent by emptiness, so
|
||||||
|
// it fills an empty table once and never touches admin edits afterwards.
|
||||||
|
await this.supportContentSeeder.run();
|
||||||
|
|
||||||
// Flags which yards can load/unload cargo (Indode, Sebeta, Modjo, Adama,
|
// Flags which yards can load/unload cargo (Indode, Sebeta, Modjo, Adama,
|
||||||
// Dire Dawa). Idempotent; creates no yards.
|
// Dire Dawa). Idempotent; creates no yards.
|
||||||
// await this.yardFacilitiesSeeder.run();
|
// await this.yardFacilitiesSeeder.run();
|
||||||
|
|||||||
@@ -95,9 +95,14 @@ export const TrainSchedulingRulesManage = () =>
|
|||||||
* wagons:delete, …). The legacy coarse fleet:view / fleet:manage keys remain
|
* wagons:delete, …). The legacy coarse fleet:view / fleet:manage keys remain
|
||||||
* valid as a one-of fallback so existing role grants keep working.
|
* valid as a one-of fallback so existing role grants keep working.
|
||||||
*/
|
*/
|
||||||
export const FleetView = (granular?: string) =>
|
export const FleetView = (granular?: string | string[]) =>
|
||||||
BookingStaff(
|
BookingStaff(
|
||||||
granular ? [granular, FREIGHT_PERMS.fleet.view] : FREIGHT_PERMS.fleet.view,
|
granular
|
||||||
|
? [
|
||||||
|
...(Array.isArray(granular) ? granular : [granular]),
|
||||||
|
FREIGHT_PERMS.fleet.view,
|
||||||
|
]
|
||||||
|
: FREIGHT_PERMS.fleet.view,
|
||||||
);
|
);
|
||||||
|
|
||||||
export const FleetManage = (granular?: string) =>
|
export const FleetManage = (granular?: string) =>
|
||||||
|
|||||||
@@ -36,4 +36,27 @@ describe('assertExportReceivedWithGrn', () => {
|
|||||||
assertExportReceivedWithGrn(db([]), { id: 'b-1', tradeDirection: 'DOMESTIC' }),
|
assertExportReceivedWithGrn(db([]), { id: 'b-1', tradeDirection: 'DOMESTIC' }),
|
||||||
).resolves.toBeUndefined();
|
).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('never blocks direct truck-to-train export — that cargo has no GRN by design', async () => {
|
||||||
|
const source = db([]);
|
||||||
|
await expect(
|
||||||
|
assertExportReceivedWithGrn(source, {
|
||||||
|
id: 'b-1',
|
||||||
|
tradeDirection: 'EXPORT',
|
||||||
|
exportHandoverMode: 'DIRECT_TO_TRAIN',
|
||||||
|
}),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
// Direct short-circuits before querying — there is no inventory to look for.
|
||||||
|
expect(source.query as jest.Mock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still gates a warehouse export booking', async () => {
|
||||||
|
await expect(
|
||||||
|
assertExportReceivedWithGrn(db([]), {
|
||||||
|
id: 'b-1',
|
||||||
|
tradeDirection: 'EXPORT',
|
||||||
|
exportHandoverMode: 'WAREHOUSE',
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,8 +5,15 @@ import type { DataSource, EntityManager } from 'typeorm';
|
|||||||
export interface ExportLoadGateBooking {
|
export interface ExportLoadGateBooking {
|
||||||
id: string;
|
id: string;
|
||||||
tradeDirection?: string | null;
|
tradeDirection?: string | null;
|
||||||
|
/** 'DIRECT_TO_TRAIN' skips the gate entirely; null/'WAREHOUSE' keeps it. */
|
||||||
|
exportHandoverMode?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Direct truck-to-train: the cargo never sees a warehouse, so it never has a GRN. */
|
||||||
|
export const DIRECT_TO_TRAIN = 'DIRECT_TO_TRAIN';
|
||||||
|
/** Warehouse-then-train: the existing flow. Also what a null mode means. */
|
||||||
|
export const WAREHOUSE = 'WAREHOUSE';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Export cargo may not be loaded onto its train until it has physically reached
|
* Export cargo may not be loaded onto its train until it has physically reached
|
||||||
* the warehouse and been issued a GRN — whether it got there by first-mile or by
|
* the warehouse and been issued a GRN — whether it got there by first-mile or by
|
||||||
@@ -21,12 +28,18 @@ export interface ExportLoadGateBooking {
|
|||||||
* "Received with a GRN" = an inventory row that has reached the warehouse
|
* "Received with a GRN" = an inventory row that has reached the warehouse
|
||||||
* (RECEIVED or any later stage) and carries a GRN, in the column or the notes
|
* (RECEIVED or any later stage) and carries a GRN, in the column or the notes
|
||||||
* fallback older rows use.
|
* fallback older rows use.
|
||||||
|
*
|
||||||
|
* Export has a second, warehouse-free shape: the customer's truck loads straight
|
||||||
|
* onto the wagon. That cargo is never received and never GRN'd, so a booking
|
||||||
|
* marked DIRECT_TO_TRAIN is outside this gate by definition — its custody is
|
||||||
|
* attested by the carriage acceptance sheet instead.
|
||||||
*/
|
*/
|
||||||
export async function assertExportReceivedWithGrn(
|
export async function assertExportReceivedWithGrn(
|
||||||
db: DataSource | EntityManager,
|
db: DataSource | EntityManager,
|
||||||
booking: ExportLoadGateBooking,
|
booking: ExportLoadGateBooking,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (booking.tradeDirection !== 'EXPORT') return;
|
if (booking.tradeDirection !== 'EXPORT') return;
|
||||||
|
if (booking.exportHandoverMode === DIRECT_TO_TRAIN) return;
|
||||||
|
|
||||||
const [row] = await db.query(
|
const [row] = await db.query(
|
||||||
`SELECT 1
|
`SELECT 1
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
|
|
||||||
import { hasFreightPermission, isSuperAdmin } from './freight-permission.util';
|
import { hasFreightPermission, isSuperAdmin } from './freight-permission.util';
|
||||||
|
import { readTwinOf } from '../seed/freight-permissions.registry';
|
||||||
|
|
||||||
// String literals on purpose (same reasoning as login-audience.middleware.ts):
|
// String literals on purpose (same reasoning as login-audience.middleware.ts):
|
||||||
// the values are wire-format constants from iam.users.user_type, and importing
|
// the values are wire-format constants from iam.users.user_type, and importing
|
||||||
@@ -22,13 +23,43 @@ const userTypeOf = (user: TCurrentUser): string | undefined =>
|
|||||||
const isEmployee = (user: TCurrentUser): boolean =>
|
const isEmployee = (user: TCurrentUser): boolean =>
|
||||||
userTypeOf(user) === 'employee' || isSuperAdmin(user);
|
userTypeOf(user) === 'employee' || isSuperAdmin(user);
|
||||||
|
|
||||||
|
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Does the caller satisfy a required permission?
|
||||||
|
*
|
||||||
|
* Holding the key outright always passes. A required `<module>:view` is ALSO
|
||||||
|
* satisfied by the weaker `<module>:read` — the key that buys API reads
|
||||||
|
* without putting the module in the backoffice sidebar — but only on a safe
|
||||||
|
* HTTP method.
|
||||||
|
*
|
||||||
|
* The method restriction is load-bearing, not caution. Nest runs class AND
|
||||||
|
* method guards, so controllers list every route key on the class gate,
|
||||||
|
* `:view` among the write keys. Without this check a `:read` holder would
|
||||||
|
* clear that class gate and then reach any write route that has no method
|
||||||
|
* gate of its own. Keying on the HTTP verb closes that by construction rather
|
||||||
|
* than by an audit that goes stale the next time a route is added.
|
||||||
|
*/
|
||||||
|
const satisfiedBy = (
|
||||||
|
user: TCurrentUser,
|
||||||
|
required: string,
|
||||||
|
method: string,
|
||||||
|
): boolean => {
|
||||||
|
if (hasFreightPermission(user, required)) return true;
|
||||||
|
if (!SAFE_METHODS.has(method)) return false;
|
||||||
|
const readTwin = readTwinOf(required);
|
||||||
|
return Boolean(readTwin && hasFreightPermission(user, readTwin));
|
||||||
|
};
|
||||||
|
|
||||||
export function FreightPermissionGuard(
|
export function FreightPermissionGuard(
|
||||||
permissions: string[],
|
permissions: string[],
|
||||||
): Type<CanActivate> {
|
): Type<CanActivate> {
|
||||||
@Injectable()
|
@Injectable()
|
||||||
class FreightPermissionsGuard implements CanActivate {
|
class FreightPermissionsGuard implements CanActivate {
|
||||||
canActivate(context: ExecutionContext): boolean {
|
canActivate(context: ExecutionContext): boolean {
|
||||||
const request = context.switchToHttp().getRequest<{ user?: TCurrentUser }>();
|
const request = context
|
||||||
|
.switchToHttp()
|
||||||
|
.getRequest<{ user?: TCurrentUser; method: string }>();
|
||||||
const user = request.user;
|
const user = request.user;
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -39,7 +70,7 @@ export function FreightPermissionGuard(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!permissions?.length) return true;
|
if (!permissions?.length) return true;
|
||||||
if (permissions.some((p) => hasFreightPermission(user, p))) {
|
if (permissions.some((p) => satisfiedBy(user, p, request.method))) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +110,9 @@ export function MixedAudienceGuard(permissions: string[]): Type<CanActivate> {
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
class MixedAudiencesGuard implements CanActivate {
|
class MixedAudiencesGuard implements CanActivate {
|
||||||
canActivate(context: ExecutionContext): boolean {
|
canActivate(context: ExecutionContext): boolean {
|
||||||
const request = context.switchToHttp().getRequest<{ user?: TCurrentUser }>();
|
const request = context
|
||||||
|
.switchToHttp()
|
||||||
|
.getRequest<{ user?: TCurrentUser; method: string }>();
|
||||||
const user = request.user;
|
const user = request.user;
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -93,7 +126,7 @@ export function MixedAudienceGuard(permissions: string[]): Type<CanActivate> {
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
!permissions?.length ||
|
!permissions?.length ||
|
||||||
permissions.some((p) => hasFreightPermission(user, p))
|
permissions.some((p) => satisfiedBy(user, p, request.method))
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -215,8 +215,11 @@ export function buildFreightMigrationDataSourceOptions(): DataSourceOptions {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default registerAs("database", (): TypeOrmModuleOptions => ({
|
export default registerAs(
|
||||||
...buildDataSourceOptions(),
|
"database",
|
||||||
autoLoadEntities: true,
|
(): TypeOrmModuleOptions => ({
|
||||||
migrationsRun: false,
|
...buildDataSourceOptions(),
|
||||||
}));
|
autoLoadEntities: true,
|
||||||
|
migrationsRun: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|||||||
205
apps/edr-freight-api/src/config/eims.config.ts
Normal file
205
apps/edr-freight-api/src/config/eims.config.ts
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import { registerAs } from "@nestjs/config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ethiopian MoR EIMS e-invoicing gateway.
|
||||||
|
*
|
||||||
|
* Disabled by default: with `EIMS_ENABLED=false` the config resolves to a stub and every EIMS
|
||||||
|
* service throws a clear error on use, so a deployment without credentials still boots.
|
||||||
|
*
|
||||||
|
* Secrets (client secret, API key) and the credential file paths live only here and are never
|
||||||
|
* logged — validation reports missing variable *names*, never their values.
|
||||||
|
*/
|
||||||
|
export interface EimsConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
baseUrl: string;
|
||||||
|
clientId: string;
|
||||||
|
clientSecret: string;
|
||||||
|
apiKey: string;
|
||||||
|
tin: string;
|
||||||
|
/**
|
||||||
|
* Optional *expectations* for the source-system identity, not inputs.
|
||||||
|
*
|
||||||
|
* The access token MoR issues carries `systemNumber` and `systemType` claims for the credentials
|
||||||
|
* that authenticated, and those are what registration uses. When these are set they are compared
|
||||||
|
* against the token and a mismatch fails fast — neither side silently wins. Leave them empty to
|
||||||
|
* take whatever the gateway says.
|
||||||
|
*/
|
||||||
|
systemNumber: string;
|
||||||
|
systemType: string;
|
||||||
|
/** Filesystem path to the INSA-issued RSA private key (PEM). Never leaves the server. */
|
||||||
|
privateKeyPath: string;
|
||||||
|
/** Filesystem path to the INSA-issued certificate bundle; sent as base64 of its exact bytes. */
|
||||||
|
certificatePath: string;
|
||||||
|
httpTimeoutMs: number;
|
||||||
|
/** Re-authenticate this many ms before the access token actually expires. */
|
||||||
|
tokenSkewMs: number;
|
||||||
|
/**
|
||||||
|
* Automatic submission of issued invoices, off by default.
|
||||||
|
*
|
||||||
|
* Invoices are produced by the workflow, so the production path is a sweep rather than a human
|
||||||
|
* action — but enabling it starts filing real documents with the tax authority, which is
|
||||||
|
* irreversible from our side. It therefore needs its own deliberate switch, separate from
|
||||||
|
* `EIMS_ENABLED`, so that authentication can be live long before filing is.
|
||||||
|
*/
|
||||||
|
autoSubmit: boolean;
|
||||||
|
autoSubmitCron: string;
|
||||||
|
/** MoR rejects a document whose date is more than 3 days old; the sweep will not attempt those. */
|
||||||
|
autoSubmitMaxAgeDays: number;
|
||||||
|
/**
|
||||||
|
* Seller identity and tax/business treatment for the invoice document.
|
||||||
|
*
|
||||||
|
* None of this is derivable from the database: EDR's own legal identity exists nowhere in the
|
||||||
|
* codebase, and the app models no tax at all. Values are required at registration time and are
|
||||||
|
* validated there rather than at boot, so a deployment can run with EIMS enabled for
|
||||||
|
* authentication before finance has signed off on the tax treatment.
|
||||||
|
*/
|
||||||
|
invoice: EimsInvoiceConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EimsInvoiceConfig {
|
||||||
|
sellerLegalName: string;
|
||||||
|
sellerVatNumber: string;
|
||||||
|
sellerPhone: string;
|
||||||
|
sellerEmail: string;
|
||||||
|
/** MoR *codes*, not names (e.g. "13" for Addis Ababa, "574"). */
|
||||||
|
sellerRegion: string;
|
||||||
|
sellerWereda: string;
|
||||||
|
sellerCity: string | null;
|
||||||
|
sellerSubCity: string | null;
|
||||||
|
sellerHouseNumber: string | null;
|
||||||
|
sellerLocality: string | null;
|
||||||
|
/** REQUIRES_BUSINESS_CONFIRMATION — no tax model exists in this application. */
|
||||||
|
taxCode: string;
|
||||||
|
taxRatePercent: number | null;
|
||||||
|
exciseTaxValue: number | null;
|
||||||
|
incomeWithholdValue: number | null;
|
||||||
|
transactionWithholdValue: number | null;
|
||||||
|
/** B2B / B2C — a tax classification, so it is configured, not inferred. */
|
||||||
|
transactionType: string;
|
||||||
|
natureOfSupplies: string;
|
||||||
|
paymentMode: string;
|
||||||
|
paymentTerm: string;
|
||||||
|
unitDefault: string;
|
||||||
|
buyerCountryCode: string | null;
|
||||||
|
/**
|
||||||
|
* Buyer region name → MoR numeric code, from `EIMS_BUYER_REGION_CODES`
|
||||||
|
* ("Addis Ababa=13,Oromia=4"). A buyer whose region is neither a code nor in this map fails
|
||||||
|
* locally rather than being filed with a guessed one.
|
||||||
|
*/
|
||||||
|
buyerRegionCodes: Record<string, string>;
|
||||||
|
/** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */
|
||||||
|
buyerWeredaCodes: Record<string, string>;
|
||||||
|
cashierName: string | null;
|
||||||
|
salesPersonName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const REQUIRED_VARS = [
|
||||||
|
"EIMS_CLIENT_ID",
|
||||||
|
"EIMS_CLIENT_SECRET",
|
||||||
|
"EIMS_API_KEY",
|
||||||
|
"EIMS_TIN",
|
||||||
|
"EIMS_PRIVATE_KEY_PATH",
|
||||||
|
"EIMS_CERTIFICATE_PATH",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const positiveInt = (raw: string | undefined, fallback: number, name: string): number => {
|
||||||
|
if (raw === undefined || raw === "") return fallback;
|
||||||
|
const value = Number.parseInt(raw, 10);
|
||||||
|
if (Number.isNaN(value) || value <= 0) {
|
||||||
|
throw new Error(`${name} must be a positive integer`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** "Addis Ababa=13,Oromia=4" → { "Addis Ababa": "13", Oromia: "4" }. */
|
||||||
|
const parseCodeMap = (raw: string | undefined): Record<string, string> => {
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
for (const pair of (raw ?? "").split(",")) {
|
||||||
|
const [name, code] = pair.split("=");
|
||||||
|
if (name?.trim() && code?.trim()) map[name.trim()] = code.trim();
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Unset stays null so the registration-time check can name it; a set-but-bogus value throws. */
|
||||||
|
const optionalNumber = (raw: string | undefined, name: string): number | null => {
|
||||||
|
if (raw === undefined || raw === "") return null;
|
||||||
|
const value = Number(raw);
|
||||||
|
if (!Number.isFinite(value)) throw new Error(`${name} must be a number`);
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default registerAs("eims", (): EimsConfig => {
|
||||||
|
const enabled = (process.env.EIMS_ENABLED ?? "false").toLowerCase() === "true";
|
||||||
|
const baseUrl = (process.env.EIMS_BASE_URL ?? "https://core.mor.gov.et").replace(/\/+$/, "");
|
||||||
|
const httpTimeoutMs = positiveInt(process.env.EIMS_HTTP_TIMEOUT_MS, 30_000, "EIMS_HTTP_TIMEOUT_MS");
|
||||||
|
const tokenSkewMs =
|
||||||
|
positiveInt(process.env.EIMS_TOKEN_SKEW_SECONDS, 45, "EIMS_TOKEN_SKEW_SECONDS") * 1000;
|
||||||
|
|
||||||
|
const base: EimsConfig = {
|
||||||
|
enabled,
|
||||||
|
baseUrl,
|
||||||
|
clientId: process.env.EIMS_CLIENT_ID ?? "",
|
||||||
|
clientSecret: process.env.EIMS_CLIENT_SECRET ?? "",
|
||||||
|
apiKey: process.env.EIMS_API_KEY ?? "",
|
||||||
|
tin: process.env.EIMS_TIN ?? "",
|
||||||
|
systemNumber: process.env.EIMS_SYSTEM_NUMBER ?? "",
|
||||||
|
systemType: process.env.EIMS_SYSTEM_TYPE ?? "",
|
||||||
|
privateKeyPath: process.env.EIMS_PRIVATE_KEY_PATH ?? "",
|
||||||
|
certificatePath: process.env.EIMS_CERTIFICATE_PATH ?? "",
|
||||||
|
httpTimeoutMs,
|
||||||
|
tokenSkewMs,
|
||||||
|
autoSubmit: (process.env.EIMS_AUTO_SUBMIT ?? "false").toLowerCase() === "true",
|
||||||
|
// Every 5 minutes by default: filing is not latency-sensitive, and a slow cadence keeps a
|
||||||
|
// misconfiguration from filing a burst of bad documents before anyone notices.
|
||||||
|
autoSubmitCron: process.env.EIMS_AUTO_SUBMIT_CRON || "0 */5 * * * *",
|
||||||
|
autoSubmitMaxAgeDays: positiveInt(
|
||||||
|
process.env.EIMS_AUTO_SUBMIT_MAX_AGE_DAYS,
|
||||||
|
3,
|
||||||
|
"EIMS_AUTO_SUBMIT_MAX_AGE_DAYS",
|
||||||
|
),
|
||||||
|
invoice: {
|
||||||
|
sellerLegalName: process.env.EIMS_SELLER_LEGAL_NAME ?? "",
|
||||||
|
sellerVatNumber: process.env.EIMS_SELLER_VAT_NUMBER ?? "",
|
||||||
|
sellerPhone: process.env.EIMS_SELLER_PHONE ?? "",
|
||||||
|
sellerEmail: process.env.EIMS_SELLER_EMAIL ?? "",
|
||||||
|
sellerRegion: process.env.EIMS_SELLER_REGION ?? "",
|
||||||
|
sellerWereda: process.env.EIMS_SELLER_WEREDA ?? "",
|
||||||
|
sellerCity: process.env.EIMS_SELLER_CITY || null,
|
||||||
|
sellerSubCity: process.env.EIMS_SELLER_SUBCITY || null,
|
||||||
|
sellerHouseNumber: process.env.EIMS_SELLER_HOUSE_NUMBER || null,
|
||||||
|
sellerLocality: process.env.EIMS_SELLER_LOCALITY || null,
|
||||||
|
taxCode: process.env.EIMS_TAX_CODE ?? "",
|
||||||
|
taxRatePercent: optionalNumber(process.env.EIMS_TAX_RATE_PERCENT, "EIMS_TAX_RATE_PERCENT"),
|
||||||
|
exciseTaxValue: optionalNumber(process.env.EIMS_EXCISE_TAX_VALUE, "EIMS_EXCISE_TAX_VALUE"),
|
||||||
|
incomeWithholdValue: optionalNumber(
|
||||||
|
process.env.EIMS_INCOME_WITHHOLD_VALUE,
|
||||||
|
"EIMS_INCOME_WITHHOLD_VALUE",
|
||||||
|
),
|
||||||
|
transactionWithholdValue: optionalNumber(
|
||||||
|
process.env.EIMS_TRANSACTION_WITHHOLD_VALUE,
|
||||||
|
"EIMS_TRANSACTION_WITHHOLD_VALUE",
|
||||||
|
),
|
||||||
|
transactionType: process.env.EIMS_TRANSACTION_TYPE ?? "",
|
||||||
|
natureOfSupplies: process.env.EIMS_NATURE_OF_SUPPLIES ?? "",
|
||||||
|
paymentMode: process.env.EIMS_PAYMENT_MODE ?? "",
|
||||||
|
paymentTerm: process.env.EIMS_PAYMENT_TERM ?? "",
|
||||||
|
unitDefault: process.env.EIMS_UNIT_DEFAULT ?? "",
|
||||||
|
buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null,
|
||||||
|
buyerRegionCodes: parseCodeMap(process.env.EIMS_BUYER_REGION_CODES),
|
||||||
|
buyerWeredaCodes: parseCodeMap(process.env.EIMS_BUYER_WEREDA_CODES),
|
||||||
|
cashierName: process.env.EIMS_CASHIER_NAME || null,
|
||||||
|
salesPersonName: process.env.EIMS_SALESPERSON_NAME || null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!enabled) return base;
|
||||||
|
|
||||||
|
const missing = REQUIRED_VARS.filter((name) => !process.env[name]);
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`EIMS integration is enabled (EIMS_ENABLED=true) but the following env vars are missing: ${missing.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
});
|
||||||
@@ -120,6 +120,8 @@ export class ContractDocumentViewModelBuilder {
|
|||||||
contract.tradeDirection,
|
contract.tradeDirection,
|
||||||
contract.freightType,
|
contract.freightType,
|
||||||
contract.customsClearingEnabled,
|
contract.customsClearingEnabled,
|
||||||
|
// Bulk templates are keyed by the contract's cargo type.
|
||||||
|
(contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId,
|
||||||
);
|
);
|
||||||
dynamicTemplate = dynamicSource
|
dynamicTemplate = dynamicSource
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audit trail for wagon status flips (Available ⇄ Maintenance and any other
|
||||||
|
* bulk-status change): who moved which wagon from what to what, when, and why.
|
||||||
|
* Written inside the same transaction as the status update itself.
|
||||||
|
*/
|
||||||
|
export class WagonStatusLogs3310000000000 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.wagon_status_logs (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
wagon_id uuid NOT NULL REFERENCES freight.wagons(id),
|
||||||
|
from_status varchar(30) NOT NULL,
|
||||||
|
to_status varchar(30) NOT NULL,
|
||||||
|
changed_by_user_id uuid,
|
||||||
|
note text,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wagon_status_logs_wagon
|
||||||
|
ON freight.wagon_status_logs (wagon_id, created_at DESC)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_status_logs`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
const CONTAINER_CODES = [
|
||||||
|
'IMPORT_CONTAINER_CUSTOMS',
|
||||||
|
'IMPORT_CONTAINER_NO_CUSTOMS',
|
||||||
|
'EXPORT_CONTAINER_CUSTOMS',
|
||||||
|
'EXPORT_CONTAINER_NO_CUSTOMS',
|
||||||
|
'INTERCITY_CONTAINER',
|
||||||
|
];
|
||||||
|
|
||||||
|
const BULK_CODES = [
|
||||||
|
'IMPORT_BULK_CUSTOMS',
|
||||||
|
'IMPORT_BULK_NO_CUSTOMS',
|
||||||
|
'EXPORT_BULK_CUSTOMS',
|
||||||
|
'EXPORT_BULK_NO_CUSTOMS',
|
||||||
|
'INTERCITY_BULK',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk contract templates become staff-created, keyed by (cargo type, customs
|
||||||
|
* clearing) instead of the fixed direction codes. The five container templates
|
||||||
|
* stay seeded and become undeletable system rows; the five seeded bulk rows are
|
||||||
|
* retired (soft-deleted). cargo_types gains has_contract_template, marking
|
||||||
|
* which bulk commodities may carry their own template.
|
||||||
|
*/
|
||||||
|
export class BulkContractTemplates3320000000000 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.cargo_types
|
||||||
|
ADD COLUMN IF NOT EXISTS has_contract_template boolean NOT NULL DEFAULT false
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.contract_templates
|
||||||
|
ADD COLUMN IF NOT EXISTS cargo_type_id uuid REFERENCES freight.cargo_types(id),
|
||||||
|
ADD COLUMN IF NOT EXISTS with_customs boolean,
|
||||||
|
ADD COLUMN IF NOT EXISTS is_system boolean NOT NULL DEFAULT false
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Generated bulk codes (BULK_<cargo code>_NO_CUSTOMS) outgrow varchar(40).
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.contract_templates
|
||||||
|
ALTER COLUMN code TYPE varchar(80)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
`UPDATE freight.contract_templates SET is_system = true WHERE code = ANY($1)`,
|
||||||
|
[CONTAINER_CODES],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Retire the fixed bulk templates; staff recreate them per cargo type.
|
||||||
|
await queryRunner.query(
|
||||||
|
`UPDATE freight.contract_templates SET deleted_at = now()
|
||||||
|
WHERE code = ANY($1) AND deleted_at IS NULL`,
|
||||||
|
[BULK_CODES],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Code stays unique among live rows only, so a deleted combo can be
|
||||||
|
// recreated under the same generated code.
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.contract_templates DROP CONSTRAINT IF EXISTS uq_contract_templates_code`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_templates_code
|
||||||
|
ON freight.contract_templates (code) WHERE deleted_at IS NULL
|
||||||
|
`);
|
||||||
|
|
||||||
|
// One template per (bulk cargo type, customs option) — the "same
|
||||||
|
// combination" rule, enforced even under concurrent creates.
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_templates_cargo_customs
|
||||||
|
ON freight.contract_templates (cargo_type_id, with_customs)
|
||||||
|
WHERE deleted_at IS NULL AND cargo_type_id IS NOT NULL
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP INDEX IF EXISTS freight.uq_contract_templates_cargo_customs`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight.uq_contract_templates_code`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.contract_templates
|
||||||
|
ADD CONSTRAINT uq_contract_templates_code UNIQUE (code)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`UPDATE freight.contract_templates SET deleted_at = NULL WHERE code = ANY($1)`,
|
||||||
|
[BULK_CODES],
|
||||||
|
);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.contract_templates
|
||||||
|
DROP COLUMN IF EXISTS cargo_type_id,
|
||||||
|
DROP COLUMN IF EXISTS with_customs,
|
||||||
|
DROP COLUMN IF EXISTS is_system
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.cargo_types DROP COLUMN IF EXISTS has_contract_template
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EIMS registration state.
|
||||||
|
*
|
||||||
|
* `freight.invoices` gains the per-invoice registration outcome: which EIMS counter the invoice
|
||||||
|
* consumed, the returned IRN, and the last failure. The partial unique index on `eims_irn` is the
|
||||||
|
* database-level guarantee that one IRN can never be recorded against two invoices, independent of
|
||||||
|
* application logic.
|
||||||
|
*
|
||||||
|
* `freight.eims_system_state` is a single row per MoR system number holding the sequence the
|
||||||
|
* gateway expects: the next `SourceSystem.InvoiceCounter` and the `ReferenceDetails.PreviousIrn`
|
||||||
|
* of the last successful registration. Registration takes `FOR UPDATE` on this row, so the counter
|
||||||
|
* and the IRN chain stay consistent under concurrent submissions.
|
||||||
|
*
|
||||||
|
* The `in_flight_*` columns make a submission a *durable reservation*: the counter is consumed and
|
||||||
|
* the holder recorded in a committed transaction before the HTTP call, so a crash mid-flight leaves
|
||||||
|
* evidence instead of silently freeing the slot for a blind resubmission. `blocked_reason` is set
|
||||||
|
* when a submission ends ambiguously (timeout, network, 5xx) — the IRN is unknown, so every later
|
||||||
|
* document for this system number would chain to a stale `PreviousIrn` and registration stops until
|
||||||
|
* a human resolves it.
|
||||||
|
*
|
||||||
|
* `eims_ack_date` is varchar, not timestamptz: EIMS returns a Java ZonedDateTime string
|
||||||
|
* ("2025-03-21T08:33:32.707753413Z[Etc/UTC]") that no JS date parser accepts. It is stored
|
||||||
|
* verbatim so a compliance value is never mangled by a parse.
|
||||||
|
*/
|
||||||
|
export class EimsInvoiceRegistration3330000000000 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.invoices
|
||||||
|
ADD COLUMN IF NOT EXISTS eims_status varchar(20) NOT NULL DEFAULT 'NOT_SUBMITTED',
|
||||||
|
ADD COLUMN IF NOT EXISTS eims_irn varchar(64),
|
||||||
|
ADD COLUMN IF NOT EXISTS eims_invoice_counter bigint,
|
||||||
|
ADD COLUMN IF NOT EXISTS eims_submitted_at timestamptz,
|
||||||
|
ADD COLUMN IF NOT EXISTS eims_ack_date varchar(64),
|
||||||
|
ADD COLUMN IF NOT EXISTS eims_last_error jsonb
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS ux_invoices_eims_irn
|
||||||
|
ON freight.invoices (eims_irn) WHERE eims_irn IS NOT NULL
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.eims_system_state (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
system_number varchar(32) NOT NULL UNIQUE,
|
||||||
|
next_invoice_counter bigint NOT NULL DEFAULT 1,
|
||||||
|
previous_irn varchar(64),
|
||||||
|
in_flight_invoice_id uuid,
|
||||||
|
in_flight_counter bigint,
|
||||||
|
blocked_reason text,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.eims_system_state`);
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight.ux_invoices_eims_irn`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.invoices
|
||||||
|
DROP COLUMN IF EXISTS eims_status,
|
||||||
|
DROP COLUMN IF EXISTS eims_irn,
|
||||||
|
DROP COLUMN IF EXISTS eims_invoice_counter,
|
||||||
|
DROP COLUMN IF EXISTS eims_submitted_at,
|
||||||
|
DROP COLUMN IF EXISTS eims_ack_date,
|
||||||
|
DROP COLUMN IF EXISTS eims_last_error
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EIMS document numbering.
|
||||||
|
*
|
||||||
|
* MoR validates `DocumentDetails.DocumentNumber` against `^(0|[1-9][0-9]{0,8})$` — a plain integer
|
||||||
|
* of at most nine digits. Our own `INV-YYYYMMDD-NNNNN` can therefore never be sent, so EIMS needs
|
||||||
|
* its own sequence, allocated from the same locked state row as the invoice counter and recorded
|
||||||
|
* on the invoice so a filed document can be traced back to it.
|
||||||
|
*/
|
||||||
|
export class EimsDocumentNumberSequence3340000000000 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.eims_system_state
|
||||||
|
ADD COLUMN IF NOT EXISTS next_document_number bigint NOT NULL DEFAULT 1,
|
||||||
|
ADD COLUMN IF NOT EXISTS in_flight_document_number bigint
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.invoices
|
||||||
|
ADD COLUMN IF NOT EXISTS eims_document_number varchar(16)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.invoices DROP COLUMN IF EXISTS eims_document_number
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.eims_system_state
|
||||||
|
DROP COLUMN IF EXISTS next_document_number,
|
||||||
|
DROP COLUMN IF EXISTS in_flight_document_number
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Editable customer-facing copy for the portal's public pages (/help, /faq,
|
||||||
|
* /terms, /privacy) plus the shared support-contact block, with an append-only
|
||||||
|
* version log behind it.
|
||||||
|
*
|
||||||
|
* `payload` is opaque jsonb: the five documents have genuinely different shapes
|
||||||
|
* and the help page's blocks change with the copy, so typed columns would mean
|
||||||
|
* a migration per wording tweak. The shape is enforced by per-slug DTOs on
|
||||||
|
* write instead.
|
||||||
|
*
|
||||||
|
* No rows are inserted here — `SupportContentSeeder` fills the table on first
|
||||||
|
* boot and skips whenever it is non-empty, so a redeploy never overwrites
|
||||||
|
* admin edits the way a migration-embedded INSERT eventually would.
|
||||||
|
*/
|
||||||
|
export class SupportContent3350000000000 implements MigrationInterface {
|
||||||
|
name = "SupportContent3350000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.support_documents (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
slug varchar(32) NOT NULL,
|
||||||
|
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
version integer NOT NULL DEFAULT 1,
|
||||||
|
updated_by_id uuid,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_support_documents_slug
|
||||||
|
ON freight.support_documents (slug);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.support_document_versions (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
document_id uuid NOT NULL
|
||||||
|
REFERENCES freight.support_documents(id) ON DELETE CASCADE,
|
||||||
|
version integer NOT NULL,
|
||||||
|
payload jsonb NOT NULL,
|
||||||
|
actor_id uuid,
|
||||||
|
note varchar(255),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Closes the concurrent-save race: two editors saving at once cannot both
|
||||||
|
// claim the same version number.
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_support_doc_version
|
||||||
|
ON freight.support_document_versions (document_id, version);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_support_doc_versions_document
|
||||||
|
ON freight.support_document_versions (document_id);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP TABLE IF EXISTS freight.support_document_versions;`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.support_documents;`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts the HELP document from its original fixed-block shape
|
||||||
|
* (`video` / `chat` / `channels` / `topics` / `checklist`) to the free-form
|
||||||
|
* `sections[]` builder, where every block is a heading plus markdown plus
|
||||||
|
* attached media.
|
||||||
|
*
|
||||||
|
* Only rows still in the old shape are touched — detected by the presence of a
|
||||||
|
* `channels` key — so this is a no-op on any environment seeded after the
|
||||||
|
* change, and re-running it does nothing.
|
||||||
|
*
|
||||||
|
* The payload literal is inlined rather than imported from
|
||||||
|
* `SUPPORT_CONTENT_DEFAULTS`: a migration must keep doing the same thing
|
||||||
|
* forever, and that constant will keep moving.
|
||||||
|
*
|
||||||
|
* The rewrite also bumps `version` and writes a matching history row. The live
|
||||||
|
* row's version always having a matching entry in
|
||||||
|
* `support_document_versions` is the invariant the history list and rollback
|
||||||
|
* both depend on, and a silent payload swap would break it.
|
||||||
|
*/
|
||||||
|
const HELP_SECTIONS = [
|
||||||
|
{
|
||||||
|
id: "help-walkthrough",
|
||||||
|
heading: "Portal walkthrough",
|
||||||
|
body: "A guided tour of the portal — registering your company, raising a booking against a contract, and settling an invoice.",
|
||||||
|
media: [
|
||||||
|
{
|
||||||
|
id: "help-walkthrough-video",
|
||||||
|
kind: "video",
|
||||||
|
src: "/assets/edr-portal-guide.webm",
|
||||||
|
caption: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "help-chat",
|
||||||
|
heading: "Chat with our team",
|
||||||
|
body: "Signed-in customers can open a support conversation from the headset button at the bottom right of every portal page. You can send screenshots and documents in the chat, and replies appear there and as a notification.\n\n[Open the portal](/portal)",
|
||||||
|
media: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "help-contact",
|
||||||
|
heading: "Contact us",
|
||||||
|
body: "- **Email** — [{{supportEmail}}](mailto:{{supportEmail}}). Best for document issues and anything needing an attachment.\n- **Phone** — [{{supportPhone}}](tel:{{supportPhoneTel}}). Best for urgent problems with cargo already in transit.\n- **Head office** — {{supportOffice}}. Walk-in support during working hours.\n- **Support hours** — {{supportHours}}. Outside these hours, email us and we reply the next working day.",
|
||||||
|
media: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "help-topics",
|
||||||
|
heading: "Common topics",
|
||||||
|
body: "- **[Account & onboarding](/faq)** — registering your company, uploading your trade licence and TIN, and getting an operational profile approved.\n- **[Contracts](/faq)** — requesting a freight contract, reviewing its terms and signing it with your saved signature and stamp.\n- **[Bookings & tracking](/faq)** — raising a booking against a contract, adding last-mile transport and following the consignment along the corridor.\n- **[Invoices & payments](/faq)** — finding invoices, paying through the bank channels and confirming a payment that has not yet settled.",
|
||||||
|
media: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "help-checklist",
|
||||||
|
heading: "What to include when you contact us",
|
||||||
|
body: "- Your company name and the email you sign in with.\n- The reference of the contract, booking or invoice involved.\n- What you expected to happen and what happened instead.\n- A screenshot of any error message the portal showed.",
|
||||||
|
media: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export class SupportHelpSections3360000000000 implements MigrationInterface {
|
||||||
|
name = "SupportHelpSections3360000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
const rows: { id: string; version: number; payload: Record<string, unknown> }[] =
|
||||||
|
await queryRunner.query(`
|
||||||
|
SELECT id, version, payload
|
||||||
|
FROM freight.support_documents
|
||||||
|
WHERE slug = 'HELP' AND payload ? 'channels'
|
||||||
|
`);
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const payload = {
|
||||||
|
title: row.payload.title ?? "Help & Support",
|
||||||
|
subtitle:
|
||||||
|
row.payload.subtitle ??
|
||||||
|
"Get answers fast — watch the walkthrough, browse the common topics, check the FAQ, or reach our team directly.",
|
||||||
|
sections: HELP_SECTIONS,
|
||||||
|
};
|
||||||
|
const version = row.version + 1;
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
`UPDATE freight.support_documents
|
||||||
|
SET payload = $1::jsonb, version = $2, updated_at = now()
|
||||||
|
WHERE id = $3`,
|
||||||
|
[JSON.stringify(payload), version, row.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO freight.support_document_versions
|
||||||
|
(document_id, version, payload, actor_id, note)
|
||||||
|
VALUES ($1, $2, $3::jsonb, NULL, $4)`,
|
||||||
|
[
|
||||||
|
row.id,
|
||||||
|
version,
|
||||||
|
JSON.stringify(payload),
|
||||||
|
"Converted help page to free-form sections",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Not reversible: the old fixed blocks cannot be recovered from markdown
|
||||||
|
* sections an editor may since have rewritten. The version history holds the
|
||||||
|
* pre-conversion payload if it is ever genuinely needed.
|
||||||
|
*/
|
||||||
|
public async down(): Promise<void> {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export cargo reaches a train two ways, and until now only one was modelled.
|
||||||
|
*
|
||||||
|
* DIRECT_TO_TRAIN — the customer's truck pulls alongside and the cargo goes
|
||||||
|
* straight onto the wagon. It never enters a warehouse, so no GRN is ever
|
||||||
|
* raised; the Carriage Acceptance Sheet is the only document handed over.
|
||||||
|
*
|
||||||
|
* WAREHOUSE — cargo is received into the warehouse, GRN'd, then loaded. This is
|
||||||
|
* the existing flow and stays gated on the GRN.
|
||||||
|
*
|
||||||
|
* NULL means WAREHOUSE, so existing rows keep today's behaviour with no backfill.
|
||||||
|
*/
|
||||||
|
export class BookingExportHandoverMode3370000000000 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.bookings
|
||||||
|
ADD COLUMN IF NOT EXISTS export_handover_mode varchar(20)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.bookings DROP COLUMN IF EXISTS export_handover_mode
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { PaginatedResponse } from '@edr/types';
|
import { PaginatedResponse } from '@edr/types';
|
||||||
import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
|
import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
|
||||||
@@ -19,6 +20,7 @@ import { paginateQuery } from '../../common/utils/pagination.util';
|
|||||||
export class ListUsersService {
|
export class ListUsersService {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(User) private readonly users: Repository<User>,
|
@InjectRepository(User) private readonly users: Repository<User>,
|
||||||
|
private readonly config: ConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
findAll(query: ListUsersQueryDto): Promise<PaginatedResponse<User>> {
|
findAll(query: ListUsersQueryDto): Promise<PaginatedResponse<User>> {
|
||||||
@@ -40,6 +42,29 @@ export class ListUsersService {
|
|||||||
])
|
])
|
||||||
.orderBy(`user.${sortBy}`, query.sortOrder ?? 'ASC');
|
.orderBy(`user.${sortBy}`, query.sortOrder ?? 'ASC');
|
||||||
|
|
||||||
|
// Restrict to one IAM organization when configured. The org key differs per
|
||||||
|
// environment (dev seeds `edr_freight`, production uses the registered
|
||||||
|
// company key), so this is config rather than a constant. An unset key
|
||||||
|
// means no restriction; a key matching no organization matches no user —
|
||||||
|
// failing closed rather than silently widening to every org.
|
||||||
|
const orgKey = this.config.get<string>('FREIGHT_ORG_KEY');
|
||||||
|
if (orgKey) {
|
||||||
|
// EXISTS, not a join: a user with several employee rows would otherwise
|
||||||
|
// be returned once per row, duplicating them in the list and inflating
|
||||||
|
// `getManyAndCount`'s total.
|
||||||
|
qb.andWhere(
|
||||||
|
`EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM iam.employees emp
|
||||||
|
JOIN iam.organizations org ON org.id = emp.organization_id
|
||||||
|
WHERE emp.user_id = "user".id
|
||||||
|
AND org.key = :orgKey
|
||||||
|
AND org.deleted_at IS NULL
|
||||||
|
)`,
|
||||||
|
{ orgKey },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (query.userType) {
|
if (query.userType) {
|
||||||
qb.andWhere('user.userType = :userType', { userType: query.userType });
|
qb.andWhere('user.userType = :userType', { userType: query.userType });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,8 @@ export class BackofficeService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* IAM user ids of every current employee across all organizations — used by
|
* IAM user ids of every current employee across all organizations — used by
|
||||||
* the notification recipients resolver's `allBackoffice` selector.
|
* support chat for staff room membership. Notifications deliberately do NOT
|
||||||
|
* use this: they target a desk via `getEmployeeUserIdsByPermission`.
|
||||||
*/
|
*/
|
||||||
async getAllCurrentEmployeeUserIds(): Promise<string[]> {
|
async getAllCurrentEmployeeUserIds(): Promise<string[]> {
|
||||||
const employees = await this.employeeRepository.find({
|
const employees = await this.employeeRepository.find({
|
||||||
@@ -63,24 +64,67 @@ export class BackofficeService {
|
|||||||
* IAM user ids of current employees (any org) holding ANY of the given
|
* IAM user ids of current employees (any org) holding ANY of the given
|
||||||
* permission keys — used by the notification recipients resolver's
|
* permission keys — used by the notification recipients resolver's
|
||||||
* `permissionKeys` selector for department/role-scoped targeting.
|
* `permissionKeys` selector for department/role-scoped targeting.
|
||||||
|
*
|
||||||
|
* This MUST agree with the request-time guard (`hasFreightPermission`,
|
||||||
|
* common/freight-permission.util.ts), which counts four grant carriers plus
|
||||||
|
* the super_admin bypass. Counting fewer silently drops legitimate
|
||||||
|
* recipients: an earlier version joined only direct position permissions, on
|
||||||
|
* which `bookings:view` resolved to 2 users — against 21 through position
|
||||||
|
* TYPES, which is where admin-created positions actually keep their grants.
|
||||||
|
*
|
||||||
|
* Raw SQL rather than QueryBuilder because `Position.positionTypePermissions`
|
||||||
|
* declares its inverse side against PositionType, so a relation join emits
|
||||||
|
* `ptp.position_type_id = position.id` and silently matches nothing. Same
|
||||||
|
* approach as FreightMeService's position-type lookups.
|
||||||
*/
|
*/
|
||||||
async getEmployeeUserIdsByPermission(
|
async getEmployeeUserIdsByPermission(
|
||||||
permissionKeys: string[],
|
permissionKeys: string[],
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
if (!permissionKeys.length) return [];
|
if (!permissionKeys.length) return [];
|
||||||
const rows: { userId: string | null }[] = await this.employeeRepository
|
const rows: { userId: string }[] = await this.dataSource.query(
|
||||||
.createQueryBuilder("employee")
|
`WITH target AS (SELECT id FROM iam.permissions WHERE key = ANY($1))
|
||||||
.innerJoin("employee.employeePositions", "employeePosition")
|
-- 1. IAM role grants (user_roles -> role_permissions).
|
||||||
.innerJoin("employeePosition.position", "position")
|
SELECT e.user_id AS "userId"
|
||||||
.innerJoin("position.positionPermission", "positionPermission")
|
FROM iam.employees e
|
||||||
.innerJoin("positionPermission.permission", "permission")
|
JOIN iam.user_roles ur ON ur.user_id = e.user_id
|
||||||
.where("employee.isCurrent = :isCurrent", { isCurrent: true })
|
JOIN iam.role_permissions rp ON rp.role_id = ur.role_id
|
||||||
.andWhere("permission.key IN (:...permissionKeys)", { permissionKeys })
|
WHERE e.is_current AND e.user_id IS NOT NULL
|
||||||
.select("DISTINCT employee.user_id", "userId")
|
AND rp.permission_id IN (SELECT id FROM target)
|
||||||
.getRawMany();
|
UNION
|
||||||
return rows
|
-- 2. Direct position grants. A delegate keeps their own position AND
|
||||||
.map((r) => r.userId)
|
-- gains the one they stand in for, so both columns count.
|
||||||
.filter((id): id is string => Boolean(id));
|
SELECT e.user_id
|
||||||
|
FROM iam.employees e
|
||||||
|
JOIN iam.employee_positions ep
|
||||||
|
ON ep.employee_id = e.id AND ep.is_current
|
||||||
|
JOIN iam.position_permissions pp
|
||||||
|
ON pp.position_id IN (ep.position_id, ep.delegatee_position_id)
|
||||||
|
WHERE e.is_current AND e.user_id IS NOT NULL
|
||||||
|
AND pp.permission_id IN (SELECT id FROM target)
|
||||||
|
UNION
|
||||||
|
-- 3. Position TYPE grants — where admin-created positions keep theirs.
|
||||||
|
SELECT e.user_id
|
||||||
|
FROM iam.employees e
|
||||||
|
JOIN iam.employee_positions ep
|
||||||
|
ON ep.employee_id = e.id AND ep.is_current
|
||||||
|
JOIN iam.positions p
|
||||||
|
ON p.id IN (ep.position_id, ep.delegatee_position_id)
|
||||||
|
JOIN iam.position_type_permissions ptp
|
||||||
|
ON ptp.position_type_id = p.position_type_id
|
||||||
|
WHERE e.is_current AND e.user_id IS NOT NULL
|
||||||
|
AND ptp.permission_id IN (SELECT id FROM target)
|
||||||
|
UNION
|
||||||
|
-- 4. super_admin passes every freight permission check, so mirror that
|
||||||
|
-- here or admins go blind on desks nobody else has been granted yet.
|
||||||
|
SELECT e.user_id
|
||||||
|
FROM iam.employees e
|
||||||
|
JOIN iam.user_roles ur ON ur.user_id = e.user_id
|
||||||
|
JOIN iam.roles r ON r.id = ur.role_id
|
||||||
|
WHERE e.is_current AND e.user_id IS NOT NULL
|
||||||
|
AND r.key = 'super_admin'`,
|
||||||
|
[permissionKeys],
|
||||||
|
);
|
||||||
|
return rows.map((r) => r.userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createOrganizationUser(
|
async createOrganizationUser(
|
||||||
|
|||||||
@@ -1,26 +1,44 @@
|
|||||||
import {
|
import {
|
||||||
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
|
Post,
|
||||||
Query,
|
Query,
|
||||||
Res,
|
Res,
|
||||||
|
UploadedFile,
|
||||||
|
UseInterceptors,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
import { FileInterceptor } from "@nestjs/platform-express";
|
||||||
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiConsumes,
|
||||||
|
ApiOperation,
|
||||||
|
ApiTags,
|
||||||
|
} from "@nestjs/swagger";
|
||||||
import type { Response } from "express";
|
import type { Response } from "express";
|
||||||
|
|
||||||
import { CurrentUser } from "@edr/api-common";
|
import { CurrentUser } from "@edr/api-common";
|
||||||
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||||
|
|
||||||
import { BookingStaff } from "../../common/booking-guards";
|
import { BookingStaff } from "../../common/booking-guards";
|
||||||
|
import { resolveAuthUserId } from "../../common/resolve-auth-user-id";
|
||||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||||
|
import { actorLabel } from "../warehouses/current-actor.util";
|
||||||
import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service";
|
import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service";
|
||||||
import { BillingService } from "./billing.service";
|
import { BillingService } from "./billing.service";
|
||||||
import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
|
import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
|
||||||
|
|
||||||
@ApiTags("billing")
|
@ApiTags("billing")
|
||||||
@Controller("billing")
|
@Controller("billing")
|
||||||
@BookingStaff(FREIGHT_PERMS.invoices.view)
|
// Class gate lists every key its routes use: Nest runs class AND method
|
||||||
|
// guards, so a key missing here would deny before the route's own key runs.
|
||||||
|
@BookingStaff([
|
||||||
|
FREIGHT_PERMS.invoices.view,
|
||||||
|
FREIGHT_PERMS.invoices.export,
|
||||||
|
FREIGHT_PERMS.invoices.confirmOffline,
|
||||||
|
])
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
export class BillingController {
|
export class BillingController {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -51,6 +69,36 @@ export class BillingController {
|
|||||||
return this.billingService.findById(id);
|
return this.billingService.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get("offline-usd")
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Finance worklist: USD invoices settled offline by bank transfer, with booking pay-window context",
|
||||||
|
})
|
||||||
|
findOfflineUsd(@Query() query: FilterInvoiceDto) {
|
||||||
|
return this.billingService.findOfflineUsdPaginated(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("invoices/:id/confirm-offline")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.invoices.confirmOffline)
|
||||||
|
@UseInterceptors(FileInterceptor("file"))
|
||||||
|
@ApiConsumes("multipart/form-data")
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Finance confirms a USD invoice paid by bank transfer — slip file required, settles the full balance",
|
||||||
|
})
|
||||||
|
confirmOffline(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@UploadedFile() file: Express.Multer.File | undefined,
|
||||||
|
@Body("reference") reference: string | undefined,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
return this.billingService.confirmOfflinePayment(id, file, {
|
||||||
|
reference: reference?.trim() || null,
|
||||||
|
userId: resolveAuthUserId(user),
|
||||||
|
userName: actorLabel(user) ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@Get("invoices/:id/document")
|
@Get("invoices/:id/document")
|
||||||
@BookingStaff(FREIGHT_PERMS.invoices.export)
|
@BookingStaff(FREIGHT_PERMS.invoices.export)
|
||||||
@ApiOperation({ summary: "Download the sealed invoice PDF" })
|
@ApiOperation({ summary: "Download the sealed invoice PDF" })
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { InvoiceRepository } from "./invoice.repository";
|
|||||||
import { InvoiceLineRepository } from "./invoice-line.repository";
|
import { InvoiceLineRepository } from "./invoice-line.repository";
|
||||||
import { PaymentModule } from "../payment/payment.module";
|
import { PaymentModule } from "../payment/payment.module";
|
||||||
import { CompaniesModule } from "../companies/companies.module";
|
import { CompaniesModule } from "../companies/companies.module";
|
||||||
|
import { FilesModule } from "../files/files.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -21,6 +22,7 @@ import { CompaniesModule } from "../companies/companies.module";
|
|||||||
CompaniesModule,
|
CompaniesModule,
|
||||||
DocumentsModule,
|
DocumentsModule,
|
||||||
UserTradeAccessModule,
|
UserTradeAccessModule,
|
||||||
|
FilesModule,
|
||||||
],
|
],
|
||||||
controllers: [BillingController, PortalBillingController, PaymentController],
|
controllers: [BillingController, PortalBillingController, PaymentController],
|
||||||
providers: [BillingService, InvoiceRepository, InvoiceLineRepository],
|
providers: [BillingService, InvoiceRepository, InvoiceLineRepository],
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ describe("BillingService.generateInvoice", () => {
|
|||||||
{} as never, // payment
|
{} as never, // payment
|
||||||
{} as never, // companies
|
{} as never, // companies
|
||||||
{} as never, // invoiceDocuments
|
{} as never, // invoiceDocuments
|
||||||
|
{} as never, // files
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -140,6 +141,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
|||||||
{} as never, // payment
|
{} as never, // payment
|
||||||
{} as never, // companies
|
{} as never, // companies
|
||||||
{} as never, // invoiceDocuments
|
{} as never, // invoiceDocuments
|
||||||
|
{} as never, // files
|
||||||
);
|
);
|
||||||
|
|
||||||
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
||||||
@@ -193,6 +195,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
|||||||
{} as never, // payment
|
{} as never, // payment
|
||||||
{} as never, // companies
|
{} as never, // companies
|
||||||
{} as never, // invoiceDocuments
|
{} as never, // invoiceDocuments
|
||||||
|
{} as never, // files
|
||||||
);
|
);
|
||||||
|
|
||||||
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
||||||
@@ -236,6 +239,7 @@ describe("BillingService.settleByPaymentId", () => {
|
|||||||
{} as never, // payment
|
{} as never, // payment
|
||||||
{} as never, // companies
|
{} as never, // companies
|
||||||
{} as never, // invoiceDocuments
|
{} as never, // invoiceDocuments
|
||||||
|
{} as never, // files
|
||||||
);
|
);
|
||||||
return { service, mg, events };
|
return { service, mg, events };
|
||||||
}
|
}
|
||||||
@@ -347,6 +351,7 @@ describe("BillingService.recordPayment", () => {
|
|||||||
{} as never, // payment
|
{} as never, // payment
|
||||||
{} as never, // companies
|
{} as never, // companies
|
||||||
{} as never, // invoiceDocuments
|
{} as never, // invoiceDocuments
|
||||||
|
{} as never, // files
|
||||||
);
|
);
|
||||||
return { service, mg, events };
|
return { service, mg, events };
|
||||||
}
|
}
|
||||||
@@ -462,6 +467,7 @@ describe("BillingService.expirePayable — locked write runs in a transaction",
|
|||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{} as never,
|
||||||
);
|
);
|
||||||
return { service, defaultManager, txManager, transaction };
|
return { service, defaultManager, txManager, transaction };
|
||||||
};
|
};
|
||||||
@@ -533,6 +539,7 @@ describe("BillingService.issuePayable", () => {
|
|||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{} as never,
|
||||||
);
|
);
|
||||||
return { service, manager };
|
return { service, manager };
|
||||||
};
|
};
|
||||||
@@ -622,6 +629,7 @@ describe("BillingService — CAC Bank (OTP debit)", () => {
|
|||||||
payment as never,
|
payment as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{} as never,
|
||||||
);
|
);
|
||||||
return { service, repo };
|
return { service, repo };
|
||||||
};
|
};
|
||||||
@@ -703,6 +711,7 @@ describe("BillingService — CBE bill amounts round UP to whole birr", () => {
|
|||||||
payment as never,
|
payment as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{} as never,
|
||||||
);
|
);
|
||||||
return { service, repo };
|
return { service, repo };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { DataSource, EntityManager, In } from "typeorm";
|
|||||||
|
|
||||||
import { Booking } from "../bookings/entities/booking.entity";
|
import { Booking } from "../bookings/entities/booking.entity";
|
||||||
import { CompaniesService } from "../companies/companies.service";
|
import { CompaniesService } from "../companies/companies.service";
|
||||||
|
import { FilesService } from "../files/files.service";
|
||||||
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
|
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
|
||||||
import { PaymentService } from "../payment/payment.service";
|
import { PaymentService } from "../payment/payment.service";
|
||||||
import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto";
|
import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto";
|
||||||
@@ -35,6 +36,14 @@ export interface PayInvoiceOptions {
|
|||||||
failureUrl?: string;
|
failureUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Booking context attached to a finance offline-USD invoice row. */
|
||||||
|
export interface OfflineUsdBookingInfo {
|
||||||
|
id: string;
|
||||||
|
reference: string;
|
||||||
|
paymentDeadline: Date | null;
|
||||||
|
paymentStatus: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** A single manual/offline settlement to record against an invoice. */
|
/** A single manual/offline settlement to record against an invoice. */
|
||||||
export interface RecordPaymentInput {
|
export interface RecordPaymentInput {
|
||||||
/** Amount settled by this payment; must be > 0. */
|
/** Amount settled by this payment; must be > 0. */
|
||||||
@@ -150,6 +159,7 @@ export class BillingService {
|
|||||||
private readonly payment: PaymentService,
|
private readonly payment: PaymentService,
|
||||||
private readonly companies: CompaniesService,
|
private readonly companies: CompaniesService,
|
||||||
private readonly invoiceDocuments: InvoiceDocumentService,
|
private readonly invoiceDocuments: InvoiceDocumentService,
|
||||||
|
private readonly files: FilesService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
// ── Reads ──────────────────────────────────────────────────────────────────
|
// ── Reads ──────────────────────────────────────────────────────────────────
|
||||||
@@ -214,6 +224,146 @@ export class BillingService {
|
|||||||
return { items, total };
|
return { items, total };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finance's offline-settlement worklist: USD invoices (paid by bank transfer,
|
||||||
|
* never through the gateway), open ones by default or a single status when
|
||||||
|
* filtered. Booking-sourced rows carry the booking's reference and pay-window
|
||||||
|
* deadline so the UI can show the countdown and link to the booking.
|
||||||
|
*/
|
||||||
|
async findOfflineUsdPaginated(
|
||||||
|
filter: {
|
||||||
|
status?: Freight.InvoiceStatus;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
} = {},
|
||||||
|
): Promise<{
|
||||||
|
items: (Invoice & { booking: OfflineUsdBookingInfo | null })[];
|
||||||
|
total: number;
|
||||||
|
}> {
|
||||||
|
const page = filter.page && filter.page > 0 ? filter.page : 1;
|
||||||
|
const pageSize =
|
||||||
|
filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20;
|
||||||
|
|
||||||
|
const qb = this.dataSource
|
||||||
|
.getRepository(Invoice)
|
||||||
|
.createQueryBuilder("invoice")
|
||||||
|
.leftJoinAndSelect("invoice.company", "company")
|
||||||
|
.where("UPPER(invoice.currency) = 'USD'")
|
||||||
|
.orderBy("invoice.issuedAt", "DESC")
|
||||||
|
.skip((page - 1) * pageSize)
|
||||||
|
.take(pageSize);
|
||||||
|
|
||||||
|
if (filter.status) {
|
||||||
|
qb.andWhere("invoice.status = :status", { status: filter.status });
|
||||||
|
} else {
|
||||||
|
qb.andWhere("invoice.status IN (:...open)", { open: OPEN_STATUSES });
|
||||||
|
}
|
||||||
|
if (filter.search) {
|
||||||
|
qb.andWhere(
|
||||||
|
"(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)",
|
||||||
|
{ search: `%${filter.search}%` },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [items, total] = await qb.getManyAndCount();
|
||||||
|
|
||||||
|
const bookingIds = items
|
||||||
|
.filter((i) => i.source === "booking")
|
||||||
|
.map((i) => i.sourceId);
|
||||||
|
const bookings = bookingIds.length
|
||||||
|
? await this.dataSource.getRepository(Booking).find({
|
||||||
|
where: { id: In(bookingIds) },
|
||||||
|
select: ["id", "reference", "paymentDeadline", "paymentStatus"],
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const byId = new Map(bookings.map((b) => [b.id, b]));
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: items.map((inv) => {
|
||||||
|
const b = byId.get(inv.sourceId);
|
||||||
|
return {
|
||||||
|
...inv,
|
||||||
|
booking: b
|
||||||
|
? {
|
||||||
|
id: b.id,
|
||||||
|
reference: b.reference,
|
||||||
|
paymentDeadline: b.paymentDeadline ?? null,
|
||||||
|
paymentStatus: b.paymentStatus,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
} as Invoice & { booking: OfflineUsdBookingInfo | null };
|
||||||
|
}),
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finance confirms a USD invoice as paid by bank transfer: stores the slip
|
||||||
|
* against the invoice and settles the FULL outstanding balance through
|
||||||
|
* {@link recordPayment}, which flips the invoice to PAID and (for bookings)
|
||||||
|
* emits `booking.invoice.paid` — the same event an online payment fires, so
|
||||||
|
* the booking advances exactly as if it had been paid through the gateway.
|
||||||
|
*
|
||||||
|
* Guarded by the booking's pay window: past the deadline the booking expires
|
||||||
|
* like any unpaid one, so confirmation is refused.
|
||||||
|
*/
|
||||||
|
async confirmOfflinePayment(
|
||||||
|
invoiceId: string,
|
||||||
|
file: Express.Multer.File | undefined,
|
||||||
|
input: {
|
||||||
|
reference?: string | null;
|
||||||
|
userId?: string | null;
|
||||||
|
userName?: string | null;
|
||||||
|
},
|
||||||
|
): Promise<Invoice> {
|
||||||
|
const invoice = await this.invoices.findById(invoiceId);
|
||||||
|
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||||
|
if (invoice.currency?.toUpperCase() !== "USD") {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Offline confirmation is only for USD invoices — this invoice is paid online.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!file) {
|
||||||
|
throw new BadRequestException("The bank payment slip file is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoice.source === "booking") {
|
||||||
|
const booking = await this.dataSource.getRepository(Booking).findOne({
|
||||||
|
where: { id: invoice.sourceId },
|
||||||
|
select: ["id", "paymentDeadline"],
|
||||||
|
});
|
||||||
|
const deadline = booking?.paymentDeadline;
|
||||||
|
if (deadline && new Date(deadline).getTime() < Date.now()) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"The payment window has closed — this booking can no longer be confirmed as paid.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const slip = await this.files.upload({
|
||||||
|
resource: "invoice",
|
||||||
|
resourceId: invoice.id,
|
||||||
|
code: "OFFLINE_PAYMENT_SLIP",
|
||||||
|
file,
|
||||||
|
title: "Bank payment slip",
|
||||||
|
uploadedByUserId: input.userId ?? null,
|
||||||
|
uploadedByName: input.userName ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.recordPayment(invoiceId, {
|
||||||
|
amount: Number(invoice.balanceAmount),
|
||||||
|
method: "BANK_TRANSFER",
|
||||||
|
reference: input.reference || slip.name,
|
||||||
|
metadata: {
|
||||||
|
offline: true,
|
||||||
|
slipFileId: slip.id,
|
||||||
|
confirmedByUserId: input.userId ?? null,
|
||||||
|
confirmedByName: input.userName ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Invoice header plus its line items. */
|
/** Invoice header plus its line items. */
|
||||||
async findById(id: string): Promise<Invoice & { lines: InvoiceLine[] }> {
|
async findById(id: string): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||||
const invoice = await this.invoices.findById(id, {
|
const invoice = await this.invoices.findById(id, {
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
import {
|
||||||
|
EimsMapperContext,
|
||||||
|
EimsMapperInvoice,
|
||||||
|
EimsSellerDetails,
|
||||||
|
formatEimsDate,
|
||||||
|
toEimsInvoice,
|
||||||
|
} from "./eims-invoice.mapper";
|
||||||
|
|
||||||
|
const seller: EimsSellerDetails = {
|
||||||
|
City: null,
|
||||||
|
Email: "finance@edr.et",
|
||||||
|
HouseNumber: null,
|
||||||
|
LegalName: "Ethio-Djibouti Railway S.C.",
|
||||||
|
Locality: null,
|
||||||
|
Phone: "0911223344",
|
||||||
|
Region: "13",
|
||||||
|
SubCity: null,
|
||||||
|
Tin: "0016324478",
|
||||||
|
VatNumber: "3215840010",
|
||||||
|
Wereda: "574",
|
||||||
|
};
|
||||||
|
|
||||||
|
const invoice = (over: Partial<EimsMapperInvoice> = {}): EimsMapperInvoice => ({
|
||||||
|
invoiceNumber: "INV-20260807-00042",
|
||||||
|
currency: "ETB",
|
||||||
|
issuedAt: new Date(2026, 7, 7, 9, 5, 3),
|
||||||
|
totalAmount: "11000.00",
|
||||||
|
company: {
|
||||||
|
name: "ABC Trading PLC",
|
||||||
|
tin: "0999930000",
|
||||||
|
vatNumber: "123475885858",
|
||||||
|
phone: "0912345678",
|
||||||
|
email: "buyer@abc.et",
|
||||||
|
region: "13",
|
||||||
|
zone: "SHA",
|
||||||
|
woreda: "574",
|
||||||
|
kebele: "03",
|
||||||
|
houseNo: "NEW",
|
||||||
|
country: "Ethiopia",
|
||||||
|
},
|
||||||
|
lines: [
|
||||||
|
{ chargeType: "RAIL_FREIGHT", description: "Addis → Djibouti", quantity: "1.00", unitRate: "10000.00", amount: "10000.00" },
|
||||||
|
{ chargeType: "HAZARD_SURCHARGE", description: null, quantity: "2.00", unitRate: "500.00", amount: "1000.00", metadata: { unit: "CTR" } },
|
||||||
|
],
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const context = (over: Partial<EimsMapperContext> = {}): EimsMapperContext => ({
|
||||||
|
systemNumber: "B0360154BA",
|
||||||
|
systemType: "SYS",
|
||||||
|
documentNumber: "24",
|
||||||
|
invoiceCounter: 7,
|
||||||
|
previousIrn: "",
|
||||||
|
cashierName: null,
|
||||||
|
salesPersonName: null,
|
||||||
|
transactionType: "B2B",
|
||||||
|
payment: { mode: "CASH", term: "IMMIDIATE" },
|
||||||
|
taxForLine: () => ({ code: "VAT15", ratePercent: 15, exciseTaxValue: 0 }),
|
||||||
|
natureOfSupplies: "Service",
|
||||||
|
unitDefault: "PCS",
|
||||||
|
incomeWithholdValue: 0,
|
||||||
|
transactionWithholdValue: 0,
|
||||||
|
buyerRegionCodes: { "Addis Ababa": "13" },
|
||||||
|
buyerWeredaCodes: {},
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toEimsInvoice", () => {
|
||||||
|
it("emits the ten EIMS sections with the collection's field names", () => {
|
||||||
|
const doc = toEimsInvoice(invoice(), seller, context());
|
||||||
|
|
||||||
|
expect(Object.keys(doc)).toEqual([
|
||||||
|
"BuyerDetails",
|
||||||
|
"DocumentDetails",
|
||||||
|
"ItemList",
|
||||||
|
"PaymentDetails",
|
||||||
|
"ReferenceDetails",
|
||||||
|
"SellerDetails",
|
||||||
|
"SourceSystem",
|
||||||
|
"TransactionType",
|
||||||
|
"ValueDetails",
|
||||||
|
"Version",
|
||||||
|
]);
|
||||||
|
expect(doc.Version).toBe("1");
|
||||||
|
expect(doc.DocumentDetails).toEqual({ DocumentNumber: "24", Date: "07-08-2026T09:05:03", Type: "INV" });
|
||||||
|
expect(doc.SourceSystem.InvoiceCounter).toBe(7);
|
||||||
|
expect(doc.SellerDetails).toBe(seller);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps the buyer from the company row and leaves unmodelled fields null", () => {
|
||||||
|
const doc = toEimsInvoice(invoice(), seller, context());
|
||||||
|
|
||||||
|
expect(doc.BuyerDetails).toEqual({
|
||||||
|
City: null,
|
||||||
|
Email: "buyer@abc.et",
|
||||||
|
HouseNumber: "NEW",
|
||||||
|
IdNumber: null,
|
||||||
|
IdType: null,
|
||||||
|
Tin: "0999930000",
|
||||||
|
LegalName: "ABC Trading PLC",
|
||||||
|
Phone: "0912345678",
|
||||||
|
Region: "13",
|
||||||
|
Country: null,
|
||||||
|
Zone: "SHA",
|
||||||
|
Kebele: "03",
|
||||||
|
VatNumber: "123475885858",
|
||||||
|
Wereda: "574",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies per-line tax and totals it into ValueDetails", () => {
|
||||||
|
const doc = toEimsInvoice(
|
||||||
|
invoice(),
|
||||||
|
seller,
|
||||||
|
context({
|
||||||
|
taxForLine: (line) =>
|
||||||
|
line.chargeType === "RAIL_FREIGHT"
|
||||||
|
? { code: "VAT15", ratePercent: 15, exciseTaxValue: 0 }
|
||||||
|
: { code: "EXEMPT", ratePercent: 0, exciseTaxValue: 50 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(doc.ItemList[0]).toMatchObject({
|
||||||
|
LineNumber: 1,
|
||||||
|
ItemCode: "RAIL_FREIGHT",
|
||||||
|
ProductDescription: "Addis → Djibouti",
|
||||||
|
Quantity: 1,
|
||||||
|
UnitPrice: 10000,
|
||||||
|
PreTaxValue: 10000,
|
||||||
|
TaxCode: "VAT15",
|
||||||
|
TaxAmount: 1500,
|
||||||
|
ExciseTaxValue: 0,
|
||||||
|
TotalLineAmount: 11500,
|
||||||
|
Unit: "PCS",
|
||||||
|
NatureOfSupplies: "service",
|
||||||
|
HarmonizationCode: null,
|
||||||
|
});
|
||||||
|
expect(doc.ItemList[1]).toMatchObject({
|
||||||
|
LineNumber: 2,
|
||||||
|
ProductDescription: "HAZARD_SURCHARGE",
|
||||||
|
TaxCode: "EXEMPT",
|
||||||
|
TaxAmount: 0,
|
||||||
|
ExciseTaxValue: 50,
|
||||||
|
TotalLineAmount: 1050,
|
||||||
|
Unit: "CTR",
|
||||||
|
});
|
||||||
|
expect(doc.ValueDetails).toEqual({
|
||||||
|
Discount: null,
|
||||||
|
ExciseValue: 50,
|
||||||
|
IncomeWithholdValue: 0,
|
||||||
|
TaxValue: 1500,
|
||||||
|
TotalValue: 12550,
|
||||||
|
TransactionWithholdValue: 0,
|
||||||
|
InvoiceCurrency: "ETB",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes PreviousIrn through verbatim and defaults RelatedDocument to null", () => {
|
||||||
|
expect(toEimsInvoice(invoice(), seller, context()).ReferenceDetails).toEqual({
|
||||||
|
PreviousIrn: "",
|
||||||
|
RelatedDocument: null,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
toEimsInvoice(invoice(), seller, context({ previousIrn: null, relatedDocument: "CN-9" }))
|
||||||
|
.ReferenceDetails,
|
||||||
|
).toEqual({ PreviousIrn: null, RelatedDocument: "CN-9" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits ExchangeRate only when supplied", () => {
|
||||||
|
expect(toEimsInvoice(invoice(), seller, context()).ValueDetails.ExchangeRate).toBeUndefined();
|
||||||
|
|
||||||
|
const usd = toEimsInvoice(
|
||||||
|
invoice({ currency: "USD" }),
|
||||||
|
seller,
|
||||||
|
context({ exchangeRate: 132.5 }),
|
||||||
|
);
|
||||||
|
expect(usd.ValueDetails).toMatchObject({ InvoiceCurrency: "USD", ExchangeRate: 132.5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honours a caller-supplied date formatter", () => {
|
||||||
|
const doc = toEimsInvoice(invoice(), seller, context({ formatDate: () => "2026-08-07T09:05:03Z" }));
|
||||||
|
expect(doc.DocumentDetails.Date).toBe("2026-08-07T09:05:03Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when tax treatment cannot be resolved for a line", () => {
|
||||||
|
expect(() =>
|
||||||
|
toEimsInvoice(
|
||||||
|
invoice(),
|
||||||
|
seller,
|
||||||
|
context({ taxForLine: () => ({ code: "", ratePercent: 15, exciseTaxValue: 0 }) }),
|
||||||
|
),
|
||||||
|
).toThrow(/unresolved tax treatment for line 1/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on a missing buyer TIN, no lines, or an unissued invoice", () => {
|
||||||
|
expect(() => toEimsInvoice(invoice({ company: null }), seller, context())).toThrow(/buyer company TIN/);
|
||||||
|
expect(() => toEimsInvoice(invoice({ lines: [] }), seller, context())).toThrow(/has no lines/);
|
||||||
|
expect(() => toEimsInvoice(invoice({ issuedAt: null }), seller, context())).toThrow(/not issued/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when the lines do not sum to the invoice total", () => {
|
||||||
|
expect(() => toEimsInvoice(invoice({ totalAmount: "9000.00" }), seller, context())).toThrow(
|
||||||
|
/lines sum to 11000 but the invoice total is 9000/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on a non-ETB invoice with no exchange rate", () => {
|
||||||
|
expect(() => toEimsInvoice(invoice({ currency: "USD" }), seller, context())).toThrow(/needs an exchangeRate/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toEimsInvoice — MoR field constraints", () => {
|
||||||
|
it("passes a buyer region through when it is already a MoR code", () => {
|
||||||
|
const doc = toEimsInvoice(invoice(), seller, context());
|
||||||
|
expect(doc.BuyerDetails.Region).toBe("13");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps a region name to its code, ignoring case and spacing", () => {
|
||||||
|
const doc = toEimsInvoice(
|
||||||
|
invoice({ company: { ...invoice().company!, region: " addis ababa " } }),
|
||||||
|
seller,
|
||||||
|
context({ buyerRegionCodes: { "Addis Ababa": "13" } }),
|
||||||
|
);
|
||||||
|
expect(doc.BuyerDetails.Region).toBe("13");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to file a buyer whose region has no mapping", () => {
|
||||||
|
expect(() =>
|
||||||
|
toEimsInvoice(
|
||||||
|
invoice({ company: { ...invoice().company!, region: "Somewhere Else" } }),
|
||||||
|
seller,
|
||||||
|
context(),
|
||||||
|
),
|
||||||
|
).toThrow(/not a MoR Region code and has no mapping/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a buyer with no region at all rather than guessing one", () => {
|
||||||
|
expect(() =>
|
||||||
|
toEimsInvoice(
|
||||||
|
invoice({ company: { ...invoice().company!, region: null } }),
|
||||||
|
seller,
|
||||||
|
context(),
|
||||||
|
),
|
||||||
|
).toThrow(/buyer Region \(unset\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes a buyer wereda through when it is already a MoR code", () => {
|
||||||
|
const doc = toEimsInvoice(invoice(), seller, context());
|
||||||
|
expect(doc.BuyerDetails.Wereda).toBe("574");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps a wereda name to its code", () => {
|
||||||
|
const doc = toEimsInvoice(
|
||||||
|
invoice({ company: { ...invoice().company!, woreda: "Yeka" } }),
|
||||||
|
seller,
|
||||||
|
context({ buyerWeredaCodes: { Yeka: "99" } }),
|
||||||
|
);
|
||||||
|
expect(doc.BuyerDetails.Wereda).toBe("99");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to file a buyer whose wereda has no mapping", () => {
|
||||||
|
expect(() =>
|
||||||
|
toEimsInvoice(
|
||||||
|
invoice({ company: { ...invoice().company!, woreda: "Yeka" } }),
|
||||||
|
seller,
|
||||||
|
context({ buyerWeredaCodes: {} }),
|
||||||
|
),
|
||||||
|
).toThrow(/buyer Wereda "Yeka".*EIMS_BUYER_WEREDA_CODES/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits NatureOfSupplies lowercase, whatever case it was configured in", () => {
|
||||||
|
const doc = toEimsInvoice(invoice(), seller, context({ natureOfSupplies: "Service" }));
|
||||||
|
expect(doc.ItemList[0].NatureOfSupplies).toBe("service");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a NatureOfSupplies MoR does not accept", () => {
|
||||||
|
expect(() =>
|
||||||
|
toEimsInvoice(invoice(), seller, context({ natureOfSupplies: "Services" })),
|
||||||
|
).toThrow(/must be one of goods, service/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatEimsDate", () => {
|
||||||
|
it("renders the observed dd-MM-yyyyTHH:mm:ss shape with zero padding", () => {
|
||||||
|
expect(formatEimsDate(new Date(2025, 2, 21, 0, 0, 0))).toBe("21-03-2025T00:00:00");
|
||||||
|
});
|
||||||
|
});
|
||||||
441
apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts
Normal file
441
apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
/**
|
||||||
|
* Pure mapper from an EDR invoice onto the Ethiopian MoR EIMS registration document
|
||||||
|
* (`POST https://core.mor.gov.et/v1/register`).
|
||||||
|
*
|
||||||
|
* Field names, casing and section layout are taken verbatim from the supplied
|
||||||
|
* `EimsCoreApiMockCollection2.postman_collection.json`. Note the payload spells the district
|
||||||
|
* `Wereda` even though the collection *variable* is named `sellerWoreda`.
|
||||||
|
*
|
||||||
|
* Scope: mapping only — no HTTP, no signing, no persistence, no counter allocation. Everything
|
||||||
|
* that does not live on the invoice (document number, counters, previous IRN, seller identity,
|
||||||
|
* tax treatment) is supplied by the caller and is never guessed here.
|
||||||
|
*
|
||||||
|
* Values that the collection only *demonstrates* by example — the date format, the meaning of an
|
||||||
|
* empty `PreviousIrn`, the `SystemType` enum, `PaymentTerm` values — are treated as observed, not
|
||||||
|
* authoritative: they are passed through or overridable rather than validated against a fixed set.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { round2 } from "./invoice-settlement.util";
|
||||||
|
|
||||||
|
/** Only proven-required constant: the 400 SCHEMA ERROR sample rejects a payload without it. */
|
||||||
|
const EIMS_VERSION = "1";
|
||||||
|
|
||||||
|
/** The only `DocumentDetails.Type` observed in the supplied material. */
|
||||||
|
const EIMS_DOCUMENT_TYPE = "INV";
|
||||||
|
|
||||||
|
export interface EimsBuyerDetails {
|
||||||
|
City: string | null;
|
||||||
|
Email: string | null;
|
||||||
|
HouseNumber: string | null;
|
||||||
|
IdNumber: string | null;
|
||||||
|
IdType: string | null;
|
||||||
|
Tin: string;
|
||||||
|
LegalName: string;
|
||||||
|
Phone: string | null;
|
||||||
|
Region: string | null;
|
||||||
|
Country: string | null;
|
||||||
|
Zone: string | null;
|
||||||
|
Kebele: string | null;
|
||||||
|
VatNumber: string | null;
|
||||||
|
Wereda: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EimsSellerDetails {
|
||||||
|
City: string | null;
|
||||||
|
Email: string | null;
|
||||||
|
HouseNumber: string | null;
|
||||||
|
LegalName: string;
|
||||||
|
Locality: string | null;
|
||||||
|
Phone: string | null;
|
||||||
|
/** MoR region *code* (e.g. "13"), not a region name. */
|
||||||
|
Region: string | null;
|
||||||
|
SubCity: string | null;
|
||||||
|
Tin: string;
|
||||||
|
VatNumber: string | null;
|
||||||
|
/** MoR wereda *code* (e.g. "574"). */
|
||||||
|
Wereda: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EimsDocumentDetails {
|
||||||
|
DocumentNumber: string;
|
||||||
|
/** Observed format `dd-MM-yyyyTHH:mm:ss`. Rule seen in the collection: within 3 days of now. */
|
||||||
|
Date: string;
|
||||||
|
Type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EimsInvoiceItem {
|
||||||
|
Discount: number;
|
||||||
|
ExciseTaxValue: number;
|
||||||
|
HarmonizationCode: string | null;
|
||||||
|
NatureOfSupplies: string;
|
||||||
|
ItemCode: string;
|
||||||
|
ProductDescription: string;
|
||||||
|
PreTaxValue: number;
|
||||||
|
Quantity: number;
|
||||||
|
LineNumber: number;
|
||||||
|
TaxAmount: number;
|
||||||
|
TaxCode: string;
|
||||||
|
TotalLineAmount: number;
|
||||||
|
Unit: string;
|
||||||
|
UnitPrice: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EimsPaymentDetails {
|
||||||
|
Mode: string;
|
||||||
|
PaymentTerm: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EimsReferenceDetails {
|
||||||
|
PreviousIrn: string | null;
|
||||||
|
RelatedDocument: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EimsSourceSystem {
|
||||||
|
CashierName: string | null;
|
||||||
|
InvoiceCounter: number;
|
||||||
|
SalesPersonName: string | null;
|
||||||
|
SystemNumber: string;
|
||||||
|
SystemType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EimsValueDetails {
|
||||||
|
Discount: number | null;
|
||||||
|
ExciseValue: number;
|
||||||
|
IncomeWithholdValue: number;
|
||||||
|
TaxValue: number;
|
||||||
|
TotalValue: number;
|
||||||
|
TransactionWithholdValue: number;
|
||||||
|
InvoiceCurrency: string;
|
||||||
|
/** Absent from the register sample, present on the verify response. Emitted only when supplied. */
|
||||||
|
ExchangeRate?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EimsInvoiceRequest {
|
||||||
|
BuyerDetails: EimsBuyerDetails;
|
||||||
|
DocumentDetails: EimsDocumentDetails;
|
||||||
|
ItemList: EimsInvoiceItem[];
|
||||||
|
PaymentDetails: EimsPaymentDetails;
|
||||||
|
ReferenceDetails: EimsReferenceDetails;
|
||||||
|
SellerDetails: EimsSellerDetails;
|
||||||
|
SourceSystem: EimsSourceSystem;
|
||||||
|
TransactionType: string;
|
||||||
|
ValueDetails: EimsValueDetails;
|
||||||
|
Version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `body` of a successful `POST /v1/register`, as observed in the collection. */
|
||||||
|
export interface EimsRegisterResponseBody {
|
||||||
|
irn: string;
|
||||||
|
ackDate: string;
|
||||||
|
signedQR: string;
|
||||||
|
signedInvoice: string;
|
||||||
|
status: string;
|
||||||
|
documentNumber: string;
|
||||||
|
errorMessage: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Numeric columns arrive from pg as strings; every money field is normalised through `num`. */
|
||||||
|
export interface EimsMapperLine {
|
||||||
|
chargeType: string;
|
||||||
|
description?: string | null;
|
||||||
|
quantity: number | string;
|
||||||
|
unitRate: number | string;
|
||||||
|
amount: number | string;
|
||||||
|
metadata?: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EimsMapperCompany {
|
||||||
|
name: string;
|
||||||
|
tin: string;
|
||||||
|
vatNumber?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
region?: string | null;
|
||||||
|
zone?: string | null;
|
||||||
|
woreda?: string | null;
|
||||||
|
kebele?: string | null;
|
||||||
|
houseNo?: string | null;
|
||||||
|
country?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structurally what `BillingService.findById` returns — the only read path that loads the header,
|
||||||
|
* the buyer company and the lines together.
|
||||||
|
*/
|
||||||
|
export interface EimsMapperInvoice {
|
||||||
|
invoiceNumber: string;
|
||||||
|
currency: string;
|
||||||
|
issuedAt?: Date | string | null;
|
||||||
|
totalAmount: number | string;
|
||||||
|
company?: EimsMapperCompany | null;
|
||||||
|
lines: EimsMapperLine[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tax treatment for a single line. EIMS models `TaxCode`/`TaxAmount`/`ExciseTaxValue` per item, and
|
||||||
|
* different charge types may eventually be treated differently, so this is resolved per line.
|
||||||
|
*
|
||||||
|
* Nothing in this repo can supply it: `Invoice.taxAmount` is hardcoded to 0 with no caller ever
|
||||||
|
* setting it, `invoice_lines` has no tax column, and the rate catalogue has no fiscal field. That
|
||||||
|
* is the absence of a tax model, not evidence of zero-rating — hence no default here.
|
||||||
|
*/
|
||||||
|
export interface EimsLineTax {
|
||||||
|
code: string;
|
||||||
|
ratePercent: number;
|
||||||
|
exciseTaxValue: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EimsMapperContext {
|
||||||
|
systemNumber: string;
|
||||||
|
/** Observed values: POS, MAN, CRM, EFD, SYS (the collection prose also mentions ERP). */
|
||||||
|
systemType: string;
|
||||||
|
/** Caller decides the source — our own `invoiceNumber` or a dedicated EIMS sequence. */
|
||||||
|
documentNumber: string;
|
||||||
|
invoiceCounter: number;
|
||||||
|
/** Passed through verbatim; the collection shows `""` used for an unchained document. */
|
||||||
|
previousIrn: string | null;
|
||||||
|
cashierName: string | null;
|
||||||
|
salesPersonName: string | null;
|
||||||
|
/** B2B / B2C — a tax classification, so the caller states it. */
|
||||||
|
transactionType: string;
|
||||||
|
payment: { mode: string; term: string };
|
||||||
|
/** Must return a treatment for every line, or throw. */
|
||||||
|
taxForLine: (line: EimsMapperLine, lineNumber: number) => EimsLineTax;
|
||||||
|
natureOfSupplies: string;
|
||||||
|
/** Used when a line carries no `metadata.unit`. */
|
||||||
|
unitDefault: string;
|
||||||
|
incomeWithholdValue: number;
|
||||||
|
transactionWithholdValue: number;
|
||||||
|
/** Null for an ordinary invoice; set only for a real related-document case. */
|
||||||
|
relatedDocument?: string | null;
|
||||||
|
/** MoR numeric country code for the buyer; our DB stores the country name. */
|
||||||
|
buyerCountryCode?: string | null;
|
||||||
|
/**
|
||||||
|
* Region name → MoR numeric code, for buyers whose stored region is free text.
|
||||||
|
*
|
||||||
|
* `companies.region` holds names ("Addis Ababa") while MoR validates `BuyerDetails.Region`
|
||||||
|
* against `^[0-9]{1,3}$`. A stored value that is already a code passes through; anything else
|
||||||
|
* must be in this map or the mapping **fails locally** — sending a guessed region code onto a
|
||||||
|
* tax document is worse than refusing to file.
|
||||||
|
*/
|
||||||
|
buyerRegionCodes: Record<string, string>;
|
||||||
|
/**
|
||||||
|
* Wereda name → MoR code, same shape as `buyerRegionCodes`. `companies.woreda` holds names
|
||||||
|
* ("Yeka") or codes inconsistently; unlike Region, MoR has never named a Wereda regex in an
|
||||||
|
* error, so this is precautionary rather than confirmed — but the fix is identical either way:
|
||||||
|
* fail locally on an unmapped name rather than file a guess.
|
||||||
|
*/
|
||||||
|
buyerWeredaCodes: Record<string, string>;
|
||||||
|
buyerIdType?: string | null;
|
||||||
|
buyerIdNumber?: string | null;
|
||||||
|
buyerCity?: string | null;
|
||||||
|
/** Required when the invoice currency is not ETB. */
|
||||||
|
exchangeRate?: number | null;
|
||||||
|
invoiceDiscount?: number | null;
|
||||||
|
/** Override while the observed `dd-MM-yyyyTHH:mm:ss` format is unconfirmed by MoR. */
|
||||||
|
formatDate?: (issuedAt: Date) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MoR's own constraint on `Region`: one to three digits, confirmed by its 400 SCHEMA ERROR. Reused
|
||||||
|
* as the pass-through test for `Wereda` too — every Wereda value MoR has actually shown us (seller
|
||||||
|
* "12"/"13", the collection's "574") fits the same shape, though MoR has not named a Wereda regex
|
||||||
|
* the way it named Region's.
|
||||||
|
*/
|
||||||
|
const LOCATION_CODE = /^[0-9]{1,3}$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The only two values MoR accepts for `NatureOfSupplies`, lowercase.
|
||||||
|
*
|
||||||
|
* Its schema branches on this as a `oneOf` with a `const` per branch, so `"Service"` fails the
|
||||||
|
* whole `ItemList` — the error reads "must be the constant value 'service'".
|
||||||
|
*/
|
||||||
|
const NATURE_OF_SUPPLIES = ["goods", "service"] as const;
|
||||||
|
|
||||||
|
const num = (v: number | string): number => {
|
||||||
|
const n = Number(v);
|
||||||
|
if (!Number.isFinite(n)) throw new Error(`EIMS mapping: expected a numeric value, got ${String(v)}`);
|
||||||
|
return n;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pad = (n: number, width = 2): string => String(n).padStart(width, "0");
|
||||||
|
|
||||||
|
/** Observed EIMS document-date format: `dd-MM-yyyyTHH:mm:ss`, no timezone marker. */
|
||||||
|
export const formatEimsDate = (issuedAt: Date): string =>
|
||||||
|
`${pad(issuedAt.getDate())}-${pad(issuedAt.getMonth() + 1)}-${issuedAt.getFullYear()}` +
|
||||||
|
`T${pad(issuedAt.getHours())}:${pad(issuedAt.getMinutes())}:${pad(issuedAt.getSeconds())}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map one loaded invoice onto an EIMS registration document.
|
||||||
|
*
|
||||||
|
* Throws rather than emitting a payload EIMS would reject opaquely: missing buyer TIN, no lines,
|
||||||
|
* an unissued invoice, unresolved line tax, a line/total mismatch, or a non-ETB invoice with no
|
||||||
|
* exchange rate.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* A buyer's location value (Region or Wereda) as a MoR code: passed through when already numeric,
|
||||||
|
* otherwise looked up by name (case- and space-insensitive). Throws when neither applies — sending
|
||||||
|
* a guessed code onto a tax document is worse than refusing to file.
|
||||||
|
*/
|
||||||
|
function resolveLocationCode(
|
||||||
|
field: "Region" | "Wereda",
|
||||||
|
value: string | null | undefined,
|
||||||
|
codes: Record<string, string>,
|
||||||
|
envVar: string,
|
||||||
|
invoiceNumber: string,
|
||||||
|
): string {
|
||||||
|
const raw = (value ?? "").trim();
|
||||||
|
if (LOCATION_CODE.test(raw)) return raw;
|
||||||
|
|
||||||
|
const key = raw.toLowerCase().replace(/\s+/g, " ");
|
||||||
|
const mapped = Object.entries(codes).find(
|
||||||
|
([name]) => name.trim().toLowerCase().replace(/\s+/g, " ") === key,
|
||||||
|
)?.[1];
|
||||||
|
if (mapped && LOCATION_CODE.test(mapped)) return mapped;
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`EIMS mapping: invoice ${invoiceNumber} has buyer ${field} ${raw ? `"${raw}"` : "(unset)"}, ` +
|
||||||
|
`which is not a MoR ${field} code and has no mapping. Add it to ${envVar}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toEimsInvoice(
|
||||||
|
invoice: EimsMapperInvoice,
|
||||||
|
seller: EimsSellerDetails,
|
||||||
|
context: EimsMapperContext,
|
||||||
|
): EimsInvoiceRequest {
|
||||||
|
const company = invoice.company;
|
||||||
|
if (!company || !company.tin?.trim()) {
|
||||||
|
throw new Error(`EIMS mapping: invoice ${invoice.invoiceNumber} has no buyer company TIN`);
|
||||||
|
}
|
||||||
|
if (!invoice.lines?.length) {
|
||||||
|
throw new Error(`EIMS mapping: invoice ${invoice.invoiceNumber} has no lines`);
|
||||||
|
}
|
||||||
|
if (!invoice.issuedAt) {
|
||||||
|
throw new Error(`EIMS mapping: invoice ${invoice.invoiceNumber} is not issued (issuedAt is null)`);
|
||||||
|
}
|
||||||
|
if (invoice.currency !== "ETB" && context.exchangeRate == null) {
|
||||||
|
throw new Error(
|
||||||
|
`EIMS mapping: invoice ${invoice.invoiceNumber} is in ${invoice.currency} and needs an exchangeRate`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const issuedAt = invoice.issuedAt instanceof Date ? invoice.issuedAt : new Date(invoice.issuedAt);
|
||||||
|
if (Number.isNaN(issuedAt.getTime())) {
|
||||||
|
throw new Error(`EIMS mapping: invoice ${invoice.invoiceNumber} has an unparseable issuedAt`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const natureOfSupplies = context.natureOfSupplies.trim().toLowerCase();
|
||||||
|
if (!NATURE_OF_SUPPLIES.includes(natureOfSupplies as (typeof NATURE_OF_SUPPLIES)[number])) {
|
||||||
|
throw new Error(
|
||||||
|
`EIMS mapping: NatureOfSupplies must be one of ${NATURE_OF_SUPPLIES.join(", ")}, ` +
|
||||||
|
`got "${context.natureOfSupplies}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ItemList: EimsInvoiceItem[] = invoice.lines.map((line, index) => {
|
||||||
|
const lineNumber = index + 1;
|
||||||
|
const tax = context.taxForLine(line, lineNumber);
|
||||||
|
if (!tax || !tax.code || !Number.isFinite(tax.ratePercent) || !Number.isFinite(tax.exciseTaxValue)) {
|
||||||
|
throw new Error(
|
||||||
|
`EIMS mapping: unresolved tax treatment for line ${lineNumber} (${line.chargeType}) ` +
|
||||||
|
`on invoice ${invoice.invoiceNumber}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PreTaxValue = round2(num(line.amount));
|
||||||
|
const TaxAmount = round2((PreTaxValue * tax.ratePercent) / 100);
|
||||||
|
const ExciseTaxValue = round2(tax.exciseTaxValue);
|
||||||
|
const unit = typeof line.metadata?.unit === "string" ? line.metadata.unit : context.unitDefault;
|
||||||
|
|
||||||
|
return {
|
||||||
|
Discount: 0,
|
||||||
|
ExciseTaxValue,
|
||||||
|
HarmonizationCode: null,
|
||||||
|
NatureOfSupplies: natureOfSupplies,
|
||||||
|
ItemCode: line.chargeType,
|
||||||
|
ProductDescription: line.description?.trim() || line.chargeType,
|
||||||
|
PreTaxValue,
|
||||||
|
Quantity: round2(num(line.quantity)),
|
||||||
|
LineNumber: lineNumber,
|
||||||
|
TaxAmount,
|
||||||
|
TaxCode: tax.code,
|
||||||
|
TotalLineAmount: round2(PreTaxValue + TaxAmount + ExciseTaxValue),
|
||||||
|
Unit: unit,
|
||||||
|
UnitPrice: round2(num(line.unitRate)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const preTaxTotal = round2(ItemList.reduce((sum, item) => sum + item.PreTaxValue, 0));
|
||||||
|
const invoiceTotal = round2(num(invoice.totalAmount));
|
||||||
|
if (Math.abs(preTaxTotal - invoiceTotal) > 0.01) {
|
||||||
|
throw new Error(
|
||||||
|
`EIMS mapping: invoice ${invoice.invoiceNumber} lines sum to ${preTaxTotal} ` +
|
||||||
|
`but the invoice total is ${invoiceTotal}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ValueDetails: EimsValueDetails = {
|
||||||
|
Discount: context.invoiceDiscount ?? null,
|
||||||
|
ExciseValue: round2(ItemList.reduce((sum, item) => sum + item.ExciseTaxValue, 0)),
|
||||||
|
IncomeWithholdValue: context.incomeWithholdValue,
|
||||||
|
TaxValue: round2(ItemList.reduce((sum, item) => sum + item.TaxAmount, 0)),
|
||||||
|
TotalValue: round2(ItemList.reduce((sum, item) => sum + item.TotalLineAmount, 0)),
|
||||||
|
TransactionWithholdValue: context.transactionWithholdValue,
|
||||||
|
InvoiceCurrency: invoice.currency,
|
||||||
|
};
|
||||||
|
if (context.exchangeRate != null) ValueDetails.ExchangeRate = context.exchangeRate;
|
||||||
|
|
||||||
|
return {
|
||||||
|
BuyerDetails: {
|
||||||
|
City: context.buyerCity ?? null,
|
||||||
|
Email: company.email ?? null,
|
||||||
|
HouseNumber: company.houseNo ?? null,
|
||||||
|
IdNumber: context.buyerIdNumber ?? null,
|
||||||
|
IdType: context.buyerIdType ?? null,
|
||||||
|
Tin: company.tin,
|
||||||
|
LegalName: company.name,
|
||||||
|
Phone: company.phone ?? null,
|
||||||
|
Region: resolveLocationCode(
|
||||||
|
"Region",
|
||||||
|
company.region,
|
||||||
|
context.buyerRegionCodes,
|
||||||
|
"EIMS_BUYER_REGION_CODES",
|
||||||
|
invoice.invoiceNumber,
|
||||||
|
),
|
||||||
|
Country: context.buyerCountryCode ?? null,
|
||||||
|
Zone: company.zone ?? null,
|
||||||
|
Kebele: company.kebele ?? null,
|
||||||
|
VatNumber: company.vatNumber ?? null,
|
||||||
|
Wereda: resolveLocationCode(
|
||||||
|
"Wereda",
|
||||||
|
company.woreda,
|
||||||
|
context.buyerWeredaCodes,
|
||||||
|
"EIMS_BUYER_WEREDA_CODES",
|
||||||
|
invoice.invoiceNumber,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
DocumentDetails: {
|
||||||
|
DocumentNumber: context.documentNumber,
|
||||||
|
Date: (context.formatDate ?? formatEimsDate)(issuedAt),
|
||||||
|
Type: EIMS_DOCUMENT_TYPE,
|
||||||
|
},
|
||||||
|
ItemList,
|
||||||
|
PaymentDetails: { Mode: context.payment.mode, PaymentTerm: context.payment.term },
|
||||||
|
ReferenceDetails: {
|
||||||
|
PreviousIrn: context.previousIrn,
|
||||||
|
RelatedDocument: context.relatedDocument ?? null,
|
||||||
|
},
|
||||||
|
SellerDetails: seller,
|
||||||
|
SourceSystem: {
|
||||||
|
CashierName: context.cashierName,
|
||||||
|
InvoiceCounter: context.invoiceCounter,
|
||||||
|
SalesPersonName: context.salesPersonName,
|
||||||
|
SystemNumber: context.systemNumber,
|
||||||
|
SystemType: context.systemType,
|
||||||
|
},
|
||||||
|
TransactionType: context.transactionType,
|
||||||
|
ValueDetails,
|
||||||
|
Version: EIMS_VERSION,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { BaseEntity } from "@edr/api-common";
|
import { BaseEntity } from "@edr/api-common";
|
||||||
import { Freight } from "@edr/types";
|
import { Freight } from "@edr/types";
|
||||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||||
|
import type { EimsInvoiceError, EimsInvoiceStatus } from "../../eims/eims-registration.types";
|
||||||
import { PaymentEntity } from "../../payment/entities/payment.entity";
|
import { PaymentEntity } from "../../payment/entities/payment.entity";
|
||||||
import { Company } from "../../companies/entities/company.entity";
|
import { Company } from "../../companies/entities/company.entity";
|
||||||
import { CompanyProfile } from "../../companies/entities/company-profile.entity";
|
import { CompanyProfile } from "../../companies/entities/company-profile.entity";
|
||||||
@@ -105,4 +106,31 @@ export class Invoice extends BaseEntity {
|
|||||||
|
|
||||||
@Column({ name: "due_at", type: "timestamptz" })
|
@Column({ name: "due_at", type: "timestamptz" })
|
||||||
dueAt!: Date;
|
dueAt!: Date;
|
||||||
|
|
||||||
|
/** MoR EIMS registration state. Set only by the EIMS module; billing never writes these. */
|
||||||
|
@Column({ name: "eims_status", type: "varchar", length: 20, default: "NOT_SUBMITTED" })
|
||||||
|
eimsStatus!: EimsInvoiceStatus;
|
||||||
|
|
||||||
|
/** Invoice Reference Number returned by EIMS. Unique across invoices (partial index). */
|
||||||
|
@Column({ name: "eims_irn", type: "varchar", length: 64, nullable: true })
|
||||||
|
eimsIrn?: string | null;
|
||||||
|
|
||||||
|
/** The numeric `DocumentDetails.DocumentNumber` filed for this invoice. */
|
||||||
|
@Column({ name: "eims_document_number", type: "varchar", length: 16, nullable: true })
|
||||||
|
eimsDocumentNumber?: string | null;
|
||||||
|
|
||||||
|
/** The `SourceSystem.InvoiceCounter` this invoice consumed. */
|
||||||
|
@Column({ name: "eims_invoice_counter", type: "bigint", nullable: true })
|
||||||
|
eimsInvoiceCounter?: number | null;
|
||||||
|
|
||||||
|
@Column({ name: "eims_submitted_at", type: "timestamptz", nullable: true })
|
||||||
|
eimsSubmittedAt?: Date | null;
|
||||||
|
|
||||||
|
/** EIMS acknowledgement timestamp, stored verbatim — it is a Java ZonedDateTime string. */
|
||||||
|
@Column({ name: "eims_ack_date", type: "varchar", length: 64, nullable: true })
|
||||||
|
eimsAckDate?: string | null;
|
||||||
|
|
||||||
|
/** Sanitized last failure: the gateway's own error fields only, never our signed envelope. */
|
||||||
|
@Column({ name: "eims_last_error", type: "jsonb", nullable: true })
|
||||||
|
eimsLastError?: EimsInvoiceError | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service';
|
import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service';
|
||||||
import type { Booking } from './entities/booking.entity';
|
import type { Booking } from './entities/booking.entity';
|
||||||
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Who hears "Operations wants changes" depends on who owns the booking. A
|
* Who hears "Operations wants changes" depends on who owns the booking. A
|
||||||
@@ -74,3 +75,39 @@ describe('BookingLifecycleNotifierService — operation changes requested', () =
|
|||||||
expect(inbox.notify.mock.calls[0][0].recipients).toEqual({ companyId: 'co-1' });
|
expect(inbox.notify.mock.calls[0][0].recipients).toEqual({ companyId: 'co-1' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Staff notifications used to go to every employee in every organization. They
|
||||||
|
* now target a desk — and the two desks are disjoint: the GL presets hold no
|
||||||
|
* bookings:view and no intake keys, so intake pings would be noise they cannot
|
||||||
|
* act on. Both branches run through the same `inAppStaff` helper, which is the
|
||||||
|
* easy place to lose the distinction again.
|
||||||
|
*/
|
||||||
|
describe('BookingLifecycleNotifierService — staff desk targeting', () => {
|
||||||
|
const booking = () =>
|
||||||
|
({ id: 'b-1', reference: 'BKG-0001', companyId: 'co-1' }) as Booking;
|
||||||
|
|
||||||
|
let inbox: { notify: jest.Mock };
|
||||||
|
let service: BookingLifecycleNotifierService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
inbox = { notify: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
service = new BookingLifecycleNotifierService(
|
||||||
|
{ directSend: jest.fn().mockResolvedValue(undefined) } as never,
|
||||||
|
inbox as never,
|
||||||
|
{ query: jest.fn().mockResolvedValue([]) } as never,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes intake items to the booking desk and clearance items to the clearance desk', () => {
|
||||||
|
service.submittedToStaff(booking());
|
||||||
|
service.clearanceDocsUploadedToStaff(booking());
|
||||||
|
|
||||||
|
expect(inbox.notify.mock.calls[0][0].recipients).toEqual({
|
||||||
|
permissionKeys: [FREIGHT_PERMS.bookings.getNotification],
|
||||||
|
});
|
||||||
|
expect(inbox.notify.mock.calls[1][0].recipients).toEqual({
|
||||||
|
permissionKeys: [FREIGHT_PERMS.bookings.clearanceGetNotification],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -11,6 +11,16 @@ import { Booking } from './entities/booking.entity';
|
|||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||||
import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util';
|
import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util';
|
||||||
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clearance items are worked by the GL desks, which hold no bookings:view and
|
||||||
|
* no intake keys — so they take their own selector rather than the booking
|
||||||
|
* desk's. Every override using this deep-links to a clearance page.
|
||||||
|
*/
|
||||||
|
const CLEARANCE_DESK = {
|
||||||
|
permissionKeys: [FREIGHT_PERMS.bookings.clearanceGetNotification],
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Customer + staff notifications for the booking lifecycle: review, clearance
|
* Customer + staff notifications for the booking lifecycle: review, clearance
|
||||||
@@ -89,7 +99,11 @@ export class BookingLifecycleNotifierService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Persist + push an in-app item to every backoffice staff user. */
|
/**
|
||||||
|
* Persist + push an in-app item to the booking desk — staff holding
|
||||||
|
* `bookings:get_notification`. Callers whose item belongs to a different desk
|
||||||
|
* override `recipients` (see {@link CLEARANCE_DESK}).
|
||||||
|
*/
|
||||||
private inAppStaff(
|
private inAppStaff(
|
||||||
b: Booking,
|
b: Booking,
|
||||||
title: string,
|
title: string,
|
||||||
@@ -97,7 +111,7 @@ export class BookingLifecycleNotifierService {
|
|||||||
overrides: Partial<NotifyInput> = {},
|
overrides: Partial<NotifyInput> = {},
|
||||||
): void {
|
): void {
|
||||||
void this.inbox.notify({
|
void this.inbox.notify({
|
||||||
recipients: { allBackoffice: true },
|
recipients: { permissionKeys: [FREIGHT_PERMS.bookings.getNotification] },
|
||||||
audience: NotificationAudience.BACKOFFICE,
|
audience: NotificationAudience.BACKOFFICE,
|
||||||
type: NotificationType.REQUEST_SUBMITTED,
|
type: NotificationType.REQUEST_SUBMITTED,
|
||||||
title,
|
title,
|
||||||
@@ -274,6 +288,7 @@ export class BookingLifecycleNotifierService {
|
|||||||
`the customs declaration can be filed.${note ? ` Note: "${note}"` : ''}`;
|
`the customs declaration can be filed.${note ? ` Note: "${note}"` : ''}`;
|
||||||
this.logger.log(`TRANSIT ASSIGNEE REQUESTED — ${this.ref(b)}`);
|
this.logger.log(`TRANSIT ASSIGNEE REQUESTED — ${this.ref(b)}`);
|
||||||
this.inAppStaff(b, `Transit assignee needed — ${b.reference}`, msg, {
|
this.inAppStaff(b, `Transit assignee needed — ${b.reference}`, msg, {
|
||||||
|
recipients: CLEARANCE_DESK,
|
||||||
type: NotificationType.CLEARANCE_REVIEW,
|
type: NotificationType.CLEARANCE_REVIEW,
|
||||||
link: `/dashboard/gl-djibouti/clearance/${b.id}`,
|
link: `/dashboard/gl-djibouti/clearance/${b.id}`,
|
||||||
});
|
});
|
||||||
@@ -288,6 +303,7 @@ export class BookingLifecycleNotifierService {
|
|||||||
`The customs declaration can now be filed.`;
|
`The customs declaration can now be filed.`;
|
||||||
this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${this.ref(b)}`);
|
this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${this.ref(b)}`);
|
||||||
this.inAppStaff(b, `Transit assignee set — ${b.reference}`, msg, {
|
this.inAppStaff(b, `Transit assignee set — ${b.reference}`, msg, {
|
||||||
|
recipients: CLEARANCE_DESK,
|
||||||
type: NotificationType.CLEARANCE_REVIEW,
|
type: NotificationType.CLEARANCE_REVIEW,
|
||||||
link: `/dashboard/bookings/${b.id}/clearance`,
|
link: `/dashboard/bookings/${b.id}/clearance`,
|
||||||
});
|
});
|
||||||
@@ -395,6 +411,7 @@ export class BookingLifecycleNotifierService {
|
|||||||
'Clearance documents uploaded',
|
'Clearance documents uploaded',
|
||||||
`Customer uploaded clearance documents for booking ${this.ref(b)} — review them in the clearance queue.`,
|
`Customer uploaded clearance documents for booking ${this.ref(b)} — review them in the clearance queue.`,
|
||||||
{
|
{
|
||||||
|
recipients: CLEARANCE_DESK,
|
||||||
type: NotificationType.CLEARANCE_REVIEW,
|
type: NotificationType.CLEARANCE_REVIEW,
|
||||||
link: `/dashboard/bookings/${b.id}/clearance`,
|
link: `/dashboard/bookings/${b.id}/clearance`,
|
||||||
},
|
},
|
||||||
@@ -422,6 +439,7 @@ export class BookingLifecycleNotifierService {
|
|||||||
`The customer requested a change to the draft declaration on booking ${this.ref(b)}: ` +
|
`The customer requested a change to the draft declaration on booking ${this.ref(b)}: ` +
|
||||||
`"${note}". Send a corrected draft from the clearance page.`;
|
`"${note}". Send a corrected draft from the clearance page.`;
|
||||||
this.inAppStaff(b, `Draft declaration change requested — ${this.ref(b)}`, msg, {
|
this.inAppStaff(b, `Draft declaration change requested — ${this.ref(b)}`, msg, {
|
||||||
|
recipients: CLEARANCE_DESK,
|
||||||
type: NotificationType.CLEARANCE_REVIEW,
|
type: NotificationType.CLEARANCE_REVIEW,
|
||||||
link: `/dashboard/bookings/${b.id}/clearance`,
|
link: `/dashboard/bookings/${b.id}/clearance`,
|
||||||
});
|
});
|
||||||
@@ -440,6 +458,7 @@ export class BookingLifecycleNotifierService {
|
|||||||
'Payment slip uploaded',
|
'Payment slip uploaded',
|
||||||
`Customer uploaded the ${label} payment slip for booking ${this.ref(b)}.`,
|
`Customer uploaded the ${label} payment slip for booking ${this.ref(b)}.`,
|
||||||
{
|
{
|
||||||
|
recipients: CLEARANCE_DESK,
|
||||||
type: NotificationType.PAYMENT_RECEIVED,
|
type: NotificationType.PAYMENT_RECEIVED,
|
||||||
link: `/dashboard/bookings/${b.id}/clearance`,
|
link: `/dashboard/bookings/${b.id}/clearance`,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { NotificationInboxService } from '../notification-inbox/notification-inb
|
|||||||
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
||||||
import { Rate } from '../rule-engine/entities/rate.entity';
|
import { Rate } from '../rule-engine/entities/rate.entity';
|
||||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
|
||||||
import { WagonAllocationBulkLoad } from '../train-schedules/entities/wagon-allocation-bulk-load.entity';
|
import { WagonAllocationBulkLoad } from '../train-schedules/entities/wagon-allocation-bulk-load.entity';
|
||||||
import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity';
|
import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity';
|
||||||
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
|
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
|
||||||
@@ -1112,12 +1112,14 @@ export class BookingWagonCancellationService {
|
|||||||
|
|
||||||
private notifyStaff(booking: Booking, title: string, body: string): void {
|
private notifyStaff(booking: Booking, title: string, body: string): void {
|
||||||
void this.inbox.notify({
|
void this.inbox.notify({
|
||||||
recipients: { allBackoffice: true },
|
recipients: { permissionKeys: [FREIGHT_PERMS.bookings.getNotification] },
|
||||||
audience: NotificationAudience.BACKOFFICE,
|
audience: NotificationAudience.BACKOFFICE,
|
||||||
type: NotificationType.BOOKING_STATUS,
|
type: NotificationType.BOOKING_STATUS,
|
||||||
title,
|
title,
|
||||||
body,
|
body,
|
||||||
link: `/bookings/${booking.id}`,
|
// The portal path `/bookings/:id` used to be sent here, which 404s in the
|
||||||
|
// dashboard. The staff view of these lives on the queue page.
|
||||||
|
link: '/dashboard/wagon-cancellations',
|
||||||
data: { bookingId: booking.id, reference: booking.reference },
|
data: { bookingId: booking.id, reference: booking.reference },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ import { LastMileService } from '../last-mile/last-mile.service';
|
|||||||
import { GenerateGrnDto } from './dto/generate-grn.dto';
|
import { GenerateGrnDto } from './dto/generate-grn.dto';
|
||||||
import { ContainerReceiptService } from './container-receipt.service';
|
import { ContainerReceiptService } from './container-receipt.service';
|
||||||
import { SignContractDto } from './dto/sign-contract.dto';
|
import { SignContractDto } from './dto/sign-contract.dto';
|
||||||
|
import { SetExportHandoverModeDto } from './dto/set-export-handover-mode.dto';
|
||||||
import { UpdateBookingDto } from './dto/update-booking.dto';
|
import { UpdateBookingDto } from './dto/update-booking.dto';
|
||||||
import { BookingWagonCancellationService } from './booking-wagon-cancellation.service';
|
import { BookingWagonCancellationService } from './booking-wagon-cancellation.service';
|
||||||
import {
|
import {
|
||||||
@@ -757,6 +758,18 @@ export class BookingsController {
|
|||||||
return this.customerTruckService.getLoadableContainers(id);
|
return this.customerTruckService.getLoadableContainers(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch(':id/export-handover-mode')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Export only: choose direct truck-to-train (no warehouse, no GRN) or warehouse first',
|
||||||
|
})
|
||||||
|
setExportHandoverMode(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: SetExportHandoverModeDto,
|
||||||
|
) {
|
||||||
|
return this.bookingsService.setExportHandoverMode(id, dto.exportHandoverMode);
|
||||||
|
}
|
||||||
|
|
||||||
@Post(':id/customer-trucks/:assignmentId/load')
|
@Post(':id/customer-trucks/:assignmentId/load')
|
||||||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||||
@ApiOperation({ summary: 'Truck_dispatch: load selected containers onto a truck (staff)' })
|
@ApiOperation({ summary: 'Truck_dispatch: load selected containers onto a truck (staff)' })
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { insertWithGeneratedReference } from '@edr/api-common';
|
|||||||
// import { CustomersService } from '../customers/customers.service';
|
// import { CustomersService } from '../customers/customers.service';
|
||||||
import { CompaniesService } from '../companies/companies.service';
|
import { CompaniesService } from '../companies/companies.service';
|
||||||
import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity';
|
import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity';
|
||||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
|
||||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||||
import { FilesService } from '../files/files.service';
|
import { FilesService } from '../files/files.service';
|
||||||
import { MinioService } from '../minio/minio.service';
|
import { MinioService } from '../minio/minio.service';
|
||||||
@@ -28,7 +28,7 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
|
|||||||
import { DataSource, In } from 'typeorm';
|
import { DataSource, In } from 'typeorm';
|
||||||
|
|
||||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||||
import { assertExportReceivedWithGrn } from '../../common/export-received-gate';
|
import { assertExportReceivedWithGrn, DIRECT_TO_TRAIN } from '../../common/export-received-gate';
|
||||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||||
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
||||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||||
@@ -285,10 +285,24 @@ export class BookingsService {
|
|||||||
// Receipt is proven by the warehouse GRN, but the GRN is warehouse paperwork
|
// Receipt is proven by the warehouse GRN, but the GRN is warehouse paperwork
|
||||||
// and never appears on this sheet — it is only the signal that EDR has taken
|
// and never appears on this sheet — it is only the signal that EDR has taken
|
||||||
// the cargo, which is what the customer's sheet attests to.
|
// the cargo, which is what the customer's sheet attests to.
|
||||||
|
const isDirectExport =
|
||||||
|
booking.tradeDirection === 'EXPORT' && booking.exportHandoverMode === DIRECT_TO_TRAIN;
|
||||||
const pendingWagons = wagons.length === 0;
|
const pendingWagons = wagons.length === 0;
|
||||||
if (pendingWagons) {
|
if (pendingWagons) {
|
||||||
const receivedLines: CarriageAcceptanceReceivedRow[] =
|
// Direct truck-to-train cargo never enters the warehouse, so there is no
|
||||||
booking.tradeDirection === 'EXPORT'
|
// GRN'd inventory to build the sheet from. Choosing direct handover is
|
||||||
|
// itself the acceptance, so the sheet issues off the booking's own
|
||||||
|
// containers (or its VGM weight when the cargo is bulk).
|
||||||
|
const receivedLines: CarriageAcceptanceReceivedRow[] = isDirectExport
|
||||||
|
? await this.dataSource.query(
|
||||||
|
`SELECT NULL::numeric AS "allocatedWeightTons",
|
||||||
|
c.container_number AS "containerNumbers"
|
||||||
|
FROM freight.containers c
|
||||||
|
WHERE c.booking_id = $1 AND c.deleted_at IS NULL
|
||||||
|
ORDER BY c.container_number`,
|
||||||
|
[bookingId],
|
||||||
|
)
|
||||||
|
: booking.tradeDirection === 'EXPORT'
|
||||||
? await this.dataSource.query(
|
? await this.dataSource.query(
|
||||||
`SELECT inv.weight AS "allocatedWeightTons",
|
`SELECT inv.weight AS "allocatedWeightTons",
|
||||||
c.container_number AS "containerNumbers"
|
c.container_number AS "containerNumbers"
|
||||||
@@ -304,6 +318,15 @@ export class BookingsService {
|
|||||||
[bookingId],
|
[bookingId],
|
||||||
)
|
)
|
||||||
: [];
|
: [];
|
||||||
|
// Bulk direct cargo has no containers — one line carrying the booking's
|
||||||
|
// declared weight still makes a valid sheet.
|
||||||
|
if (isDirectExport && receivedLines.length === 0) {
|
||||||
|
receivedLines.push({
|
||||||
|
allocatedWeightTons:
|
||||||
|
booking.bulkTotalWeightTons == null ? null : String(booking.bulkTotalWeightTons),
|
||||||
|
containerNumbers: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
if (receivedLines.length === 0) {
|
if (receivedLines.length === 0) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
booking.tradeDirection === 'EXPORT'
|
booking.tradeDirection === 'EXPORT'
|
||||||
@@ -424,7 +447,10 @@ export class BookingsService {
|
|||||||
|
|
||||||
const departureStation = booking.originYard?.label ?? booking.originYard?.code ?? '-';
|
const departureStation = booking.originYard?.label ?? booking.originYard?.code ?? '-';
|
||||||
const arrivalStation = booking.destinationYard?.label ?? booking.destinationYard?.code ?? '-';
|
const arrivalStation = booking.destinationYard?.label ?? booking.destinationYard?.code ?? '-';
|
||||||
const cargoName = booking.cargoType?.cargoTypeName ?? booking.cargoFreeText ?? '-';
|
// Container bookings carry no cargo type or free text — name the freight type
|
||||||
|
// rather than printing a dash in the Cargo Name column.
|
||||||
|
const cargoName =
|
||||||
|
booking.cargoType?.cargoTypeName ?? booking.cargoFreeText ?? booking.freightType ?? '-';
|
||||||
const currency = booking.paymentCurrency ?? 'ETB';
|
const currency = booking.paymentCurrency ?? 'ETB';
|
||||||
const totalAmount = Number(booking.adjustedTotalAmount ?? booking.totalAmount) || 0;
|
const totalAmount = Number(booking.adjustedTotalAmount ?? booking.totalAmount) || 0;
|
||||||
const prices = this.splitAmountAcrossWagons(
|
const prices = this.splitAmountAcrossWagons(
|
||||||
@@ -467,6 +493,28 @@ export class BookingsService {
|
|||||||
)
|
)
|
||||||
.join('');
|
.join('');
|
||||||
|
|
||||||
|
// The totals belong in <tbody>, not <tfoot>: the Chromium-less fallback
|
||||||
|
// renderer only parses tbody rows, so a <tfoot> silently drops every footer
|
||||||
|
// figure from the printed sheet.
|
||||||
|
const totalsRow = `<tr class="totals">
|
||||||
|
<td>TOT</td>
|
||||||
|
<td>${wagons.length} ${pendingWagons ? 'received lines' : 'wagons'}</td>
|
||||||
|
<td>${
|
||||||
|
pendingWagons
|
||||||
|
? 'pending marshalling'
|
||||||
|
: `full ${fullWagons} / empty ${wagons.length - fullWagons}`
|
||||||
|
}</td>
|
||||||
|
<td class="num">${num(totals.tare, 2)}</td>
|
||||||
|
<td class="num">${num(totals.length)}</td>
|
||||||
|
<td class="num">${num(totals.capacity)}</td>
|
||||||
|
<td></td>
|
||||||
|
<td>Gross ${num(totals.tare + totals.load)} T</td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
<td class="num">${money(totalAmount)}</td>
|
||||||
|
</tr>`;
|
||||||
|
|
||||||
return `<!doctype html>
|
return `<!doctype html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
@@ -490,7 +538,7 @@ export class BookingsService {
|
|||||||
th { background: #f8fafc; color: #475569; text-align: left; }
|
th { background: #f8fafc; color: #475569; text-align: left; }
|
||||||
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
|
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
|
||||||
.num { text-align: right; }
|
.num { text-align: right; }
|
||||||
tfoot td { background: #f8fafc; font-weight: 700; }
|
tr.totals td { background: #f8fafc; font-weight: 700; }
|
||||||
.notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; }
|
.notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; }
|
||||||
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; }
|
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; }
|
||||||
.line { border-top: 1px solid #334155; padding-top: 7px; font-size: 9px; color: #475569; min-height: 34px; }
|
.line { border-top: 1px solid #334155; padding-top: 7px; font-size: 9px; color: #475569; min-height: 34px; }
|
||||||
@@ -538,21 +586,8 @@ export class BookingsService {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
${rows}
|
${rows}
|
||||||
|
${totalsRow}
|
||||||
</tbody>
|
</tbody>
|
||||||
<tfoot>
|
|
||||||
<tr>
|
|
||||||
<td colspan="3">${
|
|
||||||
pendingWagons
|
|
||||||
? `Received lines: ${wagons.length} — wagons pending marshalling`
|
|
||||||
: `Total wagons: ${wagons.length} (full ${fullWagons} / empty ${wagons.length - fullWagons})`
|
|
||||||
}</td>
|
|
||||||
<td class="num">${num(totals.tare, 2)}</td>
|
|
||||||
<td class="num">${num(totals.length)}</td>
|
|
||||||
<td class="num">${num(totals.capacity)}</td>
|
|
||||||
<td colspan="5">Gross weight (tare + load): ${num(totals.tare + totals.load)} T</td>
|
|
||||||
<td class="num">${money(totalAmount)}</td>
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<div class="notice">
|
<div class="notice">
|
||||||
@@ -1985,6 +2020,47 @@ export class BookingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Get a single booking by ID with files. */
|
/** Get a single booking by ID with files. */
|
||||||
|
/**
|
||||||
|
* EXPORT only. Choose how the cargo reaches the train. DIRECT_TO_TRAIN takes
|
||||||
|
* the booking out of the warehouse flow entirely — no receipt, no GRN, and the
|
||||||
|
* carriage acceptance sheet becomes issuable straight away.
|
||||||
|
*
|
||||||
|
* Switching to direct is refused once the goods are already in the shed:
|
||||||
|
* inventory exists, so the cargo demonstrably went the warehouse route and its
|
||||||
|
* GRN paperwork must stand.
|
||||||
|
*/
|
||||||
|
async setExportHandoverMode(
|
||||||
|
bookingId: string,
|
||||||
|
mode: string,
|
||||||
|
): Promise<{ bookingId: string; exportHandoverMode: string }> {
|
||||||
|
const booking = await this.bookingsRepository.findById(bookingId);
|
||||||
|
if (!booking) {
|
||||||
|
throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||||
|
}
|
||||||
|
if ((booking.tradeDirection ?? '').toUpperCase() !== 'EXPORT') {
|
||||||
|
throw new BadRequestException('Handover mode applies to export bookings only');
|
||||||
|
}
|
||||||
|
if (mode === DIRECT_TO_TRAIN) {
|
||||||
|
const [stored]: Array<{ one: number }> = await this.dataSource.query(
|
||||||
|
`SELECT 1 AS one
|
||||||
|
FROM freight.warehouse_inventory
|
||||||
|
WHERE booking_id = $1 AND deleted_at IS NULL
|
||||||
|
LIMIT 1`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
if (stored) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'This booking already has cargo in the warehouse, so it cannot be switched to direct truck-to-train',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.dataSource.query(
|
||||||
|
`UPDATE freight.bookings SET export_handover_mode = $2, updated_at = NOW() WHERE id = $1`,
|
||||||
|
[bookingId, mode],
|
||||||
|
);
|
||||||
|
return { bookingId, exportHandoverMode: mode };
|
||||||
|
}
|
||||||
|
|
||||||
async findById(id: string): Promise<Booking> {
|
async findById(id: string): Promise<Booking> {
|
||||||
const booking = await this.bookingsRepository.findByIdWithFiles(id);
|
const booking = await this.bookingsRepository.findByIdWithFiles(id);
|
||||||
if (!booking) {
|
if (!booking) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -302,6 +302,18 @@ export class Booking extends BaseEntity {
|
|||||||
@Column({ name: 'customer_truck_arrived_at', type: 'timestamptz', nullable: true })
|
@Column({ name: 'customer_truck_arrived_at', type: 'timestamptz', nullable: true })
|
||||||
customerTruckArrivedAt?: Date | null;
|
customerTruckArrivedAt?: Date | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EXPORT only. How the cargo reaches the train:
|
||||||
|
* - DIRECT_TO_TRAIN — the customer's truck loads straight onto the wagon. No
|
||||||
|
* warehouse, so no GRN is ever raised and the carriage acceptance sheet is
|
||||||
|
* the only document handed over.
|
||||||
|
* - WAREHOUSE (also null) — received into the warehouse and GRN'd first.
|
||||||
|
*
|
||||||
|
* Null is treated as WAREHOUSE so existing bookings keep the GRN gate.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'export_handover_mode', type: 'varchar', length: 20, nullable: true })
|
||||||
|
exportHandoverMode?: string | null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Did the goods need re-handling in the warehouse? Recorded by warehouse
|
* Did the goods need re-handling in the warehouse? Recorded by warehouse
|
||||||
* staff after unloading. Only `true` bills the DOUBLE_HANDLING_FEE rule;
|
* staff after unloading. Only `true` bills the DOUBLE_HANDLING_FEE rule;
|
||||||
|
|||||||
@@ -20,7 +20,14 @@ import { CargoesService } from './cargoes.service';
|
|||||||
|
|
||||||
@ApiTags('cargoes')
|
@ApiTags('cargoes')
|
||||||
@Controller('cargoes')
|
@Controller('cargoes')
|
||||||
@FleetView(FREIGHT_PERMS.cargoes.view)
|
// Class gate lists every key its routes use: Nest runs class AND method
|
||||||
|
// guards, so a key missing here would deny before the route's own key runs.
|
||||||
|
@FleetView([
|
||||||
|
FREIGHT_PERMS.cargoes.view,
|
||||||
|
FREIGHT_PERMS.cargoes.create,
|
||||||
|
FREIGHT_PERMS.cargoes.update,
|
||||||
|
FREIGHT_PERMS.cargoes.delete,
|
||||||
|
])
|
||||||
export class CargoesController {
|
export class CargoesController {
|
||||||
constructor(private readonly cargoesService: CargoesService) {}
|
constructor(private readonly cargoesService: CargoesService) {}
|
||||||
|
|
||||||
|
|||||||
@@ -421,7 +421,10 @@ export class CompaniesController {
|
|||||||
@CurrentUser() user: CurrentIamUser,
|
@CurrentUser() user: CurrentIamUser,
|
||||||
@Body() dto: CompleteIdentityVerificationDto,
|
@Body() dto: CompleteIdentityVerificationDto,
|
||||||
): Promise<CompanyIdentityStateDto> {
|
): Promise<CompanyIdentityStateDto> {
|
||||||
return this.companiesService.completeIdentityVerification(user.id, dto);
|
return this.companiesService.completeIdentityVerification(user.id, dto, {
|
||||||
|
email: user.email,
|
||||||
|
phoneNumber: user.phoneNumber,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("identity/gm/same-as-owner")
|
@Post("identity/gm/same-as-owner")
|
||||||
@@ -434,7 +437,10 @@ export class CompaniesController {
|
|||||||
async setGmSameAsOwner(
|
async setGmSameAsOwner(
|
||||||
@CurrentUser() user: CurrentIamUser,
|
@CurrentUser() user: CurrentIamUser,
|
||||||
): Promise<CompanyIdentityStateDto> {
|
): Promise<CompanyIdentityStateDto> {
|
||||||
return this.companiesService.setGmSameAsOwner(user.id);
|
return this.companiesService.setGmSameAsOwner(user.id, {
|
||||||
|
email: user.email,
|
||||||
|
phoneNumber: user.phoneNumber,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete("identity/gm")
|
@Delete("identity/gm")
|
||||||
|
|||||||
@@ -322,41 +322,15 @@ describe("Fayda identity verification binds a person to the company", () => {
|
|||||||
// registered phone) with nothing at all. OWNER_VERIFIED is exactly that
|
// registered phone) with nothing at all. OWNER_VERIFIED is exactly that
|
||||||
// shape: a sub, no contact details.
|
// shape: a sub, no contact details.
|
||||||
it("keeps company contact details a Fayda verification never supplied", async () => {
|
it("keeps company contact details a Fayda verification never supplied", async () => {
|
||||||
const { service, deps } = makeService({
|
const { deps } = makeService({
|
||||||
attributes: { ...OWNER_VERIFIED },
|
attributes: { ...OWNER_VERIFIED },
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.updateProfile("user-1", {
|
|
||||||
companyEmail: "account@example.com",
|
|
||||||
companyPhone: "+251911777777",
|
|
||||||
} as never),
|
|
||||||
).resolves.toBeDefined();
|
|
||||||
const [, patch] = deps.companiesRepo.update.mock.calls.at(-1)!;
|
const [, patch] = deps.companiesRepo.update.mock.calls.at(-1)!;
|
||||||
expect(patch.email).toBe("account@example.com");
|
expect(patch.email).toBe("account@example.com");
|
||||||
expect(patch.phone).toBe("+251911777777");
|
expect(patch.phone).toBe("+251911777777");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("overwrites company contact details the verification did supply", async () => {
|
|
||||||
const { service, deps } = makeService({
|
|
||||||
attributes: {
|
|
||||||
...OWNER_VERIFIED,
|
|
||||||
ownerEmail: "abebe@example.com",
|
|
||||||
ownerPhone: "+251911000000",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.updateProfile("user-1", {
|
|
||||||
companyEmail: "someone-else@example.com",
|
|
||||||
companyPhone: "+251911999999",
|
|
||||||
} as never),
|
|
||||||
).resolves.toBeDefined();
|
|
||||||
const [, patch] = deps.companiesRepo.update.mock.calls.at(-1)!;
|
|
||||||
expect(patch.email).toBe("abebe@example.com");
|
|
||||||
expect(patch.phone).toBe("+251911000000");
|
|
||||||
});
|
|
||||||
|
|
||||||
// "Same as owner" copies `ownerEmail ?? null` onto the GM while setting
|
// "Same as owner" copies `ownerEmail ?? null` onto the GM while setting
|
||||||
// `gmFaydaSub`. Locking that null made generalManagerEmail required by
|
// `gmFaydaSub`. Locking that null made generalManagerEmail required by
|
||||||
// onboarding, hidden by the portal's link card and unwritable at once.
|
// onboarding, hidden by the portal's link card and unwritable at once.
|
||||||
|
|||||||
@@ -231,41 +231,39 @@ export class CompaniesService {
|
|||||||
label: string;
|
label: string;
|
||||||
get: (company: Company) => unknown;
|
get: (company: Company) => unknown;
|
||||||
}[] = [
|
}[] = [
|
||||||
{
|
{
|
||||||
key: "tinNumber",
|
key: "tinNumber",
|
||||||
label: "Company TIN",
|
label: "Company TIN",
|
||||||
get: (c) => (c.tin && !c.tin.startsWith("D") ? c.tin : null),
|
get: (c) => (c.tin && !c.tin.startsWith("D") ? c.tin : null),
|
||||||
},
|
},
|
||||||
{ key: "companyEmail", label: "Company email", get: (c) => c.email },
|
{ key: "companyAddress", label: "Company address", get: (c) => c.address },
|
||||||
{ key: "companyPhone", label: "Company phone", get: (c) => c.phone },
|
{ key: "fanNumber", label: "FAN number", get: (c) => c.fanNumber },
|
||||||
{ key: "companyAddress", label: "Company address", get: (c) => c.address },
|
{
|
||||||
{ key: "fanNumber", label: "FAN number", get: (c) => c.fanNumber },
|
key: "contactPersonName",
|
||||||
{
|
label: "Contact person name",
|
||||||
key: "contactPersonName",
|
get: (c) => c.attributes?.contactPersonName,
|
||||||
label: "Contact person name",
|
},
|
||||||
get: (c) => c.attributes?.contactPersonName,
|
{
|
||||||
},
|
key: "contactPersonPhone",
|
||||||
{
|
label: "Contact person phone",
|
||||||
key: "contactPersonPhone",
|
get: (c) => c.attributes?.contactPersonPhone,
|
||||||
label: "Contact person phone",
|
},
|
||||||
get: (c) => c.attributes?.contactPersonPhone,
|
{
|
||||||
},
|
key: "generalManagerName",
|
||||||
{
|
label: "General manager name",
|
||||||
key: "generalManagerName",
|
get: (c) => c.attributes?.generalManagerName,
|
||||||
label: "General manager name",
|
},
|
||||||
get: (c) => c.attributes?.generalManagerName,
|
{
|
||||||
},
|
key: "generalManagerEmail",
|
||||||
{
|
label: "General manager email",
|
||||||
key: "generalManagerEmail",
|
get: (c) => c.attributes?.generalManagerEmail,
|
||||||
label: "General manager email",
|
},
|
||||||
get: (c) => c.attributes?.generalManagerEmail,
|
{
|
||||||
},
|
key: "generalManagerPhone",
|
||||||
{
|
label: "General manager phone",
|
||||||
key: "generalManagerPhone",
|
get: (c) => c.attributes?.generalManagerPhone,
|
||||||
label: "General manager phone",
|
},
|
||||||
get: (c) => c.attributes?.generalManagerPhone,
|
];
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
/** The nationality-based document setting code for a company. */
|
/** The nationality-based document setting code for a company. */
|
||||||
private documentSettingCodeFor(
|
private documentSettingCodeFor(
|
||||||
@@ -314,8 +312,6 @@ export class CompaniesService {
|
|||||||
fanNumber: dto.fanNumber ?? null,
|
fanNumber: dto.fanNumber ?? null,
|
||||||
country: dto.companyLocation ?? "Ethiopia",
|
country: dto.companyLocation ?? "Ethiopia",
|
||||||
address: dto.companyAddress ?? null,
|
address: dto.companyAddress ?? null,
|
||||||
phone: normalizeE164(dto.companyPhone) ?? null,
|
|
||||||
email: dto.companyEmail ?? null,
|
|
||||||
attributes: dto.attributes ?? null,
|
attributes: dto.attributes ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -492,7 +488,8 @@ export class CompaniesService {
|
|||||||
async findCompanyById(id: string): Promise<Company> {
|
async findCompanyById(id: string): Promise<Company> {
|
||||||
const company = await this.companiesRepo.findById(id);
|
const company = await this.companiesRepo.findById(id);
|
||||||
if (!company) throw new NotFoundException(`Company ${id} not found`);
|
if (!company) throw new NotFoundException(`Company ${id} not found`);
|
||||||
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(id);
|
company.companyProfiles =
|
||||||
|
await this.companyProfilesRepo.findByCompanyId(id);
|
||||||
// External profiles carry the onboarding flag the backoffice gates
|
// External profiles carry the onboarding flag the backoffice gates
|
||||||
// approval decisions on (see ResponseCompanyDto.onboardingCompleted).
|
// approval decisions on (see ResponseCompanyDto.onboardingCompleted).
|
||||||
company.profiles = await this.profilesRepo.findByCompanyId(id);
|
company.profiles = await this.profilesRepo.findByCompanyId(id);
|
||||||
@@ -515,9 +512,7 @@ export class CompaniesService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (profile.status !== ProfileStatus.Active) {
|
if (profile.status !== ProfileStatus.Active) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException("Selected company profile is not active");
|
||||||
"Selected company profile is not active",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return profile;
|
return profile;
|
||||||
}
|
}
|
||||||
@@ -760,11 +755,6 @@ export class CompaniesService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const keys: string[] = [];
|
const keys: string[] = [];
|
||||||
if (attrs.ownerFaydaSub) {
|
|
||||||
// The Company-column mirrors of the owner's verified contact details.
|
|
||||||
if (held("ownerEmail")) keys.push("companyEmail");
|
|
||||||
if (held("ownerPhone")) keys.push("companyPhone");
|
|
||||||
}
|
|
||||||
for (const subject of IDENTITY_SUBJECTS) {
|
for (const subject of IDENTITY_SUBJECTS) {
|
||||||
if (!attrs[`${IDENTITY_PREFIX[subject]}FaydaSub`]) continue;
|
if (!attrs[`${IDENTITY_PREFIX[subject]}FaydaSub`]) continue;
|
||||||
keys.push(...IDENTITY_OWNED_FIELDS[subject].filter(held));
|
keys.push(...IDENTITY_OWNED_FIELDS[subject].filter(held));
|
||||||
@@ -789,9 +779,6 @@ export class CompaniesService {
|
|||||||
if (dto.nationality !== undefined)
|
if (dto.nationality !== undefined)
|
||||||
companyUpdates.nationality = dto.nationality;
|
companyUpdates.nationality = dto.nationality;
|
||||||
if (dto.companyName !== undefined) companyUpdates.name = dto.companyName;
|
if (dto.companyName !== undefined) companyUpdates.name = dto.companyName;
|
||||||
if (dto.companyEmail !== undefined) companyUpdates.email = dto.companyEmail;
|
|
||||||
if (dto.companyPhone !== undefined)
|
|
||||||
companyUpdates.phone = normalizeE164(dto.companyPhone);
|
|
||||||
if (dto.companyLocation !== undefined)
|
if (dto.companyLocation !== undefined)
|
||||||
companyUpdates.country = dto.companyLocation;
|
companyUpdates.country = dto.companyLocation;
|
||||||
if (dto.companyAddress !== undefined)
|
if (dto.companyAddress !== undefined)
|
||||||
@@ -809,7 +796,9 @@ export class CompaniesService {
|
|||||||
if (dto.contactPersonPhone !== undefined)
|
if (dto.contactPersonPhone !== undefined)
|
||||||
attrUpdates.contactPersonPhone = normalizeE164(dto.contactPersonPhone);
|
attrUpdates.contactPersonPhone = normalizeE164(dto.contactPersonPhone);
|
||||||
if (dto.contactVerifiedPhone !== undefined)
|
if (dto.contactVerifiedPhone !== undefined)
|
||||||
attrUpdates.contactVerifiedPhone = normalizeE164(dto.contactVerifiedPhone);
|
attrUpdates.contactVerifiedPhone = normalizeE164(
|
||||||
|
dto.contactVerifiedPhone,
|
||||||
|
);
|
||||||
if (dto.generalManagerName !== undefined)
|
if (dto.generalManagerName !== undefined)
|
||||||
attrUpdates.generalManagerName = dto.generalManagerName;
|
attrUpdates.generalManagerName = dto.generalManagerName;
|
||||||
if (dto.generalManagerEmail !== undefined)
|
if (dto.generalManagerEmail !== undefined)
|
||||||
@@ -820,7 +809,8 @@ export class CompaniesService {
|
|||||||
if (dto.poaPhone !== undefined)
|
if (dto.poaPhone !== undefined)
|
||||||
attrUpdates.poaPhone = normalizeE164(dto.poaPhone);
|
attrUpdates.poaPhone = normalizeE164(dto.poaPhone);
|
||||||
if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail;
|
if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail;
|
||||||
if (dto.poaLocation !== undefined) attrUpdates.poaLocation = dto.poaLocation;
|
if (dto.poaLocation !== undefined)
|
||||||
|
attrUpdates.poaLocation = dto.poaLocation;
|
||||||
if (dto.poaAddress !== undefined) attrUpdates.poaAddress = dto.poaAddress;
|
if (dto.poaAddress !== undefined) attrUpdates.poaAddress = dto.poaAddress;
|
||||||
|
|
||||||
if (dto.licenceNumber !== undefined)
|
if (dto.licenceNumber !== undefined)
|
||||||
@@ -857,12 +847,6 @@ export class CompaniesService {
|
|||||||
Object.assign(attrUpdates, dto.faydaIdentity);
|
Object.assign(attrUpdates, dto.faydaIdentity);
|
||||||
}
|
}
|
||||||
|
|
||||||
// companyEmail/companyPhone are the Company-column mirrors of the owner's
|
|
||||||
// verified contact details (the portal derives and submits them, it never
|
|
||||||
// lets the customer type them once verified) — lock them the same way
|
|
||||||
// ownerEmail/ownerPhone themselves are locked below, once there is a
|
|
||||||
// verified owner to lock them to.
|
|
||||||
//
|
|
||||||
// Keyed on the verified VALUE, not on `ownerFaydaSub`: Fayda's email and
|
// Keyed on the verified VALUE, not on `ownerFaydaSub`: Fayda's email and
|
||||||
// phone claims are optional, so a verification can prove the person while
|
// phone claims are optional, so a verification can prove the person while
|
||||||
// supplying neither (see completeIdentityVerification's conditional
|
// supplying neither (see completeIdentityVerification's conditional
|
||||||
@@ -872,9 +856,8 @@ export class CompaniesService {
|
|||||||
// forever, and re-verifying could never clear it because Fayda still has
|
// forever, and re-verifying could never clear it because Fayda still has
|
||||||
// nothing to return.
|
// nothing to return.
|
||||||
if (attrUpdates.ownerFaydaSub) {
|
if (attrUpdates.ownerFaydaSub) {
|
||||||
if (attrUpdates.ownerEmail && dto.companyEmail !== undefined)
|
if (attrUpdates.ownerEmail) companyUpdates.email = attrUpdates.ownerEmail;
|
||||||
companyUpdates.email = attrUpdates.ownerEmail;
|
if (attrUpdates.ownerPhone)
|
||||||
if (attrUpdates.ownerPhone && dto.companyPhone !== undefined)
|
|
||||||
companyUpdates.phone = normalizeE164(String(attrUpdates.ownerPhone));
|
companyUpdates.phone = normalizeE164(String(attrUpdates.ownerPhone));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1086,9 +1069,7 @@ export class CompaniesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** List a company's change requests, newest first (backoffice review). */
|
/** List a company's change requests, newest first (backoffice review). */
|
||||||
async listChangeRequests(
|
async listChangeRequests(companyId: string): Promise<CompanyChangeRequest[]> {
|
||||||
companyId: string,
|
|
||||||
): Promise<CompanyChangeRequest[]> {
|
|
||||||
await this.findCompanyById(companyId);
|
await this.findCompanyById(companyId);
|
||||||
return this.changeRequestRepo.findByCompanyId(companyId);
|
return this.changeRequestRepo.findByCompanyId(companyId);
|
||||||
}
|
}
|
||||||
@@ -1150,8 +1131,7 @@ export class CompaniesService {
|
|||||||
reviewerId?: string,
|
reviewerId?: string,
|
||||||
): Promise<CompanyChangeRequest> {
|
): Promise<CompanyChangeRequest> {
|
||||||
const request = await this.changeRequestRepo.findById(id);
|
const request = await this.changeRequestRepo.findById(id);
|
||||||
if (!request)
|
if (!request) throw new NotFoundException(`Change request ${id} not found`);
|
||||||
throw new NotFoundException(`Change request ${id} not found`);
|
|
||||||
if (request.status !== ChangeRequestStatus.Pending) {
|
if (request.status !== ChangeRequestStatus.Pending) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Change request ${id} is already ${request.status}`,
|
`Change request ${id} is already ${request.status}`,
|
||||||
@@ -1164,7 +1144,10 @@ export class CompaniesService {
|
|||||||
|
|
||||||
const snapshot = (request.snapshot ?? {}) as Partial<UpdateProfileDto>;
|
const snapshot = (request.snapshot ?? {}) as Partial<UpdateProfileDto>;
|
||||||
await this.assertTinAvailable(company, snapshot.tin);
|
await this.assertTinAvailable(company, snapshot.tin);
|
||||||
const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, snapshot);
|
const companyUpdates = this.mapProfileDtoToCompanyUpdates(
|
||||||
|
company,
|
||||||
|
snapshot,
|
||||||
|
);
|
||||||
await this.companiesRepo.update(company.id, companyUpdates);
|
await this.companiesRepo.update(company.id, companyUpdates);
|
||||||
await this.applyLicenseChanges(request);
|
await this.applyLicenseChanges(request);
|
||||||
await this.applyDocumentChanges(request);
|
await this.applyDocumentChanges(request);
|
||||||
@@ -1294,7 +1277,12 @@ export class CompaniesService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (documentChanges.length > 0) {
|
if (documentChanges.length > 0) {
|
||||||
await this.recordCompanyRevision(company, {}, submittedBy, documentChanges);
|
await this.recordCompanyRevision(
|
||||||
|
company,
|
||||||
|
{},
|
||||||
|
submittedBy,
|
||||||
|
documentChanges,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return uploaded;
|
return uploaded;
|
||||||
}
|
}
|
||||||
@@ -1414,7 +1402,11 @@ export class CompaniesService {
|
|||||||
status: ChangeRequestStatus.Pending,
|
status: ChangeRequestStatus.Pending,
|
||||||
});
|
});
|
||||||
if (company) {
|
if (company) {
|
||||||
this.companyNotifier.changeRequestSubmitted(company, existing.id, false);
|
this.companyNotifier.changeRequestSubmitted(
|
||||||
|
company,
|
||||||
|
existing.id,
|
||||||
|
false,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const history = await this.changeRequestRepo.findByCompanyId(companyId);
|
const history = await this.changeRequestRepo.findByCompanyId(companyId);
|
||||||
@@ -1446,8 +1438,7 @@ export class CompaniesService {
|
|||||||
reviewerId?: string,
|
reviewerId?: string,
|
||||||
): Promise<CompanyChangeRequest> {
|
): Promise<CompanyChangeRequest> {
|
||||||
const request = await this.changeRequestRepo.findById(id);
|
const request = await this.changeRequestRepo.findById(id);
|
||||||
if (!request)
|
if (!request) throw new NotFoundException(`Change request ${id} not found`);
|
||||||
throw new NotFoundException(`Change request ${id} not found`);
|
|
||||||
if (request.status !== ChangeRequestStatus.Pending) {
|
if (request.status !== ChangeRequestStatus.Pending) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Change request ${id} is already ${request.status}`,
|
`Change request ${id} is already ${request.status}`,
|
||||||
@@ -1486,8 +1477,7 @@ export class CompaniesService {
|
|||||||
reviewerId?: string,
|
reviewerId?: string,
|
||||||
): Promise<CompanyChangeRequest> {
|
): Promise<CompanyChangeRequest> {
|
||||||
const request = await this.changeRequestRepo.findById(id);
|
const request = await this.changeRequestRepo.findById(id);
|
||||||
if (!request)
|
if (!request) throw new NotFoundException(`Change request ${id} not found`);
|
||||||
throw new NotFoundException(`Change request ${id} not found`);
|
|
||||||
if (request.status !== ChangeRequestStatus.Pending) {
|
if (request.status !== ChangeRequestStatus.Pending) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Change request ${id} is already ${request.status}`,
|
`Change request ${id} is already ${request.status}`,
|
||||||
@@ -1569,10 +1559,7 @@ export class CompaniesService {
|
|||||||
const reactivating =
|
const reactivating =
|
||||||
status === ProfileStatus.Active &&
|
status === ProfileStatus.Active &&
|
||||||
existing.status === ProfileStatus.Suspended;
|
existing.status === ProfileStatus.Suspended;
|
||||||
if (
|
if ((status === ProfileStatus.Suspended || reactivating) && !note?.trim()) {
|
||||||
(status === ProfileStatus.Suspended || reactivating) &&
|
|
||||||
!note?.trim()
|
|
||||||
) {
|
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
status === ProfileStatus.Suspended
|
status === ProfileStatus.Suspended
|
||||||
? "A message explaining the suspension is required — the customer will see it."
|
? "A message explaining the suspension is required — the customer will see it."
|
||||||
@@ -1594,7 +1581,9 @@ export class CompaniesService {
|
|||||||
existing.status === ProfileStatus.Pending ||
|
existing.status === ProfileStatus.Pending ||
|
||||||
existing.status === ProfileStatus.Rejected;
|
existing.status === ProfileStatus.Rejected;
|
||||||
if (awaitingReview) {
|
if (awaitingReview) {
|
||||||
const owners = await this.profilesRepo.findByCompanyId(existing.companyId);
|
const owners = await this.profilesRepo.findByCompanyId(
|
||||||
|
existing.companyId,
|
||||||
|
);
|
||||||
if (owners.length > 0 && !owners.some((o) => o.onboardingCompleted)) {
|
if (owners.length > 0 && !owners.some((o) => o.onboardingCompleted)) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"This customer hasn't finished onboarding yet. Their roles can be reviewed once they submit their application.",
|
"This customer hasn't finished onboarding yet. Their roles can be reviewed once they submit their application.",
|
||||||
@@ -1652,11 +1641,17 @@ export class CompaniesService {
|
|||||||
const names = pending.map((f) => f.name).join(", ");
|
const names = pending.map((f) => f.name).join(", ");
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`This role has ${pending.length} document(s) awaiting customer correction (${names}). ` +
|
`This role has ${pending.length} document(s) awaiting customer correction (${names}). ` +
|
||||||
`Approve it once the customer has re-uploaded them, or withdraw the change request first.`,
|
`Approve it once the customer has re-uploaded them, or withdraw the change request first.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.applyProfileStatus(manager, existing, status, note, reviewerId);
|
return this.applyProfileStatus(
|
||||||
|
manager,
|
||||||
|
existing,
|
||||||
|
status,
|
||||||
|
note,
|
||||||
|
reviewerId,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1992,7 +1987,9 @@ export class CompaniesService {
|
|||||||
.map((f) => ({ key: f.key, label: f.label }));
|
.map((f) => ({ key: f.key, label: f.label }));
|
||||||
|
|
||||||
// 2. Nationality-based company documents + which are already uploaded.
|
// 2. Nationality-based company documents + which are already uploaded.
|
||||||
const documentSettingCode = this.documentSettingCodeFor(company.nationality);
|
const documentSettingCode = this.documentSettingCodeFor(
|
||||||
|
company.nationality,
|
||||||
|
);
|
||||||
const [setting, uploadedFiles] = await Promise.all([
|
const [setting, uploadedFiles] = await Promise.all([
|
||||||
this.fileUploadSettingsService
|
this.fileUploadSettingsService
|
||||||
.getByCode(documentSettingCode)
|
.getByCode(documentSettingCode)
|
||||||
@@ -2074,7 +2071,9 @@ export class CompaniesService {
|
|||||||
? [`Upload the ${POA_DELEGATION_LABEL} for your Power of Attorney`]
|
? [`Upload the ${POA_DELEGATION_LABEL} for your Power of Attorney`]
|
||||||
: []),
|
: []),
|
||||||
...(flaggedDelegation
|
...(flaggedDelegation
|
||||||
? [`Re-upload your ${POA_DELEGATION_LABEL} — EDR asked for a correction`]
|
? [
|
||||||
|
`Re-upload your ${POA_DELEGATION_LABEL} — EDR asked for a correction`,
|
||||||
|
]
|
||||||
: []),
|
: []),
|
||||||
...(identity.faydaRequired && !identity.owner.verified
|
...(identity.faydaRequired && !identity.owner.verified
|
||||||
? ["Verify the company owner's identity with Fayda"]
|
? ["Verify the company owner's identity with Fayda"]
|
||||||
@@ -2088,10 +2087,10 @@ export class CompaniesService {
|
|||||||
// verification its representative may have no way to obtain.
|
// verification its representative may have no way to obtain.
|
||||||
...((poaRequired || poaProvided) && !poaProven
|
...((poaRequired || poaProvided) && !poaProven
|
||||||
? [
|
? [
|
||||||
identity.faydaRequired
|
identity.faydaRequired
|
||||||
? "Verify your Power of Attorney's identity with Fayda"
|
? "Verify your Power of Attorney's identity with Fayda"
|
||||||
: "Name your Power of Attorney, or verify them with Fayda",
|
: "Name your Power of Attorney, or verify them with Fayda",
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
...(identity.passportRequired && !identity.owner.passportNumber
|
...(identity.passportRequired && !identity.owner.passportNumber
|
||||||
? ["Add the company owner's passport number"]
|
? ["Add the company owner's passport number"]
|
||||||
@@ -2137,7 +2136,10 @@ export class CompaniesService {
|
|||||||
return new OnboardingRequirementsResponseDto({
|
return new OnboardingRequirementsResponseDto({
|
||||||
documentSettingCode,
|
documentSettingCode,
|
||||||
nationality: company.nationality ?? CompanyNationality.Ethiopian,
|
nationality: company.nationality ?? CompanyNationality.Ethiopian,
|
||||||
companyInfo: { complete: missingInfo.length === 0, missingFields: missingInfo },
|
companyInfo: {
|
||||||
|
complete: missingInfo.length === 0,
|
||||||
|
missingFields: missingInfo,
|
||||||
|
},
|
||||||
documents,
|
documents,
|
||||||
licenseProfiles,
|
licenseProfiles,
|
||||||
poa: {
|
poa: {
|
||||||
@@ -2179,7 +2181,7 @@ export class CompaniesService {
|
|||||||
if (!requirements.isComplete) {
|
if (!requirements.isComplete) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
requirements.outstanding[0] ??
|
requirements.outstanding[0] ??
|
||||||
"Your onboarding is incomplete. Please complete all required steps before submitting.",
|
"Your onboarding is incomplete. Please complete all required steps before submitting.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2188,7 +2190,10 @@ export class CompaniesService {
|
|||||||
const profiles = await this.companyProfilesRepo.findByCompanyId(companyId);
|
const profiles = await this.companyProfilesRepo.findByCompanyId(companyId);
|
||||||
for (const cp of profiles) {
|
for (const cp of profiles) {
|
||||||
if (cp.status !== ProfileStatus.Pending) {
|
if (cp.status !== ProfileStatus.Pending) {
|
||||||
await this.companyProfilesRepo.updateStatus(cp.id, ProfileStatus.Pending);
|
await this.companyProfilesRepo.updateStatus(
|
||||||
|
cp.id,
|
||||||
|
ProfileStatus.Pending,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2214,12 +2219,12 @@ export class CompaniesService {
|
|||||||
case CompanyStatus.Suspended:
|
case CompanyStatus.Suspended:
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
`Your company account is suspended — you can't create ${action} right now. ` +
|
`Your company account is suspended — you can't create ${action} right now. ` +
|
||||||
`Please contact EDR support for details.`,
|
`Please contact EDR support for details.`,
|
||||||
);
|
);
|
||||||
case CompanyStatus.Blacklisted:
|
case CompanyStatus.Blacklisted:
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
`Your company account is blacklisted — you can't create ${action}. ` +
|
`Your company account is blacklisted — you can't create ${action}. ` +
|
||||||
`Please contact EDR support.`,
|
`Please contact EDR support.`,
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
@@ -2248,8 +2253,7 @@ export class CompaniesService {
|
|||||||
switch (profile.status) {
|
switch (profile.status) {
|
||||||
case ProfileStatus.Suspended:
|
case ProfileStatus.Suspended:
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
`Your ${role} role is suspended${
|
`Your ${role} role is suspended${profile.reviewNote ? ` — ${profile.reviewNote}` : ""
|
||||||
profile.reviewNote ? ` — ${profile.reviewNote}` : ""
|
|
||||||
}. Your other roles are unaffected. Please contact EDR support to resolve this.`,
|
}. Your other roles are unaffected. Please contact EDR support to resolve this.`,
|
||||||
);
|
);
|
||||||
case ProfileStatus.Blacklisted:
|
case ProfileStatus.Blacklisted:
|
||||||
@@ -2258,8 +2262,7 @@ export class CompaniesService {
|
|||||||
);
|
);
|
||||||
case ProfileStatus.Rejected:
|
case ProfileStatus.Rejected:
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
`Your ${role} role was rejected${
|
`Your ${role} role was rejected${profile.reviewNote ? ` — ${profile.reviewNote}` : ""
|
||||||
profile.reviewNote ? ` — ${profile.reviewNote}` : ""
|
|
||||||
}. Amend and resubmit it from your settings page.`,
|
}. Amend and resubmit it from your settings page.`,
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
@@ -2518,9 +2521,7 @@ export class CompaniesService {
|
|||||||
LICENSE_RESOURCE,
|
LICENSE_RESOURCE,
|
||||||
);
|
);
|
||||||
return records
|
return records
|
||||||
.filter(
|
.filter((r) => r.code === LICENSE_CODE || r.code === LICENSE_PENDING_CODE)
|
||||||
(r) => r.code === LICENSE_CODE || r.code === LICENSE_PENDING_CODE,
|
|
||||||
)
|
|
||||||
.map((r) => ({
|
.map((r) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
name: r.name,
|
name: r.name,
|
||||||
@@ -2687,7 +2688,7 @@ export class CompaniesService {
|
|||||||
if (missing.length > 0) {
|
if (missing.length > 0) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`A freight forwarder acts on other companies' behalf, so a Power of Attorney is required. ` +
|
`A freight forwarder acts on other companies' behalf, so a Power of Attorney is required. ` +
|
||||||
`Add the ${missing.map((f) => f.label.toLowerCase()).join(", ")} first.`,
|
`Add the ${missing.map((f) => f.label.toLowerCase()).join(", ")} first.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2699,13 +2700,13 @@ export class CompaniesService {
|
|||||||
if (!onFile) {
|
if (!onFile) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Upload the ${POA_DELEGATION_LABEL} for the Power of Attorney` +
|
`Upload the ${POA_DELEGATION_LABEL} for the Power of Attorney` +
|
||||||
(opts.requirePoa ? " — it is required for freight forwarders." : "."),
|
(opts.requirePoa ? " — it is required for freight forwarders." : "."),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (flagged) {
|
if (flagged) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`The ${POA_DELEGATION_LABEL} on file needs to be corrected. ` +
|
`The ${POA_DELEGATION_LABEL} on file needs to be corrected. ` +
|
||||||
`Re-upload it before continuing.`,
|
`Re-upload it before continuing.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2748,6 +2749,12 @@ export class CompaniesService {
|
|||||||
async completeIdentityVerification(
|
async completeIdentityVerification(
|
||||||
userId: string,
|
userId: string,
|
||||||
dto: CompleteIdentityVerificationDto,
|
dto: CompleteIdentityVerificationDto,
|
||||||
|
/**
|
||||||
|
* The signed-in account, used as the owner's fallback contact details.
|
||||||
|
* Optional so the callers that only have a user id keep compiling — they
|
||||||
|
* simply get no fallback.
|
||||||
|
*/
|
||||||
|
account?: { email?: string; phoneNumber?: string },
|
||||||
): Promise<CompanyIdentityStateDto> {
|
): Promise<CompanyIdentityStateDto> {
|
||||||
const { company } = await this.getCompanyInfoByUserId(userId);
|
const { company } = await this.getCompanyInfoByUserId(userId);
|
||||||
const prefix = IDENTITY_PREFIX[dto.subject];
|
const prefix = IDENTITY_PREFIX[dto.subject];
|
||||||
@@ -2769,7 +2776,8 @@ export class CompaniesService {
|
|||||||
// of this check entirely.
|
// of this check entirely.
|
||||||
if (dto.subject === "owner" || dto.subject === "poa") {
|
if (dto.subject === "owner" || dto.subject === "poa") {
|
||||||
const other: IdentitySubject = dto.subject === "poa" ? "owner" : "poa";
|
const other: IdentitySubject = dto.subject === "poa" ? "owner" : "poa";
|
||||||
const otherSub = company.attributes?.[`${IDENTITY_PREFIX[other]}FaydaSub`];
|
const otherSub =
|
||||||
|
company.attributes?.[`${IDENTITY_PREFIX[other]}FaydaSub`];
|
||||||
if (otherSub && otherSub === result.sub) {
|
if (otherSub && otherSub === result.sub) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`This identity is already registered as the company's ${IDENTITY_LABEL[other]}. The Power of Attorney must be a different person from the owner.`,
|
`This identity is already registered as the company's ${IDENTITY_LABEL[other]}. The Power of Attorney must be a different person from the owner.`,
|
||||||
@@ -2778,6 +2786,22 @@ export class CompaniesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
// Fayda's email and phone claims are optional and routinely come back empty.
|
||||||
|
// For the owner that leaves the company with no contact details at all: the
|
||||||
|
// step renders no input for them (they are the verification's output), and
|
||||||
|
// "same as owner" then copies those blanks onto `generalManagerEmail` /
|
||||||
|
// `generalManagerPhone`, which `REQUIRED_COMPANY_INFO` demands at submit —
|
||||||
|
// an unfixable dead end. The account doing the onboarding is the one contact
|
||||||
|
// we always have, and it is already OTP-proven, so it stands in.
|
||||||
|
//
|
||||||
|
// Owner only: the PoA and the GM are other people, and the registering
|
||||||
|
// account's address is not theirs to wear.
|
||||||
|
const isOwner = dto.subject === "owner";
|
||||||
|
const email = result.email || (isOwner ? account?.email : undefined);
|
||||||
|
const phone =
|
||||||
|
result.phoneNumber || (isOwner ? account?.phoneNumber : undefined);
|
||||||
|
|
||||||
const identity: VerifiedIdentityAttributes = {
|
const identity: VerifiedIdentityAttributes = {
|
||||||
[`${prefix}FaydaSub`]: result.sub,
|
[`${prefix}FaydaSub`]: result.sub,
|
||||||
[`${prefix}FaydaVerifiedAt`]: now,
|
[`${prefix}FaydaVerifiedAt`]: now,
|
||||||
@@ -2785,14 +2809,12 @@ export class CompaniesService {
|
|||||||
[`${prefix}Gender`]: result.gender ?? null,
|
[`${prefix}Gender`]: result.gender ?? null,
|
||||||
// The verified payload owns the person's details from here on.
|
// The verified payload owns the person's details from here on.
|
||||||
...(result.fullName ? { [`${prefix}Name`]: result.fullName } : {}),
|
...(result.fullName ? { [`${prefix}Name`]: result.fullName } : {}),
|
||||||
...(result.email ? { [`${prefix}Email`]: result.email } : {}),
|
...(email ? { [`${prefix}Email`]: email } : {}),
|
||||||
// Fayda returns whatever the national registry holds, which is routinely a
|
// Fayda returns whatever the national registry holds, which is routinely a
|
||||||
// local number ("0911223344"). Every typed phone in this service is stored
|
// local number ("0911223344"). Every typed phone in this service is stored
|
||||||
// E.164, and `@IsValidPhone()` rejects anything else — so a raw claim here
|
// E.164, and `@IsValidPhone()` rejects anything else — so a raw claim here
|
||||||
// becomes a value the portal reads back and cannot resubmit.
|
// becomes a value the portal reads back and cannot resubmit.
|
||||||
...(result.phoneNumber
|
...(phone ? { [`${prefix}Phone`]: normalizeE164(phone) } : {}),
|
||||||
? { [`${prefix}Phone`]: normalizeE164(result.phoneNumber) }
|
|
||||||
: {}),
|
|
||||||
...(result.address ? { [`${prefix}Address`]: result.address } : {}),
|
...(result.address ? { [`${prefix}Address`]: result.address } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2842,7 +2864,13 @@ export class CompaniesService {
|
|||||||
* proven identity to copy, only typed text that would arrive wearing a
|
* proven identity to copy, only typed text that would arrive wearing a
|
||||||
* verified badge.
|
* verified badge.
|
||||||
*/
|
*/
|
||||||
async setGmSameAsOwner(userId: string): Promise<CompanyIdentityStateDto> {
|
async setGmSameAsOwner(
|
||||||
|
userId: string,
|
||||||
|
/** Same fallback as {@link completeIdentityVerification}, for owners
|
||||||
|
* verified before that fallback existed — their stored contact details are
|
||||||
|
* blank, and copying blanks here would block the submit. */
|
||||||
|
account?: { email?: string; phoneNumber?: string },
|
||||||
|
): Promise<CompanyIdentityStateDto> {
|
||||||
const { company } = await this.getCompanyInfoByUserId(userId);
|
const { company } = await this.getCompanyInfoByUserId(userId);
|
||||||
const attrs = company.attributes ?? {};
|
const attrs = company.attributes ?? {};
|
||||||
const ownerSub = attrs.ownerFaydaSub as string | undefined;
|
const ownerSub = attrs.ownerFaydaSub as string | undefined;
|
||||||
@@ -2852,20 +2880,23 @@ export class CompaniesService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ownerEmail = (attrs.ownerEmail as string | undefined) || account?.email || null;
|
||||||
|
const ownerPhone = (attrs.ownerPhone as string | undefined) || account?.phoneNumber || null;
|
||||||
|
|
||||||
const copied: Record<string, unknown> = {
|
const copied: Record<string, unknown> = {
|
||||||
gmSameAsOwner: true,
|
gmSameAsOwner: true,
|
||||||
gmFaydaSub: ownerSub,
|
gmFaydaSub: ownerSub,
|
||||||
gmFaydaVerifiedAt: attrs.ownerFaydaVerifiedAt ?? new Date().toISOString(),
|
gmFaydaVerifiedAt: attrs.ownerFaydaVerifiedAt ?? new Date().toISOString(),
|
||||||
gmName: attrs.ownerName ?? null,
|
gmName: attrs.ownerName ?? null,
|
||||||
gmEmail: attrs.ownerEmail ?? null,
|
gmEmail: ownerEmail,
|
||||||
gmPhone: attrs.ownerPhone ?? null,
|
gmPhone: ownerPhone ? normalizeE164(ownerPhone) : null,
|
||||||
gmAddress: attrs.ownerAddress ?? null,
|
gmAddress: attrs.ownerAddress ?? null,
|
||||||
gmBirthdate: attrs.ownerBirthdate ?? null,
|
gmBirthdate: attrs.ownerBirthdate ?? null,
|
||||||
gmGender: attrs.ownerGender ?? null,
|
gmGender: attrs.ownerGender ?? null,
|
||||||
// Kept in step for the notifiers, same as a GM verification does.
|
// Kept in step for the notifiers, same as a GM verification does.
|
||||||
generalManagerName: attrs.ownerName ?? null,
|
generalManagerName: attrs.ownerName ?? null,
|
||||||
generalManagerEmail: attrs.ownerEmail ?? null,
|
generalManagerEmail: ownerEmail,
|
||||||
generalManagerPhone: attrs.ownerPhone ?? null,
|
generalManagerPhone: ownerPhone ? normalizeE164(ownerPhone) : null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const updated = await this.companiesRepo.update(company.id, {
|
const updated = await this.companiesRepo.update(company.id, {
|
||||||
@@ -2976,8 +3007,8 @@ export class CompaniesService {
|
|||||||
const snapshot = {
|
const snapshot = {
|
||||||
...(existing?.snapshot ?? {}),
|
...(existing?.snapshot ?? {}),
|
||||||
faydaIdentity: {
|
faydaIdentity: {
|
||||||
...(((existing?.snapshot ?? {}) as Record<string, any>)
|
...(((existing?.snapshot ?? {}) as Record<string, any>).faydaIdentity ??
|
||||||
.faydaIdentity ?? {}),
|
{}),
|
||||||
...identity,
|
...identity,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -3389,7 +3420,10 @@ export class CompaniesService {
|
|||||||
"We couldn't find a business license for this TIN with eTrade. Please double-check the number and try again.",
|
"We couldn't find a business license for this TIN with eTrade. Please double-check the number and try again.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return this.etradeService.extractRegistrationData(businessInfo, companyInfo);
|
return this.etradeService.extractRegistrationData(
|
||||||
|
businessInfo,
|
||||||
|
companyInfo,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchETradeData(tin: string, excludeCompanyId?: string) {
|
async fetchETradeData(tin: string, excludeCompanyId?: string) {
|
||||||
@@ -3421,7 +3455,9 @@ export class CompaniesService {
|
|||||||
|
|
||||||
const tin = dto.tin ?? company.tin;
|
const tin = dto.tin ?? company.tin;
|
||||||
const registration = await this.resolveEtradeRegistration(tin);
|
const registration = await this.resolveEtradeRegistration(tin);
|
||||||
const fresh: Partial<Record<(typeof ETRADE_SOURCED_FIELDS)[number], string>> = {
|
const fresh: Partial<
|
||||||
|
Record<(typeof ETRADE_SOURCED_FIELDS)[number], string>
|
||||||
|
> = {
|
||||||
companyName: registration.companyName,
|
companyName: registration.companyName,
|
||||||
licenceNumber: registration.licenceNumber,
|
licenceNumber: registration.licenceNumber,
|
||||||
statusDescription: registration.statusDescription,
|
statusDescription: registration.statusDescription,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Company, CompanyStatus } from "./entities/company.entity";
|
|||||||
import { NotificationsService } from "../notifications/notifications.service";
|
import { NotificationsService } from "../notifications/notifications.service";
|
||||||
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
|
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
|
||||||
import { resolveCompanyNotifyPhone } from "../notifications/resolve-company-phone.util";
|
import { resolveCompanyNotifyPhone } from "../notifications/resolve-company-phone.util";
|
||||||
|
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||||
|
|
||||||
/** Account statuses that lock the customer out and therefore must be told to them. */
|
/** Account statuses that lock the customer out and therefore must be told to them. */
|
||||||
const PUNITIVE_STATUSES: readonly CompanyStatus[] = [
|
const PUNITIVE_STATUSES: readonly CompanyStatus[] = [
|
||||||
@@ -175,12 +176,9 @@ export class CompanyNotifierService {
|
|||||||
// ── Backoffice-facing: work has arrived back in the review queue ────────────
|
// ── Backoffice-facing: work has arrived back in the review queue ────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persist + push an in-app item to every backoffice staff user, deep-linked to
|
* Persist + push an in-app item to the customer desk — staff holding
|
||||||
* the customer's detail page.
|
* `customers:get_notification` — deep-linked to the customer's detail page,
|
||||||
*
|
* which is itself gated on `customers:view`.
|
||||||
* The recipient resolver has no role/permission targeting (see
|
|
||||||
* `notification-recipients.service.ts`) — `allBackoffice` is the narrowest
|
|
||||||
* selector available, so marketing is reached by notifying all staff.
|
|
||||||
*/
|
*/
|
||||||
private notifyStaff(
|
private notifyStaff(
|
||||||
company: Company,
|
company: Company,
|
||||||
@@ -189,7 +187,7 @@ export class CompanyNotifierService {
|
|||||||
data: Record<string, unknown> = {},
|
data: Record<string, unknown> = {},
|
||||||
): void {
|
): void {
|
||||||
void this.inbox.notify({
|
void this.inbox.notify({
|
||||||
recipients: { allBackoffice: true },
|
recipients: { permissionKeys: [FREIGHT_PERMS.customers.getNotification] },
|
||||||
audience: NotificationAudience.BACKOFFICE,
|
audience: NotificationAudience.BACKOFFICE,
|
||||||
type: NotificationType.REQUEST_SUBMITTED,
|
type: NotificationType.REQUEST_SUBMITTED,
|
||||||
title,
|
title,
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsEnum, IsArray, ValidateNested, ArrayMinSize } from 'class-validator';
|
import {
|
||||||
import { Type } from 'class-transformer';
|
IsString,
|
||||||
import { CompanyType } from '../entities/company.entity';
|
IsNotEmpty,
|
||||||
import { ProfileType } from '../entities/company-profile.entity';
|
IsOptional,
|
||||||
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
MaxLength,
|
||||||
import { IsTin } from '../../../common/validators/is-tin.validator';
|
IsBoolean,
|
||||||
|
IsEnum,
|
||||||
|
IsArray,
|
||||||
|
ValidateNested,
|
||||||
|
ArrayMinSize,
|
||||||
|
} from "class-validator";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
import { CompanyType } from "../entities/company.entity";
|
||||||
|
import { ProfileType } from "../entities/company-profile.entity";
|
||||||
|
import { IsTin } from "../../../common/validators/is-tin.validator";
|
||||||
|
|
||||||
export class CompanyProfileInputDto {
|
export class CompanyProfileInputDto {
|
||||||
@IsEnum(ProfileType)
|
@IsEnum(ProfileType)
|
||||||
@@ -24,17 +33,6 @@ export class CreateCompanyWithProfileDto {
|
|||||||
@MaxLength(200)
|
@MaxLength(200)
|
||||||
companyName!: string;
|
companyName!: string;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsEmail()
|
|
||||||
@MaxLength(150)
|
|
||||||
companyEmail?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(20)
|
|
||||||
@IsValidPhone()
|
|
||||||
companyPhone?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(32)
|
@MaxLength(32)
|
||||||
@@ -46,7 +44,7 @@ export class CreateCompanyWithProfileDto {
|
|||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsTin({ message: 'TIN must be exactly 10 digits' })
|
@IsTin({ message: "TIN must be exactly 10 digits" })
|
||||||
tin?: string;
|
tin?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -2,21 +2,19 @@ import {
|
|||||||
buildCompanyIdentityState,
|
buildCompanyIdentityState,
|
||||||
CompanyIdentityStateDto,
|
CompanyIdentityStateDto,
|
||||||
} from "./complete-identity-verification.dto";
|
} from "./complete-identity-verification.dto";
|
||||||
import { Company } from '../entities/company.entity';
|
import { Company } from "../entities/company.entity";
|
||||||
import { ExternalProfile } from '../entities/external-profile.entity';
|
import { ExternalProfile } from "../entities/external-profile.entity";
|
||||||
import {
|
import {
|
||||||
ChangeRequestStatus,
|
ChangeRequestStatus,
|
||||||
CompanyChangeRequest,
|
CompanyChangeRequest,
|
||||||
} from '../entities/company-change-request.entity';
|
} from "../entities/company-change-request.entity";
|
||||||
import { ResponseCompanyProfileDto } from './response-company.dto';
|
import { ResponseCompanyProfileDto } from "./response-company.dto";
|
||||||
|
|
||||||
export class ProfileResponseDto {
|
export class ProfileResponseDto {
|
||||||
companyId: string;
|
companyId: string;
|
||||||
companyName: string;
|
companyName: string;
|
||||||
companyType: string;
|
companyType: string;
|
||||||
nationality: string | null;
|
nationality: string | null;
|
||||||
companyEmail: string | null;
|
|
||||||
companyPhone: string | null;
|
|
||||||
companyLocation: string;
|
companyLocation: string;
|
||||||
companyAddress: string | null;
|
companyAddress: string | null;
|
||||||
tinNumber: string;
|
tinNumber: string;
|
||||||
@@ -89,8 +87,6 @@ export class ProfileResponseDto {
|
|||||||
this.companyProfiles =
|
this.companyProfiles =
|
||||||
company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)) ??
|
company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)) ??
|
||||||
[];
|
[];
|
||||||
this.companyEmail = company.email ?? null;
|
|
||||||
this.companyPhone = company.phone ?? null;
|
|
||||||
this.companyLocation = company.country;
|
this.companyLocation = company.country;
|
||||||
this.companyAddress = company.address ?? null;
|
this.companyAddress = company.address ?? null;
|
||||||
this.tinNumber = company.tin;
|
this.tinNumber = company.tin;
|
||||||
@@ -128,9 +124,9 @@ export class ProfileResponseDto {
|
|||||||
|
|
||||||
const openReview =
|
const openReview =
|
||||||
changeRequest &&
|
changeRequest &&
|
||||||
(changeRequest.status === ChangeRequestStatus.Pending ||
|
(changeRequest.status === ChangeRequestStatus.Pending ||
|
||||||
changeRequest.status === ChangeRequestStatus.Rejected ||
|
changeRequest.status === ChangeRequestStatus.Rejected ||
|
||||||
changeRequest.status === ChangeRequestStatus.ChangesRequested)
|
changeRequest.status === ChangeRequestStatus.ChangesRequested)
|
||||||
? changeRequest
|
? changeRequest
|
||||||
: null;
|
: null;
|
||||||
this.reviewStatus =
|
this.reviewStatus =
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ import {
|
|||||||
IsEnum,
|
IsEnum,
|
||||||
IsIn,
|
IsIn,
|
||||||
Matches,
|
Matches,
|
||||||
} from 'class-validator';
|
} from "class-validator";
|
||||||
import { ETHIOPIAN_REGIONS, type EthiopianRegion } from '@edr/types';
|
import { ETHIOPIAN_REGIONS, type EthiopianRegion } from "@edr/types";
|
||||||
import { CompanyNationality } from '../entities/company.entity';
|
import { CompanyNationality } from "../entities/company.entity";
|
||||||
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
import { IsValidPhone } from "../../../common/validators/is-phone-number.validator";
|
||||||
import { IsTin } from '../../../common/validators/is-tin.validator';
|
import { IsTin } from "../../../common/validators/is-tin.validator";
|
||||||
|
|
||||||
export class UpdateProfileDto {
|
export class UpdateProfileDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -22,17 +22,6 @@ export class UpdateProfileDto {
|
|||||||
@MaxLength(200)
|
@MaxLength(200)
|
||||||
companyName?: string;
|
companyName?: string;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsEmail()
|
|
||||||
@MaxLength(150)
|
|
||||||
companyEmail?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(20)
|
|
||||||
@IsValidPhone()
|
|
||||||
companyPhone?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(32)
|
@MaxLength(32)
|
||||||
@@ -44,7 +33,7 @@ export class UpdateProfileDto {
|
|||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsTin({ message: 'TIN must be exactly 10 digits' })
|
@IsTin({ message: "TIN must be exactly 10 digits" })
|
||||||
tin?: string;
|
tin?: string;
|
||||||
|
|
||||||
// Ethiopian VAT registration numbers are 10 digits, the same shape as the
|
// Ethiopian VAT registration numbers are 10 digits, the same shape as the
|
||||||
@@ -53,7 +42,7 @@ export class UpdateProfileDto {
|
|||||||
// column may hold.
|
// column may hold.
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@Matches(/^\d{10}$/, { message: 'VAT number must be exactly 10 digits' })
|
@Matches(/^\d{10}$/, { message: "VAT number must be exactly 10 digits" })
|
||||||
vatNumber?: string;
|
vatNumber?: string;
|
||||||
|
|
||||||
// `fanNumber` is deliberately absent: the FAN is the Fayda number of the
|
// `fanNumber` is deliberately absent: the FAN is the Fayda number of the
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ import { ComplianceType } from './entities/compliance-record.entity';
|
|||||||
|
|
||||||
@ApiTags('Vehicle Compliance')
|
@ApiTags('Vehicle Compliance')
|
||||||
@Controller('compliance')
|
@Controller('compliance')
|
||||||
@BookingStaff(FREIGHT_PERMS.compliance.view)
|
// Class gate lists every key its routes use: Nest runs class AND method
|
||||||
|
// guards, so a key missing here would deny before the route's own key runs.
|
||||||
|
@BookingStaff([
|
||||||
|
FREIGHT_PERMS.compliance.view,
|
||||||
|
FREIGHT_PERMS.compliance.manage,
|
||||||
|
])
|
||||||
export class ComplianceController {
|
export class ComplianceController {
|
||||||
constructor(private readonly complianceService: ComplianceService) {}
|
constructor(private readonly complianceService: ComplianceService) {}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,12 @@ import { FilterConsignmentDto } from "./dto/filter-consignment.dto";
|
|||||||
|
|
||||||
@ApiTags("consignments")
|
@ApiTags("consignments")
|
||||||
@Controller("consignments")
|
@Controller("consignments")
|
||||||
@FleetView(FREIGHT_PERMS.consignments.view)
|
// Class gate lists every key its routes use: Nest runs class AND method
|
||||||
|
// guards, so a key missing here would deny before the route's own key runs.
|
||||||
|
@FleetView([
|
||||||
|
FREIGHT_PERMS.consignments.view,
|
||||||
|
FREIGHT_PERMS.consignments.create,
|
||||||
|
])
|
||||||
export class ConsignmentsController {
|
export class ConsignmentsController {
|
||||||
constructor(private readonly consignmentsService: ConsignmentsService) {}
|
constructor(private readonly consignmentsService: ConsignmentsService) {}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,14 @@ import { ContainersService } from './containers.service';
|
|||||||
|
|
||||||
@ApiTags('containers')
|
@ApiTags('containers')
|
||||||
@Controller('containers')
|
@Controller('containers')
|
||||||
@FleetView(FREIGHT_PERMS.containers.view)
|
// Class gate lists every key its routes use: Nest runs class AND method
|
||||||
|
// guards, so a key missing here would deny before the route's own key runs.
|
||||||
|
@FleetView([
|
||||||
|
FREIGHT_PERMS.containers.view,
|
||||||
|
FREIGHT_PERMS.containers.create,
|
||||||
|
FREIGHT_PERMS.containers.update,
|
||||||
|
FREIGHT_PERMS.containers.delete,
|
||||||
|
])
|
||||||
export class ContainersController {
|
export class ContainersController {
|
||||||
constructor(private readonly containersService: ContainersService) {}
|
constructor(private readonly containersService: ContainersService) {}
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export class Container extends BaseEntity {
|
|||||||
type: 'varchar',
|
type: 'varchar',
|
||||||
nullable: true,
|
nullable: true,
|
||||||
})
|
})
|
||||||
sealNumber!: string | null;
|
sealNumber!: string | null;
|
||||||
|
|
||||||
@Column({ type: 'varchar', default: 'AVAILABLE' })
|
@Column({ type: 'varchar', default: 'AVAILABLE' })
|
||||||
status!: string; // AVAILABLE, LOADED, IN_TRANSIT, MAINTENANCE, DAMAGED
|
status!: string; // AVAILABLE, LOADED, IN_TRANSIT, MAINTENANCE, DAMAGED
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
@@ -15,55 +17,85 @@ import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
|||||||
import { ContractTemplatesService } from "./contract-templates.service";
|
import { ContractTemplatesService } from "./contract-templates.service";
|
||||||
import {
|
import {
|
||||||
CreateArticleDto,
|
CreateArticleDto,
|
||||||
|
CreateContractTemplateDto,
|
||||||
PreviewContractTemplateDto,
|
PreviewContractTemplateDto,
|
||||||
ReplaceArticlesDto,
|
ReplaceArticlesDto,
|
||||||
UpdateArticleDto,
|
UpdateArticleDto,
|
||||||
UpdateContractTemplateDto,
|
UpdateContractTemplateDto,
|
||||||
} from "./dto/contract-template.dto";
|
} from "./dto/contract-template.dto";
|
||||||
|
|
||||||
|
// `view` opens the Templates page; `read` is API-read-only for other pages
|
||||||
|
// that show template data; create/update/delete gate each write. `manage` is
|
||||||
|
// the legacy write key and keeps working for roles that already hold it.
|
||||||
|
const TEMPLATE_READ = [
|
||||||
|
FREIGHT_PERMS.settings.contractTemplates.view,
|
||||||
|
FREIGHT_PERMS.settings.contractTemplates.read,
|
||||||
|
FREIGHT_PERMS.settings.contractTemplates.update,
|
||||||
|
FREIGHT_PERMS.settings.contractTemplates.manage,
|
||||||
|
FREIGHT_PERMS.admin,
|
||||||
|
];
|
||||||
|
|
||||||
|
const TEMPLATE_UPDATE = [
|
||||||
|
FREIGHT_PERMS.settings.contractTemplates.update,
|
||||||
|
FREIGHT_PERMS.settings.contractTemplates.manage,
|
||||||
|
FREIGHT_PERMS.admin,
|
||||||
|
];
|
||||||
|
|
||||||
@ApiTags("contract-templates")
|
@ApiTags("contract-templates")
|
||||||
@Controller("contract-templates")
|
@Controller("contract-templates")
|
||||||
export class ContractTemplatesController {
|
export class ContractTemplatesController {
|
||||||
constructor(private readonly service: ContractTemplatesService) {}
|
constructor(private readonly service: ContractTemplatesService) {}
|
||||||
|
|
||||||
// Reads are staff-only (the backoffice Templates tab is the only consumer);
|
|
||||||
// writes are admin-guarded like other freight configuration resources.
|
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@BookingStaff([
|
@BookingStaff(TEMPLATE_READ)
|
||||||
FREIGHT_PERMS.settings.contractTemplates.view,
|
@ApiOperation({ summary: "List contract templates (system container + staff-created bulk)" })
|
||||||
FREIGHT_PERMS.settings.contractTemplates.manage,
|
|
||||||
FREIGHT_PERMS.admin,
|
|
||||||
])
|
|
||||||
@ApiOperation({ summary: "List the six contract document templates" })
|
|
||||||
list() {
|
list() {
|
||||||
return this.service.list();
|
return this.service.list();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(":code")
|
@Post()
|
||||||
@BookingStaff([
|
@BookingStaff([
|
||||||
FREIGHT_PERMS.settings.contractTemplates.view,
|
FREIGHT_PERMS.settings.contractTemplates.create,
|
||||||
FREIGHT_PERMS.settings.contractTemplates.manage,
|
FREIGHT_PERMS.settings.contractTemplates.manage,
|
||||||
FREIGHT_PERMS.admin,
|
FREIGHT_PERMS.admin,
|
||||||
])
|
])
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Create a bulk contract template for a (cargo type, customs option) pair",
|
||||||
|
})
|
||||||
|
create(@Body() dto: CreateContractTemplateDto) {
|
||||||
|
return this.service.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(":code")
|
||||||
|
@BookingStaff(TEMPLATE_READ)
|
||||||
@ApiOperation({ summary: "Get one contract template by code" })
|
@ApiOperation({ summary: "Get one contract template by code" })
|
||||||
getByCode(@Param("code") code: string) {
|
getByCode(@Param("code") code: string) {
|
||||||
return this.service.getByCode(code);
|
return this.service.getByCode(code);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(":code")
|
@Patch(":code")
|
||||||
@BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin])
|
@BookingStaff(TEMPLATE_UPDATE)
|
||||||
@ApiOperation({ summary: "Update template metadata (name, title, recitals, active flag)" })
|
@ApiOperation({ summary: "Update template metadata (name, title, recitals, active flag)" })
|
||||||
update(@Param("code") code: string, @Body() dto: UpdateContractTemplateDto) {
|
update(@Param("code") code: string, @Body() dto: UpdateContractTemplateDto) {
|
||||||
return this.service.update(code, dto);
|
return this.service.update(code, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(":code/preview")
|
@Delete(":code")
|
||||||
@BookingStaff([
|
@BookingStaff([
|
||||||
FREIGHT_PERMS.settings.contractTemplates.view,
|
FREIGHT_PERMS.settings.contractTemplates.delete,
|
||||||
FREIGHT_PERMS.settings.contractTemplates.manage,
|
|
||||||
FREIGHT_PERMS.admin,
|
FREIGHT_PERMS.admin,
|
||||||
])
|
])
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Delete a staff-created bulk template (system templates refuse)",
|
||||||
|
})
|
||||||
|
remove(@Param("code") code: string) {
|
||||||
|
return this.service.remove(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":code/preview")
|
||||||
|
@BookingStaff(TEMPLATE_READ)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Render an HTML preview of the template against mock contract data",
|
summary: "Render an HTML preview of the template against mock contract data",
|
||||||
})
|
})
|
||||||
@@ -77,21 +109,21 @@ export class ContractTemplatesController {
|
|||||||
/* ------------------------- article routes ------------------------- */
|
/* ------------------------- article routes ------------------------- */
|
||||||
|
|
||||||
@Put(":code/articles")
|
@Put(":code/articles")
|
||||||
@BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin])
|
@BookingStaff(TEMPLATE_UPDATE)
|
||||||
@ApiOperation({ summary: "Replace the full ordered article list (used for reorder)" })
|
@ApiOperation({ summary: "Replace the full ordered article list (used for reorder)" })
|
||||||
replaceArticles(@Param("code") code: string, @Body() dto: ReplaceArticlesDto) {
|
replaceArticles(@Param("code") code: string, @Body() dto: ReplaceArticlesDto) {
|
||||||
return this.service.replaceArticles(code, dto.articles);
|
return this.service.replaceArticles(code, dto.articles);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(":code/articles")
|
@Post(":code/articles")
|
||||||
@BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin])
|
@BookingStaff(TEMPLATE_UPDATE)
|
||||||
@ApiOperation({ summary: "Add an article to the template" })
|
@ApiOperation({ summary: "Add an article to the template" })
|
||||||
addArticle(@Param("code") code: string, @Body() dto: CreateArticleDto) {
|
addArticle(@Param("code") code: string, @Body() dto: CreateArticleDto) {
|
||||||
return this.service.addArticle(code, dto);
|
return this.service.addArticle(code, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(":code/articles/:articleId")
|
@Patch(":code/articles/:articleId")
|
||||||
@BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin])
|
@BookingStaff(TEMPLATE_UPDATE)
|
||||||
@ApiOperation({ summary: "Update an article's title or body" })
|
@ApiOperation({ summary: "Update an article's title or body" })
|
||||||
updateArticle(
|
updateArticle(
|
||||||
@Param("code") code: string,
|
@Param("code") code: string,
|
||||||
@@ -102,7 +134,7 @@ export class ContractTemplatesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete(":code/articles/:articleId")
|
@Delete(":code/articles/:articleId")
|
||||||
@BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin])
|
@BookingStaff(TEMPLATE_UPDATE)
|
||||||
@ApiOperation({ summary: "Remove an article from the template" })
|
@ApiOperation({ summary: "Remove an article from the template" })
|
||||||
removeArticle(
|
removeArticle(
|
||||||
@Param("code") code: string,
|
@Param("code") code: string,
|
||||||
|
|||||||
@@ -3,10 +3,8 @@ import { Injectable } from "@nestjs/common";
|
|||||||
import { InjectRepository } from "@nestjs/typeorm";
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
import { Repository } from "typeorm";
|
import { Repository } from "typeorm";
|
||||||
|
|
||||||
import {
|
import { CargoType } from "../rule-engine/entities/cargo-type.entity";
|
||||||
ContractTemplate,
|
import { ContractTemplate } from "./entities/contract-template.entity";
|
||||||
ContractTemplateCode,
|
|
||||||
} from "./entities/contract-template.entity";
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ContractTemplatesRepository extends BaseRepository<ContractTemplate> {
|
export class ContractTemplatesRepository extends BaseRepository<ContractTemplate> {
|
||||||
@@ -17,12 +15,51 @@ export class ContractTemplatesRepository extends BaseRepository<ContractTemplate
|
|||||||
super(repository);
|
super(repository);
|
||||||
}
|
}
|
||||||
|
|
||||||
findByCode(code: ContractTemplateCode): Promise<ContractTemplate | null> {
|
findByCode(code: string): Promise<ContractTemplate | null> {
|
||||||
return this.repository.findOne({ where: { code } });
|
return this.repository.findOne({ where: { code } });
|
||||||
}
|
}
|
||||||
|
|
||||||
override findAll(): Promise<ContractTemplate[]> {
|
override findAll(): Promise<ContractTemplate[]> {
|
||||||
return this.repository.find({ order: { code: "ASC" } });
|
return this.repository.find({
|
||||||
|
relations: { cargoType: true },
|
||||||
|
order: { code: "ASC" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
findByCargoCombo(
|
||||||
|
cargoTypeId: string,
|
||||||
|
withCustoms: boolean,
|
||||||
|
): Promise<ContractTemplate | null> {
|
||||||
|
return this.repository.findOne({ where: { cargoTypeId, withCustoms } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The active bulk template covering this cargo type: written against the
|
||||||
|
* cargo type itself or against its parent group (the two are mutually
|
||||||
|
* exclusive, so at most one row matches).
|
||||||
|
*/
|
||||||
|
findActiveBulkTemplate(
|
||||||
|
cargoTypeId: string,
|
||||||
|
withCustoms: boolean,
|
||||||
|
): Promise<ContractTemplate | null> {
|
||||||
|
return this.repository
|
||||||
|
.createQueryBuilder("t")
|
||||||
|
.where("t.is_active = true")
|
||||||
|
.andWhere("t.with_customs = :withCustoms", { withCustoms })
|
||||||
|
.andWhere(
|
||||||
|
`(t.cargo_type_id = :cargoTypeId OR t.cargo_type_id = (
|
||||||
|
SELECT c.parent_group_id FROM freight.cargo_types c
|
||||||
|
WHERE c.id = :cargoTypeId AND c.deleted_at IS NULL
|
||||||
|
))`,
|
||||||
|
{ cargoTypeId },
|
||||||
|
)
|
||||||
|
.getOne();
|
||||||
|
}
|
||||||
|
|
||||||
|
findCargoType(id: string): Promise<CargoType | null> {
|
||||||
|
return this.repository.manager
|
||||||
|
.getRepository(CargoType)
|
||||||
|
.findOne({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveTemplate(template: ContractTemplate): Promise<ContractTemplate> {
|
async saveTemplate(template: ContractTemplate): Promise<ContractTemplate> {
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from "@nestjs/common";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
import { ContractRendererService } from "../../contracts/contract-renderer.service";
|
import { ContractRendererService } from "../../contracts/contract-renderer.service";
|
||||||
@@ -11,6 +16,7 @@ import {
|
|||||||
import { ContractTemplatesRepository } from "./contract-templates.repository";
|
import { ContractTemplatesRepository } from "./contract-templates.repository";
|
||||||
import {
|
import {
|
||||||
CreateArticleDto,
|
CreateArticleDto,
|
||||||
|
CreateContractTemplateDto,
|
||||||
PreviewContractTemplateDto,
|
PreviewContractTemplateDto,
|
||||||
ReplaceArticleDto,
|
ReplaceArticleDto,
|
||||||
UpdateArticleDto,
|
UpdateArticleDto,
|
||||||
@@ -53,13 +59,17 @@ export class ContractTemplatesService {
|
|||||||
async list(): Promise<ContractTemplate[]> {
|
async list(): Promise<ContractTemplate[]> {
|
||||||
const templates = await this.repository.findAll();
|
const templates = await this.repository.findAll();
|
||||||
const rank = new Map(CONTRACT_TEMPLATE_CODES.map((code, i) => [code, i] as const));
|
const rank = new Map(CONTRACT_TEMPLATE_CODES.map((code, i) => [code, i] as const));
|
||||||
return templates.sort(
|
// Seeded container templates first in canonical order, then staff-created
|
||||||
(a, b) => (rank.get(a.code) ?? 99) - (rank.get(b.code) ?? 99),
|
// bulk templates alphabetically.
|
||||||
);
|
return templates.sort((a, b) => {
|
||||||
|
const ra = rank.get(a.code as ContractTemplateCode) ?? 99;
|
||||||
|
const rb = rank.get(b.code as ContractTemplateCode) ?? 99;
|
||||||
|
return ra !== rb ? ra - rb : a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getByCode(code: string): Promise<ContractTemplate> {
|
async getByCode(code: string): Promise<ContractTemplate> {
|
||||||
const template = await this.repository.findByCode(this.assertCode(code));
|
const template = await this.repository.findByCode(code?.toUpperCase() ?? "");
|
||||||
if (!template) {
|
if (!template) {
|
||||||
throw new NotFoundException(`Contract template ${code} not found`);
|
throw new NotFoundException(`Contract template ${code} not found`);
|
||||||
}
|
}
|
||||||
@@ -67,15 +77,93 @@ export class ContractTemplatesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The active template used when generating a contract document for the given
|
* Staff-created bulk template for one (cargo type, customs option) pair.
|
||||||
* direction/freight/customs triple; null when missing or deactivated (the
|
* The cargo type must have hasContractTemplate enabled and the combination
|
||||||
* renderer then falls back to the built-in generic layout).
|
* must not already exist — the same commodity + customs pairing is edited,
|
||||||
|
* never duplicated.
|
||||||
|
*/
|
||||||
|
async create(dto: CreateContractTemplateDto): Promise<ContractTemplate> {
|
||||||
|
const cargoType = await this.repository.findCargoType(dto.cargoTypeId);
|
||||||
|
if (!cargoType) {
|
||||||
|
throw new NotFoundException(`Cargo type ${dto.cargoTypeId} not found`);
|
||||||
|
}
|
||||||
|
if (!cargoType.hasContractTemplate) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`"${cargoType.cargoTypeName}" does not allow contract templates — enable "has contract template" on the cargo type first`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const variant = dto.withCustoms ? "with" : "without";
|
||||||
|
const existing = await this.repository.findByCargoCombo(
|
||||||
|
dto.cargoTypeId,
|
||||||
|
dto.withCustoms,
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`A "${cargoType.cargoTypeName}" template ${variant} customs clearing already exists — edit that template instead`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = new ContractTemplate();
|
||||||
|
template.code = `BULK_${cargoType.code}_${dto.withCustoms ? "CUSTOMS" : "NO_CUSTOMS"}`.toUpperCase();
|
||||||
|
template.name =
|
||||||
|
dto.name ??
|
||||||
|
`${cargoType.cargoTypeName} Bulk Contract (${variant} customs clearing)`;
|
||||||
|
template.description = dto.description ?? null;
|
||||||
|
template.documentTitle = dto.withCustoms
|
||||||
|
? `${cargoType.cargoTypeName} Transportation and Customs Clearance Services`
|
||||||
|
: `${cargoType.cargoTypeName} Transportation Services`;
|
||||||
|
template.whereasClauses = [];
|
||||||
|
template.articles = [];
|
||||||
|
template.isActive = true;
|
||||||
|
template.cargoTypeId = cargoType.id;
|
||||||
|
template.withCustoms = dto.withCustoms;
|
||||||
|
template.isSystem = false;
|
||||||
|
try {
|
||||||
|
return await this.repository.saveTemplate(template);
|
||||||
|
} catch (error) {
|
||||||
|
// Partial unique index backstop for concurrent creates of the same combo.
|
||||||
|
if ((error as { code?: string })?.code === "23505") {
|
||||||
|
throw new ConflictException(
|
||||||
|
`A "${cargoType.cargoTypeName}" template ${variant} customs clearing already exists — edit that template instead`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bulk templates only — the five seeded container templates are permanent. */
|
||||||
|
async remove(code: string): Promise<void> {
|
||||||
|
const template = await this.getByCode(code);
|
||||||
|
if (template.isSystem) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"System container templates cannot be deleted",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.repository.softDelete(template.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The active template used when generating a contract document. Container
|
||||||
|
* contracts resolve through the fixed direction/customs codes; bulk contracts
|
||||||
|
* resolve through the staff-created template for the contract's cargo type
|
||||||
|
* (or its parent group) and customs option. Null when nothing matches or the
|
||||||
|
* match is deactivated (the renderer then falls back to the built-in generic
|
||||||
|
* layout).
|
||||||
*/
|
*/
|
||||||
async findActiveForContract(
|
async findActiveForContract(
|
||||||
tradeDirection?: string | null,
|
tradeDirection?: string | null,
|
||||||
freightType?: string | null,
|
freightType?: string | null,
|
||||||
customsClearingEnabled?: boolean | null,
|
customsClearingEnabled?: boolean | null,
|
||||||
|
cargoTypeId?: string | null,
|
||||||
): Promise<ContractTemplate | null> {
|
): Promise<ContractTemplate | null> {
|
||||||
|
const isBulk = (freightType ?? "").toUpperCase().includes("BULK");
|
||||||
|
if (isBulk) {
|
||||||
|
if (!cargoTypeId) return null;
|
||||||
|
return this.repository.findActiveBulkTemplate(
|
||||||
|
cargoTypeId,
|
||||||
|
Boolean(customsClearingEnabled),
|
||||||
|
);
|
||||||
|
}
|
||||||
const code = contractTemplateCodeFor(
|
const code = contractTemplateCodeFor(
|
||||||
tradeDirection,
|
tradeDirection,
|
||||||
freightType,
|
freightType,
|
||||||
@@ -180,16 +268,32 @@ export class ContractTemplatesService {
|
|||||||
: this.sorted(template.articles),
|
: this.sorted(template.articles),
|
||||||
};
|
};
|
||||||
|
|
||||||
const view = this.buildMockView(template.code, dynamicTemplate);
|
const view = this.buildMockView(template, dynamicTemplate);
|
||||||
return { html: this.renderer.render(view) };
|
return { html: this.renderer.render(view) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registry key the mock preview renders against. Staff-created bulk
|
||||||
|
* templates aren't in the fixed code map — they preview against the
|
||||||
|
* representative bulk import pack matching their customs option.
|
||||||
|
*/
|
||||||
|
private previewKeyFor(template: ContractTemplate): string {
|
||||||
|
if (template.cargoTypeId) {
|
||||||
|
return template.withCustoms
|
||||||
|
? "IMP_BULK_USD_FORWARDING"
|
||||||
|
: "IMP_BULK_USD_TRANSPORT_ONLY";
|
||||||
|
}
|
||||||
|
return PREVIEW_TEMPLATE_KEYS[template.code as ContractTemplateCode];
|
||||||
|
}
|
||||||
|
|
||||||
private buildMockView(
|
private buildMockView(
|
||||||
code: ContractTemplateCode,
|
template: ContractTemplate,
|
||||||
dynamicTemplate: ContractDynamicTemplateView,
|
dynamicTemplate: ContractDynamicTemplateView,
|
||||||
): ContractViewModel {
|
): ContractViewModel {
|
||||||
const meta = getTemplateMeta(PREVIEW_TEMPLATE_KEYS[code]);
|
const code = template.code;
|
||||||
const isBulk = code.endsWith("_BULK");
|
const previewKey = this.previewKeyFor(template);
|
||||||
|
const meta = getTemplateMeta(previewKey);
|
||||||
|
const isBulk = Boolean(template.cargoTypeId) || code.includes("BULK");
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
// Representative rate schedule so the admin preview shows the live-rate
|
// Representative rate schedule so the admin preview shows the live-rate
|
||||||
@@ -200,7 +304,7 @@ export class ContractTemplatesService {
|
|||||||
bookingId: "00000000-0000-0000-0000-000000000000",
|
bookingId: "00000000-0000-0000-0000-000000000000",
|
||||||
reference: "EDR/CT/2026/0042",
|
reference: "EDR/CT/2026/0042",
|
||||||
status: "CONTRACT_READY",
|
status: "CONTRACT_READY",
|
||||||
templateKey: PREVIEW_TEMPLATE_KEYS[code],
|
templateKey: previewKey,
|
||||||
template: { ...meta, title: dynamicTemplate.name, templateFile: "edr-dynamic.hbs" },
|
template: { ...meta, title: dynamicTemplate.name, templateFile: "edr-dynamic.hbs" },
|
||||||
contractDate: now.toLocaleDateString("en-GB", {
|
contractDate: now.toLocaleDateString("en-GB", {
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
@@ -275,7 +379,7 @@ export class ContractTemplatesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Static, representative rate schedule for the admin preview only. */
|
/** Static, representative rate schedule for the admin preview only. */
|
||||||
private mockRateSchedule(code: ContractTemplateCode, isBulk: boolean): RateSchedule {
|
private mockRateSchedule(code: string, isBulk: boolean): RateSchedule {
|
||||||
const dir = code.startsWith("IMPORT")
|
const dir = code.startsWith("IMPORT")
|
||||||
? "import"
|
? "import"
|
||||||
: code.startsWith("EXPORT")
|
: code.startsWith("EXPORT")
|
||||||
@@ -311,16 +415,6 @@ export class ContractTemplatesService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private assertCode(code: string): ContractTemplateCode {
|
|
||||||
const upper = code?.toUpperCase() as ContractTemplateCode;
|
|
||||||
if (!CONTRACT_TEMPLATE_CODES.includes(upper)) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
`Unknown contract template code "${code}". Valid codes: ${CONTRACT_TEMPLATE_CODES.join(", ")}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return upper;
|
|
||||||
}
|
|
||||||
|
|
||||||
private sorted(articles: ContractTemplateArticle[]): ContractTemplateArticle[] {
|
private sorted(articles: ContractTemplateArticle[]): ContractTemplateArticle[] {
|
||||||
return [...(articles ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
return [...(articles ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,41 @@ import {
|
|||||||
IsInt,
|
IsInt,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
|
IsUUID,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
Min,
|
Min,
|
||||||
MinLength,
|
MinLength,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
|
|
||||||
|
export class CreateContractTemplateDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description:
|
||||||
|
"Bulk cargo type this template is written for (must have hasContractTemplate enabled)",
|
||||||
|
format: "uuid",
|
||||||
|
})
|
||||||
|
@IsUUID()
|
||||||
|
cargoTypeId!: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Whether this is the with-customs-clearing variant",
|
||||||
|
})
|
||||||
|
@IsBoolean()
|
||||||
|
withCustoms!: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "Display name (derived from the cargo type when omitted)" })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
|
@MaxLength(200)
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "Short description shown on the template card" })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class UpdateContractTemplateDto {
|
export class UpdateContractTemplateDto {
|
||||||
@ApiPropertyOptional({ description: "Display name of the template" })
|
@ApiPropertyOptional({ description: "Display name of the template" })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import { BaseEntity } from "@edr/api-common";
|
import { BaseEntity } from "@edr/api-common";
|
||||||
import { Column, Entity, Index } from "typeorm";
|
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||||
|
|
||||||
|
import { CargoType } from "../../rule-engine/entities/cargo-type.entity";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The ten canonical contract document templates. Import and export split by
|
* The five seeded container templates (import/export split by customs
|
||||||
* customs clearing (× freight type = 8); intercity does not, because it is a
|
* clearing; intercity is domestic, crosses no border, so it has a single
|
||||||
* purely domestic Ethiopian movement that crosses no border and therefore has
|
* template). These are system rows: always present, never deletable.
|
||||||
* no customs leg at all (× freight type = 2).
|
*
|
||||||
|
* Bulk templates are NOT seeded — staff create them per bulk cargo type
|
||||||
|
* (`cargoTypeId`) and customs option (`withCustoms`), one template per
|
||||||
|
* combination. Their codes are generated as BULK_<cargo code>_(NO_)CUSTOMS.
|
||||||
|
* The retired direction-keyed bulk codes remain listed so old frozen document
|
||||||
|
* snapshots still label correctly.
|
||||||
*
|
*
|
||||||
* Contracts store DOMESTIC for intercity movements; the template layer labels
|
* Contracts store DOMESTIC for intercity movements; the template layer labels
|
||||||
* those INTERCITY to match the commercial vocabulary used on the printed
|
* those INTERCITY to match the commercial vocabulary used on the printed
|
||||||
@@ -76,10 +83,12 @@ export function contractTemplateCodeFor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Entity({ schema: "freight", name: "contract_templates" })
|
@Entity({ schema: "freight", name: "contract_templates" })
|
||||||
@Index(["code"], { unique: true })
|
// Uniqueness lives in partial DB indexes (live rows only): code, and
|
||||||
|
// (cargo_type_id, with_customs) for staff-created bulk templates.
|
||||||
|
@Index(["code"])
|
||||||
export class ContractTemplate extends BaseEntity {
|
export class ContractTemplate extends BaseEntity {
|
||||||
@Column({ name: "code", type: "varchar", length: 40, unique: true })
|
@Column({ name: "code", type: "varchar", length: 80 })
|
||||||
code!: ContractTemplateCode;
|
code!: string;
|
||||||
|
|
||||||
@Column({ name: "name", type: "varchar", length: 200 })
|
@Column({ name: "name", type: "varchar", length: 200 })
|
||||||
name!: string;
|
name!: string;
|
||||||
@@ -100,4 +109,20 @@ export class ContractTemplate extends BaseEntity {
|
|||||||
|
|
||||||
@Column({ name: "is_active", type: "boolean", default: true })
|
@Column({ name: "is_active", type: "boolean", default: true })
|
||||||
isActive!: boolean;
|
isActive!: boolean;
|
||||||
|
|
||||||
|
/** Bulk templates only: the cargo type this template is written for. */
|
||||||
|
@Column({ name: "cargo_type_id", type: "uuid", nullable: true })
|
||||||
|
cargoTypeId?: string | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => CargoType, { nullable: true })
|
||||||
|
@JoinColumn({ name: "cargo_type_id" })
|
||||||
|
cargoType?: CargoType | null;
|
||||||
|
|
||||||
|
/** Bulk templates only: whether this is the with-customs-clearing variant. */
|
||||||
|
@Column({ name: "with_customs", type: "boolean", nullable: true })
|
||||||
|
withCustoms?: boolean | null;
|
||||||
|
|
||||||
|
/** The five seeded container templates — cannot be deleted. */
|
||||||
|
@Column({ name: "is_system", type: "boolean", default: false })
|
||||||
|
isSystem!: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
|
|||||||
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
|
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
|
||||||
import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
|
import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
|
||||||
import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity';
|
import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity';
|
||||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
|
||||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||||
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ContractExpiryService } from './contract-expiry.service';
|
import { ContractExpiryService } from './contract-expiry.service';
|
||||||
import type { Contract } from './entities/contract.entity';
|
import type { Contract } from './entities/contract.entity';
|
||||||
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The reminder must warn each customer once, ten days out, and must never let a
|
* The reminder must warn each customer once, ten days out, and must never let a
|
||||||
@@ -60,4 +61,18 @@ describe('ContractExpiryService — expiry reminder', () => {
|
|||||||
inbox.notify.mockRejectedValue(new Error('inbox down'));
|
inbox.notify.mockRejectedValue(new Error('inbox down'));
|
||||||
await expect(service.remindExpiringContracts()).resolves.toBeUndefined();
|
await expect(service.remindExpiringContracts()).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The sweep-failure alert is staff-facing. It used to go to every employee;
|
||||||
|
// it belongs to the people who would notice expired contracts still listed
|
||||||
|
// as active, i.e. the contract desk.
|
||||||
|
it('alerts the contract desk when the sweep itself fails', async () => {
|
||||||
|
repo.expireLapsedContracts.mockRejectedValue(new Error('deadlock'));
|
||||||
|
|
||||||
|
await service.expireLapsedContracts();
|
||||||
|
|
||||||
|
expect(inbox.notify).toHaveBeenCalledTimes(1);
|
||||||
|
expect(inbox.notify.mock.calls[0][0].recipients).toEqual({
|
||||||
|
permissionKeys: [FREIGHT_PERMS.contracts.getNotification],
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { NotificationAudience, NotificationType } from '@edr/types';
|
|||||||
|
|
||||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||||
import { ContractsRepository } from './contracts.repository';
|
import { ContractsRepository } from './contracts.repository';
|
||||||
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How many days before a contract lapses the customer is reminded. Mirrored by
|
* How many days before a contract lapses the customer is reminded. Mirrored by
|
||||||
@@ -79,7 +80,11 @@ export class ContractExpiryService {
|
|||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await this.inbox.notify({
|
await this.inbox.notify({
|
||||||
recipients: { allBackoffice: true },
|
// The people who would notice expired contracts still listed as
|
||||||
|
// active are the ones working the contract desk.
|
||||||
|
recipients: {
|
||||||
|
permissionKeys: [FREIGHT_PERMS.contracts.getNotification],
|
||||||
|
},
|
||||||
audience: NotificationAudience.BACKOFFICE,
|
audience: NotificationAudience.BACKOFFICE,
|
||||||
type: NotificationType.GENERIC,
|
type: NotificationType.GENERIC,
|
||||||
title: 'Contract expiry sweep failed',
|
title: 'Contract expiry sweep failed',
|
||||||
|
|||||||
@@ -11,6 +11,16 @@ import { Contract } from './entities/contract.entity';
|
|||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||||
import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util';
|
import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util';
|
||||||
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clearance items are worked by the GL desks, which hold no intake keys — so
|
||||||
|
* they take their own selector rather than the contract desk's. Every override
|
||||||
|
* using this deep-links to a clearance or shipment-request page.
|
||||||
|
*/
|
||||||
|
const CLEARANCE_DESK = {
|
||||||
|
permissionKeys: [FREIGHT_PERMS.contracts.clearanceGetNotification],
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Customer + staff notifications for the contract lifecycle. Every customer
|
* Customer + staff notifications for the contract lifecycle. Every customer
|
||||||
@@ -86,7 +96,11 @@ export class ContractNotifierService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Persist + push an in-app item to every backoffice staff user. */
|
/**
|
||||||
|
* Persist + push an in-app item to the contract desk — staff holding
|
||||||
|
* `contracts:get_notification`. Callers whose item belongs to a different
|
||||||
|
* desk override `recipients` (see {@link CLEARANCE_DESK}).
|
||||||
|
*/
|
||||||
private inAppStaff(
|
private inAppStaff(
|
||||||
c: Contract,
|
c: Contract,
|
||||||
title: string,
|
title: string,
|
||||||
@@ -94,7 +108,7 @@ export class ContractNotifierService {
|
|||||||
overrides: Partial<NotifyInput> = {},
|
overrides: Partial<NotifyInput> = {},
|
||||||
): void {
|
): void {
|
||||||
void this.inbox.notify({
|
void this.inbox.notify({
|
||||||
recipients: { allBackoffice: true },
|
recipients: { permissionKeys: [FREIGHT_PERMS.contracts.getNotification] },
|
||||||
audience: NotificationAudience.BACKOFFICE,
|
audience: NotificationAudience.BACKOFFICE,
|
||||||
type: NotificationType.REQUEST_SUBMITTED,
|
type: NotificationType.REQUEST_SUBMITTED,
|
||||||
title,
|
title,
|
||||||
@@ -230,6 +244,7 @@ export class ContractNotifierService {
|
|||||||
`the customs declaration can be filed.${note ? ` Note: "${note}"` : ''}`;
|
`the customs declaration can be filed.${note ? ` Note: "${note}"` : ''}`;
|
||||||
this.logger.log(`TRANSIT ASSIGNEE REQUESTED — ${c.reference}`);
|
this.logger.log(`TRANSIT ASSIGNEE REQUESTED — ${c.reference}`);
|
||||||
this.inAppStaff(c, `Transit assignee needed — ${c.reference}`, msg, {
|
this.inAppStaff(c, `Transit assignee needed — ${c.reference}`, msg, {
|
||||||
|
recipients: CLEARANCE_DESK,
|
||||||
type: NotificationType.CLEARANCE_REVIEW,
|
type: NotificationType.CLEARANCE_REVIEW,
|
||||||
link: `/dashboard/gl-djibouti/clearance/${c.id}`,
|
link: `/dashboard/gl-djibouti/clearance/${c.id}`,
|
||||||
});
|
});
|
||||||
@@ -248,6 +263,7 @@ export class ContractNotifierService {
|
|||||||
`The customs declaration can now be filed.`;
|
`The customs declaration can now be filed.`;
|
||||||
this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${c.reference}`);
|
this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${c.reference}`);
|
||||||
this.inAppStaff(c, `Transit assignee set — ${c.reference}`, msg, {
|
this.inAppStaff(c, `Transit assignee set — ${c.reference}`, msg, {
|
||||||
|
recipients: CLEARANCE_DESK,
|
||||||
type: NotificationType.CLEARANCE_REVIEW,
|
type: NotificationType.CLEARANCE_REVIEW,
|
||||||
link: `/dashboard/contracts/clearance/${c.id}`,
|
link: `/dashboard/contracts/clearance/${c.id}`,
|
||||||
});
|
});
|
||||||
@@ -264,6 +280,7 @@ export class ContractNotifierService {
|
|||||||
`"${note}". Review and re-advise the amount on the clearance page.`;
|
`"${note}". Review and re-advise the amount on the clearance page.`;
|
||||||
this.logger.log(`DUTY DISPUTED — ${c.reference}`);
|
this.logger.log(`DUTY DISPUTED — ${c.reference}`);
|
||||||
this.inAppStaff(c, `Duty disputed on ${c.reference}`, msg, {
|
this.inAppStaff(c, `Duty disputed on ${c.reference}`, msg, {
|
||||||
|
recipients: CLEARANCE_DESK,
|
||||||
type: NotificationType.CLEARANCE_REVIEW,
|
type: NotificationType.CLEARANCE_REVIEW,
|
||||||
link: `/dashboard/contracts/clearance/${c.id}`,
|
link: `/dashboard/contracts/clearance/${c.id}`,
|
||||||
});
|
});
|
||||||
@@ -321,6 +338,7 @@ export class ContractNotifierService {
|
|||||||
'Clearance documents uploaded',
|
'Clearance documents uploaded',
|
||||||
`Customer uploaded clearance documents for contract ${this.ref(c)} — review them in the clearance queue.`,
|
`Customer uploaded clearance documents for contract ${this.ref(c)} — review them in the clearance queue.`,
|
||||||
{
|
{
|
||||||
|
recipients: CLEARANCE_DESK,
|
||||||
type: NotificationType.CLEARANCE_REVIEW,
|
type: NotificationType.CLEARANCE_REVIEW,
|
||||||
link: `/dashboard/contracts/clearance/${c.id}`,
|
link: `/dashboard/contracts/clearance/${c.id}`,
|
||||||
},
|
},
|
||||||
@@ -334,6 +352,7 @@ export class ContractNotifierService {
|
|||||||
'Duty slip uploaded',
|
'Duty slip uploaded',
|
||||||
`Customer uploaded the duty & tax payment slip for contract ${this.ref(c)}.`,
|
`Customer uploaded the duty & tax payment slip for contract ${this.ref(c)}.`,
|
||||||
{
|
{
|
||||||
|
recipients: CLEARANCE_DESK,
|
||||||
type: NotificationType.PAYMENT_RECEIVED,
|
type: NotificationType.PAYMENT_RECEIVED,
|
||||||
link: `/dashboard/contracts/clearance/${c.id}`,
|
link: `/dashboard/contracts/clearance/${c.id}`,
|
||||||
},
|
},
|
||||||
@@ -347,6 +366,9 @@ export class ContractNotifierService {
|
|||||||
'New shipment request',
|
'New shipment request',
|
||||||
`Shipment request ${requestRef} was filed under contract ${this.ref(c)} and awaits GL review.`,
|
`Shipment request ${requestRef} was filed under contract ${this.ref(c)} and awaits GL review.`,
|
||||||
{
|
{
|
||||||
|
// GL reviews these, and the shipment-requests page is gated on
|
||||||
|
// contracts:create_booking — a key only the GL Ethiopia preset holds.
|
||||||
|
recipients: CLEARANCE_DESK,
|
||||||
link: `/dashboard/shipment-requests/${requestId}`,
|
link: `/dashboard/shipment-requests/${requestId}`,
|
||||||
data: { contractId: c.id, requestId, reference: requestRef },
|
data: { contractId: c.id, requestId, reference: requestRef },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -424,6 +424,8 @@ export class ContractTransitionService {
|
|||||||
contract.tradeDirection,
|
contract.tradeDirection,
|
||||||
contract.freightType,
|
contract.freightType,
|
||||||
contract.customsClearingEnabled,
|
contract.customsClearingEnabled,
|
||||||
|
// Bulk templates are keyed by the contract's cargo type.
|
||||||
|
(contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId,
|
||||||
);
|
);
|
||||||
if (!active) return null;
|
if (!active) return null;
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1074,16 +1074,23 @@ export class ContractsController {
|
|||||||
// ── Booking under contract (Path A customer / Path B GL ET) ────────────────
|
// ── Booking under contract (Path A customer / Path B GL ET) ────────────────
|
||||||
|
|
||||||
@Post(':id/bookings')
|
@Post(':id/bookings')
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.createBooking)
|
// Path A is a customer flow — both audiences must reach the service, whose
|
||||||
|
// assertGate decides per role. Staff still need contracts:create_booking.
|
||||||
|
@MixedAudience(FREIGHT_PERMS.contracts.createBooking)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
'Create a shipment booking under a contract — Path A (customer) or Path B (GL Ethiopia).',
|
'Create a shipment booking under a contract — Path A (customer) or Path B (GL Ethiopia).',
|
||||||
})
|
})
|
||||||
createBooking(
|
async createBooking(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@Body() dto: CreateBookingUnderContractDto,
|
@Body() dto: CreateBookingUnderContractDto,
|
||||||
@CurrentUser() user: AuthUserPayload,
|
@CurrentUser() user: TCurrentUser & { sub?: string },
|
||||||
) {
|
) {
|
||||||
|
// Customer callers may only book on their own contract.
|
||||||
|
if (!hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking)) {
|
||||||
|
const contract = await this.contractsService.findById(id);
|
||||||
|
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
|
||||||
|
}
|
||||||
// The service decides the execution path from the contract:
|
// The service decides the execution path from the contract:
|
||||||
// Path A (customs disabled) → customer/staff create; status checks apply.
|
// Path A (customs disabled) → customer/staff create; status checks apply.
|
||||||
// Path B (customs enabled) → GL Ethiopia only, once clearance is ready.
|
// Path B (customs enabled) → GL Ethiopia only, once clearance is ready.
|
||||||
@@ -1096,16 +1103,25 @@ export class ContractsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/bookings/initiate')
|
@Post(':id/bookings/initiate')
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.createBooking)
|
// Customer initiates their own ONE_TIME instance; GL initiates on customs
|
||||||
|
// contracts — the service's assertGate decides per role, so both audiences
|
||||||
|
// must reach it. Staff still need contracts:create_booking.
|
||||||
|
@MixedAudience(FREIGHT_PERMS.contracts.createBooking)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
'Initiate a bare booking instance under an import/export contract (ONE_TIME or GENERAL) — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS). ONE_TIME customs instances are opened by the customer (or GL); GENERAL customs comes from a shipment request.',
|
'Initiate a bare booking instance under an import/export contract (ONE_TIME or GENERAL) — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS). ONE_TIME customs instances are opened by the customer (or GL); GENERAL customs comes from a shipment request.',
|
||||||
})
|
})
|
||||||
initiateBooking(
|
async initiateBooking(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@Body() dto: CreateBookingUnderContractDto,
|
@Body() dto: CreateBookingUnderContractDto,
|
||||||
@CurrentUser() user: AuthUserPayload,
|
@CurrentUser() user: TCurrentUser & { sub?: string },
|
||||||
) {
|
) {
|
||||||
|
// Customer callers may only initiate on their own contract; the service's
|
||||||
|
// assertGate then decides what a customer may do on it.
|
||||||
|
if (!hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking)) {
|
||||||
|
const contract = await this.contractsService.findById(id);
|
||||||
|
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
|
||||||
|
}
|
||||||
return this.contractBookingService.initiateUnderContract(
|
return this.contractBookingService.initiateUnderContract(
|
||||||
id,
|
id,
|
||||||
{ contractRouteId: dto?.contractRouteId },
|
{ contractRouteId: dto?.contractRouteId },
|
||||||
@@ -1115,17 +1131,24 @@ export class ContractsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/bookings/:bookingId/complete')
|
@Post(':id/bookings/:bookingId/complete')
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.createBooking)
|
// Customers complete their own initiated (non-customs) instances; the
|
||||||
|
// service keeps customs completion GL-only via the actor's permissions.
|
||||||
|
@MixedAudience(FREIGHT_PERMS.contracts.createBooking)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
'Complete an initiated booking after Operations finalized its clearance — cargo + binding day, window and departure checks, pricing and invoicing.',
|
'Complete an initiated booking after Operations finalized its clearance — cargo + binding day, window and departure checks, pricing and invoicing.',
|
||||||
})
|
})
|
||||||
completeBooking(
|
async completeBooking(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||||
@Body() dto: CreateBookingUnderContractDto,
|
@Body() dto: CreateBookingUnderContractDto,
|
||||||
@CurrentUser() user: AuthUserPayload,
|
@CurrentUser() user: TCurrentUser & { sub?: string },
|
||||||
) {
|
) {
|
||||||
|
// Customer callers may only complete bookings on their own contract.
|
||||||
|
if (!hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking)) {
|
||||||
|
const contract = await this.contractsService.findById(id);
|
||||||
|
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
|
||||||
|
}
|
||||||
// Customs (Path B) instances may only be completed by GL Ethiopia — the
|
// Customs (Path B) instances may only be completed by GL Ethiopia — the
|
||||||
// service checks the actor's contracts:create_booking permission.
|
// service checks the actor's contracts:create_booking permission.
|
||||||
return this.contractBookingService.completeUnderContract(
|
return this.contractBookingService.completeUnderContract(
|
||||||
|
|||||||
@@ -104,15 +104,19 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
|||||||
.andWhere('contract.status NOT IN (:...terminal)', {
|
.andWhere('contract.status NOT IN (:...terminal)', {
|
||||||
terminal: TERMINAL_CONTRACT_STATUSES,
|
terminal: TERMINAL_CONTRACT_STATUSES,
|
||||||
})
|
})
|
||||||
// A ONE_TIME contract allows a single booking, so once that booking
|
// A ONE_TIME contract allows a single booking, so once that booking is
|
||||||
// exists the contract is spent and can never carry another shipment.
|
// PAID the contract is spent and can never carry another shipment.
|
||||||
// Without this it kept blocking new requests on the same service type +
|
// Without this it kept blocking new requests on the same service type +
|
||||||
// route until its validity lapsed — locking a customer out of a lane for
|
// route until its validity lapsed — locking a customer out of a lane for
|
||||||
// the rest of the term after one completed shipment.
|
// the rest of the term after one completed shipment.
|
||||||
|
// Payment is the gate, not the booking row: a DRAFT or abandoned unpaid
|
||||||
|
// booking must keep the contract blocking, otherwise a customer holds an
|
||||||
|
// unpaid booking and requests an identical contract alongside it.
|
||||||
.andWhere(
|
.andWhere(
|
||||||
`(contract.contract_kind <> 'ONE_TIME' OR NOT EXISTS (
|
`(contract.contract_kind <> 'ONE_TIME' OR NOT EXISTS (
|
||||||
SELECT 1 FROM freight.bookings b
|
SELECT 1 FROM freight.bookings b
|
||||||
WHERE b.contract_id = contract.id AND b.deleted_at IS NULL
|
WHERE b.contract_id = contract.id AND b.deleted_at IS NULL
|
||||||
|
AND b.payment_status = 'PAID'
|
||||||
))`,
|
))`,
|
||||||
)
|
)
|
||||||
.getMany();
|
.getMany();
|
||||||
|
|||||||
@@ -24,7 +24,14 @@ import { FleetHistoryService } from '../fleet-history/fleet-history.service';
|
|||||||
@ApiTags('drivers')
|
@ApiTags('drivers')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller('drivers')
|
@Controller('drivers')
|
||||||
@BookingStaff(FREIGHT_PERMS.drivers.view)
|
// Class gate lists every key its routes use: Nest runs class AND method
|
||||||
|
// guards, so a key missing here would deny before the route's own key runs.
|
||||||
|
@BookingStaff([
|
||||||
|
FREIGHT_PERMS.drivers.view,
|
||||||
|
FREIGHT_PERMS.drivers.create,
|
||||||
|
FREIGHT_PERMS.drivers.update,
|
||||||
|
FREIGHT_PERMS.drivers.delete,
|
||||||
|
])
|
||||||
export class DriversController {
|
export class DriversController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly driversService: DriversService,
|
private readonly driversService: DriversService,
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { IsBoolean, IsOptional, IsString, Length } from "class-validator";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manual reconciliation of a submission that was never acknowledged. Exactly one of the two is
|
||||||
|
* meaningful: supply the IRN confirmed with MoR, or discard the attempt.
|
||||||
|
*/
|
||||||
|
export class ResolveEimsRegistrationDto {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: "IRN confirmed in the MoR portal. Records the registration and resumes the chain.",
|
||||||
|
example: "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(1, 64)
|
||||||
|
irn?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: "Abandon the submission: the invoice is marked FAILED and the chain is unchanged.",
|
||||||
|
example: true,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
discard?: boolean;
|
||||||
|
}
|
||||||
256
apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts
Normal file
256
apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
import { HttpService } from "@nestjs/axios";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { AxiosError, AxiosHeaders } from "axios";
|
||||||
|
import { of, throwError } from "rxjs";
|
||||||
|
import { EimsConfig } from "../../config/eims.config";
|
||||||
|
import { eimsConfig, eimsToken } from "./eims-test-fixtures";
|
||||||
|
import { EimsAuthService } from "./eims-auth.service";
|
||||||
|
import { EimsSignerService } from "./eims-signer.service";
|
||||||
|
|
||||||
|
const CLIENT_SECRET = "super-secret-value";
|
||||||
|
const API_KEY = "super-secret-apikey";
|
||||||
|
|
||||||
|
const cfg = (over: Partial<EimsConfig> = {}): EimsConfig => eimsConfig(over);
|
||||||
|
|
||||||
|
const TOKEN_1 = eimsToken({ jti: "one" });
|
||||||
|
const TOKEN_2 = eimsToken({ jti: "two" });
|
||||||
|
|
||||||
|
const loginBody = (accessToken: string, expiresIn = 3600) => ({
|
||||||
|
data: { accessToken, refreshToken: "refresh-1", encryptionKey: null, expiresIn },
|
||||||
|
status: "SUCCESS",
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Stub signer: the real signing path has its own spec and needs no key material here. */
|
||||||
|
const signer = {
|
||||||
|
signRequest: <T>(request: T) => ({ request, signature: "SIGNATURE", certificate: "CERTIFICATE" }),
|
||||||
|
} as unknown as EimsSignerService;
|
||||||
|
|
||||||
|
const build = (post: jest.Mock, config: EimsConfig = cfg()) =>
|
||||||
|
new EimsAuthService(
|
||||||
|
{ post } as unknown as HttpService,
|
||||||
|
{ get: () => config } as unknown as ConfigService,
|
||||||
|
signer,
|
||||||
|
);
|
||||||
|
|
||||||
|
const axiosErr = (status: number, data: unknown) =>
|
||||||
|
new AxiosError("Request failed", undefined, undefined, undefined, {
|
||||||
|
status,
|
||||||
|
statusText: "",
|
||||||
|
data,
|
||||||
|
headers: new AxiosHeaders(),
|
||||||
|
config: { headers: new AxiosHeaders() },
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("EimsAuthService.getValidAccessToken", () => {
|
||||||
|
it("posts the signed login envelope to /auth/login with no Authorization header", async () => {
|
||||||
|
const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) }));
|
||||||
|
|
||||||
|
await build(post).getValidAccessToken();
|
||||||
|
|
||||||
|
expect(post).toHaveBeenCalledTimes(1);
|
||||||
|
const [url, body, options] = post.mock.calls[0];
|
||||||
|
expect(url).toBe("https://core.mor.gov.et/auth/login");
|
||||||
|
expect(options.headers).toEqual({ "Content-Type": "application/json" });
|
||||||
|
expect(options.headers.Authorization).toBeUndefined();
|
||||||
|
|
||||||
|
expect(typeof body).toBe("string");
|
||||||
|
expect(JSON.parse(body)).toEqual({
|
||||||
|
request: { clientId: "cid", clientSecret: CLIENT_SECRET, apikey: API_KEY, tin: "0000034558" },
|
||||||
|
signature: "SIGNATURE",
|
||||||
|
certificate: "CERTIFICATE",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the access token from data.accessToken", async () => {
|
||||||
|
const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) }));
|
||||||
|
await expect(build(post).getValidAccessToken()).resolves.toBe(TOKEN_1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reuses a cached token instead of logging in again", async () => {
|
||||||
|
const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) }));
|
||||||
|
const auth = build(post);
|
||||||
|
|
||||||
|
await auth.getValidAccessToken();
|
||||||
|
await expect(auth.getValidAccessToken()).resolves.toBe(TOKEN_1);
|
||||||
|
expect(post).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-authenticates a skew-window before the token actually expires", async () => {
|
||||||
|
const post = jest
|
||||||
|
.fn()
|
||||||
|
.mockReturnValueOnce(of({ data: loginBody(TOKEN_1, 100) })) // 100s ttl, 45s skew ⇒ usable 55s
|
||||||
|
.mockReturnValueOnce(of({ data: loginBody(TOKEN_2) }));
|
||||||
|
const auth = build(post);
|
||||||
|
const start = Date.now();
|
||||||
|
const clock = jest.spyOn(Date, "now");
|
||||||
|
|
||||||
|
try {
|
||||||
|
clock.mockReturnValue(start);
|
||||||
|
await expect(auth.getValidAccessToken()).resolves.toBe(TOKEN_1);
|
||||||
|
|
||||||
|
clock.mockReturnValue(start + 50_000); // inside the window: still cached
|
||||||
|
await expect(auth.getValidAccessToken()).resolves.toBe(TOKEN_1);
|
||||||
|
expect(post).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
clock.mockReturnValue(start + 56_000); // past ttl-minus-skew, before the real 100s expiry
|
||||||
|
await expect(auth.getValidAccessToken()).resolves.toBe(TOKEN_2);
|
||||||
|
expect(post).toHaveBeenCalledTimes(2);
|
||||||
|
} finally {
|
||||||
|
clock.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs in again after invalidate()", async () => {
|
||||||
|
const post = jest
|
||||||
|
.fn()
|
||||||
|
.mockReturnValueOnce(of({ data: loginBody(TOKEN_1) }))
|
||||||
|
.mockReturnValueOnce(of({ data: loginBody(TOKEN_2) }));
|
||||||
|
const auth = build(post);
|
||||||
|
|
||||||
|
await auth.getValidAccessToken();
|
||||||
|
auth.invalidate();
|
||||||
|
await expect(auth.getValidAccessToken()).resolves.toBe(TOKEN_2);
|
||||||
|
expect(post).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("performs exactly one login for many concurrent callers", async () => {
|
||||||
|
const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) }));
|
||||||
|
const auth = build(post);
|
||||||
|
|
||||||
|
const tokens = await Promise.all(Array.from({ length: 20 }, () => auth.getValidAccessToken()));
|
||||||
|
|
||||||
|
expect(post).toHaveBeenCalledTimes(1);
|
||||||
|
expect(new Set(tokens)).toEqual(new Set([TOKEN_1]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not put the access token in its own log line", async () => {
|
||||||
|
const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) }));
|
||||||
|
const logged: string[] = [];
|
||||||
|
const auth = build(post);
|
||||||
|
jest
|
||||||
|
.spyOn(auth["logger"], "log")
|
||||||
|
.mockImplementation((message: unknown) => void logged.push(String(message)));
|
||||||
|
|
||||||
|
await auth.getValidAccessToken();
|
||||||
|
|
||||||
|
expect(logged.join("\n")).not.toContain(TOKEN_1);
|
||||||
|
expect(logged.join("\n")).toContain("B0360154BA");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to call the gateway when EIMS is disabled", async () => {
|
||||||
|
const post = jest.fn();
|
||||||
|
await expect(build(post, cfg({ enabled: false })).getValidAccessToken()).rejects.toThrow(
|
||||||
|
/EIMS integration is disabled/,
|
||||||
|
);
|
||||||
|
expect(post).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a 200 response that carries no access token", async () => {
|
||||||
|
const post = jest.fn().mockReturnValue(of({ data: { data: {}, status: "SUCCESS" } }));
|
||||||
|
await expect(build(post).getValidAccessToken()).rejects.toThrow(/returned no accessToken/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces gateway errors without leaking credentials or the envelope", async () => {
|
||||||
|
const post = jest.fn().mockReturnValue(
|
||||||
|
throwError(() =>
|
||||||
|
axiosErr(401, {
|
||||||
|
message: "GATEWAY ERROR",
|
||||||
|
statusCode: 401,
|
||||||
|
code: "4400",
|
||||||
|
details: [{ errorMessage: "Invalid Credentials" }],
|
||||||
|
// Fields the gateway must never echo back into our logs or exceptions:
|
||||||
|
signature: "SIGNATURE",
|
||||||
|
certificate: "CERTIFICATE",
|
||||||
|
accessToken: "leaked-token",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const error = (await build(post)
|
||||||
|
.getValidAccessToken()
|
||||||
|
.catch((e: Error) => e)) as Error & { response?: unknown };
|
||||||
|
const serialized = JSON.stringify({ message: error.message, response: error.response });
|
||||||
|
|
||||||
|
expect(error.message).toContain("EIMS login failed (401)");
|
||||||
|
expect(error.message).toContain("Invalid Credentials");
|
||||||
|
for (const secret of [CLIENT_SECRET, API_KEY, "SIGNATURE", "CERTIFICATE", "leaked-token"]) {
|
||||||
|
expect(serialized).not.toContain(secret);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps a timeout to a TIMEOUT failure without a status", async () => {
|
||||||
|
const timeout = new AxiosError("timeout of 30000ms exceeded", "ECONNABORTED");
|
||||||
|
const post = jest.fn().mockReturnValue(throwError(() => timeout));
|
||||||
|
|
||||||
|
await expect(build(post).getValidAccessToken()).rejects.toThrow(/EIMS login timed out/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps an unreachable gateway to a NETWORK failure", async () => {
|
||||||
|
const refused = new AxiosError("connect ECONNREFUSED", "ECONNREFUSED");
|
||||||
|
const post = jest.fn().mockReturnValue(throwError(() => refused));
|
||||||
|
|
||||||
|
await expect(build(post).getValidAccessToken()).rejects.toThrow(/could not reach the gateway/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("EimsAuthService.getSessionContext", () => {
|
||||||
|
it("takes the source system from the token's claims", async () => {
|
||||||
|
const post = jest
|
||||||
|
.fn()
|
||||||
|
.mockReturnValue(
|
||||||
|
of({ data: loginBody(eimsToken({ systemNumber: "FROM-TOKEN", systemType: "POS" })) }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Env deliberately left empty: with nothing to check against, the token is simply believed.
|
||||||
|
await expect(
|
||||||
|
build(post, cfg({ systemNumber: "", systemType: "" })).getSessionContext(),
|
||||||
|
).resolves.toEqual({ systemNumber: "FROM-TOKEN", systemType: "POS" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves the session from the cached login rather than re-authenticating", async () => {
|
||||||
|
const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) }));
|
||||||
|
const auth = build(post);
|
||||||
|
|
||||||
|
await auth.getSessionContext();
|
||||||
|
await expect(auth.getSessionContext()).resolves.toEqual({
|
||||||
|
systemNumber: "B0360154BA",
|
||||||
|
systemType: "SYS",
|
||||||
|
});
|
||||||
|
expect(post).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["systemNumber", "systemType"])("rejects a token with no %s claim", async (claim) => {
|
||||||
|
const post = jest
|
||||||
|
.fn()
|
||||||
|
.mockReturnValue(of({ data: loginBody(eimsToken({ [claim]: undefined })) }));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
build(post, cfg({ systemNumber: "", systemType: "" })).getSessionContext(),
|
||||||
|
).rejects.toThrow(new RegExp(`no ${claim} claim`));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an access token that is not a decodable JWT", async () => {
|
||||||
|
const post = jest.fn().mockReturnValue(of({ data: loginBody("not-a-jwt") }));
|
||||||
|
|
||||||
|
await expect(build(post).getSessionContext()).rejects.toThrow(/not a JWT/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["systemNumber", { systemNumber: "SOMETHING-ELSE" }, /EIMS_SYSTEM_NUMBER=B0360154BA/],
|
||||||
|
["systemType", { systemType: "POS" }, /EIMS_SYSTEM_TYPE=SYS/],
|
||||||
|
])("fails fast when the configured %s disagrees with the token", async (_name, over, pattern) => {
|
||||||
|
const post = jest.fn().mockReturnValue(of({ data: loginBody(eimsToken(over)) }));
|
||||||
|
|
||||||
|
// cfg() sets EIMS_SYSTEM_NUMBER=B0360154BA and EIMS_SYSTEM_TYPE=SYS as expectations.
|
||||||
|
await expect(build(post).getSessionContext()).rejects.toThrow(pattern);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a configured value that matches the token", async () => {
|
||||||
|
const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) }));
|
||||||
|
|
||||||
|
await expect(build(post).getSessionContext()).resolves.toEqual({
|
||||||
|
systemNumber: "B0360154BA",
|
||||||
|
systemType: "SYS",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
212
apps/edr-freight-api/src/modules/eims/eims-auth.service.ts
Normal file
212
apps/edr-freight-api/src/modules/eims/eims-auth.service.ts
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import { HttpService } from "@nestjs/axios";
|
||||||
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { firstValueFrom } from "rxjs";
|
||||||
|
import { EimsConfig } from "../../config/eims.config";
|
||||||
|
import { EimsSignerService, toSignedBody } from "./eims-signer.service";
|
||||||
|
import { EimsApiException, EimsConfigException, toEimsApiException } from "./eims.errors";
|
||||||
|
import { EimsLoginRequest, EimsLoginResponse } from "./eims.types";
|
||||||
|
|
||||||
|
interface TokenCache {
|
||||||
|
accessToken: string;
|
||||||
|
/** Epoch ms, already reduced by the configured skew. */
|
||||||
|
expiresAt: number;
|
||||||
|
session: EimsSessionContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Source-system identity, taken from the access token MoR issues us.
|
||||||
|
*
|
||||||
|
* The gateway stamps `systemNumber` and `systemType` into the token for the credentials that
|
||||||
|
* authenticated, which makes the token the authority on them — not our environment file. Anything
|
||||||
|
* we configured locally can only ever disagree with what MoR believes.
|
||||||
|
*/
|
||||||
|
export interface EimsSessionContext {
|
||||||
|
systemNumber: string;
|
||||||
|
systemType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decode a JWT payload without verifying it: this is MoR's token, signed with MoR's key. */
|
||||||
|
function decodeTokenClaims(accessToken: string): Record<string, unknown> {
|
||||||
|
const payload = accessToken.split(".")[1];
|
||||||
|
if (!payload) {
|
||||||
|
throw new EimsApiException("UNKNOWN", "EIMS access token is not a JWT (no payload segment)");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as Record<string, unknown>;
|
||||||
|
} catch (err) {
|
||||||
|
// The token itself is never included — only that its payload would not parse.
|
||||||
|
throw new EimsApiException(
|
||||||
|
"UNKNOWN",
|
||||||
|
`EIMS access token payload could not be decoded: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const claimString = (claims: Record<string, unknown>, name: string): string => {
|
||||||
|
const value = claims[name];
|
||||||
|
return typeof value === "string" ? value.trim() : "";
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Used when the gateway omits `expiresIn`; the observed value is 3600. */
|
||||||
|
const FALLBACK_EXPIRES_IN_SECONDS = 3600;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EIMS authentication: signed `POST /auth/login`, plus an in-memory access-token cache.
|
||||||
|
*
|
||||||
|
* Login is the one EIMS call that carries no bearer token, which is why it lives here rather than
|
||||||
|
* in the generic client. Tokens are held in memory only — never persisted, never logged, never
|
||||||
|
* returned to a frontend.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class EimsAuthService {
|
||||||
|
private readonly logger = new Logger(EimsAuthService.name);
|
||||||
|
private cache: TokenCache | null = null;
|
||||||
|
private loginInFlight: Promise<string> | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly http: HttpService,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
private readonly signer: EimsSignerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private get cfg(): EimsConfig {
|
||||||
|
return this.config.get<EimsConfig>("eims")!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A non-expired access token, logging in if needed. Concurrent callers share one login: the
|
||||||
|
* first caller stores the in-flight promise and everyone else awaits it.
|
||||||
|
*/
|
||||||
|
async getValidAccessToken(): Promise<string> {
|
||||||
|
if (this.cache && Date.now() < this.cache.expiresAt) {
|
||||||
|
return this.cache.accessToken;
|
||||||
|
}
|
||||||
|
if (this.loginInFlight) return this.loginInFlight;
|
||||||
|
|
||||||
|
this.loginInFlight = this.login();
|
||||||
|
try {
|
||||||
|
return await this.loginInFlight;
|
||||||
|
} finally {
|
||||||
|
this.loginInFlight = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The source-system identity MoR issued this session, refreshing the login if needed.
|
||||||
|
*
|
||||||
|
* This is the authority for `SourceSystem.SystemNumber` / `SystemType`: the gateway stamps both
|
||||||
|
* into the access token for the authenticating credentials, so a local env value could only ever
|
||||||
|
* disagree with it.
|
||||||
|
*/
|
||||||
|
async getSessionContext(): Promise<EimsSessionContext> {
|
||||||
|
await this.getValidAccessToken();
|
||||||
|
return this.cache!.session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop the cached token — called after a 401 so the next request re-authenticates. */
|
||||||
|
invalidate(): void {
|
||||||
|
this.cache = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the source-system claims out of the token, and cross-check anything configured locally.
|
||||||
|
*
|
||||||
|
* `EIMS_SYSTEM_NUMBER` / `EIMS_SYSTEM_TYPE` are optional expectations, not inputs: when set they
|
||||||
|
* are compared and a mismatch fails immediately rather than one silently winning. Registering
|
||||||
|
* under the wrong source system is not something to discover from a rejected invoice.
|
||||||
|
*/
|
||||||
|
private readSessionContext(accessToken: string, cfg: EimsConfig): EimsSessionContext {
|
||||||
|
const claims = decodeTokenClaims(accessToken);
|
||||||
|
const systemNumber = claimString(claims, "systemNumber");
|
||||||
|
const systemType = claimString(claims, "systemType");
|
||||||
|
|
||||||
|
const missing = [
|
||||||
|
!systemNumber && "systemNumber",
|
||||||
|
!systemType && "systemType",
|
||||||
|
].filter(Boolean);
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new EimsApiException(
|
||||||
|
"UNKNOWN",
|
||||||
|
`EIMS access token carries no ${missing.join(" or ")} claim; cannot identify the source system`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mismatches = [
|
||||||
|
cfg.systemNumber && cfg.systemNumber !== systemNumber
|
||||||
|
? `EIMS_SYSTEM_NUMBER=${cfg.systemNumber} but the token says ${systemNumber}`
|
||||||
|
: null,
|
||||||
|
cfg.systemType && cfg.systemType !== systemType
|
||||||
|
? `EIMS_SYSTEM_TYPE=${cfg.systemType} but the token says ${systemType}`
|
||||||
|
: null,
|
||||||
|
].filter(Boolean);
|
||||||
|
if (mismatches.length > 0) {
|
||||||
|
throw new EimsConfigException(
|
||||||
|
`EIMS source-system configuration disagrees with the issued token: ${mismatches.join("; ")}. ` +
|
||||||
|
"Correct the environment or the credentials — neither value is assumed to win.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { systemNumber, systemType };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async login(): Promise<string> {
|
||||||
|
const cfg = this.cfg;
|
||||||
|
if (!cfg.enabled) {
|
||||||
|
throw new EimsConfigException("EIMS integration is disabled; set EIMS_ENABLED=true to use it");
|
||||||
|
}
|
||||||
|
|
||||||
|
const request: EimsLoginRequest = {
|
||||||
|
clientId: cfg.clientId,
|
||||||
|
clientSecret: cfg.clientSecret,
|
||||||
|
apikey: cfg.apiKey,
|
||||||
|
tin: cfg.tin,
|
||||||
|
};
|
||||||
|
const body = toSignedBody(this.signer.signRequest(request));
|
||||||
|
|
||||||
|
let response: EimsLoginResponse;
|
||||||
|
try {
|
||||||
|
const res = await firstValueFrom(
|
||||||
|
this.http.post<EimsLoginResponse>(`${cfg.baseUrl}/auth/login`, body, {
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
timeout: cfg.httpTimeoutMs,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
response = res.data;
|
||||||
|
} catch (err) {
|
||||||
|
const mapped = toEimsApiException(err, "login");
|
||||||
|
this.logger.error(mapped.message);
|
||||||
|
throw mapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = response?.data?.accessToken;
|
||||||
|
if (!accessToken) {
|
||||||
|
throw new EimsApiException("UNKNOWN", "EIMS login returned no accessToken");
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresIn =
|
||||||
|
Number.isFinite(response.data.expiresIn) && response.data.expiresIn > 0
|
||||||
|
? response.data.expiresIn
|
||||||
|
: FALLBACK_EXPIRES_IN_SECONDS;
|
||||||
|
|
||||||
|
// TODO: implement `POST /auth/refresh-token` and hold `response.data.refreshToken`. The
|
||||||
|
// collection shows a bare `{refreshToken}` body with no envelope, but it also carries unsigned
|
||||||
|
// examples of calls that do require signing, so whether refresh must be signed is unconfirmed.
|
||||||
|
// Until MoR confirms it, an expired token just triggers a fresh login — `expiresIn` is 3600s,
|
||||||
|
// so that is one extra call an hour.
|
||||||
|
// Reject the session before caching it: a token we cannot identify a source system from is
|
||||||
|
// useless for registration, and a configured expectation that disagrees is a deployment fault.
|
||||||
|
const session = this.readSessionContext(accessToken, cfg);
|
||||||
|
|
||||||
|
this.cache = {
|
||||||
|
accessToken,
|
||||||
|
expiresAt: Date.now() + Math.max(expiresIn * 1000 - cfg.tokenSkewMs, 1000),
|
||||||
|
session,
|
||||||
|
};
|
||||||
|
this.logger.log(
|
||||||
|
`EIMS login succeeded; token cached for ~${expiresIn}s ` +
|
||||||
|
`(system ${session.systemNumber}, type ${session.systemType})`,
|
||||||
|
);
|
||||||
|
return accessToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { DataSource } from "typeorm";
|
||||||
|
|
||||||
|
import { EimsConfig } from "../../config/eims.config";
|
||||||
|
import { EimsAutoSubmitService } from "./eims-auto-submit.service";
|
||||||
|
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
|
||||||
|
import { EimsInvoiceStatus } from "./eims-registration.types";
|
||||||
|
import { eimsConfig } from "./eims-test-fixtures";
|
||||||
|
|
||||||
|
const INVOICE_ID = "11111111-1111-4111-8111-111111111111";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `query` is answered by shape: the first call is the system-state guard, the second is the
|
||||||
|
* candidate lookup. Keeps the fake honest about the order the service actually asks in.
|
||||||
|
*/
|
||||||
|
const build = (
|
||||||
|
opts: {
|
||||||
|
cfg?: Partial<EimsConfig>;
|
||||||
|
state?: { in_flight_invoice_id?: string | null; blocked_reason?: string | null };
|
||||||
|
candidate?: { id: string; invoiceNumber: string } | null;
|
||||||
|
register?: jest.Mock;
|
||||||
|
} = {},
|
||||||
|
) => {
|
||||||
|
const register =
|
||||||
|
opts.register ??
|
||||||
|
jest.fn().mockResolvedValue({ eimsStatus: EimsInvoiceStatus.Registered, eimsIrn: "IRN-1" });
|
||||||
|
|
||||||
|
const query = jest.fn().mockImplementation((sql: string) => {
|
||||||
|
if (sql.includes("eims_system_state")) {
|
||||||
|
return Promise.resolve(
|
||||||
|
opts.state ? [{ in_flight_invoice_id: null, blocked_reason: null, ...opts.state }] : [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Promise.resolve(opts.candidate === undefined ? [] : opts.candidate ? [opts.candidate] : []);
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = new EimsAutoSubmitService(
|
||||||
|
{ query } as unknown as DataSource,
|
||||||
|
{ get: () => eimsConfig({ autoSubmit: true, ...opts.cfg }) } as unknown as ConfigService,
|
||||||
|
{ registerInvoiceWithEims: register } as unknown as EimsInvoiceRegistrationService,
|
||||||
|
);
|
||||||
|
return { service, register, query };
|
||||||
|
};
|
||||||
|
|
||||||
|
const candidate = { id: INVOICE_ID, invoiceNumber: "INV-20260807-00006" };
|
||||||
|
|
||||||
|
describe("EimsAutoSubmitService.tick", () => {
|
||||||
|
it("files the oldest eligible invoice through the registration service", async () => {
|
||||||
|
const { service, register } = build({ candidate });
|
||||||
|
|
||||||
|
await service.tick();
|
||||||
|
|
||||||
|
expect(register).toHaveBeenCalledTimes(1);
|
||||||
|
expect(register).toHaveBeenCalledWith(INVOICE_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("files nothing when EIMS_AUTO_SUBMIT is off", async () => {
|
||||||
|
const { service, register, query } = build({ cfg: { autoSubmit: false }, candidate });
|
||||||
|
|
||||||
|
await service.tick();
|
||||||
|
|
||||||
|
expect(register).not.toHaveBeenCalled();
|
||||||
|
expect(query).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("files nothing when EIMS itself is disabled, even with auto-submit on", async () => {
|
||||||
|
const { service, register, query } = build({ cfg: { enabled: false }, candidate });
|
||||||
|
|
||||||
|
await service.tick();
|
||||||
|
|
||||||
|
expect(register).not.toHaveBeenCalled();
|
||||||
|
expect(query).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not submit while another submission is in flight", async () => {
|
||||||
|
const { service, register } = build({
|
||||||
|
state: { in_flight_invoice_id: "22222222-2222-4222-8222-222222222222" },
|
||||||
|
candidate,
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.tick();
|
||||||
|
|
||||||
|
expect(register).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not submit while the system number is blocked", async () => {
|
||||||
|
const { service, register } = build({
|
||||||
|
state: { blocked_reason: "never acknowledged" },
|
||||||
|
candidate,
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.tick();
|
||||||
|
|
||||||
|
expect(register).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing when no invoice is eligible", async () => {
|
||||||
|
const { service, register } = build({ candidate: null });
|
||||||
|
|
||||||
|
await service.tick();
|
||||||
|
|
||||||
|
expect(register).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks only for NOT_SUBMITTED invoices, so UNKNOWN and FAILED are never retried", async () => {
|
||||||
|
const { service, query } = build({ candidate });
|
||||||
|
|
||||||
|
await service.tick();
|
||||||
|
|
||||||
|
const [sql, params] = query.mock.calls.find(([s]: [string]) => s.includes("freight.invoices"))!;
|
||||||
|
expect(sql).toContain("i.eims_status = $1");
|
||||||
|
expect(params[0]).toBe(EimsInvoiceStatus.NotSubmitted);
|
||||||
|
expect(sql).toContain("i.issued_at IS NOT NULL");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("survives a filing failure so the job keeps running", async () => {
|
||||||
|
const register = jest.fn().mockRejectedValue(new Error("EIMS register failed (406)"));
|
||||||
|
const { service } = build({ candidate, register });
|
||||||
|
|
||||||
|
await expect(service.tick()).resolves.toBeUndefined();
|
||||||
|
expect(register).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not start a second tick while one is still filing", async () => {
|
||||||
|
let release: () => void = () => {};
|
||||||
|
const register = jest.fn().mockImplementation(
|
||||||
|
() => new Promise((resolve) => (release = () => resolve({ eimsStatus: "REGISTERED" }))),
|
||||||
|
);
|
||||||
|
const { service } = build({ candidate, register });
|
||||||
|
|
||||||
|
const first = service.tick();
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
await service.tick(); // overlapping tick, must be a no-op
|
||||||
|
|
||||||
|
expect(register).toHaveBeenCalledTimes(1);
|
||||||
|
release();
|
||||||
|
await first;
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { Cron } from "@nestjs/schedule";
|
||||||
|
import { InjectDataSource } from "@nestjs/typeorm";
|
||||||
|
import { DataSource } from "typeorm";
|
||||||
|
|
||||||
|
import { EimsConfig } from "../../config/eims.config";
|
||||||
|
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
|
||||||
|
import { EimsInvoiceStatus } from "./eims-registration.types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Files issued invoices with MoR EIMS on a timer.
|
||||||
|
*
|
||||||
|
* Invoices are produced by the freight workflow rather than by a person, so this — not the manual
|
||||||
|
* endpoint — is the production path. It is a sweep rather than a hook on the eleven places an
|
||||||
|
* invoice can be created or issued, which buys three things: the workflow is untouched, the HTTP
|
||||||
|
* call is by construction outside the invoice's transaction, and an invoice missed through a crash
|
||||||
|
* or a restart is picked up on the next tick.
|
||||||
|
*
|
||||||
|
* `invoices.eims_status` is the queue — nothing new is persisted. Only `NOT_SUBMITTED` is eligible:
|
||||||
|
* `UNKNOWN` must never be retried automatically (the document may already be filed), and `FAILED`
|
||||||
|
* waits for an explicit retry policy rather than a timer's guess.
|
||||||
|
*
|
||||||
|
* Off unless **both** `EIMS_ENABLED` and `EIMS_AUTO_SUBMIT` are true. Enabling it starts filing
|
||||||
|
* real documents with the tax authority, and a registration cannot be undone from this side.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class EimsAutoSubmitService {
|
||||||
|
private readonly logger = new Logger(EimsAutoSubmitService.name);
|
||||||
|
/** Guards against a tick starting while the previous one is still filing. */
|
||||||
|
private running = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectDataSource() private readonly dataSource: DataSource,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
private readonly registration: EimsInvoiceRegistrationService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private get cfg(): EimsConfig {
|
||||||
|
return this.config.get<EimsConfig>("eims")!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One invoice per tick.
|
||||||
|
*
|
||||||
|
* Deliberately not a batch: each filing consumes a counter and advances the IRN chain, an
|
||||||
|
* ambiguous result blocks the system number until a human resolves it, and a misconfiguration
|
||||||
|
* should cost one rejected document rather than a burst of them.
|
||||||
|
*/
|
||||||
|
@Cron(process.env.EIMS_AUTO_SUBMIT_CRON ?? "0 */5 * * * *", { name: "eims-auto-submit" })
|
||||||
|
async tick(): Promise<void> {
|
||||||
|
const cfg = this.cfg;
|
||||||
|
if (!cfg.enabled || !cfg.autoSubmit) return;
|
||||||
|
if (this.running) return;
|
||||||
|
|
||||||
|
this.running = true;
|
||||||
|
try {
|
||||||
|
// Rule of the chain: nothing may be filed while a submission is in flight or the system is
|
||||||
|
// blocked. The reservation would refuse anyway — checking first keeps the log quiet and
|
||||||
|
// avoids burning a tick on a guaranteed conflict.
|
||||||
|
const blocked = await this.systemBlockReason();
|
||||||
|
if (blocked) {
|
||||||
|
this.logger.warn(`EIMS auto-submit paused: ${blocked}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = await this.nextCandidate();
|
||||||
|
if (!candidate) return;
|
||||||
|
|
||||||
|
const view = await this.registration.registerInvoiceWithEims(candidate.id);
|
||||||
|
this.logger.log(
|
||||||
|
`EIMS auto-submit: invoice ${candidate.invoiceNumber} -> ${view.eimsStatus}` +
|
||||||
|
(view.eimsIrn ? ` (IRN ${view.eimsIrn})` : ""),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
// Never let a filing failure kill the job. The outcome is already persisted on the invoice
|
||||||
|
// (FAILED or UNKNOWN with the gateway's own message), and a blocked system number stops the
|
||||||
|
// next tick at the guard above.
|
||||||
|
this.logger.error(`EIMS auto-submit tick failed: ${(err as Error).message}`);
|
||||||
|
} finally {
|
||||||
|
this.running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Why filing is currently impossible for this system number, or null when it is free. */
|
||||||
|
private async systemBlockReason(): Promise<string | null> {
|
||||||
|
const rows: { in_flight_invoice_id: string | null; blocked_reason: string | null }[] =
|
||||||
|
await this.dataSource.query(
|
||||||
|
`SELECT in_flight_invoice_id, blocked_reason
|
||||||
|
FROM freight.eims_system_state
|
||||||
|
WHERE system_number = $1 AND deleted_at IS NULL
|
||||||
|
LIMIT 1`,
|
||||||
|
[this.cfg.systemNumber],
|
||||||
|
);
|
||||||
|
const state = rows[0];
|
||||||
|
if (!state) return null;
|
||||||
|
if (state.blocked_reason) return state.blocked_reason;
|
||||||
|
if (state.in_flight_invoice_id) {
|
||||||
|
return `a submission for invoice ${state.in_flight_invoice_id} is still in flight`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Oldest never-submitted invoice that is issued, still inside MoR's document-age window, and
|
||||||
|
* carries at least one line.
|
||||||
|
*/
|
||||||
|
private async nextCandidate(): Promise<{ id: string; invoiceNumber: string } | null> {
|
||||||
|
const rows: { id: string; invoiceNumber: string }[] = await this.dataSource.query(
|
||||||
|
`SELECT i.id, i.invoice_number AS "invoiceNumber"
|
||||||
|
FROM freight.invoices i
|
||||||
|
WHERE i.eims_status = $1
|
||||||
|
AND i.issued_at IS NOT NULL
|
||||||
|
AND i.deleted_at IS NULL
|
||||||
|
AND i.issued_at > now() - ($2 || ' days')::interval
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM freight.invoice_lines l
|
||||||
|
WHERE l.invoice_id = i.id AND l.deleted_at IS NULL
|
||||||
|
)
|
||||||
|
ORDER BY i.issued_at ASC
|
||||||
|
LIMIT 1`,
|
||||||
|
[EimsInvoiceStatus.NotSubmitted, this.cfg.autoSubmitMaxAgeDays],
|
||||||
|
);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
80
apps/edr-freight-api/src/modules/eims/eims-client.service.ts
Normal file
80
apps/edr-freight-api/src/modules/eims/eims-client.service.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { HttpService } from "@nestjs/axios";
|
||||||
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { firstValueFrom } from "rxjs";
|
||||||
|
import { EimsConfig } from "../../config/eims.config";
|
||||||
|
import { EimsAuthService } from "./eims-auth.service";
|
||||||
|
import { EimsSignerService, toSignedBody } from "./eims-signer.service";
|
||||||
|
import { toEimsApiException } from "./eims.errors";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Foundation for EIMS's bearer-authenticated endpoints (`/v1/register`, `/v1/verify`, …).
|
||||||
|
*
|
||||||
|
* Login is not routed through here: `/auth/login` carries no bearer token and lives in
|
||||||
|
* `EimsAuthService`. Nothing calls `postSigned` yet — invoice registration is a later phase.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class EimsClientService {
|
||||||
|
private readonly logger = new Logger(EimsClientService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly http: HttpService,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
private readonly auth: EimsAuthService,
|
||||||
|
private readonly signer: EimsSignerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private get cfg(): EimsConfig {
|
||||||
|
return this.config.get<EimsConfig>("eims")!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign `request`, POST it to `path` with a valid bearer token, and return the parsed response.
|
||||||
|
* A 401 invalidates the cached token and retries exactly once.
|
||||||
|
*/
|
||||||
|
async postSigned<TRequest, TResponse>(path: string, request: TRequest): Promise<TResponse> {
|
||||||
|
return this.send<TRequest, TResponse>(path, request, false, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST `request` verbatim — bearer-authenticated but **not** wrapped in a signed envelope.
|
||||||
|
*
|
||||||
|
* `/v1/verify` is the only endpoint observed to work this way: the supplied collection sends a
|
||||||
|
* raw `{"irn":"…"}` body with no `signature`/`certificate` siblings. Kept as its own entry point
|
||||||
|
* so that if the live gateway turns out to require signing after all, exactly one call site
|
||||||
|
* changes — `postSigned` is already the alternative.
|
||||||
|
*/
|
||||||
|
async postBearer<TRequest, TResponse>(path: string, request: TRequest): Promise<TResponse> {
|
||||||
|
return this.send<TRequest, TResponse>(path, request, false, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async send<TRequest, TResponse>(
|
||||||
|
path: string,
|
||||||
|
request: TRequest,
|
||||||
|
isRetry: boolean,
|
||||||
|
signed: boolean,
|
||||||
|
): Promise<TResponse> {
|
||||||
|
const cfg = this.cfg;
|
||||||
|
const token = await this.auth.getValidAccessToken();
|
||||||
|
const body = signed ? toSignedBody(this.signer.signRequest(request)) : request;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await firstValueFrom(
|
||||||
|
this.http.post<TResponse>(`${cfg.baseUrl}${path}`, body, {
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
||||||
|
timeout: cfg.httpTimeoutMs,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
} catch (err) {
|
||||||
|
const mapped = toEimsApiException(err, `POST ${path}`);
|
||||||
|
if (mapped.kind === "AUTH" && !isRetry) {
|
||||||
|
this.logger.warn(`EIMS rejected the token on ${path}; re-authenticating once`);
|
||||||
|
this.auth.invalidate();
|
||||||
|
return this.send<TRequest, TResponse>(path, request, true, signed);
|
||||||
|
}
|
||||||
|
this.logger.error(mapped.message);
|
||||||
|
throw mapped;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { KeyObject, createPrivateKey } from "node:crypto";
|
||||||
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { EimsConfig } from "../../config/eims.config";
|
||||||
|
import { EimsConfigException } from "./eims.errors";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the INSA-issued EIMS credentials from disk, once, and keeps them in memory.
|
||||||
|
*
|
||||||
|
* The certificate is sent as base64 of the **exact bytes of the issued file** — it is deliberately
|
||||||
|
* never parsed, re-encoded or re-exported, because that is what produced a working live login.
|
||||||
|
* The private key never leaves this process: it is only ever used to produce a signature.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class EimsCredentialsProvider {
|
||||||
|
private readonly logger = new Logger(EimsCredentialsProvider.name);
|
||||||
|
private privateKey: KeyObject | null = null;
|
||||||
|
private certificateBase64: string | null = null;
|
||||||
|
|
||||||
|
constructor(private readonly config: ConfigService) {}
|
||||||
|
|
||||||
|
private get cfg(): EimsConfig {
|
||||||
|
return this.config.get<EimsConfig>("eims")!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RSA private key, parsed once. Throws a config error if the path is missing or unusable. */
|
||||||
|
getPrivateKey(): KeyObject {
|
||||||
|
if (this.privateKey) return this.privateKey;
|
||||||
|
|
||||||
|
const path = this.cfg.privateKeyPath;
|
||||||
|
if (!path) throw new EimsConfigException("EIMS_PRIVATE_KEY_PATH is not set");
|
||||||
|
|
||||||
|
let key: KeyObject;
|
||||||
|
try {
|
||||||
|
key = createPrivateKey(readFileSync(path));
|
||||||
|
} catch (err) {
|
||||||
|
// The path is operational information, not a secret; the key material never appears.
|
||||||
|
throw new EimsConfigException(
|
||||||
|
`EIMS private key at ${path} could not be read or parsed: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (key.asymmetricKeyType !== "rsa") {
|
||||||
|
throw new EimsConfigException(
|
||||||
|
`EIMS private key at ${path} is ${key.asymmetricKeyType ?? "of unknown type"}; EIMS requires RSA`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.privateKey = key;
|
||||||
|
this.logger.log(`EIMS private key loaded (RSA-${key.asymmetricKeyDetails?.modulusLength ?? "?"})`);
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Base64 of the certificate file's exact bytes. No parsing, no re-encoding. */
|
||||||
|
getCertificateBase64(): string {
|
||||||
|
if (this.certificateBase64) return this.certificateBase64;
|
||||||
|
|
||||||
|
const path = this.cfg.certificatePath;
|
||||||
|
if (!path) throw new EimsConfigException("EIMS_CERTIFICATE_PATH is not set");
|
||||||
|
|
||||||
|
let bytes: Buffer;
|
||||||
|
try {
|
||||||
|
bytes = readFileSync(path);
|
||||||
|
} catch (err) {
|
||||||
|
throw new EimsConfigException(
|
||||||
|
`EIMS certificate at ${path} could not be read: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (bytes.length === 0) {
|
||||||
|
throw new EimsConfigException(`EIMS certificate at ${path} is empty`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.certificateBase64 = bytes.toString("base64");
|
||||||
|
this.logger.log(`EIMS certificate bundle loaded (${bytes.length} bytes)`);
|
||||||
|
return this.certificateBase64;
|
||||||
|
}
|
||||||
|
}
|
||||||
150
apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts
Normal file
150
apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { BadRequestException } from "@nestjs/common";
|
||||||
|
import { EimsConfig } from "../../config/eims.config";
|
||||||
|
import { EimsSessionContext } from "./eims-auth.service";
|
||||||
|
import {
|
||||||
|
EimsMapperContext,
|
||||||
|
EimsMapperLine,
|
||||||
|
EimsSellerDetails,
|
||||||
|
} from "../billing/eims-invoice.mapper";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns configuration into the seller identity and mapper context that `toEimsInvoice` requires.
|
||||||
|
*
|
||||||
|
* Everything here is unavailable from the database by construction: EDR's own legal identity is not
|
||||||
|
* modelled anywhere, and the application has no tax model at all (`invoice.taxAmount` is always 0,
|
||||||
|
* `invoice_lines` and the rate catalogue carry no fiscal columns). Rather than defaulting any of it,
|
||||||
|
* a missing value fails **here** — locally, before a single byte reaches the gateway — naming the
|
||||||
|
* exact environment variables to set.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface RequiredSpec {
|
||||||
|
env: string;
|
||||||
|
value: string | number | null | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `systemNumber` / `systemType` are absent by design: they come from the access token, which is
|
||||||
|
// MoR's own statement of who we are. See EimsAuthService.getSessionContext.
|
||||||
|
const REQUIRED = (invoice: EimsConfig["invoice"], tin: string): RequiredSpec[] => [
|
||||||
|
{ env: "EIMS_TIN", value: tin },
|
||||||
|
{ env: "EIMS_SELLER_LEGAL_NAME", value: invoice.sellerLegalName },
|
||||||
|
{ env: "EIMS_SELLER_VAT_NUMBER", value: invoice.sellerVatNumber },
|
||||||
|
{ env: "EIMS_SELLER_PHONE", value: invoice.sellerPhone },
|
||||||
|
{ env: "EIMS_SELLER_EMAIL", value: invoice.sellerEmail },
|
||||||
|
{ env: "EIMS_SELLER_REGION", value: invoice.sellerRegion },
|
||||||
|
{ env: "EIMS_SELLER_WEREDA", value: invoice.sellerWereda },
|
||||||
|
{ env: "EIMS_TAX_CODE", value: invoice.taxCode },
|
||||||
|
{ env: "EIMS_TAX_RATE_PERCENT", value: invoice.taxRatePercent },
|
||||||
|
{ env: "EIMS_INCOME_WITHHOLD_VALUE", value: invoice.incomeWithholdValue },
|
||||||
|
{ env: "EIMS_TRANSACTION_WITHHOLD_VALUE", value: invoice.transactionWithholdValue },
|
||||||
|
{ env: "EIMS_TRANSACTION_TYPE", value: invoice.transactionType },
|
||||||
|
{ env: "EIMS_NATURE_OF_SUPPLIES", value: invoice.natureOfSupplies },
|
||||||
|
{ env: "EIMS_PAYMENT_MODE", value: invoice.paymentMode },
|
||||||
|
{ env: "EIMS_PAYMENT_TERM", value: invoice.paymentTerm },
|
||||||
|
{ env: "EIMS_UNIT_DEFAULT", value: invoice.unitDefault },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Throws naming every unset variable at once, so one round trip fixes the whole configuration. */
|
||||||
|
export function assertEimsInvoiceConfig(config: EimsConfig): void {
|
||||||
|
const missing = REQUIRED(config.invoice, config.tin)
|
||||||
|
.filter(({ value }) => value === null || value === undefined || value === "")
|
||||||
|
.map(({ env }) => env);
|
||||||
|
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: "EIMS_INVOICE_CONFIG_INCOMPLETE",
|
||||||
|
message:
|
||||||
|
"EIMS invoice registration is not configured. Set these environment variables " +
|
||||||
|
`(tax values need finance sign-off — they are deliberately not defaulted): ${missing.join(", ")}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
assertSellerFormats(config.invoice);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MoR's own patterns for the seller fields, checked here rather than at the gateway.
|
||||||
|
*
|
||||||
|
* A placeholder like `_` is "set" but unfilable, and finding that out costs a real request and a
|
||||||
|
* consumed counter — these are the exact regexes its 400 SCHEMA ERROR quoted back at us.
|
||||||
|
*/
|
||||||
|
const SELLER_FORMATS: { env: string; value: (i: EimsConfig["invoice"]) => string; pattern: RegExp }[] = [
|
||||||
|
{ env: "EIMS_SELLER_PHONE", value: (i) => i.sellerPhone, pattern: /^\+?[0-9]{6,}$/ },
|
||||||
|
{
|
||||||
|
env: "EIMS_SELLER_EMAIL",
|
||||||
|
value: (i) => i.sellerEmail,
|
||||||
|
pattern: /^[a-zA-Z0-9+_.-]+@[a-zA-Z0-9.-]+$/,
|
||||||
|
},
|
||||||
|
{ env: "EIMS_SELLER_REGION", value: (i) => i.sellerRegion, pattern: /^[0-9]{1,3}$/ },
|
||||||
|
{ env: "EIMS_SELLER_WEREDA", value: (i) => i.sellerWereda, pattern: /^[0-9A-Za-z]{1,10}$/ },
|
||||||
|
];
|
||||||
|
|
||||||
|
function assertSellerFormats(invoice: EimsConfig["invoice"]): void {
|
||||||
|
const bad = SELLER_FORMATS.filter(({ value, pattern }) => !pattern.test(value(invoice))).map(
|
||||||
|
({ env, pattern }) => `${env} (must match ${pattern.source})`,
|
||||||
|
);
|
||||||
|
if (bad.length > 0) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: "EIMS_INVOICE_CONFIG_INVALID",
|
||||||
|
message: `EIMS seller details would be rejected by MoR: ${bad.join("; ")}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildEimsSeller(config: EimsConfig): EimsSellerDetails {
|
||||||
|
const { invoice } = config;
|
||||||
|
return {
|
||||||
|
City: invoice.sellerCity,
|
||||||
|
Email: invoice.sellerEmail,
|
||||||
|
HouseNumber: invoice.sellerHouseNumber,
|
||||||
|
LegalName: invoice.sellerLegalName,
|
||||||
|
Locality: invoice.sellerLocality,
|
||||||
|
Phone: invoice.sellerPhone,
|
||||||
|
Region: invoice.sellerRegion,
|
||||||
|
SubCity: invoice.sellerSubCity,
|
||||||
|
Tin: config.tin,
|
||||||
|
VatNumber: invoice.sellerVatNumber,
|
||||||
|
Wereda: invoice.sellerWereda,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EimsContextInput {
|
||||||
|
/** `DocumentDetails.DocumentNumber`. The caller decides its source. */
|
||||||
|
documentNumber: string;
|
||||||
|
invoiceCounter: number;
|
||||||
|
previousIrn: string | null;
|
||||||
|
/** Source-system identity from the access token, never from configuration. */
|
||||||
|
session: EimsSessionContext;
|
||||||
|
/** Required when the invoice currency is not ETB. */
|
||||||
|
exchangeRate?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildEimsContext(config: EimsConfig, input: EimsContextInput): EimsMapperContext {
|
||||||
|
const { invoice } = config;
|
||||||
|
// Validated by assertEimsInvoiceConfig; the non-null assertions below are safe after that call.
|
||||||
|
const taxCode = invoice.taxCode;
|
||||||
|
const ratePercent = invoice.taxRatePercent!;
|
||||||
|
const exciseTaxValue = invoice.exciseTaxValue ?? 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
systemNumber: input.session.systemNumber,
|
||||||
|
systemType: input.session.systemType,
|
||||||
|
documentNumber: input.documentNumber,
|
||||||
|
invoiceCounter: input.invoiceCounter,
|
||||||
|
previousIrn: input.previousIrn,
|
||||||
|
cashierName: invoice.cashierName,
|
||||||
|
salesPersonName: invoice.salesPersonName,
|
||||||
|
transactionType: invoice.transactionType,
|
||||||
|
payment: { mode: invoice.paymentMode, term: invoice.paymentTerm },
|
||||||
|
// One treatment for every line today. The mapper resolves tax per line, so a future
|
||||||
|
// charge-type-specific rule slots in here without touching the mapper.
|
||||||
|
taxForLine: (_line: EimsMapperLine) => ({ code: taxCode, ratePercent, exciseTaxValue }),
|
||||||
|
natureOfSupplies: invoice.natureOfSupplies,
|
||||||
|
unitDefault: invoice.unitDefault,
|
||||||
|
incomeWithholdValue: invoice.incomeWithholdValue!,
|
||||||
|
transactionWithholdValue: invoice.transactionWithholdValue!,
|
||||||
|
buyerCountryCode: invoice.buyerCountryCode,
|
||||||
|
buyerRegionCodes: invoice.buyerRegionCodes,
|
||||||
|
buyerWeredaCodes: invoice.buyerWeredaCodes,
|
||||||
|
exchangeRate: input.exchangeRate ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,657 @@
|
|||||||
|
import { BadRequestException, ConflictException } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { DataSource } from "typeorm";
|
||||||
|
|
||||||
|
import { EimsConfig } from "../../config/eims.config";
|
||||||
|
import { Invoice } from "../billing/entities/invoice.entity";
|
||||||
|
import { EimsInvoiceRequest } from "../billing/eims-invoice.mapper";
|
||||||
|
import { eimsInvoiceConfig } from "./eims-test-fixtures";
|
||||||
|
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
|
||||||
|
import { EimsAuthService } from "./eims-auth.service";
|
||||||
|
import { EimsClientService } from "./eims-client.service";
|
||||||
|
import { EimsApiException } from "./eims.errors";
|
||||||
|
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
|
||||||
|
import { EimsSystemState } from "./entities/eims-system-state.entity";
|
||||||
|
import { EimsInvoiceStatus } from "./eims-registration.types";
|
||||||
|
|
||||||
|
const SYSTEM_NUMBER = "B0360154BA";
|
||||||
|
const INVOICE_ID = "11111111-1111-4111-8111-111111111111";
|
||||||
|
const OTHER_INVOICE_ID = "22222222-2222-4222-8222-222222222222";
|
||||||
|
const IRN = "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0";
|
||||||
|
|
||||||
|
const config = (over: Partial<EimsConfig["invoice"]> = {}): EimsConfig =>
|
||||||
|
({
|
||||||
|
enabled: true,
|
||||||
|
baseUrl: "https://core.mor.gov.et",
|
||||||
|
clientId: "cid",
|
||||||
|
clientSecret: "secret",
|
||||||
|
apiKey: "key",
|
||||||
|
tin: "0000034558",
|
||||||
|
systemNumber: SYSTEM_NUMBER,
|
||||||
|
systemType: "SYS",
|
||||||
|
privateKeyPath: "/dev/null",
|
||||||
|
certificatePath: "/dev/null",
|
||||||
|
httpTimeoutMs: 30_000,
|
||||||
|
tokenSkewMs: 45_000,
|
||||||
|
invoice: eimsInvoiceConfig(over),
|
||||||
|
}) as EimsConfig;
|
||||||
|
|
||||||
|
const invoiceRow = (over: Partial<Invoice> = {}): Invoice =>
|
||||||
|
({
|
||||||
|
id: INVOICE_ID,
|
||||||
|
invoiceNumber: "INV-20260807-00042",
|
||||||
|
currency: "ETB",
|
||||||
|
issuedAt: new Date(2026, 7, 7, 9, 5, 3),
|
||||||
|
totalAmount: "10000.00",
|
||||||
|
eimsStatus: EimsInvoiceStatus.NotSubmitted,
|
||||||
|
eimsIrn: null,
|
||||||
|
eimsInvoiceCounter: null,
|
||||||
|
eimsSubmittedAt: null,
|
||||||
|
eimsAckDate: null,
|
||||||
|
eimsLastError: null,
|
||||||
|
company: {
|
||||||
|
name: "ABC Trading PLC",
|
||||||
|
tin: "0999930000",
|
||||||
|
vatNumber: "123475885858",
|
||||||
|
phone: "0912345678",
|
||||||
|
email: "buyer@abc.et",
|
||||||
|
region: "13",
|
||||||
|
zone: "SHA",
|
||||||
|
woreda: "574",
|
||||||
|
kebele: "03",
|
||||||
|
houseNo: "NEW",
|
||||||
|
country: "Ethiopia",
|
||||||
|
},
|
||||||
|
...over,
|
||||||
|
}) as unknown as Invoice;
|
||||||
|
|
||||||
|
const LINES = [
|
||||||
|
{
|
||||||
|
chargeType: "RAIL_FREIGHT",
|
||||||
|
description: "Addis to Djibouti",
|
||||||
|
quantity: "1.00",
|
||||||
|
unitRate: "10000.00",
|
||||||
|
amount: "10000.00",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory stand-in for the two locked rows. `update` merges, `createQueryBuilder(...).getOne()`
|
||||||
|
* returns the live object — enough to assert ordering, values and the reservation lifecycle without
|
||||||
|
* a database.
|
||||||
|
*/
|
||||||
|
class FakeDb {
|
||||||
|
invoices = new Map<string, Invoice>();
|
||||||
|
state: EimsSystemState | null = null;
|
||||||
|
/** Runs before every transaction body, to simulate a concurrent writer. */
|
||||||
|
onTransaction: (() => void) | null = null;
|
||||||
|
|
||||||
|
constructor(invoices: Invoice[], state?: Partial<EimsSystemState>) {
|
||||||
|
for (const inv of invoices) this.invoices.set(inv.id, inv);
|
||||||
|
this.state = {
|
||||||
|
id: "state-1",
|
||||||
|
systemNumber: SYSTEM_NUMBER,
|
||||||
|
nextInvoiceCounter: 7,
|
||||||
|
nextDocumentNumber: 5,
|
||||||
|
previousIrn: null,
|
||||||
|
inFlightInvoiceId: null,
|
||||||
|
inFlightCounter: null,
|
||||||
|
inFlightDocumentNumber: null,
|
||||||
|
blockedReason: null,
|
||||||
|
...state,
|
||||||
|
} as EimsSystemState;
|
||||||
|
}
|
||||||
|
|
||||||
|
private manager = {
|
||||||
|
createQueryBuilder: (entity: unknown) => {
|
||||||
|
const isInvoice = entity === Invoice;
|
||||||
|
let id: string | undefined;
|
||||||
|
const builder = {
|
||||||
|
setLock: () => builder,
|
||||||
|
where: (_clause: string, params: Record<string, string>) => {
|
||||||
|
id = params.invoiceId ?? params.systemNumber;
|
||||||
|
return builder;
|
||||||
|
},
|
||||||
|
getOne: async () => (isInvoice ? (this.invoices.get(id!) ?? null) : this.state),
|
||||||
|
};
|
||||||
|
return builder;
|
||||||
|
},
|
||||||
|
findOne: async (_entity: unknown, options: { where: { id: string } }) =>
|
||||||
|
this.invoices.get(options.where.id) ?? null,
|
||||||
|
update: async (entity: unknown, id: string, patch: Record<string, unknown>) => {
|
||||||
|
if (entity === Invoice) Object.assign(this.invoices.get(id)!, patch);
|
||||||
|
else Object.assign(this.state!, patch);
|
||||||
|
},
|
||||||
|
query: async () => [],
|
||||||
|
getRepository: () => ({
|
||||||
|
findOne: async (options: { where: { id: string } }) =>
|
||||||
|
this.invoices.get(options.where.id) ?? null,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
asDataSource(): DataSource {
|
||||||
|
return {
|
||||||
|
manager: this.manager,
|
||||||
|
getRepository: this.manager.getRepository,
|
||||||
|
query: async (sql: string) =>
|
||||||
|
sql.includes("eims_system_state")
|
||||||
|
? [{ in_flight_invoice_id: this.state?.inFlightInvoiceId ?? null }]
|
||||||
|
: LINES,
|
||||||
|
transaction: async (body: (m: unknown) => Promise<unknown>) => {
|
||||||
|
this.onTransaction?.();
|
||||||
|
return body(this.manager);
|
||||||
|
},
|
||||||
|
} as unknown as DataSource;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The source system comes from the access token, so the service is handed a session, not config. */
|
||||||
|
const SESSION = { systemNumber: SYSTEM_NUMBER, systemType: "SYS" };
|
||||||
|
|
||||||
|
const build = (
|
||||||
|
db: FakeDb,
|
||||||
|
postSigned: jest.Mock,
|
||||||
|
cfg: EimsConfig = config(),
|
||||||
|
postBearer: jest.Mock = jest.fn(),
|
||||||
|
getSessionContext: jest.Mock | undefined = undefined,
|
||||||
|
notify: jest.Mock = jest.fn().mockResolvedValue(undefined),
|
||||||
|
) =>
|
||||||
|
new EimsInvoiceRegistrationService(
|
||||||
|
db.asDataSource(),
|
||||||
|
{ get: () => cfg } as unknown as ConfigService,
|
||||||
|
{ postSigned, postBearer } as unknown as EimsClientService,
|
||||||
|
{
|
||||||
|
getSessionContext: getSessionContext ?? jest.fn().mockResolvedValue(SESSION),
|
||||||
|
} as unknown as EimsAuthService,
|
||||||
|
{ notify } as unknown as NotificationInboxService,
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Document number the fixtures register under; `/v1/verify` must echo it back.
|
||||||
|
*
|
||||||
|
* A plain integer, not our `invoiceNumber`: MoR validates the field against
|
||||||
|
* `^(0|[1-9][0-9]{0,8})$`. It is allocated from `nextDocumentNumber` above.
|
||||||
|
*/
|
||||||
|
const DOCUMENT_NUMBER = "5";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `/v1/verify` success. The response spells the reference `Irn` while the request sends lowercase
|
||||||
|
* `irn`.
|
||||||
|
*
|
||||||
|
* The fixture is deliberately *coherent* — same IRN on both sides. The supplied Postman collection
|
||||||
|
* pairs a saved request and a saved response whose literal IRNs disagree, which is an artefact of
|
||||||
|
* the mock rather than gateway behaviour; asserting against that inconsistency would encode the
|
||||||
|
* mock's bug as a requirement. Resolution requires the returned `Irn` to match the one asked for,
|
||||||
|
* and these fixtures exercise that honestly.
|
||||||
|
*/
|
||||||
|
const verifyResponse = (over: Record<string, unknown> = {}) => ({
|
||||||
|
statusCode: 200,
|
||||||
|
message: "SUCCESS",
|
||||||
|
body: {
|
||||||
|
Irn: IRN,
|
||||||
|
TransactionType: "B2B",
|
||||||
|
DocumentDetails: { Type: "INV", DocumentNumber: DOCUMENT_NUMBER, Date: "07-08-2026T09:05:03" },
|
||||||
|
Version: "1",
|
||||||
|
...over,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const okResponse = (irn = IRN) =>
|
||||||
|
({ statusCode: 200, message: "SUCCESS", body: { irn, ackDate: "2026-08-07T09:05:03Z[Etc/UTC]" } });
|
||||||
|
|
||||||
|
const apiError = (kind: string, status?: number) =>
|
||||||
|
new EimsApiException(kind as never, `EIMS register failed (${status ?? "-"})`, status);
|
||||||
|
|
||||||
|
describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
|
||||||
|
it("registers, persists the IRN and advances the chain", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()]);
|
||||||
|
const postSigned = jest.fn().mockResolvedValue(okResponse());
|
||||||
|
|
||||||
|
const view = await build(db, postSigned).registerInvoiceWithEims(INVOICE_ID);
|
||||||
|
|
||||||
|
expect(postSigned).toHaveBeenCalledTimes(1);
|
||||||
|
expect(postSigned.mock.calls[0][0]).toBe("/v1/register");
|
||||||
|
expect(view).toMatchObject({
|
||||||
|
eimsStatus: EimsInvoiceStatus.Registered,
|
||||||
|
eimsIrn: IRN,
|
||||||
|
eimsInvoiceCounter: 7,
|
||||||
|
eimsAckDate: "2026-08-07T09:05:03Z[Etc/UTC]",
|
||||||
|
});
|
||||||
|
expect(db.state).toMatchObject({
|
||||||
|
previousIrn: IRN,
|
||||||
|
nextInvoiceCounter: 8,
|
||||||
|
inFlightInvoiceId: null,
|
||||||
|
inFlightCounter: null,
|
||||||
|
blockedReason: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends the exact reserved counter and previous IRN to the mapper", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()], { nextInvoiceCounter: 42, previousIrn: "PRIOR-IRN" });
|
||||||
|
const postSigned = jest.fn().mockResolvedValue(okResponse());
|
||||||
|
|
||||||
|
await build(db, postSigned).registerInvoiceWithEims(INVOICE_ID);
|
||||||
|
|
||||||
|
const request = postSigned.mock.calls[0][1] as EimsInvoiceRequest;
|
||||||
|
expect(request.SourceSystem.InvoiceCounter).toBe(42);
|
||||||
|
expect(request.ReferenceDetails.PreviousIrn).toBe("PRIOR-IRN");
|
||||||
|
expect(request.DocumentDetails.DocumentNumber).toBe(DOCUMENT_NUMBER);
|
||||||
|
expect(request.SourceSystem.SystemNumber).toBe(SYSTEM_NUMBER);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes SourceSystem from the token session, not from configuration", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()]);
|
||||||
|
const postSigned = jest.fn().mockResolvedValue(okResponse());
|
||||||
|
// Config disagrees on purpose: only the session may reach the wire.
|
||||||
|
const cfg = config();
|
||||||
|
(cfg as { systemNumber: string }).systemNumber = "CONFIG-ONLY";
|
||||||
|
(cfg as { systemType: string }).systemType = "MAN";
|
||||||
|
|
||||||
|
await build(
|
||||||
|
db,
|
||||||
|
postSigned,
|
||||||
|
cfg,
|
||||||
|
jest.fn(),
|
||||||
|
jest.fn().mockResolvedValue({ systemNumber: "FROM-TOKEN", systemType: "POS" }),
|
||||||
|
).registerInvoiceWithEims(INVOICE_ID);
|
||||||
|
|
||||||
|
const request = postSigned.mock.calls[0][1] as EimsInvoiceRequest;
|
||||||
|
expect(request.SourceSystem.SystemNumber).toBe("FROM-TOKEN");
|
||||||
|
expect(request.SourceSystem.SystemType).toBe("POS");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not consume a counter when authentication fails", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()]);
|
||||||
|
const postSigned = jest.fn();
|
||||||
|
const getSessionContext = jest.fn().mockRejectedValue(new Error("login failed"));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
build(db, postSigned, config(), jest.fn(), getSessionContext).registerInvoiceWithEims(
|
||||||
|
INVOICE_ID,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/login failed/);
|
||||||
|
|
||||||
|
expect(postSigned).not.toHaveBeenCalled();
|
||||||
|
expect(db.state).toMatchObject({ nextInvoiceCounter: 7, inFlightInvoiceId: null });
|
||||||
|
expect(db.invoices.get(INVOICE_ID)!.eimsStatus).toBe(EimsInvoiceStatus.NotSubmitted);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent — an invoice with an IRN never reaches EIMS", async () => {
|
||||||
|
const db = new FakeDb([
|
||||||
|
invoiceRow({ eimsIrn: IRN, eimsStatus: EimsInvoiceStatus.Registered }),
|
||||||
|
]);
|
||||||
|
const postSigned = jest.fn();
|
||||||
|
|
||||||
|
const view = await build(db, postSigned).registerInvoiceWithEims(INVOICE_ID);
|
||||||
|
|
||||||
|
expect(postSigned).not.toHaveBeenCalled();
|
||||||
|
expect(view.eimsIrn).toBe(IRN);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets only one of two concurrent calls reach EIMS", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()]);
|
||||||
|
let resolvePost: (v: unknown) => void = () => {};
|
||||||
|
const postSigned = jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementation(() => new Promise((resolve) => (resolvePost = resolve)));
|
||||||
|
const service = build(db, postSigned);
|
||||||
|
|
||||||
|
const first = service.registerInvoiceWithEims(INVOICE_ID);
|
||||||
|
// Let the first reservation commit and its HTTP call start; it is now parked on `resolvePost`.
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
expect(postSigned).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const second = service.registerInvoiceWithEims(INVOICE_ID);
|
||||||
|
|
||||||
|
await expect(second).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
resolvePost(okResponse());
|
||||||
|
await first;
|
||||||
|
expect(postSigned).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks a different invoice while a submission is in flight (survives a restart)", async () => {
|
||||||
|
// A committed reservation left behind by a dead process.
|
||||||
|
const db = new FakeDb(
|
||||||
|
[
|
||||||
|
invoiceRow({ eimsStatus: EimsInvoiceStatus.Submitting, eimsInvoiceCounter: 7 }),
|
||||||
|
invoiceRow({ id: OTHER_INVOICE_ID, invoiceNumber: "INV-20260807-00043" }),
|
||||||
|
],
|
||||||
|
{ inFlightInvoiceId: INVOICE_ID, inFlightCounter: 7, nextInvoiceCounter: 8 },
|
||||||
|
);
|
||||||
|
const postSigned = jest.fn();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
build(db, postSigned).registerInvoiceWithEims(OTHER_INVOICE_ID),
|
||||||
|
).rejects.toThrow(/already in flight/);
|
||||||
|
expect(postSigned).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails locally on incomplete tax configuration, with zero HTTP calls", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()]);
|
||||||
|
const postSigned = jest.fn();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
build(db, postSigned, config({ taxCode: "", taxRatePercent: null })).registerInvoiceWithEims(
|
||||||
|
INVOICE_ID,
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
|
||||||
|
expect(postSigned).not.toHaveBeenCalled();
|
||||||
|
expect(db.invoices.get(INVOICE_ID)!.eimsStatus).toBe(EimsInvoiceStatus.NotSubmitted);
|
||||||
|
expect(db.state).toMatchObject({ nextInvoiceCounter: 7, inFlightInvoiceId: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["SCHEMA_VALIDATION", 400],
|
||||||
|
["RULE_VALIDATION", 406],
|
||||||
|
])("marks %s (%i) FAILED and clears the global block", async (kind, status) => {
|
||||||
|
const db = new FakeDb([invoiceRow()]);
|
||||||
|
const postSigned = jest.fn().mockRejectedValue(apiError(kind, status));
|
||||||
|
|
||||||
|
await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf(
|
||||||
|
EimsApiException,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(db.invoices.get(INVOICE_ID)).toMatchObject({
|
||||||
|
eimsStatus: EimsInvoiceStatus.Failed,
|
||||||
|
eimsIrn: null,
|
||||||
|
});
|
||||||
|
expect(db.state).toMatchObject({
|
||||||
|
inFlightInvoiceId: null,
|
||||||
|
blockedReason: null,
|
||||||
|
previousIrn: null,
|
||||||
|
// Returned, not consumed: MoR tracks the sequence and rejects a gap
|
||||||
|
// ("Invoice counter is not correct. expected : 1").
|
||||||
|
nextInvoiceCounter: 7,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a success response with no IRN as a failed registration", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()]);
|
||||||
|
const postSigned = jest.fn().mockResolvedValue({ statusCode: 200, body: { irn: "" } });
|
||||||
|
|
||||||
|
await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toThrow(
|
||||||
|
/returned no IRN/,
|
||||||
|
);
|
||||||
|
expect(db.invoices.get(INVOICE_ID)!.eimsStatus).toBe(EimsInvoiceStatus.Failed);
|
||||||
|
expect(db.state).toMatchObject({ inFlightInvoiceId: null, blockedReason: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks a timeout UNKNOWN and keeps the system blocked", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()]);
|
||||||
|
const postSigned = jest.fn().mockRejectedValue(apiError("TIMEOUT"));
|
||||||
|
|
||||||
|
await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf(
|
||||||
|
EimsApiException,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(db.invoices.get(INVOICE_ID)).toMatchObject({
|
||||||
|
eimsStatus: EimsInvoiceStatus.Unknown,
|
||||||
|
eimsIrn: null,
|
||||||
|
});
|
||||||
|
expect(db.state!.inFlightInvoiceId).toBe(INVOICE_ID);
|
||||||
|
expect(db.state!.blockedReason).toMatch(/never acknowledged/);
|
||||||
|
expect(db.state!.previousIrn).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an UNKNOWN result blocks a different invoice too", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID })]);
|
||||||
|
const postSigned = jest.fn().mockRejectedValueOnce(apiError("TIMEOUT"));
|
||||||
|
const service = build(db, postSigned);
|
||||||
|
|
||||||
|
await expect(service.registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf(
|
||||||
|
EimsApiException,
|
||||||
|
);
|
||||||
|
await expect(service.registerInvoiceWithEims(OTHER_INVOICE_ID)).rejects.toThrow(
|
||||||
|
/registration is blocked/,
|
||||||
|
);
|
||||||
|
expect(postSigned).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the counter after a refusal, but keeps it after an ambiguous result", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID })]);
|
||||||
|
const postSigned = jest
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(apiError("RULE_VALIDATION", 406))
|
||||||
|
.mockResolvedValueOnce(okResponse());
|
||||||
|
const service = build(db, postSigned);
|
||||||
|
|
||||||
|
await expect(service.registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf(
|
||||||
|
EimsApiException,
|
||||||
|
);
|
||||||
|
await service.registerInvoiceWithEims(OTHER_INVOICE_ID);
|
||||||
|
|
||||||
|
// The two numbers move differently, because MoR constrains them differently: the counter must
|
||||||
|
// not skip (it returns), the document number must not repeat (it is burned).
|
||||||
|
const first = postSigned.mock.calls[0][1] as EimsInvoiceRequest;
|
||||||
|
const second = postSigned.mock.calls[1][1] as EimsInvoiceRequest;
|
||||||
|
expect(first.SourceSystem.InvoiceCounter).toBe(7);
|
||||||
|
expect(second.SourceSystem.InvoiceCounter).toBe(7);
|
||||||
|
expect(first.DocumentDetails.DocumentNumber).toBe("5");
|
||||||
|
expect(second.DocumentDetails.DocumentNumber).toBe("6");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("EimsInvoiceRegistrationService staff alerting", () => {
|
||||||
|
it("raises a high-priority alert when a result is ambiguous, because all filing is blocked", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()]);
|
||||||
|
const notify = jest.fn().mockResolvedValue(undefined);
|
||||||
|
const postSigned = jest.fn().mockRejectedValue(apiError("TIMEOUT"));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
build(db, postSigned, config(), jest.fn(), undefined, notify).registerInvoiceWithEims(
|
||||||
|
INVOICE_ID,
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(EimsApiException);
|
||||||
|
|
||||||
|
expect(notify).toHaveBeenCalledTimes(1);
|
||||||
|
const sent = notify.mock.calls[0][0];
|
||||||
|
expect(sent.priority).toBe("HIGH");
|
||||||
|
expect(sent.title).toMatch(/blocked/i);
|
||||||
|
expect(sent.recipients.permissionKeys).toContain("edr_freight_app:invoices:eims_resolve");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("raises a normal-priority alert for a deterministic rejection", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()]);
|
||||||
|
const notify = jest.fn().mockResolvedValue(undefined);
|
||||||
|
const postSigned = jest.fn().mockRejectedValue(apiError("RULE_VALIDATION", 406));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
build(db, postSigned, config(), jest.fn(), undefined, notify).registerInvoiceWithEims(
|
||||||
|
INVOICE_ID,
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(EimsApiException);
|
||||||
|
|
||||||
|
expect(notify.mock.calls[0][0].priority).toBe("NORMAL");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not alert on a successful filing", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()]);
|
||||||
|
const notify = jest.fn();
|
||||||
|
|
||||||
|
await build(db, jest.fn().mockResolvedValue(okResponse()), config(), jest.fn(), undefined, notify)
|
||||||
|
.registerInvoiceWithEims(INVOICE_ID);
|
||||||
|
|
||||||
|
expect(notify).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets the filing outcome stand even if the alert itself fails", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()]);
|
||||||
|
const notify = jest.fn().mockRejectedValue(new Error("inbox down"));
|
||||||
|
const postSigned = jest.fn().mockRejectedValue(apiError("RULE_VALIDATION", 406));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
build(db, postSigned, config(), jest.fn(), undefined, notify).registerInvoiceWithEims(
|
||||||
|
INVOICE_ID,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/EIMS register failed \(406\)/);
|
||||||
|
|
||||||
|
expect(db.invoices.get(INVOICE_ID)!.eimsStatus).toBe(EimsInvoiceStatus.Failed);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("EimsInvoiceRegistrationService.verifyInvoiceWithEims", () => {
|
||||||
|
it("verifies the stored IRN over the unsigned bearer transport", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow({ eimsIrn: IRN })]);
|
||||||
|
const postSigned = jest.fn();
|
||||||
|
const postBearer = jest.fn().mockResolvedValue(verifyResponse());
|
||||||
|
|
||||||
|
const result = await build(db, postSigned, config(), postBearer).verifyInvoiceWithEims(
|
||||||
|
INVOICE_ID,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Lowercase `irn`, raw body — not a signed envelope. `postSigned` must stay untouched.
|
||||||
|
expect(postBearer).toHaveBeenCalledWith("/v1/verify", { irn: IRN });
|
||||||
|
expect(postSigned).not.toHaveBeenCalled();
|
||||||
|
expect(result.body).toMatchObject({ Irn: IRN });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a 200 that carries no Irn", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow({ eimsIrn: IRN })]);
|
||||||
|
const postBearer = jest.fn().mockResolvedValue({ statusCode: 200, body: { Irn: " " } });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
build(db, jest.fn(), config(), postBearer).verifyInvoiceWithEims(INVOICE_ID),
|
||||||
|
).rejects.toThrow(/returned no Irn/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to verify an invoice with no IRN", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow({ eimsStatus: EimsInvoiceStatus.Unknown })]);
|
||||||
|
const postBearer = jest.fn();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
build(db, jest.fn(), config(), postBearer).verifyInvoiceWithEims(INVOICE_ID),
|
||||||
|
).rejects.toThrow(/no EIMS IRN to verify/);
|
||||||
|
expect(postBearer).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => {
|
||||||
|
const blocked = () =>
|
||||||
|
new FakeDb(
|
||||||
|
[
|
||||||
|
invoiceRow({
|
||||||
|
eimsStatus: EimsInvoiceStatus.Unknown,
|
||||||
|
eimsInvoiceCounter: 7,
|
||||||
|
eimsDocumentNumber: DOCUMENT_NUMBER,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
inFlightInvoiceId: INVOICE_ID,
|
||||||
|
inFlightCounter: 7,
|
||||||
|
nextInvoiceCounter: 8,
|
||||||
|
blockedReason: "never acknowledged",
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records a confirmed IRN, resumes the chain and clears the block", async () => {
|
||||||
|
const db = blocked();
|
||||||
|
const postBearer = jest.fn().mockResolvedValue(verifyResponse());
|
||||||
|
|
||||||
|
const view = await build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(
|
||||||
|
INVOICE_ID,
|
||||||
|
{ irn: IRN },
|
||||||
|
);
|
||||||
|
|
||||||
|
// The IRN is confirmed at the gateway before it is ever written.
|
||||||
|
expect(postBearer).toHaveBeenCalledWith("/v1/verify", { irn: IRN });
|
||||||
|
expect(view).toMatchObject({ eimsStatus: EimsInvoiceStatus.Registered, eimsIrn: IRN });
|
||||||
|
expect(db.state).toMatchObject({
|
||||||
|
previousIrn: IRN,
|
||||||
|
inFlightInvoiceId: null,
|
||||||
|
blockedReason: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an IRN the gateway answers with a different one, leaving the block intact", async () => {
|
||||||
|
const db = blocked();
|
||||||
|
const postBearer = jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(verifyResponse({ Irn: "0000000000000000000000000000000000000000" }));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(INVOICE_ID, { irn: IRN }),
|
||||||
|
).rejects.toThrow(/answered the lookup for IRN/);
|
||||||
|
|
||||||
|
expect(db.invoices.get(INVOICE_ID)).toMatchObject({
|
||||||
|
eimsStatus: EimsInvoiceStatus.Unknown,
|
||||||
|
eimsIrn: null,
|
||||||
|
});
|
||||||
|
expect(db.state).toMatchObject({
|
||||||
|
inFlightInvoiceId: INVOICE_ID,
|
||||||
|
blockedReason: "never acknowledged",
|
||||||
|
previousIrn: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an IRN whose document number is not this invoice, leaving the block intact", async () => {
|
||||||
|
const db = blocked();
|
||||||
|
const postBearer = jest.fn().mockResolvedValue(
|
||||||
|
verifyResponse({
|
||||||
|
DocumentDetails: { Type: "INV", DocumentNumber: "99999" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(INVOICE_ID, { irn: IRN }),
|
||||||
|
).rejects.toThrow(/not 5/);
|
||||||
|
|
||||||
|
expect(db.invoices.get(INVOICE_ID)).toMatchObject({
|
||||||
|
eimsStatus: EimsInvoiceStatus.Unknown,
|
||||||
|
eimsIrn: null,
|
||||||
|
});
|
||||||
|
expect(db.state).toMatchObject({
|
||||||
|
inFlightInvoiceId: INVOICE_ID,
|
||||||
|
blockedReason: "never acknowledged",
|
||||||
|
previousIrn: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an IRN the gateway does not acknowledge at all", async () => {
|
||||||
|
const db = blocked();
|
||||||
|
const postBearer = jest.fn().mockResolvedValue({ statusCode: 200, body: {} });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(INVOICE_ID, { irn: IRN }),
|
||||||
|
).rejects.toThrow(/returned no Irn/);
|
||||||
|
expect(db.state!.blockedReason).toBe("never acknowledged");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("discards the attempt, leaving the chain where it was", async () => {
|
||||||
|
const db = blocked();
|
||||||
|
const postBearer = jest.fn();
|
||||||
|
|
||||||
|
const view = await build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(
|
||||||
|
INVOICE_ID,
|
||||||
|
{ discard: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(view).toMatchObject({ eimsStatus: EimsInvoiceStatus.Failed, eimsIrn: null });
|
||||||
|
expect(postBearer).not.toHaveBeenCalled(); // nothing to confirm
|
||||||
|
expect(db.state).toMatchObject({
|
||||||
|
previousIrn: null,
|
||||||
|
inFlightInvoiceId: null,
|
||||||
|
blockedReason: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to resolve an invoice that is not the in-flight one", async () => {
|
||||||
|
const db = blocked();
|
||||||
|
db.invoices.set(
|
||||||
|
OTHER_INVOICE_ID,
|
||||||
|
invoiceRow({ id: OTHER_INVOICE_ID, eimsDocumentNumber: "6" }),
|
||||||
|
);
|
||||||
|
const postBearer = jest.fn().mockResolvedValue(verifyResponse());
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(OTHER_INVOICE_ID, {
|
||||||
|
irn: IRN,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/in-flight EIMS submission is invoice/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires either an IRN or an explicit discard", async () => {
|
||||||
|
await expect(
|
||||||
|
build(blocked(), jest.fn()).resolveEimsRegistration(INVOICE_ID, {}),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,588 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { InjectDataSource } from "@nestjs/typeorm";
|
||||||
|
import { DataSource, EntityManager } from "typeorm";
|
||||||
|
import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity.js";
|
||||||
|
|
||||||
|
import { EimsConfig } from "../../config/eims.config";
|
||||||
|
import { Invoice } from "../billing/entities/invoice.entity";
|
||||||
|
import {
|
||||||
|
EimsInvoiceRequest,
|
||||||
|
EimsMapperLine,
|
||||||
|
toEimsInvoice,
|
||||||
|
} from "../billing/eims-invoice.mapper";
|
||||||
|
import { NotificationAudience, NotificationPriority, NotificationType } from "@edr/types";
|
||||||
|
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
|
||||||
|
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||||
|
import { EimsAuthService } from "./eims-auth.service";
|
||||||
|
import { EimsClientService } from "./eims-client.service";
|
||||||
|
import { EimsApiException } from "./eims.errors";
|
||||||
|
import { EimsSystemState } from "./entities/eims-system-state.entity";
|
||||||
|
import {
|
||||||
|
assertEimsInvoiceConfig,
|
||||||
|
buildEimsContext,
|
||||||
|
buildEimsSeller,
|
||||||
|
} from "./eims-invoice-context";
|
||||||
|
import {
|
||||||
|
EimsInvoiceError,
|
||||||
|
EimsInvoiceStatus,
|
||||||
|
EimsInvoiceStatusView,
|
||||||
|
EimsRegisterResponse,
|
||||||
|
EimsVerifyRequest,
|
||||||
|
EimsVerifyResponse,
|
||||||
|
} from "./eims-registration.types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Failure kinds where the gateway gave a complete answer: the document was rejected and is
|
||||||
|
* definitively not registered. These clear the system-wide block; anything else does not.
|
||||||
|
*/
|
||||||
|
const DETERMINISTIC_KINDS = new Set(["SCHEMA_VALIDATION", "RULE_VALIDATION", "AUTH", "FORBIDDEN"]);
|
||||||
|
|
||||||
|
interface Reservation {
|
||||||
|
stateId: string;
|
||||||
|
invoiceCounter: number;
|
||||||
|
/** MoR requires a plain integer here, so it cannot be our own `invoiceNumber`. */
|
||||||
|
documentNumber: string;
|
||||||
|
previousIrn: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a single invoice with MoR EIMS.
|
||||||
|
*
|
||||||
|
* Sequencing is a **durable reservation**: the counter is consumed and the holder recorded in a
|
||||||
|
* committed transaction *before* the request leaves the process, and the network call happens
|
||||||
|
* outside any transaction. That gives three properties the naive design could not:
|
||||||
|
*
|
||||||
|
* - a counter is never reused once an attempt has begun, even across a crash;
|
||||||
|
* - a crash mid-flight leaves the reservation standing, so nothing blindly resubmits a document
|
||||||
|
* that may already have reached MoR;
|
||||||
|
* - an ambiguous result blocks every invoice for the system number, not just its own, because
|
||||||
|
* `PreviousIrn` is unknown and any later document would chain to a stale IRN.
|
||||||
|
*
|
||||||
|
* Signing, authentication and error normalisation belong to `EimsClientService`. Manual only —
|
||||||
|
* nothing in invoice creation calls this.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class EimsInvoiceRegistrationService {
|
||||||
|
private readonly logger = new Logger(EimsInvoiceRegistrationService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectDataSource() private readonly dataSource: DataSource,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
private readonly client: EimsClientService,
|
||||||
|
private readonly auth: EimsAuthService,
|
||||||
|
private readonly inbox: NotificationInboxService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private get cfg(): EimsConfig {
|
||||||
|
return this.config.get<EimsConfig>("eims")!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async registerInvoiceWithEims(invoiceId: string): Promise<EimsInvoiceStatusView> {
|
||||||
|
const cfg = this.cfg;
|
||||||
|
// Static seller/tax configuration is validated before anything is locked, allocated or sent.
|
||||||
|
assertEimsInvoiceConfig(cfg);
|
||||||
|
|
||||||
|
const invoice = await this.loadInvoiceForMapping(invoiceId);
|
||||||
|
if (invoice.eimsIrn) return this.toView(invoice);
|
||||||
|
|
||||||
|
// Authenticate before reserving: the source system comes from the token, and the state row is
|
||||||
|
// keyed by it. A login failure here costs nothing — no counter has been consumed yet.
|
||||||
|
const session = await this.auth.getSessionContext();
|
||||||
|
|
||||||
|
const reservation = await this.reserve(invoiceId, session.systemNumber);
|
||||||
|
if (!reservation) return this.getEimsStatus(invoiceId);
|
||||||
|
|
||||||
|
// The request can only be built now: InvoiceCounter and PreviousIrn come from the reservation.
|
||||||
|
const request = toEimsInvoice(
|
||||||
|
invoice,
|
||||||
|
buildEimsSeller(cfg),
|
||||||
|
buildEimsContext(cfg, {
|
||||||
|
// Allocated from the system state, not our invoiceNumber: MoR validates DocumentNumber
|
||||||
|
// against ^(0|[1-9][0-9]{0,8})$, which "INV-20260807-00006" can never satisfy.
|
||||||
|
documentNumber: reservation.documentNumber,
|
||||||
|
invoiceCounter: reservation.invoiceCounter,
|
||||||
|
previousIrn: reservation.previousIrn,
|
||||||
|
session,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let irn: string;
|
||||||
|
let ackDate: string | undefined;
|
||||||
|
try {
|
||||||
|
// Deliberately outside every transaction — no DB lock is held across the wire.
|
||||||
|
const result = await this.submit(request);
|
||||||
|
irn = result.irn;
|
||||||
|
ackDate = result.ackDate;
|
||||||
|
} catch (err) {
|
||||||
|
await this.settleFailure(invoiceId, reservation, err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.settleSuccess(invoiceId, reservation, irn, ackDate);
|
||||||
|
this.logger.log(
|
||||||
|
`Invoice ${invoice.invoiceNumber} registered with EIMS (counter ${reservation.invoiceCounter})`,
|
||||||
|
);
|
||||||
|
return this.getEimsStatus(invoiceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a registered invoice at `POST /v1/verify`.
|
||||||
|
*
|
||||||
|
* Requires a stored IRN. An invoice whose submission was never acknowledged cannot be reconciled
|
||||||
|
* here — the gateway offers no lookup by document number — so it must be resolved with MoR and
|
||||||
|
* recorded through `resolveEimsRegistration`.
|
||||||
|
*/
|
||||||
|
async verifyInvoiceWithEims(invoiceId: string): Promise<EimsVerifyResponse> {
|
||||||
|
const invoice = await this.loadInvoiceRow(this.dataSource.manager, invoiceId);
|
||||||
|
if (!invoice.eimsIrn) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: "EIMS_NO_IRN",
|
||||||
|
message:
|
||||||
|
`Invoice ${invoice.invoiceNumber} has no EIMS IRN to verify (status ${invoice.eimsStatus}). ` +
|
||||||
|
"EIMS can only be queried by IRN, so an unacknowledged submission must be resolved with MoR first.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.queryVerify(invoice.eimsIrn);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `POST /v1/verify` for one IRN, with the one check that always applies: the gateway must echo
|
||||||
|
* an `Irn` back. A 200 without it is not a confirmation of anything.
|
||||||
|
*
|
||||||
|
* The request property is lowercase `irn`; the response spells it `Irn`. The two are never
|
||||||
|
* compared — the supplied collection's own fixture uses different example values on each side,
|
||||||
|
* so equality there would assert a property of the mock rather than of the gateway.
|
||||||
|
*
|
||||||
|
* Bearer-authenticated but unsigned, via `postBearer` — see that method for why.
|
||||||
|
*/
|
||||||
|
private async queryVerify(irn: string): Promise<EimsVerifyResponse> {
|
||||||
|
const response = await this.client.postBearer<EimsVerifyRequest, EimsVerifyResponse>(
|
||||||
|
"/v1/verify",
|
||||||
|
{ irn },
|
||||||
|
);
|
||||||
|
if (!response?.body?.Irn?.trim()) {
|
||||||
|
throw new EimsApiException(
|
||||||
|
"SCHEMA_VALIDATION",
|
||||||
|
"EIMS verify returned no Irn in its response body",
|
||||||
|
response?.statusCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refuse a manual resolution unless the gateway confirms *both* halves of the claim: that this
|
||||||
|
* IRN is the one it holds, and that it belongs to this invoice.
|
||||||
|
*
|
||||||
|
* The document-number check is against `DocumentDetails.DocumentNumber`, which registration
|
||||||
|
* allocated and stored on the invoice as `eimsDocumentNumber` — the only field tying an IRN back
|
||||||
|
* to a row in this database.
|
||||||
|
*
|
||||||
|
* Recording a wrong IRN is not a local mistake: it marks an unregistered invoice as filed and
|
||||||
|
* chains every later document to a stranger's reference, so both checks are refusals rather
|
||||||
|
* than warnings.
|
||||||
|
*/
|
||||||
|
private async assertIrnBelongsToInvoice(
|
||||||
|
irn: string,
|
||||||
|
expectedDocumentNumber: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await this.queryVerify(irn);
|
||||||
|
const returnedIrn = response.body?.Irn?.trim();
|
||||||
|
const documentNumber = response.body?.DocumentDetails?.DocumentNumber?.trim();
|
||||||
|
|
||||||
|
if (returnedIrn !== irn) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: "EIMS_RESOLVE_IRN_MISMATCH",
|
||||||
|
message:
|
||||||
|
`EIMS answered the lookup for IRN ${irn} with ${returnedIrn ?? "(none)"}. ` +
|
||||||
|
"Refusing to record it — recheck the IRN in the MoR portal.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (documentNumber !== expectedDocumentNumber) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: "EIMS_RESOLVE_DOCUMENT_MISMATCH",
|
||||||
|
message:
|
||||||
|
`EIMS reports IRN ${irn} against document ${documentNumber ?? "(none)"}, not ` +
|
||||||
|
`${expectedDocumentNumber}. Refusing to record it — recheck the IRN in the MoR portal.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manual reconciliation of a blocked system number.
|
||||||
|
*
|
||||||
|
* With an `irn` (found in the MoR portal) the invoice is recorded as registered and the chain
|
||||||
|
* resumes from it. With `discard` the invoice is marked failed and the chain resumes from the
|
||||||
|
* previous IRN. Either way the block is cleared — this is the only exit from an ambiguous result.
|
||||||
|
*
|
||||||
|
* An IRN is never taken on trust: it is verified at the gateway first, and the document it
|
||||||
|
* belongs to must be *this* invoice. A transposed digit would otherwise chain every later
|
||||||
|
* document to a stranger's IRN and mark this invoice registered when it is not.
|
||||||
|
*/
|
||||||
|
async resolveEimsRegistration(
|
||||||
|
invoiceId: string,
|
||||||
|
input: { irn?: string; discard?: boolean },
|
||||||
|
): Promise<EimsInvoiceStatusView> {
|
||||||
|
const irn = input.irn?.trim();
|
||||||
|
if (!irn && !input.discard) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: "EIMS_RESOLVE_INPUT_REQUIRED",
|
||||||
|
message: "Provide the IRN confirmed with MoR, or discard: true to abandon the submission",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cheap ownership check before touching the gateway: resolving an invoice that does not hold
|
||||||
|
// the reservation is a caller mistake, not something to spend a MoR round trip on. The
|
||||||
|
// authoritative re-check happens under lock in the transaction below.
|
||||||
|
const [preState]: { in_flight_invoice_id: string | null }[] = await this.dataSource.query(
|
||||||
|
`SELECT in_flight_invoice_id FROM freight.eims_system_state
|
||||||
|
WHERE system_number = $1 AND deleted_at IS NULL LIMIT 1`,
|
||||||
|
[(await this.auth.getSessionContext()).systemNumber],
|
||||||
|
);
|
||||||
|
if (preState?.in_flight_invoice_id && preState.in_flight_invoice_id !== invoiceId) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: "EIMS_RESOLVE_WRONG_INVOICE",
|
||||||
|
message: `The in-flight EIMS submission is invoice ${preState.in_flight_invoice_id}, not ${invoiceId}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outside the transaction: no lock is held across the wire, and a refused verification must
|
||||||
|
// leave the block exactly as it was.
|
||||||
|
if (irn) {
|
||||||
|
const invoice = await this.loadInvoiceRow(this.dataSource.manager, invoiceId);
|
||||||
|
if (!invoice.eimsDocumentNumber) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: "EIMS_NO_DOCUMENT_NUMBER",
|
||||||
|
message:
|
||||||
|
`Invoice ${invoice.invoiceNumber} was never allocated an EIMS document number, so a ` +
|
||||||
|
"returned IRN cannot be tied back to it.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await this.assertIrnBelongsToInvoice(irn, invoice.eimsDocumentNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same source of truth as registration: the state row is keyed by the token's system number.
|
||||||
|
const session = await this.auth.getSessionContext();
|
||||||
|
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
const state = await this.lockSystemState(manager, session.systemNumber);
|
||||||
|
if (state.inFlightInvoiceId && state.inFlightInvoiceId !== invoiceId) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: "EIMS_RESOLVE_WRONG_INVOICE",
|
||||||
|
message: `The in-flight EIMS submission is invoice ${state.inFlightInvoiceId}, not ${invoiceId}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const invoice = await this.lockInvoice(manager, invoiceId);
|
||||||
|
if (invoice.eimsIrn) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: "EIMS_ALREADY_REGISTERED",
|
||||||
|
message: `Invoice ${invoice.invoiceNumber} already has IRN ${invoice.eimsIrn}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await manager.update(Invoice, invoiceId, {
|
||||||
|
eimsStatus: irn ? EimsInvoiceStatus.Registered : EimsInvoiceStatus.Failed,
|
||||||
|
eimsIrn: irn ?? null,
|
||||||
|
});
|
||||||
|
await manager.update(EimsSystemState, state.id, {
|
||||||
|
// Only a confirmed IRN may advance the chain; a discard leaves it where it was.
|
||||||
|
...(irn ? { previousIrn: irn } : {}),
|
||||||
|
inFlightInvoiceId: null,
|
||||||
|
inFlightCounter: null,
|
||||||
|
inFlightDocumentNumber: null,
|
||||||
|
blockedReason: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.warn(
|
||||||
|
`EIMS block on invoice ${invoiceId} resolved manually (${irn ? "IRN recorded" : "discarded"})`,
|
||||||
|
);
|
||||||
|
return this.getEimsStatus(invoiceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEimsStatus(invoiceId: string): Promise<EimsInvoiceStatusView> {
|
||||||
|
return this.toView(await this.loadInvoiceRow(this.dataSource.manager, invoiceId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── transactions ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TX1. Consume a counter and record the holder, committed before any HTTP call. Returns `null`
|
||||||
|
* when the invoice turned out to be registered already (checked under the lock).
|
||||||
|
*/
|
||||||
|
private async reserve(invoiceId: string, systemNumber: string): Promise<Reservation | null> {
|
||||||
|
return this.dataSource.transaction(async (manager) => {
|
||||||
|
const state = await this.lockSystemState(manager, systemNumber);
|
||||||
|
|
||||||
|
if (state.blockedReason) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: "EIMS_SYSTEM_BLOCKED",
|
||||||
|
message:
|
||||||
|
`EIMS registration is blocked for system ${systemNumber}: ${state.blockedReason}. ` +
|
||||||
|
"Resolve the affected invoice before registering anything else.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (state.inFlightInvoiceId) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: "EIMS_SUBMISSION_IN_FLIGHT",
|
||||||
|
message:
|
||||||
|
`A submission for invoice ${state.inFlightInvoiceId} is already in flight on system ` +
|
||||||
|
`${systemNumber}. Wait for it to settle, or resolve it if the process was interrupted.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoice = await this.lockInvoice(manager, invoiceId);
|
||||||
|
if (invoice.eimsIrn) return null;
|
||||||
|
|
||||||
|
const invoiceCounter = Number(state.nextInvoiceCounter);
|
||||||
|
const documentNumber = String(Number(state.nextDocumentNumber));
|
||||||
|
const previousIrn = state.previousIrn ?? "";
|
||||||
|
|
||||||
|
// Counter consumed here, not on success: once an attempt begins it can never be reused,
|
||||||
|
// whatever happens next. A gap is harmless at MoR; a collision is not.
|
||||||
|
await manager.update(EimsSystemState, state.id, {
|
||||||
|
nextInvoiceCounter: invoiceCounter + 1,
|
||||||
|
nextDocumentNumber: Number(documentNumber) + 1,
|
||||||
|
inFlightInvoiceId: invoiceId,
|
||||||
|
inFlightCounter: invoiceCounter,
|
||||||
|
inFlightDocumentNumber: Number(documentNumber),
|
||||||
|
});
|
||||||
|
await manager.update(Invoice, invoiceId, {
|
||||||
|
eimsStatus: EimsInvoiceStatus.Submitting,
|
||||||
|
eimsInvoiceCounter: invoiceCounter,
|
||||||
|
eimsDocumentNumber: documentNumber,
|
||||||
|
eimsSubmittedAt: new Date(),
|
||||||
|
eimsLastError: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { stateId: state.id, invoiceCounter, documentNumber, previousIrn };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TX2a. Record the IRN, advance the chain, release the reservation. */
|
||||||
|
private async settleSuccess(
|
||||||
|
invoiceId: string,
|
||||||
|
reservation: Reservation,
|
||||||
|
irn: string,
|
||||||
|
ackDate?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
await this.lockInvoice(manager, invoiceId);
|
||||||
|
await manager.update(Invoice, invoiceId, {
|
||||||
|
eimsStatus: EimsInvoiceStatus.Registered,
|
||||||
|
eimsIrn: irn,
|
||||||
|
eimsAckDate: ackDate ?? null,
|
||||||
|
eimsLastError: null,
|
||||||
|
});
|
||||||
|
await manager.update(EimsSystemState, reservation.stateId, {
|
||||||
|
previousIrn: irn,
|
||||||
|
inFlightInvoiceId: null,
|
||||||
|
inFlightCounter: null,
|
||||||
|
inFlightDocumentNumber: null,
|
||||||
|
blockedReason: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TX2b. A deterministic rejection releases the reservation **and returns the counter**; an
|
||||||
|
* ambiguous result keeps both and blocks the system number, because `PreviousIrn` is now unknown
|
||||||
|
* for every later document.
|
||||||
|
*
|
||||||
|
* The two numbers move differently, because MoR constrains them differently:
|
||||||
|
*
|
||||||
|
* - `InvoiceCounter` must not **skip** — "Invoice counter is not correct. expected : 1". A
|
||||||
|
* document MoR definitively refused was never counted there, so ours must not advance either.
|
||||||
|
* - `DocumentNumber` must not **repeat** — the documented rule is "Document number is not
|
||||||
|
* unique". It is therefore spent by the attempt itself and never handed back, even for a
|
||||||
|
* refusal.
|
||||||
|
*
|
||||||
|
* An ambiguous result keeps both: MoR may have counted and stored the document.
|
||||||
|
*/
|
||||||
|
private async settleFailure(
|
||||||
|
invoiceId: string,
|
||||||
|
reservation: Reservation,
|
||||||
|
err: unknown,
|
||||||
|
): Promise<void> {
|
||||||
|
const api = err instanceof EimsApiException ? err : null;
|
||||||
|
const deterministic = api ? DETERMINISTIC_KINDS.has(api.kind) : false;
|
||||||
|
const status = deterministic ? EimsInvoiceStatus.Failed : EimsInvoiceStatus.Unknown;
|
||||||
|
const lastError: EimsInvoiceError = {
|
||||||
|
kind: api?.kind ?? "UNKNOWN",
|
||||||
|
message: (err as Error)?.message ?? "unknown error",
|
||||||
|
httpStatus: api?.httpStatus,
|
||||||
|
details: api?.details,
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
await manager.update(Invoice, invoiceId, {
|
||||||
|
eimsStatus: status,
|
||||||
|
eimsLastError: lastError,
|
||||||
|
} as QueryDeepPartialEntity<Invoice>);
|
||||||
|
|
||||||
|
await manager.update(
|
||||||
|
EimsSystemState,
|
||||||
|
reservation.stateId,
|
||||||
|
deterministic
|
||||||
|
? {
|
||||||
|
// Counter returns (MoR never counted a refused document); the document number does
|
||||||
|
// not (MoR requires it to be unique, so it is burned by the attempt).
|
||||||
|
nextInvoiceCounter: reservation.invoiceCounter,
|
||||||
|
inFlightInvoiceId: null,
|
||||||
|
inFlightCounter: null,
|
||||||
|
inFlightDocumentNumber: null,
|
||||||
|
blockedReason: null,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
blockedReason:
|
||||||
|
`Invoice ${invoiceId} was submitted with counter ${reservation.invoiceCounter} but ` +
|
||||||
|
`never acknowledged (${lastError.kind}). Its IRN is unknown, so no further document ` +
|
||||||
|
"can be chained until it is resolved with MoR.",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.error(`Invoice ${invoiceId} EIMS registration ${status}: ${lastError.message}`);
|
||||||
|
await this.alertStaff(invoiceId, status, lastError, deterministic);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tell the people who can act about a failed filing.
|
||||||
|
*
|
||||||
|
* An ambiguous result is the urgent one: it blocks *every* further invoice for this system
|
||||||
|
* number until a human resolves it, and nothing else in the system would surface that — the
|
||||||
|
* sweep just goes quiet. A deterministic rejection affects one invoice, so it is normal
|
||||||
|
* priority. Never throws: an alert that fails must not mask the filing outcome.
|
||||||
|
*/
|
||||||
|
private async alertStaff(
|
||||||
|
invoiceId: string,
|
||||||
|
status: EimsInvoiceStatus,
|
||||||
|
error: EimsInvoiceError,
|
||||||
|
deterministic: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.inbox.notify({
|
||||||
|
recipients: { permissionKeys: [FREIGHT_PERMS.invoices.eimsResolve] },
|
||||||
|
audience: NotificationAudience.BACKOFFICE,
|
||||||
|
type: NotificationType.GENERIC,
|
||||||
|
priority: deterministic ? NotificationPriority.NORMAL : NotificationPriority.HIGH,
|
||||||
|
title: deterministic
|
||||||
|
? "EIMS rejected an invoice"
|
||||||
|
: "EIMS filing unresolved — all further filing is blocked",
|
||||||
|
body: deterministic
|
||||||
|
? `MoR rejected the filing (${error.kind}): ${error.message}. The invoice is marked FAILED; correct it and file again.`
|
||||||
|
: `A submission was sent but never acknowledged (${error.kind}). Its IRN is unknown, so no further invoice can be filed until it is resolved with MoR.`,
|
||||||
|
link: `/dashboard/invoices/${invoiceId}`,
|
||||||
|
data: { invoiceId, eimsStatus: status, kind: error.kind, action: "EIMS_FILING_FAILED" },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`EIMS staff alert failed for invoice ${invoiceId}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── internals ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** A non-empty IRN is the only success signal; anything else is a failed registration. */
|
||||||
|
private async submit(request: EimsInvoiceRequest): Promise<{ irn: string; ackDate?: string }> {
|
||||||
|
const response = await this.client.postSigned<EimsInvoiceRequest, EimsRegisterResponse>(
|
||||||
|
"/v1/register",
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
const irn = response?.body?.irn;
|
||||||
|
if (!irn) {
|
||||||
|
// The gateway answered, so this is deterministic: the document is not registered.
|
||||||
|
throw new EimsApiException(
|
||||||
|
"SCHEMA_VALIDATION",
|
||||||
|
`EIMS register returned no IRN${response?.body?.errorMessage ? `: ${response.body.errorMessage}` : ""}`,
|
||||||
|
response?.statusCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { irn, ackDate: response.body?.ackDate };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async lockInvoice(manager: EntityManager, invoiceId: string): Promise<Invoice> {
|
||||||
|
const invoice = await manager
|
||||||
|
.createQueryBuilder(Invoice, "invoice")
|
||||||
|
.setLock("pessimistic_write")
|
||||||
|
.where("invoice.id = :invoiceId", { invoiceId })
|
||||||
|
.getOne();
|
||||||
|
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||||
|
return invoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Locks the system-state row, creating it on first use. */
|
||||||
|
private async lockSystemState(
|
||||||
|
manager: EntityManager,
|
||||||
|
systemNumber: string,
|
||||||
|
): Promise<EimsSystemState> {
|
||||||
|
const select = () =>
|
||||||
|
manager
|
||||||
|
.createQueryBuilder(EimsSystemState, "state")
|
||||||
|
.setLock("pessimistic_write")
|
||||||
|
.where("state.system_number = :systemNumber", { systemNumber })
|
||||||
|
.getOne();
|
||||||
|
|
||||||
|
const existing = await select();
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
await manager.query(
|
||||||
|
`INSERT INTO freight.eims_system_state (system_number) VALUES ($1)
|
||||||
|
ON CONFLICT (system_number) DO NOTHING`,
|
||||||
|
[systemNumber],
|
||||||
|
);
|
||||||
|
const created = await select();
|
||||||
|
if (!created) throw new Error(`Could not initialise EIMS system state for ${systemNumber}`);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Header + buyer + lines — everything the mapper needs. */
|
||||||
|
private async loadInvoiceForMapping(
|
||||||
|
invoiceId: string,
|
||||||
|
): Promise<Invoice & { lines: EimsMapperLine[] }> {
|
||||||
|
const invoice = await this.dataSource.getRepository(Invoice).findOne({
|
||||||
|
where: { id: invoiceId },
|
||||||
|
relations: { company: true, companyProfile: true },
|
||||||
|
});
|
||||||
|
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||||
|
|
||||||
|
const lines: EimsMapperLine[] = await this.dataSource.query(
|
||||||
|
`SELECT charge_type AS "chargeType", description, quantity, unit_rate AS "unitRate",
|
||||||
|
amount, currency, metadata
|
||||||
|
FROM freight.invoice_lines
|
||||||
|
WHERE invoice_id = $1 AND deleted_at IS NULL
|
||||||
|
ORDER BY created_at ASC`,
|
||||||
|
[invoiceId],
|
||||||
|
);
|
||||||
|
return Object.assign(invoice, { lines });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadInvoiceRow(manager: EntityManager, invoiceId: string): Promise<Invoice> {
|
||||||
|
const invoice = await manager.findOne(Invoice, { where: { id: invoiceId } });
|
||||||
|
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||||
|
return invoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toView(invoice: Invoice): EimsInvoiceStatusView {
|
||||||
|
const counter = invoice.eimsInvoiceCounter;
|
||||||
|
return {
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
invoiceNumber: invoice.invoiceNumber,
|
||||||
|
eimsStatus: invoice.eimsStatus ?? EimsInvoiceStatus.NotSubmitted,
|
||||||
|
eimsIrn: invoice.eimsIrn ?? null,
|
||||||
|
eimsDocumentNumber: invoice.eimsDocumentNumber ?? null,
|
||||||
|
eimsInvoiceCounter: counter === null || counter === undefined ? null : Number(counter),
|
||||||
|
eimsSubmittedAt: invoice.eimsSubmittedAt ?? null,
|
||||||
|
eimsAckDate: invoice.eimsAckDate ?? null,
|
||||||
|
eimsLastError: invoice.eimsLastError ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { Body, Controller, Get, Param, ParseUUIDPipe, Post } from "@nestjs/common";
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
|
|
||||||
|
import { BookingStaff } from "../../common/booking-guards";
|
||||||
|
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||||
|
import { ResolveEimsRegistrationDto } from "./dto/resolve-eims-registration.dto";
|
||||||
|
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manual EIMS actions on an existing invoice.
|
||||||
|
*
|
||||||
|
* Invoices are produced by the freight workflow, not by a person, so these routes are **not** the
|
||||||
|
* normal production path — they exist for controlled testing and exceptional operations. Automatic
|
||||||
|
* submission after an invoice is issued is a separate phase; nothing here is called by it.
|
||||||
|
*
|
||||||
|
* `eims_register` and `eims_resolve` are intentionally left out of every role preset and assigned
|
||||||
|
* to named admins instead. They are also separate permissions: resolving clears the system-wide
|
||||||
|
* chain block and can record an IRN against an invoice, which is a supervisor action, not an
|
||||||
|
* operational one. Only `eims/status` rides on the ordinary `invoices:view`.
|
||||||
|
*
|
||||||
|
* Filing gets its own permission (`invoices:eims_register`) rather than riding on an existing key:
|
||||||
|
* registration is irreversible at MoR, so it must not follow from the right to download a PDF.
|
||||||
|
* The key is seeded through FINANCE_PERMISSIONS, which reaches `iam.permissions` via
|
||||||
|
* ADVANCED_BACKOFFICE_PERMISSIONS → BOOKING_RULE_ENGINE_PERMISSIONS → EDR_FREIGHT_PERMISSIONS.
|
||||||
|
*/
|
||||||
|
@ApiTags("eims")
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller("invoices")
|
||||||
|
export class EimsInvoiceController {
|
||||||
|
constructor(private readonly registration: EimsInvoiceRegistrationService) {}
|
||||||
|
|
||||||
|
@Post(":id/eims/register")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.invoices.eimsRegister)
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Register the invoice with MoR EIMS. Idempotent — an invoice that already has an IRN is returned unchanged.",
|
||||||
|
})
|
||||||
|
register(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
|
return this.registration.registerInvoiceWithEims(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":id/eims/verify")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.invoices.eimsRegister)
|
||||||
|
@ApiOperation({ summary: "Verify the invoice's stored IRN against EIMS" })
|
||||||
|
verify(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
|
return this.registration.verifyInvoiceWithEims(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":id/eims/resolve")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.invoices.eimsResolve)
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Resolve an unacknowledged submission: record the IRN confirmed with MoR, or discard it. Clears the system-wide block.",
|
||||||
|
})
|
||||||
|
resolve(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: ResolveEimsRegistrationDto,
|
||||||
|
) {
|
||||||
|
return this.registration.resolveEimsRegistration(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(":id/eims/status")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.invoices.view)
|
||||||
|
@ApiOperation({ summary: "EIMS registration status, IRN and last error for the invoice" })
|
||||||
|
status(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
|
return this.registration.getEimsStatus(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { createVerify, generateKeyPairSync } from "node:crypto";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { EimsCredentialsProvider } from "./eims-credentials.provider";
|
||||||
|
import { EimsSignerService, toSignedBody } from "./eims-signer.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test-only key material: generated per run, never a production key. The "certificate" fixture is
|
||||||
|
* an arbitrary byte blob — the point is that its exact bytes survive base64 round-tripping, not
|
||||||
|
* that it is a valid X.509 chain.
|
||||||
|
*/
|
||||||
|
const CERTIFICATE_FIXTURE = "Subject: CN=TEST\n-----BEGIN CERTIFICATE-----\nZm9vYmFy\n-----END CERTIFICATE-----\n";
|
||||||
|
|
||||||
|
let dir: string;
|
||||||
|
let keyPath: string;
|
||||||
|
let certPath: string;
|
||||||
|
let publicKeyPem: string;
|
||||||
|
let signer: EimsSignerService;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), "eims-signer-"));
|
||||||
|
keyPath = join(dir, "private_key.key");
|
||||||
|
certPath = join(dir, "certificate.pem.txt");
|
||||||
|
|
||||||
|
const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
|
||||||
|
writeFileSync(keyPath, privateKey.export({ type: "pkcs8", format: "pem" }));
|
||||||
|
writeFileSync(certPath, CERTIFICATE_FIXTURE, "utf8");
|
||||||
|
publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString();
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
get: () => ({ privateKeyPath: keyPath, certificatePath: certPath }),
|
||||||
|
} as unknown as ConfigService;
|
||||||
|
signer = new EimsSignerService(new EimsCredentialsProvider(config));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => rmSync(dir, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
const login = () => ({ clientId: "cid", clientSecret: "secret", apikey: "key", tin: "0000000000" });
|
||||||
|
|
||||||
|
const verify = (payload: string, signature: string): boolean =>
|
||||||
|
createVerify("RSA-SHA512").update(payload, "utf8").verify(publicKeyPem, signature, "base64");
|
||||||
|
|
||||||
|
describe("EimsSignerService", () => {
|
||||||
|
it("produces a signature that verifies against the matching public key", () => {
|
||||||
|
const signed = signer.signRequest(login());
|
||||||
|
expect(verify(JSON.stringify(signed.request), signed.signature)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails verification when a single request field changes", () => {
|
||||||
|
const signed = signer.signRequest(login());
|
||||||
|
const tampered = JSON.stringify({ ...signed.request, tin: "9999999999" });
|
||||||
|
expect(verify(tampered, signed.signature)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits a 256-byte signature for an RSA-2048 key", () => {
|
||||||
|
const signed = signer.signRequest(login());
|
||||||
|
expect(Buffer.from(signed.signature, "base64")).toHaveLength(256);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends the certificate as base64 of the file's exact bytes", () => {
|
||||||
|
const signed = signer.signRequest(login());
|
||||||
|
expect(signed.certificate).toBe(readFileSync(certPath).toString("base64"));
|
||||||
|
expect(Buffer.from(signed.certificate, "base64").equals(readFileSync(certPath))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("signs the inner request only, and the wire body carries those exact bytes", () => {
|
||||||
|
const signed = signer.signRequest(login());
|
||||||
|
const body = toSignedBody(signed);
|
||||||
|
|
||||||
|
// The signed string appears verbatim inside the transmitted envelope.
|
||||||
|
expect(body).toContain(`"request":${JSON.stringify(signed.request)}`);
|
||||||
|
// Compact, never pretty-printed.
|
||||||
|
expect(body).not.toMatch(/\n/);
|
||||||
|
expect(JSON.parse(body)).toEqual({
|
||||||
|
request: login(),
|
||||||
|
signature: signed.signature,
|
||||||
|
certificate: signed.certificate,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mutate the request object", () => {
|
||||||
|
const request = login();
|
||||||
|
const signed = signer.signRequest(request);
|
||||||
|
expect(signed.request).toBe(request);
|
||||||
|
expect(request).toEqual(login());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reuses the loaded key and certificate across calls", () => {
|
||||||
|
const first = signer.signRequest(login());
|
||||||
|
const second = signer.signRequest(login());
|
||||||
|
// PKCS#1 v1.5 is deterministic: same key + same payload ⇒ identical signature.
|
||||||
|
expect(second.signature).toBe(first.signature);
|
||||||
|
expect(second.certificate).toBe(first.certificate);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("EimsCredentialsProvider", () => {
|
||||||
|
const providerFor = (paths: { privateKeyPath?: string; certificatePath?: string }) =>
|
||||||
|
new EimsCredentialsProvider({ get: () => paths } as unknown as ConfigService);
|
||||||
|
|
||||||
|
it("fails clearly when the key path is unset", () => {
|
||||||
|
expect(() => providerFor({}).getPrivateKey()).toThrow(/EIMS_PRIVATE_KEY_PATH is not set/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails clearly when the key file is missing", () => {
|
||||||
|
expect(() => providerFor({ privateKeyPath: join(dir, "nope.key") }).getPrivateKey()).toThrow(
|
||||||
|
/could not be read or parsed/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails clearly when the certificate file is empty", () => {
|
||||||
|
const emptyPath = join(dir, "empty.txt");
|
||||||
|
writeFileSync(emptyPath, "");
|
||||||
|
expect(() => providerFor({ certificatePath: emptyPath }).getCertificateBase64()).toThrow(/is empty/);
|
||||||
|
});
|
||||||
|
});
|
||||||
37
apps/edr-freight-api/src/modules/eims/eims-signer.service.ts
Normal file
37
apps/edr-freight-api/src/modules/eims/eims-signer.service.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { createSign } from "node:crypto";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { EimsCredentialsProvider } from "./eims-credentials.provider";
|
||||||
|
import { EimsSignedRequest } from "./eims.types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signs EIMS request objects, reproducing the process that produced a working live access token:
|
||||||
|
*
|
||||||
|
* 1. compact `JSON.stringify` of the **inner** request object only,
|
||||||
|
* 2. those exact UTF-8 bytes,
|
||||||
|
* 3. RSA + SHA-512 (`SHA512withRSA`, PKCS#1 v1.5 — Node's default RSA padding),
|
||||||
|
* 4. base64 of the raw signature bytes (256 bytes for an RSA-2048 key),
|
||||||
|
* 5. base64 of the certificate file's exact bytes.
|
||||||
|
*
|
||||||
|
* The outer `{request, signature, certificate}` envelope is never itself signed, and the request
|
||||||
|
* object is never mutated after serialization.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class EimsSignerService {
|
||||||
|
constructor(private readonly credentials: EimsCredentialsProvider) {}
|
||||||
|
|
||||||
|
signRequest<T>(request: T): EimsSignedRequest<T> {
|
||||||
|
const payload = JSON.stringify(request);
|
||||||
|
const signature = createSign("RSA-SHA512")
|
||||||
|
.update(payload, "utf8")
|
||||||
|
.sign(this.credentials.getPrivateKey(), "base64");
|
||||||
|
|
||||||
|
return { request, signature, certificate: this.credentials.getCertificateBase64() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exact wire body for a signed envelope. Serializing here (rather than handing axios an object)
|
||||||
|
* keeps one serializer in play: the `request` segment of this string is byte-identical to the
|
||||||
|
* string that was signed.
|
||||||
|
*/
|
||||||
|
export const toSignedBody = <T>(signed: EimsSignedRequest<T>): string => JSON.stringify(signed);
|
||||||
80
apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts
Normal file
80
apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { EimsConfig, EimsInvoiceConfig } from "../../config/eims.config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fixtures shared by the EIMS specs.
|
||||||
|
*
|
||||||
|
* Deliberately not a `.spec.ts`: importing fixtures from a spec file makes jest execute that
|
||||||
|
* file's `describe` blocks inside every importing suite, so the same tests run — and report —
|
||||||
|
* twice.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const EIMS_SYSTEM_NUMBER = "B0360154BA";
|
||||||
|
export const EIMS_SYSTEM_TYPE = "SYS";
|
||||||
|
|
||||||
|
export const eimsInvoiceConfig = (over: Partial<EimsInvoiceConfig> = {}): EimsInvoiceConfig => ({
|
||||||
|
sellerLegalName: "Ethio-Djibouti Railway S.C.",
|
||||||
|
sellerVatNumber: "0000000000",
|
||||||
|
sellerPhone: "0911223344",
|
||||||
|
sellerEmail: "finance@example.et",
|
||||||
|
sellerRegion: "13",
|
||||||
|
sellerWereda: "574",
|
||||||
|
sellerCity: null,
|
||||||
|
sellerSubCity: null,
|
||||||
|
sellerHouseNumber: null,
|
||||||
|
sellerLocality: null,
|
||||||
|
taxCode: "VAT15",
|
||||||
|
taxRatePercent: 15,
|
||||||
|
exciseTaxValue: 0,
|
||||||
|
incomeWithholdValue: 0,
|
||||||
|
transactionWithholdValue: 0,
|
||||||
|
transactionType: "B2B",
|
||||||
|
natureOfSupplies: "Service",
|
||||||
|
paymentMode: "CASH",
|
||||||
|
paymentTerm: "IMMIDIATE",
|
||||||
|
unitDefault: "PCS",
|
||||||
|
buyerCountryCode: null,
|
||||||
|
buyerRegionCodes: { "Addis Ababa": "13" },
|
||||||
|
buyerWeredaCodes: { Yeka: "99" }, // test-only, not a real MoR code
|
||||||
|
cashierName: null,
|
||||||
|
salesPersonName: null,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const eimsConfig = (over: Partial<EimsConfig> = {}): EimsConfig => ({
|
||||||
|
enabled: true,
|
||||||
|
baseUrl: "https://core.mor.gov.et",
|
||||||
|
clientId: "cid",
|
||||||
|
clientSecret: "super-secret-value",
|
||||||
|
apiKey: "super-secret-apikey",
|
||||||
|
tin: "0000034558",
|
||||||
|
systemNumber: EIMS_SYSTEM_NUMBER,
|
||||||
|
systemType: EIMS_SYSTEM_TYPE,
|
||||||
|
privateKeyPath: "/dev/null",
|
||||||
|
certificatePath: "/dev/null",
|
||||||
|
httpTimeoutMs: 30_000,
|
||||||
|
tokenSkewMs: 45_000,
|
||||||
|
autoSubmit: false,
|
||||||
|
autoSubmitCron: "0 */5 * * * *",
|
||||||
|
autoSubmitMaxAgeDays: 3,
|
||||||
|
invoice: eimsInvoiceConfig(),
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A structurally real access token. MoR stamps the source-system identity into the JWT payload and
|
||||||
|
* `EimsAuthService` reads it from there; only the payload segment is meaningful, since the token is
|
||||||
|
* never verified locally — it is MoR's, signed with MoR's key.
|
||||||
|
*
|
||||||
|
* Pass a claim as `undefined` to omit it (spreading beats `delete`, which the defaults would undo).
|
||||||
|
*/
|
||||||
|
export const eimsToken = (claims: Record<string, unknown> = {}): string => {
|
||||||
|
const payload = { systemNumber: EIMS_SYSTEM_NUMBER, systemType: EIMS_SYSTEM_TYPE, ...claims };
|
||||||
|
for (const [key, value] of Object.entries(payload)) {
|
||||||
|
if (value === undefined) delete (payload as Record<string, unknown>)[key];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
"eyJhbGciOiJSUzI1NiJ9",
|
||||||
|
Buffer.from(JSON.stringify(payload)).toString("base64url"),
|
||||||
|
"signature",
|
||||||
|
].join(".");
|
||||||
|
};
|
||||||
89
apps/edr-freight-api/src/modules/eims/eims.errors.ts
Normal file
89
apps/edr-freight-api/src/modules/eims/eims.errors.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { BadGatewayException, ServiceUnavailableException } from "@nestjs/common";
|
||||||
|
import { AxiosError } from "axios";
|
||||||
|
import { EimsErrorResponse } from "./eims.types";
|
||||||
|
|
||||||
|
export type EimsFailureKind =
|
||||||
|
| "NETWORK"
|
||||||
|
| "TIMEOUT"
|
||||||
|
| "SCHEMA_VALIDATION"
|
||||||
|
| "AUTH"
|
||||||
|
| "FORBIDDEN"
|
||||||
|
| "RULE_VALIDATION"
|
||||||
|
| "SERVER"
|
||||||
|
| "UNKNOWN";
|
||||||
|
|
||||||
|
/** Raised when EIMS is disabled or its credential files are unusable. */
|
||||||
|
export class EimsConfigException extends ServiceUnavailableException {
|
||||||
|
constructor(message: string) {
|
||||||
|
super({ code: "EIMS_NOT_CONFIGURED", message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A failed EIMS call. Carries only the gateway's own error reporting — never the request body,
|
||||||
|
* signature, certificate, bearer token or any configured secret.
|
||||||
|
*/
|
||||||
|
export class EimsApiException extends BadGatewayException {
|
||||||
|
constructor(
|
||||||
|
readonly kind: EimsFailureKind,
|
||||||
|
message: string,
|
||||||
|
readonly httpStatus?: number,
|
||||||
|
readonly details?: EimsErrorResponse,
|
||||||
|
) {
|
||||||
|
super({ code: `EIMS_${kind}`, message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAFE_KEYS = ["message", "statusCode", "code", "details", "body"] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep only the gateway's error-reporting fields. Anything else a response might carry — an echoed
|
||||||
|
* request, a token, a signature — is dropped before it can reach a log or an exception payload.
|
||||||
|
*/
|
||||||
|
export function redactEimsBody(data: unknown): EimsErrorResponse | undefined {
|
||||||
|
if (!data || typeof data !== "object") return undefined;
|
||||||
|
const source = data as Record<string, unknown>;
|
||||||
|
const safe: Record<string, unknown> = {};
|
||||||
|
for (const key of SAFE_KEYS) {
|
||||||
|
if (source[key] !== undefined) safe[key] = source[key];
|
||||||
|
}
|
||||||
|
return Object.keys(safe).length > 0 ? (safe as EimsErrorResponse) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const kindFor = (status: number): EimsFailureKind => {
|
||||||
|
if (status === 400) return "SCHEMA_VALIDATION";
|
||||||
|
if (status === 401) return "AUTH";
|
||||||
|
if (status === 403) return "FORBIDDEN";
|
||||||
|
if (status === 406) return "RULE_VALIDATION";
|
||||||
|
if (status >= 500) return "SERVER";
|
||||||
|
return "UNKNOWN";
|
||||||
|
};
|
||||||
|
|
||||||
|
/** First error line the gateway gives us, whichever shape it used. */
|
||||||
|
const describe = (body: EimsErrorResponse | undefined): string => {
|
||||||
|
if (!body) return "no error body";
|
||||||
|
const detail = body.details?.find((d) => d.errorMessage)?.errorMessage;
|
||||||
|
return [body.message, body.code && `code=${body.code}`, detail].filter(Boolean).join(" ") || "no error body";
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise anything thrown by an EIMS HTTP call into an `EimsApiException`. `operation` is a
|
||||||
|
* short label such as `"login"` or `"POST /v1/register"` — never a payload.
|
||||||
|
*/
|
||||||
|
export function toEimsApiException(err: unknown, operation: string): EimsApiException {
|
||||||
|
if (err instanceof EimsApiException) return err;
|
||||||
|
|
||||||
|
if (err instanceof AxiosError) {
|
||||||
|
if (err.code === "ECONNABORTED" || err.code === "ETIMEDOUT") {
|
||||||
|
return new EimsApiException("TIMEOUT", `EIMS ${operation} timed out`);
|
||||||
|
}
|
||||||
|
if (!err.response) {
|
||||||
|
return new EimsApiException("NETWORK", `EIMS ${operation} could not reach the gateway (${err.code ?? "no code"})`);
|
||||||
|
}
|
||||||
|
const status = err.response.status;
|
||||||
|
const body = redactEimsBody(err.response.data);
|
||||||
|
return new EimsApiException(kindFor(status), `EIMS ${operation} failed (${status}): ${describe(body)}`, status, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new EimsApiException("UNKNOWN", `EIMS ${operation} failed: ${(err as Error)?.message ?? "unknown error"}`);
|
||||||
|
}
|
||||||
39
apps/edr-freight-api/src/modules/eims/eims.module.ts
Normal file
39
apps/edr-freight-api/src/modules/eims/eims.module.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { HttpModule } from "@nestjs/axios";
|
||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
|
|
||||||
|
import { Invoice } from "../billing/entities/invoice.entity";
|
||||||
|
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
|
||||||
|
import { EimsAuthService } from "./eims-auth.service";
|
||||||
|
import { EimsAutoSubmitService } from "./eims-auto-submit.service";
|
||||||
|
import { EimsClientService } from "./eims-client.service";
|
||||||
|
import { EimsCredentialsProvider } from "./eims-credentials.provider";
|
||||||
|
import { EimsInvoiceController } from "./eims-invoice.controller";
|
||||||
|
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
|
||||||
|
import { EimsSignerService } from "./eims-signer.service";
|
||||||
|
import { EimsSystemState } from "./entities/eims-system-state.entity";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MoR EIMS e-invoicing: signed transport, authentication, and manual single-invoice registration.
|
||||||
|
*
|
||||||
|
* Exports only what other modules will consume; the credential loader and signer stay internal so
|
||||||
|
* the private key has exactly one user. Nothing here is called from invoice creation.
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
HttpModule.register({ timeout: Number(process.env.EIMS_HTTP_TIMEOUT_MS) || 30_000 }),
|
||||||
|
TypeOrmModule.forFeature([EimsSystemState, Invoice]),
|
||||||
|
NotificationInboxModule,
|
||||||
|
],
|
||||||
|
controllers: [EimsInvoiceController],
|
||||||
|
providers: [
|
||||||
|
EimsCredentialsProvider,
|
||||||
|
EimsSignerService,
|
||||||
|
EimsAuthService,
|
||||||
|
EimsClientService,
|
||||||
|
EimsInvoiceRegistrationService,
|
||||||
|
EimsAutoSubmitService,
|
||||||
|
],
|
||||||
|
exports: [EimsAuthService, EimsClientService, EimsInvoiceRegistrationService],
|
||||||
|
})
|
||||||
|
export class EimsModule {}
|
||||||
46
apps/edr-freight-api/src/modules/eims/eims.types.ts
Normal file
46
apps/edr-freight-api/src/modules/eims/eims.types.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* Wire types for the MoR EIMS gateway, taken from the supplied Postman collection.
|
||||||
|
*
|
||||||
|
* Every protected payload is the same envelope: the business object under `request`, a base64
|
||||||
|
* RSA-SHA512 signature over the *inner* object only, and the base64 certificate bundle.
|
||||||
|
*/
|
||||||
|
export interface EimsSignedRequest<T> {
|
||||||
|
request: T;
|
||||||
|
signature: string;
|
||||||
|
certificate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inner request of `POST /auth/login`. Note the lowercase `apikey` — that is the wire name. */
|
||||||
|
export interface EimsLoginRequest {
|
||||||
|
clientId: string;
|
||||||
|
clientSecret: string;
|
||||||
|
apikey: string;
|
||||||
|
tin: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EimsLoginData {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
/** Observed as a UUID on login and `null` on refresh; unused today. */
|
||||||
|
encryptionKey: string | null;
|
||||||
|
/** Seconds. Observed value: 3600. */
|
||||||
|
expiresIn: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EimsLoginResponse {
|
||||||
|
data: EimsLoginData;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error bodies differ per failure mode: gateway errors carry `message`/`code`/`details`,
|
||||||
|
* schema errors carry a JSON-Schema violation array under `body`, rule errors carry
|
||||||
|
* `[{portion, errorMessage[]}]` under `body`. Only these fields are ever surfaced or logged.
|
||||||
|
*/
|
||||||
|
export interface EimsErrorResponse {
|
||||||
|
message?: string;
|
||||||
|
statusCode?: number;
|
||||||
|
code?: string;
|
||||||
|
details?: { errorMessage?: string; field?: string }[];
|
||||||
|
body?: unknown;
|
||||||
|
}
|
||||||
@@ -0,0 +1,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;
|
||||||
|
}
|
||||||
@@ -10,7 +10,12 @@ import { FacilitiesService } from './facilities.service';
|
|||||||
|
|
||||||
@ApiTags('Facilities')
|
@ApiTags('Facilities')
|
||||||
@Controller('facilities')
|
@Controller('facilities')
|
||||||
@BookingStaff(FREIGHT_PERMS.facilities.view)
|
// Class gate lists every key its routes use: Nest runs class AND method
|
||||||
|
// guards, so a key missing here would deny before the route's own key runs.
|
||||||
|
@BookingStaff([
|
||||||
|
FREIGHT_PERMS.facilities.view,
|
||||||
|
FREIGHT_PERMS.facilities.manage,
|
||||||
|
])
|
||||||
export class FacilitiesController {
|
export class FacilitiesController {
|
||||||
constructor(private readonly facilitiesService: FacilitiesService) {}
|
constructor(private readonly facilitiesService: FacilitiesService) {}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,12 @@ import { RegisterDeviceDto, UpdateDeviceDto } from './dto/gps-device.dto';
|
|||||||
@ApiTags('gps-tracking')
|
@ApiTags('gps-tracking')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller('gps')
|
@Controller('gps')
|
||||||
@BookingStaff(FREIGHT_PERMS.tracking.view)
|
// Class gate lists every key its routes use: Nest runs class AND method
|
||||||
|
// guards, so a key missing here would deny before the route's own key runs.
|
||||||
|
@BookingStaff([
|
||||||
|
FREIGHT_PERMS.tracking.view,
|
||||||
|
FREIGHT_PERMS.tracking.manage,
|
||||||
|
])
|
||||||
export class GpsTrackingController {
|
export class GpsTrackingController {
|
||||||
constructor(private readonly gps: GpsTrackingService) {}
|
constructor(private readonly gps: GpsTrackingService) {}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,14 @@ import { IncidentStatus, IncidentType } from './entities/incident.entity';
|
|||||||
// No incidents-specific permission exists in the registry, so this reuses the
|
// No incidents-specific permission exists in the registry, so this reuses the
|
||||||
// (real) drivers.* fleet-road keys — incident records are driver-safety data
|
// (real) drivers.* fleet-road keys — incident records are driver-safety data
|
||||||
// (driver stats / incident history). TODO: add a dedicated incidents:* key.
|
// (driver stats / incident history). TODO: add a dedicated incidents:* key.
|
||||||
@BookingStaff(FREIGHT_PERMS.drivers.view)
|
// Class gate lists every key its routes use: Nest runs class AND method
|
||||||
|
// guards, so a key missing here would deny before the route's own key runs.
|
||||||
|
@BookingStaff([
|
||||||
|
FREIGHT_PERMS.drivers.view,
|
||||||
|
FREIGHT_PERMS.drivers.create,
|
||||||
|
FREIGHT_PERMS.drivers.update,
|
||||||
|
FREIGHT_PERMS.drivers.delete,
|
||||||
|
])
|
||||||
export class IncidentsController {
|
export class IncidentsController {
|
||||||
constructor(private readonly incidentsService: IncidentsService) {}
|
constructor(private readonly incidentsService: IncidentsService) {}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,14 @@ import { InterchangeDocumentsService } from './interchange-documents.service';
|
|||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller('interchange-documents')
|
@Controller('interchange-documents')
|
||||||
// Class-level view guard; each write route adds its own manage permission below.
|
// Class-level view guard; each write route adds its own manage permission below.
|
||||||
@BookingStaff(FREIGHT_PERMS.interchangeDocuments.view)
|
// Class gate lists every key its routes use: Nest runs class AND method
|
||||||
|
// guards, so a key missing here would deny before the route's own key runs.
|
||||||
|
@BookingStaff([
|
||||||
|
FREIGHT_PERMS.interchangeDocuments.view,
|
||||||
|
FREIGHT_PERMS.interchangeDocuments.generate,
|
||||||
|
FREIGHT_PERMS.interchangeDocuments.acknowledge,
|
||||||
|
FREIGHT_PERMS.interchangeDocuments.dispute,
|
||||||
|
])
|
||||||
export class InterchangeDocumentsController {
|
export class InterchangeDocumentsController {
|
||||||
constructor(private readonly service: InterchangeDocumentsService) {}
|
constructor(private readonly service: InterchangeDocumentsService) {}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Transform } from 'class-transformer';
|
import { Transform } from 'class-transformer';
|
||||||
import { IsNumber, Min } from 'class-validator';
|
import { IsNumber, IsOptional, Min } from 'class-validator';
|
||||||
|
|
||||||
export class ApproveLastMileRequestDto {
|
export class ApproveLastMileRequestDto {
|
||||||
// The approve dialog prefills this from GET :id/price-estimate (rule-based),
|
// Omitted = the rule-based last-mile rate estimate is the advance. The chief
|
||||||
// but the chief can still override — the typed value is what's invoiced.
|
// can still override with an explicit amount (required when no rate covers
|
||||||
@ApiProperty({ description: 'Advance amount the customer must pay before execution proceeds', example: 3000 })
|
// the job).
|
||||||
@Transform(({ value }) => Number(value))
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'Advance override. Omitted = the amount comes from the live last-mile rates (km × rate).',
|
||||||
|
example: 3000,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => (value === null || value === undefined || value === '' ? undefined : Number(value)))
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(0.01)
|
@Min(0.01)
|
||||||
advanceAmount!: number;
|
advanceAmount?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export class LastMileRequestsController {
|
|||||||
|
|
||||||
@Post(':id/approve')
|
@Post(':id/approve')
|
||||||
@BookingStaff(FREIGHT_PERMS.lastMile.requestApprove)
|
@BookingStaff(FREIGHT_PERMS.lastMile.requestApprove)
|
||||||
@ApiOperation({ summary: 'Truck & Machinery chief approves the request — LM contract becomes signable; the advance invoice follows the customer signature' })
|
@ApiOperation({ summary: 'Truck & Machinery chief approves the request — the advance defaults to the live last-mile rate; LM contract becomes signable and the advance invoice follows the customer signature' })
|
||||||
approve(
|
approve(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@Body() dto: ApproveLastMileRequestDto,
|
@Body() dto: ApproveLastMileRequestDto,
|
||||||
|
|||||||
@@ -299,7 +299,11 @@ export class LastMileRequestsService {
|
|||||||
return this.findById(id);
|
return this.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async approve(id: string, staffId: string | null, advanceAmount: number): Promise<LastMileRequest> {
|
async approve(
|
||||||
|
id: string,
|
||||||
|
staffId: string | null,
|
||||||
|
advanceOverride?: number | null,
|
||||||
|
): Promise<LastMileRequest> {
|
||||||
const request = await this.findById(id);
|
const request = await this.findById(id);
|
||||||
if (request.status !== LastMileRequestStatus.Submitted) {
|
if (request.status !== LastMileRequestStatus.Submitted) {
|
||||||
throw new BadRequestException(`Only a submitted request can be approved (current status: ${request.status})`);
|
throw new BadRequestException(`Only a submitted request can be approved (current status: ${request.status})`);
|
||||||
@@ -307,6 +311,18 @@ export class LastMileRequestsService {
|
|||||||
const booking = request.booking ?? (await this.bookingsRepository.findById(request.bookingId));
|
const booking = request.booking ?? (await this.bookingsRepository.findById(request.bookingId));
|
||||||
if (!booking) throw new NotFoundException(`Booking ${request.bookingId} not found`);
|
if (!booking) throw new NotFoundException(`Booking ${request.bookingId} not found`);
|
||||||
|
|
||||||
|
// The live last-mile rates are the authority on the advance (km × rate);
|
||||||
|
// the chief's typed amount is only an override — and the only path when no
|
||||||
|
// rate covers the job. Snapshotted so the contract and invoice stay immune
|
||||||
|
// to later rate edits.
|
||||||
|
const estimate = await this.priceEstimate(id);
|
||||||
|
const advanceAmount = advanceOverride ?? estimate.total;
|
||||||
|
if (!advanceAmount || advanceAmount <= 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'No live last-mile rate covers this job — enter the advance amount manually.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Idempotent per booking — reuses the record if one already exists.
|
// Idempotent per booking — reuses the record if one already exists.
|
||||||
const lastMile = await this.lastMileService.create({
|
const lastMile = await this.lastMileService.create({
|
||||||
bookingId: request.bookingId,
|
bookingId: request.bookingId,
|
||||||
@@ -316,10 +332,6 @@ export class LastMileRequestsService {
|
|||||||
|
|
||||||
// No invoice yet: the advance is invoiced by LastMileContractService.sign()
|
// No invoice yet: the advance is invoiced by LastMileContractService.sign()
|
||||||
// once the customer has signed the LM contract — doc first, then payment.
|
// once the customer has signed the LM contract — doc first, then payment.
|
||||||
// Snapshot the rate estimate now so the contract shows the numbers the
|
|
||||||
// chief actually approved against, immune to later rate edits.
|
|
||||||
const estimate = await this.priceEstimate(id);
|
|
||||||
|
|
||||||
await this.requestsRepository.update(id, {
|
await this.requestsRepository.update(id, {
|
||||||
status: LastMileRequestStatus.Approved,
|
status: LastMileRequestStatus.Approved,
|
||||||
reviewedByStaffId: staffId,
|
reviewedByStaffId: staffId,
|
||||||
@@ -364,7 +376,10 @@ export class LastMileRequestsService {
|
|||||||
type: 'LAST_MILE_ADVANCE',
|
type: 'LAST_MILE_ADVANCE',
|
||||||
companyId: booking.companyId,
|
companyId: booking.companyId,
|
||||||
companyProfileId: booking.companyProfileId || '',
|
companyProfileId: booking.companyProfileId || '',
|
||||||
currency: booking.paymentCurrency || 'ETB',
|
// The advance is priced by the last-mile rate, so it bills in that
|
||||||
|
// rate's currency (birr for domestic trucking) — the booking's payment
|
||||||
|
// currency is only the fallback when the amount was a manual override.
|
||||||
|
currency: request.contractSummary?.currency || booking.paymentCurrency || 'ETB',
|
||||||
lines: [
|
lines: [
|
||||||
{
|
{
|
||||||
chargeType: 'LAST_MILE_ADVANCE',
|
chargeType: 'LAST_MILE_ADVANCE',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NotificationAudience } from '@edr/types';
|
import { NotificationAudience } from '@edr/types';
|
||||||
|
|
||||||
import { MaintenanceService } from './maintenance.service';
|
import { MaintenanceService } from './maintenance.service';
|
||||||
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The daily due-alert: a SCHEDULED item that crossed its km or date threshold
|
* The daily due-alert: a SCHEDULED item that crossed its km or date threshold
|
||||||
@@ -37,6 +38,10 @@ describe('MaintenanceService.sendDueAlerts', () => {
|
|||||||
expect(notify).toHaveBeenCalledWith(
|
expect(notify).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
audience: NotificationAudience.BACKOFFICE,
|
audience: NotificationAudience.BACKOFFICE,
|
||||||
|
// The fleet desk, not every employee in the company.
|
||||||
|
recipients: {
|
||||||
|
permissionKeys: [FREIGHT_PERMS.maintenance.getNotification],
|
||||||
|
},
|
||||||
title: 'Maintenance due — ET-9875',
|
title: 'Maintenance due — ET-9875',
|
||||||
body: expect.stringContaining('driven 50200 km (due at 50000 km)'),
|
body: expect.stringContaining('driven 50200 km (due at 50000 km)'),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
UpsertMaintenanceIntervalDto,
|
UpsertMaintenanceIntervalDto,
|
||||||
} from './dto/create-maintenance.dto';
|
} from './dto/create-maintenance.dto';
|
||||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||||
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MaintenanceService {
|
export class MaintenanceService {
|
||||||
@@ -53,7 +54,9 @@ export class MaintenanceService {
|
|||||||
? `driven ${item.currentKm} km (due at ${item.nextDueKm} km)`
|
? `driven ${item.currentKm} km (due at ${item.nextDueKm} km)`
|
||||||
: `due ${new Date(item.nextDueDate as Date).toLocaleDateString()}`;
|
: `due ${new Date(item.nextDueDate as Date).toLocaleDateString()}`;
|
||||||
await this.inbox.notify({
|
await this.inbox.notify({
|
||||||
recipients: { allBackoffice: true },
|
recipients: {
|
||||||
|
permissionKeys: [FREIGHT_PERMS.maintenance.getNotification],
|
||||||
|
},
|
||||||
audience: NotificationAudience.BACKOFFICE,
|
audience: NotificationAudience.BACKOFFICE,
|
||||||
type: NotificationType.GENERIC,
|
type: NotificationType.GENERIC,
|
||||||
title: `Maintenance due — ${item.plateNumber}`,
|
title: `Maintenance due — ${item.plateNumber}`,
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ import { ExternalProfileRepository } from "../companies/external-profile.reposit
|
|||||||
* - `companyProfileId` → resolved to its company, then to that company's users.
|
* - `companyProfileId` → resolved to its company, then to that company's users.
|
||||||
* - `organizationId` → all current employees of the org (backoffice staff).
|
* - `organizationId` → all current employees of the org (backoffice staff).
|
||||||
* - `permissionKeys` → current employees (any org) holding any of these
|
* - `permissionKeys` → current employees (any org) holding any of these
|
||||||
* permission keys (e.g. department/role-scoped targeting).
|
* permission keys — how every staff-facing notification is targeted. There is
|
||||||
|
* deliberately no "all backoffice" selector: staff notifications belong to a
|
||||||
|
* desk, and the `<module>:get_notification` keys name which one.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class NotificationRecipientsService {
|
export class NotificationRecipientsService {
|
||||||
@@ -69,18 +71,6 @@ export class NotificationRecipientsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (recipients.allBackoffice) {
|
|
||||||
try {
|
|
||||||
for (const uid of await this.backoffice.getAllCurrentEmployeeUserIds()) {
|
|
||||||
ids.add(uid);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.warn(
|
|
||||||
`Failed to resolve allBackoffice recipients: ${(err as Error).message}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (recipients.permissionKeys?.length) {
|
if (recipients.permissionKeys?.length) {
|
||||||
try {
|
try {
|
||||||
for (const uid of await this.backoffice.getEmployeeUserIdsByPermission(
|
for (const uid of await this.backoffice.getEmployeeUserIdsByPermission(
|
||||||
|
|||||||
@@ -60,14 +60,38 @@ export class RefundDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class ClientActionDto {
|
export class ClientActionDto {
|
||||||
|
// INVOKE_BRIDGE (SuperApp mini-app payload) is part of the shared ClientAction union and so
|
||||||
|
// must be assignable here, but freight never requests platform=inapp and therefore never
|
||||||
|
// receives one. Passenger owns that flow — see docs/telebirr-miniapp/.
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP", "SHOW_BILL_REFERENCE"],
|
enum: [
|
||||||
|
"REDIRECT",
|
||||||
|
"LAUNCH_APP",
|
||||||
|
"INVOKE_BRIDGE",
|
||||||
|
"COLLECT_OTP",
|
||||||
|
"SHOW_BILL_REFERENCE",
|
||||||
|
],
|
||||||
})
|
})
|
||||||
type!: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP" | "SHOW_BILL_REFERENCE";
|
type!:
|
||||||
|
| "REDIRECT"
|
||||||
|
| "LAUNCH_APP"
|
||||||
|
| "INVOKE_BRIDGE"
|
||||||
|
| "COLLECT_OTP"
|
||||||
|
| "SHOW_BILL_REFERENCE";
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" })
|
@ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" })
|
||||||
url?: string;
|
url?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: "Set when type=INVOKE_BRIDGE (SuperApp mini app) — not used by freight",
|
||||||
|
})
|
||||||
|
bridge?: "TELEBIRR";
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: "Set when type=INVOKE_BRIDGE (SuperApp mini app) — not used by freight",
|
||||||
|
})
|
||||||
|
rawRequest?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" })
|
@ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" })
|
||||||
appId?: string;
|
appId?: string;
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,14 @@ import {
|
|||||||
|
|
||||||
@ApiTags('Procurement & Asset Lifecycle')
|
@ApiTags('Procurement & Asset Lifecycle')
|
||||||
@Controller('procurement')
|
@Controller('procurement')
|
||||||
@BookingStaff(FREIGHT_PERMS.procurement.view)
|
// Class gate lists every key its routes use: Nest runs class AND method
|
||||||
|
// guards, so a key missing here would deny before the route's own key runs.
|
||||||
|
@BookingStaff([
|
||||||
|
FREIGHT_PERMS.procurement.view,
|
||||||
|
FREIGHT_PERMS.procurement.vendorManage,
|
||||||
|
FREIGHT_PERMS.procurement.acquisitionManage,
|
||||||
|
FREIGHT_PERMS.procurement.disposalManage,
|
||||||
|
])
|
||||||
export class ProcurementController {
|
export class ProcurementController {
|
||||||
constructor(private readonly procurementService: ProcurementService) {}
|
constructor(private readonly procurementService: ProcurementService) {}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,15 @@ import { RoutesService } from './routes.service';
|
|||||||
@ApiTags('routes')
|
@ApiTags('routes')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller('routes')
|
@Controller('routes')
|
||||||
@FleetView(FREIGHT_PERMS.routes.view)
|
// Class gate lists every key its routes use: Nest runs class AND method
|
||||||
|
// guards, so a key missing here would deny before the route's own key runs.
|
||||||
|
@FleetView([
|
||||||
|
FREIGHT_PERMS.routes.view,
|
||||||
|
FREIGHT_PERMS.routes.create,
|
||||||
|
FREIGHT_PERMS.routes.update,
|
||||||
|
FREIGHT_PERMS.routes.hardDelete,
|
||||||
|
FREIGHT_PERMS.routes.delete,
|
||||||
|
])
|
||||||
export class RoutesController {
|
export class RoutesController {
|
||||||
constructor(private readonly routesService: RoutesService) {}
|
constructor(private readonly routesService: RoutesService) {}
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,16 @@ export class CreateCargoTypeDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
hasLashing?: boolean;
|
hasLashing?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
default: false,
|
||||||
|
description:
|
||||||
|
'Allow staff to write bulk contract templates for this cargo type. ' +
|
||||||
|
'Mutually exclusive with the parent group / children having it.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
hasContractTemplate?: boolean;
|
||||||
|
|
||||||
@ApiPropertyOptional({ default: true })
|
@ApiPropertyOptional({ default: true })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
|
|||||||
@@ -82,6 +82,14 @@ export class CargoType extends BaseEntity {
|
|||||||
@Column({ name: 'has_lashing', type: 'boolean', default: false })
|
@Column({ name: 'has_lashing', type: 'boolean', default: false })
|
||||||
hasLashing!: boolean;
|
hasLashing!: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether staff may write bulk contract templates against this cargo type.
|
||||||
|
* Mutually exclusive between a parent group and its children: if the parent
|
||||||
|
* provides the template, no child may, and vice versa.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'has_contract_template', type: 'boolean', default: false })
|
||||||
|
hasContractTemplate!: boolean;
|
||||||
|
|
||||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||||
isActive!: boolean;
|
isActive!: boolean;
|
||||||
|
|
||||||
|
|||||||
@@ -135,6 +135,35 @@ export class CargoTypesService {
|
|||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A cargo type and its parent group may not BOTH offer a contract template —
|
||||||
|
* the template would be ambiguous for bookings of the child. To enable the
|
||||||
|
* child, the parent must be turned off first (and vice versa).
|
||||||
|
*/
|
||||||
|
private async assertContractTemplateExclusive(input: {
|
||||||
|
id?: string;
|
||||||
|
parentGroupId?: string | null;
|
||||||
|
}): Promise<void> {
|
||||||
|
if (input.parentGroupId) {
|
||||||
|
const parent = await this.repository.findById(input.parentGroupId);
|
||||||
|
if (parent?.hasContractTemplate) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Parent group "${parent.cargoTypeName}" already has a contract template — turn it off there first`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (input.id) {
|
||||||
|
const children = await this.repository.findAll({
|
||||||
|
where: { parentGroupId: input.id, hasContractTemplate: true },
|
||||||
|
});
|
||||||
|
if (children.length) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Child cargo type(s) ${children.map((c) => `"${c.cargoTypeName}"`).join(', ')} already have their own contract template — turn those off first`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Create a new cargo type. */
|
/** Create a new cargo type. */
|
||||||
async create(dto: CreateCargoTypeDto): Promise<CargoType> {
|
async create(dto: CreateCargoTypeDto): Promise<CargoType> {
|
||||||
const code = generateCode(dto.cargoTypeName);
|
const code = generateCode(dto.cargoTypeName);
|
||||||
@@ -144,6 +173,9 @@ export class CargoTypesService {
|
|||||||
const parent = await this.repository.findById(dto.parentGroupId);
|
const parent = await this.repository.findById(dto.parentGroupId);
|
||||||
if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`);
|
if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`);
|
||||||
}
|
}
|
||||||
|
if (dto.hasContractTemplate) {
|
||||||
|
await this.assertContractTemplateExclusive({ parentGroupId: dto.parentGroupId });
|
||||||
|
}
|
||||||
|
|
||||||
const displayOrder = await this.displayOrder.resolveCreateOrder(CargoType, 'displayOrder', {
|
const displayOrder = await this.displayOrder.resolveCreateOrder(CargoType, 'displayOrder', {
|
||||||
explicitOrder: dto.displayOrder,
|
explicitOrder: dto.displayOrder,
|
||||||
@@ -160,6 +192,7 @@ export class CargoTypesService {
|
|||||||
code,
|
code,
|
||||||
cargoTypeName: dto.cargoTypeName,
|
cargoTypeName: dto.cargoTypeName,
|
||||||
parentGroupId: dto.parentGroupId ?? null,
|
parentGroupId: dto.parentGroupId ?? null,
|
||||||
|
hasContractTemplate: dto.hasContractTemplate ?? false,
|
||||||
requiresDirectorApproval: dto.requiresDirectorApproval ?? false,
|
requiresDirectorApproval: dto.requiresDirectorApproval ?? false,
|
||||||
isActive: dto.isActive ?? true,
|
isActive: dto.isActive ?? true,
|
||||||
unitOfMeasure: dto.unitOfMeasure ?? null,
|
unitOfMeasure: dto.unitOfMeasure ?? null,
|
||||||
@@ -183,6 +216,18 @@ export class CargoTypesService {
|
|||||||
const parent = await this.repository.findById(dto.parentGroupId);
|
const parent = await this.repository.findById(dto.parentGroupId);
|
||||||
if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`);
|
if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`);
|
||||||
}
|
}
|
||||||
|
// Re-check the parent/child template exclusivity whenever the flag or the
|
||||||
|
// parent moves and the row ends up flagged.
|
||||||
|
const willHaveTemplate = dto.hasContractTemplate ?? existing.hasContractTemplate;
|
||||||
|
if (
|
||||||
|
willHaveTemplate &&
|
||||||
|
(dto.hasContractTemplate !== undefined || dto.parentGroupId !== undefined)
|
||||||
|
) {
|
||||||
|
await this.assertContractTemplateExclusive({
|
||||||
|
id,
|
||||||
|
parentGroupId: dto.parentGroupId ?? existing.parentGroupId,
|
||||||
|
});
|
||||||
|
}
|
||||||
const {
|
const {
|
||||||
wagonTypeIds,
|
wagonTypeIds,
|
||||||
itemsPerWagonMap,
|
itemsPerWagonMap,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
PriorityRuleChangeStatus,
|
PriorityRuleChangeStatus,
|
||||||
} from '../entities/priority-rule-change-request.entity';
|
} from '../entities/priority-rule-change-request.entity';
|
||||||
import { PriorityConfigsService } from './priority-configs.service';
|
import { PriorityConfigsService } from './priority-configs.service';
|
||||||
|
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
|
||||||
|
|
||||||
/** Backoffice rule-engine page — where both queue and rules live. */
|
/** Backoffice rule-engine page — where both queue and rules live. */
|
||||||
const RULES_LINK = '/dashboard/rules/priority-configs';
|
const RULES_LINK = '/dashboard/rules/priority-configs';
|
||||||
@@ -221,7 +222,9 @@ export class PriorityRuleChangeRequestsService {
|
|||||||
): void {
|
): void {
|
||||||
void this.inbox
|
void this.inbox
|
||||||
.notify({
|
.notify({
|
||||||
recipients: { allBackoffice: true },
|
recipients: {
|
||||||
|
permissionKeys: [FREIGHT_PERMS.ruleEngine.getNotification],
|
||||||
|
},
|
||||||
audience: NotificationAudience.BACKOFFICE,
|
audience: NotificationAudience.BACKOFFICE,
|
||||||
type: NotificationType.REQUEST_SUBMITTED,
|
type: NotificationType.REQUEST_SUBMITTED,
|
||||||
title,
|
title,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
} from '../entities/rate-change-request.entity';
|
} from '../entities/rate-change-request.entity';
|
||||||
import { Rate } from '../entities/rate.entity';
|
import { Rate } from '../entities/rate.entity';
|
||||||
import { RatesService } from './rates.service';
|
import { RatesService } from './rates.service';
|
||||||
|
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
|
||||||
|
|
||||||
/** Backoffice page where both the queue and the rates live. */
|
/** Backoffice page where both the queue and the rates live. */
|
||||||
const RATES_LINK = '/dashboard/rules/rates';
|
const RATES_LINK = '/dashboard/rules/rates';
|
||||||
@@ -234,7 +235,9 @@ export class RateChangeRequestsService {
|
|||||||
private notifyTeam(title: string, body: string, request: RateChangeRequest): void {
|
private notifyTeam(title: string, body: string, request: RateChangeRequest): void {
|
||||||
void this.inbox
|
void this.inbox
|
||||||
.notify({
|
.notify({
|
||||||
recipients: { allBackoffice: true },
|
recipients: {
|
||||||
|
permissionKeys: [FREIGHT_PERMS.ruleEngine.getNotification],
|
||||||
|
},
|
||||||
audience: NotificationAudience.BACKOFFICE,
|
audience: NotificationAudience.BACKOFFICE,
|
||||||
type: NotificationType.REQUEST_SUBMITTED,
|
type: NotificationType.REQUEST_SUBMITTED,
|
||||||
title,
|
title,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { Booking } from '../bookings/entities/booking.entity';
|
|||||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
import { compareSchedulingPriority } from '../scheduling/compare-scheduling-priority.util';
|
import { compareSchedulingPriority } from '../scheduling/compare-scheduling-priority.util';
|
||||||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
|
||||||
import { BookingNotifierService } from '../train-scheduling/booking-notifier.service';
|
import { BookingNotifierService } from '../train-scheduling/booking-notifier.service';
|
||||||
import { ExecuteRescheduleDto, PreviewRescheduleDto } from './dto/preview-reschedule.dto';
|
import { ExecuteRescheduleDto, PreviewRescheduleDto } from './dto/preview-reschedule.dto';
|
||||||
import { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto';
|
import { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto';
|
||||||
|
|||||||
@@ -0,0 +1,311 @@
|
|||||||
|
import { SUPPORT_MEDIA_PREFIX, SupportDocSlug } from "@edr/types";
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
import {
|
||||||
|
ArrayMaxSize,
|
||||||
|
IsArray,
|
||||||
|
IsIn,
|
||||||
|
IsObject,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Matches,
|
||||||
|
MaxLength,
|
||||||
|
MinLength,
|
||||||
|
ValidateNested,
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Markdown bodies are safe on read — the portal renders them with
|
||||||
|
* `react-markdown` and no `rehype-raw`, so any HTML in them is inert. The
|
||||||
|
* fields worth validating are these: they land in `href`/`src` attributes and
|
||||||
|
* bypass markdown entirely, which is where a `javascript:` URL would actually
|
||||||
|
* execute.
|
||||||
|
*
|
||||||
|
* Placeholders survive the check because they sit after the scheme
|
||||||
|
* (`mailto:{{supportEmail}}`, `tel:{{supportPhoneTel}}`).
|
||||||
|
*/
|
||||||
|
const LINK_PATTERN = /^(https?:\/\/|mailto:|tel:|\/)/;
|
||||||
|
const LINK_MESSAGE =
|
||||||
|
"$property must start with http(s)://, mailto:, tel: or /";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A media source is either an uploaded MinIO object key, a same-origin path, or
|
||||||
|
* an https URL. Anything else — notably `javascript:` — is refused, since this
|
||||||
|
* value lands in an `<img>`/`<video>` src.
|
||||||
|
*/
|
||||||
|
const MEDIA_SRC_PATTERN = new RegExp(
|
||||||
|
`^(https?:\\/\\/|\\/|${SUPPORT_MEDIA_PREFIX.replace("/", "\\/")})`,
|
||||||
|
);
|
||||||
|
const MEDIA_SRC_MESSAGE =
|
||||||
|
`$property must be an uploaded ${SUPPORT_MEDIA_PREFIX} key, a /path, or an http(s):// URL`;
|
||||||
|
|
||||||
|
/* ------------------------------- CONTACT ------------------------------- */
|
||||||
|
|
||||||
|
export class PortalSupportContactDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
|
@MaxLength(200)
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
|
@MaxLength(60)
|
||||||
|
phone!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
@MaxLength(200)
|
||||||
|
office!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
@MaxLength(200)
|
||||||
|
hours!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------- PRIVACY / TERMS --------------------------- */
|
||||||
|
|
||||||
|
export class PortalDocSectionDto {
|
||||||
|
@ApiPropertyOptional({ description: "Stable id; generated when omitted" })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(64)
|
||||||
|
id?: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(200)
|
||||||
|
heading!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Markdown" })
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20_000)
|
||||||
|
body!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PortalLegalContentDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(200)
|
||||||
|
title!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
subtitle!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Free text, e.g. "6 August 2026"' })
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(60)
|
||||||
|
lastUpdated!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [PortalDocSectionDto] })
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(60)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => PortalDocSectionDto)
|
||||||
|
sections!: PortalDocSectionDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------- FAQ --------------------------------- */
|
||||||
|
|
||||||
|
export class PortalCtaCardDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(200)
|
||||||
|
heading!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Markdown" })
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(2_000)
|
||||||
|
body!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(100)
|
||||||
|
ctaLabel!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@Matches(LINK_PATTERN, { message: LINK_MESSAGE })
|
||||||
|
@MaxLength(500)
|
||||||
|
ctaTo!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PortalFaqItemDto {
|
||||||
|
@ApiPropertyOptional({ description: "Stable id; generated when omitted" })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(64)
|
||||||
|
id?: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(300)
|
||||||
|
question!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Markdown" })
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(5_000)
|
||||||
|
answer!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PortalFaqGroupDto {
|
||||||
|
@ApiPropertyOptional({ description: "Stable id; generated when omitted" })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(64)
|
||||||
|
id?: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(200)
|
||||||
|
title!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [PortalFaqItemDto] })
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(50)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => PortalFaqItemDto)
|
||||||
|
items!: PortalFaqItemDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PortalFaqContentDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(200)
|
||||||
|
title!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
subtitle!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [PortalFaqGroupDto] })
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(20)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => PortalFaqGroupDto)
|
||||||
|
groups!: PortalFaqGroupDto[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: PortalCtaCardDto, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => PortalCtaCardDto)
|
||||||
|
footer?: PortalCtaCardDto | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------- HELP -------------------------------- */
|
||||||
|
|
||||||
|
export class PortalMediaDto {
|
||||||
|
@ApiPropertyOptional({ description: "Stable id; generated when omitted" })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(64)
|
||||||
|
id?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: ["image", "video"] })
|
||||||
|
@IsIn(["image", "video"])
|
||||||
|
kind!: "image" | "video";
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: `An uploaded ${SUPPORT_MEDIA_PREFIX} key, a same-origin /path, or an https:// URL`,
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@Matches(MEDIA_SRC_PATTERN, { message: MEDIA_SRC_MESSAGE })
|
||||||
|
@MaxLength(500)
|
||||||
|
src!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(300)
|
||||||
|
caption?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PortalHelpSectionDto {
|
||||||
|
@ApiPropertyOptional({ description: "Stable id; generated when omitted" })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(64)
|
||||||
|
id?: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(200)
|
||||||
|
heading!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Markdown" })
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20_000)
|
||||||
|
body!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [PortalMediaDto] })
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(12)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => PortalMediaDto)
|
||||||
|
media!: PortalMediaDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PortalHelpContentDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(200)
|
||||||
|
title!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
subtitle!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [PortalHelpSectionDto] })
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(40)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => PortalHelpSectionDto)
|
||||||
|
sections!: PortalHelpSectionDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------- request ------------------------------- */
|
||||||
|
|
||||||
|
export class UpdateSupportDocumentDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description:
|
||||||
|
"The document's whole payload. Validated against the shape for its slug.",
|
||||||
|
type: Object,
|
||||||
|
})
|
||||||
|
@IsObject()
|
||||||
|
payload!: Record<string, unknown>;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "Why this change was made" })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which DTO class a slug's payload is validated against on write. */
|
||||||
|
export const PAYLOAD_DTO_BY_SLUG: Record<
|
||||||
|
SupportDocSlug,
|
||||||
|
new () => object
|
||||||
|
> = {
|
||||||
|
CONTACT: PortalSupportContactDto,
|
||||||
|
HELP: PortalHelpContentDto,
|
||||||
|
FAQ: PortalFaqContentDto,
|
||||||
|
PRIVACY: PortalLegalContentDto,
|
||||||
|
TERMS: PortalLegalContentDto,
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user