mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
5
.github/workflows/deploy.yml
vendored
5
.github/workflows/deploy.yml
vendored
@@ -110,6 +110,7 @@ jobs:
|
||||
DEPLOY_USER: tria
|
||||
DOCKER_BUILDKIT: "1"
|
||||
COMPOSE_DOCKER_CLI_BUILD: "1"
|
||||
ENV_MANAGER_TOKEN: ${{ secrets.ENV_MANAGER_TOKEN }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -135,10 +136,10 @@ jobs:
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Sync environment from server
|
||||
- name: Sync environment from Env manager app
|
||||
run: |
|
||||
chmod +x scripts/deploy/*.sh
|
||||
./scripts/deploy/sync-env-from-server.sh "${{ matrix.service }}"
|
||||
./scripts/deploy/sync-env-from-env-manager.sh "${{ matrix.service }}"
|
||||
|
||||
- name: Set compose project name
|
||||
run: |
|
||||
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -53,3 +53,12 @@ RUNNING_LOCALLY.md
|
||||
# Generated per-shard compose file for the integration suite (it.mjs).
|
||||
integration/.it-shards.yaml
|
||||
|
||||
# private keys / certificates (EIMS INSA credentials and anything like them) — never commit
|
||||
*.key
|
||||
*.pem
|
||||
*.pem.txt
|
||||
*.p12
|
||||
*.pfx
|
||||
*.crt
|
||||
secrets/
|
||||
certs/
|
||||
|
||||
@@ -76,6 +76,12 @@ SEED_EDR_ORG=true
|
||||
SEED_FREIGHT_STAFF=true
|
||||
SEED_EXPORT_DJIBOUTI_INTERCHANGE_DEMO=false
|
||||
|
||||
# Limits GET /staff/users to employees of this IAM organization (iam.organizations.key).
|
||||
# Unset = every employee. A key matching no organization returns no users.
|
||||
# Dev seed key: edr_freight
|
||||
# Production: ETHIO_DJIBOUTI_STANDARD_GAUGE_RAILWAY_SHARE_COMPANY_001
|
||||
FREIGHT_ORG_KEY=edr_freight
|
||||
|
||||
# MinIO (used by @tria-plc/iamapi-common for file storage)
|
||||
MINIO_ENDPOINT=localhost
|
||||
MINIO_PORT=9000
|
||||
@@ -126,3 +132,75 @@ EMAIL_QUEUE=email_queue
|
||||
# Shared secret for service-to-service calls (payment microservice <-> freight).
|
||||
# Required at boot; set ALLOW_UNAUTH_INTERNAL=true instead ONLY for local dev.
|
||||
SERVICE_AUTH_TOKEN=change-me
|
||||
|
||||
# ── MoR EIMS e-invoicing (core.mor.gov.et) ─────────────────────────────────
|
||||
# Disabled by default; every EIMS call fails fast with EIMS_NOT_CONFIGURED until enabled.
|
||||
EIMS_ENABLED=false
|
||||
EIMS_BASE_URL=https://core.mor.gov.et
|
||||
EIMS_CLIENT_ID=
|
||||
EIMS_CLIENT_SECRET=
|
||||
EIMS_API_KEY=
|
||||
EIMS_TIN=
|
||||
# Source-system identity comes from the access token's systemNumber/systemType claims.
|
||||
# Setting these turns them into expected-value checks: a mismatch against the token fails
|
||||
# fast rather than one side silently winning. Leave empty to take the gateway's word.
|
||||
EIMS_SYSTEM_NUMBER=
|
||||
EIMS_SYSTEM_TYPE=
|
||||
# Absolute paths to the INSA-issued credentials. Keep them OUTSIDE the repo; the file
|
||||
# patterns are gitignored, but a path outside the working tree is safer still.
|
||||
# The certificate is transmitted as base64 of this file's exact bytes — do not convert it.
|
||||
EIMS_PRIVATE_KEY_PATH=
|
||||
EIMS_CERTIFICATE_PATH=
|
||||
# Optional tuning
|
||||
EIMS_HTTP_TIMEOUT_MS=30000
|
||||
EIMS_TOKEN_SKEW_SECONDS=45
|
||||
|
||||
# ── EIMS invoice registration (required only to register invoices) ─────────
|
||||
# Seller identity: EDR's own legal details are not modelled anywhere in the DB.
|
||||
# Region and Wereda are MoR *codes* (e.g. 13 / 574), not names.
|
||||
EIMS_SELLER_LEGAL_NAME=
|
||||
EIMS_SELLER_VAT_NUMBER=
|
||||
EIMS_SELLER_PHONE=
|
||||
EIMS_SELLER_EMAIL=
|
||||
EIMS_SELLER_REGION=
|
||||
EIMS_SELLER_WEREDA=
|
||||
# Optional seller address parts; sent as null when unset.
|
||||
EIMS_SELLER_CITY=
|
||||
EIMS_SELLER_SUBCITY=
|
||||
EIMS_SELLER_HOUSE_NUMBER=
|
||||
EIMS_SELLER_LOCALITY=
|
||||
# Tax treatment — REQUIRES FINANCE SIGN-OFF. The application models no tax at all
|
||||
# (invoice.taxAmount is always 0), so nothing here is defaulted: registration fails
|
||||
# locally, naming the missing variables, until these are set.
|
||||
# Required, and deliberately unset: the choice is a tax position, not a default.
|
||||
# MoR's enum (from its own 400): TOT10 TOT2 VAT15 VWHT TWHT VATEX VATWH WHOP2 WTHOI VAT0 VWTH
|
||||
# Pending finance confirmation of VAT0 (zero-rated) vs VATEX (exempt).
|
||||
EIMS_TAX_CODE=
|
||||
EIMS_TAX_RATE_PERCENT=0
|
||||
EIMS_EXCISE_TAX_VALUE=0
|
||||
EIMS_INCOME_WITHHOLD_VALUE=0
|
||||
EIMS_TRANSACTION_WITHHOLD_VALUE=0
|
||||
# Document classification and payment presentation.
|
||||
EIMS_TRANSACTION_TYPE=B2B
|
||||
# Lowercase constant: MoR's oneOf branches require exactly 'goods' or 'service'.
|
||||
EIMS_NATURE_OF_SUPPLIES=service
|
||||
EIMS_PAYMENT_MODE=CASH
|
||||
EIMS_PAYMENT_TERM=IMMIDIATE
|
||||
EIMS_UNIT_DEFAULT=PCS
|
||||
# MoR numeric country code for the buyer; our companies store the country name.
|
||||
EIMS_BUYER_COUNTRY_CODE=
|
||||
# Buyer region name -> MoR numeric code. companies.region holds names; MoR wants ^[0-9]{1,3}$.
|
||||
# An unmapped region fails locally rather than being filed with a guess.
|
||||
EIMS_BUYER_REGION_CODES=Addis Ababa=13
|
||||
# Same mechanism for Wereda. MoR has never named a Wereda regex in an error (only Region's is
|
||||
# confirmed), so this is precautionary — but an unmapped name still fails locally, not filed as a guess.
|
||||
EIMS_BUYER_WEREDA_CODES=
|
||||
EIMS_CASHIER_NAME=
|
||||
EIMS_SALESPERSON_NAME=
|
||||
# Automatic filing of issued invoices (@Cron sweep, one invoice per tick).
|
||||
# Independent of EIMS_ENABLED on purpose: authentication can be live long before
|
||||
# filing is. Both must be true before anything is submitted automatically.
|
||||
EIMS_AUTO_SUBMIT=false
|
||||
EIMS_AUTO_SUBMIT_CRON=0 */5 * * * *
|
||||
# MoR rejects documents older than 3 days; the sweep will not attempt those.
|
||||
EIMS_AUTO_SUBMIT_MAX_AGE_DAYS=3
|
||||
|
||||
@@ -37,7 +37,8 @@
|
||||
"iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert",
|
||||
"iam:migration:show": "pnpm run iam:typeorm:cli migration:show",
|
||||
"migration:run": "nest build && node dist/scripts/migrate.js",
|
||||
"script": "ts-node -r tsconfig-paths/register src/scripts/main.ts"
|
||||
"script": "ts-node -r tsconfig-paths/register src/scripts/main.ts",
|
||||
"eims:login": "ts-node -r tsconfig-paths/register src/scripts/eims-login.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edr/api-common": "workspace:*",
|
||||
|
||||
@@ -23,6 +23,7 @@ import databaseConfig from "./config/database.config";
|
||||
import telebirrConfig from "./config/telebirr.config";
|
||||
import rabbitmqConfig from "./config/rabbitmq.config";
|
||||
import faydaConfig from "./config/fayda.config";
|
||||
import eimsConfig from "./config/eims.config";
|
||||
|
||||
import { BookingsModule } from "./modules/bookings/bookings.module";
|
||||
import { ContractsModule } from "./modules/contracts/contracts.module";
|
||||
@@ -49,6 +50,7 @@ import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-up
|
||||
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
|
||||
import { ExchangeSettingsModule } from "./modules/exchange-settings/exchange-settings.module";
|
||||
import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module";
|
||||
import { SupportContentModule } from "./modules/support-content/support-content.module";
|
||||
import { OtpModule } from "./modules/otp/otp.module";
|
||||
import { HealthModule } from "./modules/health/health.module";
|
||||
import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module";
|
||||
@@ -66,6 +68,7 @@ import { FreightPositionsSeeder } from "./seed/freight-positions.seeder";
|
||||
import { PaymentModule } from "./modules/payment/payment.module";
|
||||
// import { PricingDataSeeder } from "./seed/pricing-data.seeder";
|
||||
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
|
||||
import { SupportContentSeeder } from "./seed/support-content.seeder";
|
||||
// import { YardFacilitiesSeeder } from "./seed/yard-facilities.seeder";
|
||||
// import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder";
|
||||
// import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder";
|
||||
@@ -76,6 +79,7 @@ import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
|
||||
// import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder";
|
||||
// import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder";
|
||||
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
|
||||
import { FreightNotificationPermissionsSeeder } from "./seed/freight-notification-permissions.seeder";
|
||||
// import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
|
||||
// import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
|
||||
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
|
||||
@@ -83,6 +87,7 @@ import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-d
|
||||
//New Trains, Wagons, Container and Cargo management modules
|
||||
import { TrainsModule } from "./modules/trains/trains.module";
|
||||
import { VerifaydaModule } from "./modules/verifayda/verifayda.module";
|
||||
import { EimsModule } from "./modules/eims/eims.module";
|
||||
import { FleetHistoryModule } from "./modules/fleet-history/fleet-history.module";
|
||||
import { WagonsModule } from "./modules/wagons/wagons.module";
|
||||
import { ContainersModule } from "./modules/container-management/containers.module";
|
||||
@@ -126,6 +131,7 @@ if (!process.env.APPLICATION_NAME) {
|
||||
telebirrConfig,
|
||||
rabbitmqConfig,
|
||||
faydaConfig,
|
||||
eimsConfig,
|
||||
],
|
||||
}),
|
||||
ScheduleModule.forRoot(),
|
||||
@@ -216,6 +222,7 @@ if (!process.env.APPLICATION_NAME) {
|
||||
DropdownSettingsModule,
|
||||
ExchangeSettingsModule,
|
||||
ContractTemplatesModule,
|
||||
SupportContentModule,
|
||||
OtpModule,
|
||||
HealthModule,
|
||||
RuleEngineModule,
|
||||
@@ -247,6 +254,7 @@ if (!process.env.APPLICATION_NAME) {
|
||||
InterchangeDocumentsModule,
|
||||
ImportOperationsModule,
|
||||
VerifaydaModule,
|
||||
EimsModule,
|
||||
FleetHistoryModule,
|
||||
AiModule,
|
||||
AuditModule,
|
||||
@@ -255,8 +263,10 @@ if (!process.env.APPLICATION_NAME) {
|
||||
EdrOrgSeeder,
|
||||
FreightPositionsSeeder,
|
||||
FileUploadSettingsSeeder,
|
||||
SupportContentSeeder,
|
||||
// YardFacilitiesSeeder,
|
||||
FreightPermissionKeyMigrationSeeder,
|
||||
FreightNotificationPermissionsSeeder,
|
||||
// Disabled seeds — providers commented out (imports/injection/run too):
|
||||
// DemoUsersSeeder,
|
||||
// FreightStaffUsersSeeder,
|
||||
@@ -285,8 +295,10 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
private readonly edrOrgSeeder: EdrOrgSeeder,
|
||||
private readonly freightPositionsSeeder: FreightPositionsSeeder,
|
||||
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
|
||||
private readonly supportContentSeeder: SupportContentSeeder,
|
||||
// private readonly yardFacilitiesSeeder: YardFacilitiesSeeder,
|
||||
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
|
||||
private readonly freightNotificationPermissionsSeeder: FreightNotificationPermissionsSeeder,
|
||||
// Disabled seeds — injections commented out (imports/provider/run too):
|
||||
// private readonly demoUsersSeeder: DemoUsersSeeder,
|
||||
// private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
|
||||
@@ -328,10 +340,24 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
await this.edrOrgSeeder.run();
|
||||
await this.iamBaselineSeeder.run();
|
||||
await this.freightPositionsSeeder.run();
|
||||
// freightNotificationPermissions → seeds the <module>:get_notification
|
||||
// keys and backfills them onto whoever
|
||||
// already holds each desk's anchor
|
||||
// permission. Runs LAST in this block so
|
||||
// it sees a freshly-seeded catalog and
|
||||
// freshly-seeded positions. Unlike the
|
||||
// seeders above it is NOT gated behind
|
||||
// SEED_EDR_ORG — without it every staff
|
||||
// notification resolves to no one.
|
||||
await this.freightNotificationPermissionsSeeder.run();
|
||||
|
||||
// File upload settings — keep enabled.
|
||||
await this.fileUploadSettingsSeeder.run();
|
||||
|
||||
// Portal help/FAQ/legal copy — keep enabled. Idempotent by emptiness, so
|
||||
// it fills an empty table once and never touches admin edits afterwards.
|
||||
await this.supportContentSeeder.run();
|
||||
|
||||
// Flags which yards can load/unload cargo (Indode, Sebeta, Modjo, Adama,
|
||||
// Dire Dawa). Idempotent; creates no yards.
|
||||
// await this.yardFacilitiesSeeder.run();
|
||||
|
||||
@@ -95,9 +95,14 @@ export const TrainSchedulingRulesManage = () =>
|
||||
* wagons:delete, …). The legacy coarse fleet:view / fleet:manage keys remain
|
||||
* valid as a one-of fallback so existing role grants keep working.
|
||||
*/
|
||||
export const FleetView = (granular?: string) =>
|
||||
export const FleetView = (granular?: string | string[]) =>
|
||||
BookingStaff(
|
||||
granular ? [granular, FREIGHT_PERMS.fleet.view] : FREIGHT_PERMS.fleet.view,
|
||||
granular
|
||||
? [
|
||||
...(Array.isArray(granular) ? granular : [granular]),
|
||||
FREIGHT_PERMS.fleet.view,
|
||||
]
|
||||
: FREIGHT_PERMS.fleet.view,
|
||||
);
|
||||
|
||||
export const FleetManage = (granular?: string) =>
|
||||
|
||||
@@ -36,4 +36,27 @@ describe('assertExportReceivedWithGrn', () => {
|
||||
assertExportReceivedWithGrn(db([]), { id: 'b-1', tradeDirection: 'DOMESTIC' }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('never blocks direct truck-to-train export — that cargo has no GRN by design', async () => {
|
||||
const source = db([]);
|
||||
await expect(
|
||||
assertExportReceivedWithGrn(source, {
|
||||
id: 'b-1',
|
||||
tradeDirection: 'EXPORT',
|
||||
exportHandoverMode: 'DIRECT_TO_TRAIN',
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
// Direct short-circuits before querying — there is no inventory to look for.
|
||||
expect(source.query as jest.Mock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still gates a warehouse export booking', async () => {
|
||||
await expect(
|
||||
assertExportReceivedWithGrn(db([]), {
|
||||
id: 'b-1',
|
||||
tradeDirection: 'EXPORT',
|
||||
exportHandoverMode: 'WAREHOUSE',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,8 +5,15 @@ import type { DataSource, EntityManager } from 'typeorm';
|
||||
export interface ExportLoadGateBooking {
|
||||
id: string;
|
||||
tradeDirection?: string | null;
|
||||
/** 'DIRECT_TO_TRAIN' skips the gate entirely; null/'WAREHOUSE' keeps it. */
|
||||
exportHandoverMode?: string | null;
|
||||
}
|
||||
|
||||
/** Direct truck-to-train: the cargo never sees a warehouse, so it never has a GRN. */
|
||||
export const DIRECT_TO_TRAIN = 'DIRECT_TO_TRAIN';
|
||||
/** Warehouse-then-train: the existing flow. Also what a null mode means. */
|
||||
export const WAREHOUSE = 'WAREHOUSE';
|
||||
|
||||
/**
|
||||
* Export cargo may not be loaded onto its train until it has physically reached
|
||||
* the warehouse and been issued a GRN — whether it got there by first-mile or by
|
||||
@@ -21,12 +28,18 @@ export interface ExportLoadGateBooking {
|
||||
* "Received with a GRN" = an inventory row that has reached the warehouse
|
||||
* (RECEIVED or any later stage) and carries a GRN, in the column or the notes
|
||||
* fallback older rows use.
|
||||
*
|
||||
* Export has a second, warehouse-free shape: the customer's truck loads straight
|
||||
* onto the wagon. That cargo is never received and never GRN'd, so a booking
|
||||
* marked DIRECT_TO_TRAIN is outside this gate by definition — its custody is
|
||||
* attested by the carriage acceptance sheet instead.
|
||||
*/
|
||||
export async function assertExportReceivedWithGrn(
|
||||
db: DataSource | EntityManager,
|
||||
booking: ExportLoadGateBooking,
|
||||
): Promise<void> {
|
||||
if (booking.tradeDirection !== 'EXPORT') return;
|
||||
if (booking.exportHandoverMode === DIRECT_TO_TRAIN) return;
|
||||
|
||||
const [row] = await db.query(
|
||||
`SELECT 1
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
|
||||
import { hasFreightPermission, isSuperAdmin } from './freight-permission.util';
|
||||
import { readTwinOf } from '../seed/freight-permissions.registry';
|
||||
|
||||
// String literals on purpose (same reasoning as login-audience.middleware.ts):
|
||||
// the values are wire-format constants from iam.users.user_type, and importing
|
||||
@@ -22,13 +23,43 @@ const userTypeOf = (user: TCurrentUser): string | undefined =>
|
||||
const isEmployee = (user: TCurrentUser): boolean =>
|
||||
userTypeOf(user) === 'employee' || isSuperAdmin(user);
|
||||
|
||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
|
||||
/**
|
||||
* Does the caller satisfy a required permission?
|
||||
*
|
||||
* Holding the key outright always passes. A required `<module>:view` is ALSO
|
||||
* satisfied by the weaker `<module>:read` — the key that buys API reads
|
||||
* without putting the module in the backoffice sidebar — but only on a safe
|
||||
* HTTP method.
|
||||
*
|
||||
* The method restriction is load-bearing, not caution. Nest runs class AND
|
||||
* method guards, so controllers list every route key on the class gate,
|
||||
* `:view` among the write keys. Without this check a `:read` holder would
|
||||
* clear that class gate and then reach any write route that has no method
|
||||
* gate of its own. Keying on the HTTP verb closes that by construction rather
|
||||
* than by an audit that goes stale the next time a route is added.
|
||||
*/
|
||||
const satisfiedBy = (
|
||||
user: TCurrentUser,
|
||||
required: string,
|
||||
method: string,
|
||||
): boolean => {
|
||||
if (hasFreightPermission(user, required)) return true;
|
||||
if (!SAFE_METHODS.has(method)) return false;
|
||||
const readTwin = readTwinOf(required);
|
||||
return Boolean(readTwin && hasFreightPermission(user, readTwin));
|
||||
};
|
||||
|
||||
export function FreightPermissionGuard(
|
||||
permissions: string[],
|
||||
): Type<CanActivate> {
|
||||
@Injectable()
|
||||
class FreightPermissionsGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest<{ user?: TCurrentUser }>();
|
||||
const request = context
|
||||
.switchToHttp()
|
||||
.getRequest<{ user?: TCurrentUser; method: string }>();
|
||||
const user = request.user;
|
||||
|
||||
if (!user) {
|
||||
@@ -39,7 +70,7 @@ export function FreightPermissionGuard(
|
||||
}
|
||||
|
||||
if (!permissions?.length) return true;
|
||||
if (permissions.some((p) => hasFreightPermission(user, p))) {
|
||||
if (permissions.some((p) => satisfiedBy(user, p, request.method))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -79,7 +110,9 @@ export function MixedAudienceGuard(permissions: string[]): Type<CanActivate> {
|
||||
@Injectable()
|
||||
class MixedAudiencesGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest<{ user?: TCurrentUser }>();
|
||||
const request = context
|
||||
.switchToHttp()
|
||||
.getRequest<{ user?: TCurrentUser; method: string }>();
|
||||
const user = request.user;
|
||||
|
||||
if (!user) {
|
||||
@@ -93,7 +126,7 @@ export function MixedAudienceGuard(permissions: string[]): Type<CanActivate> {
|
||||
}
|
||||
if (
|
||||
!permissions?.length ||
|
||||
permissions.some((p) => hasFreightPermission(user, p))
|
||||
permissions.some((p) => satisfiedBy(user, p, request.method))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -215,8 +215,11 @@ export function buildFreightMigrationDataSourceOptions(): DataSourceOptions {
|
||||
};
|
||||
}
|
||||
|
||||
export default registerAs("database", (): TypeOrmModuleOptions => ({
|
||||
...buildDataSourceOptions(),
|
||||
autoLoadEntities: true,
|
||||
migrationsRun: false,
|
||||
}));
|
||||
export default registerAs(
|
||||
"database",
|
||||
(): TypeOrmModuleOptions => ({
|
||||
...buildDataSourceOptions(),
|
||||
autoLoadEntities: true,
|
||||
migrationsRun: false,
|
||||
}),
|
||||
);
|
||||
|
||||
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.freightType,
|
||||
contract.customsClearingEnabled,
|
||||
// Bulk templates are keyed by the contract's cargo type.
|
||||
(contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId,
|
||||
);
|
||||
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 { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
|
||||
@@ -19,6 +20,7 @@ import { paginateQuery } from '../../common/utils/pagination.util';
|
||||
export class ListUsersService {
|
||||
constructor(
|
||||
@InjectRepository(User) private readonly users: Repository<User>,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
findAll(query: ListUsersQueryDto): Promise<PaginatedResponse<User>> {
|
||||
@@ -40,6 +42,29 @@ export class ListUsersService {
|
||||
])
|
||||
.orderBy(`user.${sortBy}`, query.sortOrder ?? 'ASC');
|
||||
|
||||
// Restrict to one IAM organization when configured. The org key differs per
|
||||
// environment (dev seeds `edr_freight`, production uses the registered
|
||||
// company key), so this is config rather than a constant. An unset key
|
||||
// means no restriction; a key matching no organization matches no user —
|
||||
// failing closed rather than silently widening to every org.
|
||||
const orgKey = this.config.get<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) {
|
||||
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
|
||||
* the notification recipients resolver's `allBackoffice` selector.
|
||||
* support chat for staff room membership. Notifications deliberately do NOT
|
||||
* use this: they target a desk via `getEmployeeUserIdsByPermission`.
|
||||
*/
|
||||
async getAllCurrentEmployeeUserIds(): Promise<string[]> {
|
||||
const employees = await this.employeeRepository.find({
|
||||
@@ -63,24 +64,67 @@ export class BackofficeService {
|
||||
* IAM user ids of current employees (any org) holding ANY of the given
|
||||
* permission keys — used by the notification recipients resolver's
|
||||
* `permissionKeys` selector for department/role-scoped targeting.
|
||||
*
|
||||
* This MUST agree with the request-time guard (`hasFreightPermission`,
|
||||
* common/freight-permission.util.ts), which counts four grant carriers plus
|
||||
* the super_admin bypass. Counting fewer silently drops legitimate
|
||||
* recipients: an earlier version joined only direct position permissions, on
|
||||
* which `bookings:view` resolved to 2 users — against 21 through position
|
||||
* TYPES, which is where admin-created positions actually keep their grants.
|
||||
*
|
||||
* Raw SQL rather than QueryBuilder because `Position.positionTypePermissions`
|
||||
* declares its inverse side against PositionType, so a relation join emits
|
||||
* `ptp.position_type_id = position.id` and silently matches nothing. Same
|
||||
* approach as FreightMeService's position-type lookups.
|
||||
*/
|
||||
async getEmployeeUserIdsByPermission(
|
||||
permissionKeys: string[],
|
||||
): Promise<string[]> {
|
||||
if (!permissionKeys.length) return [];
|
||||
const rows: { userId: string | null }[] = await this.employeeRepository
|
||||
.createQueryBuilder("employee")
|
||||
.innerJoin("employee.employeePositions", "employeePosition")
|
||||
.innerJoin("employeePosition.position", "position")
|
||||
.innerJoin("position.positionPermission", "positionPermission")
|
||||
.innerJoin("positionPermission.permission", "permission")
|
||||
.where("employee.isCurrent = :isCurrent", { isCurrent: true })
|
||||
.andWhere("permission.key IN (:...permissionKeys)", { permissionKeys })
|
||||
.select("DISTINCT employee.user_id", "userId")
|
||||
.getRawMany();
|
||||
return rows
|
||||
.map((r) => r.userId)
|
||||
.filter((id): id is string => Boolean(id));
|
||||
const rows: { userId: string }[] = await this.dataSource.query(
|
||||
`WITH target AS (SELECT id FROM iam.permissions WHERE key = ANY($1))
|
||||
-- 1. IAM role grants (user_roles -> role_permissions).
|
||||
SELECT e.user_id AS "userId"
|
||||
FROM iam.employees e
|
||||
JOIN iam.user_roles ur ON ur.user_id = e.user_id
|
||||
JOIN iam.role_permissions rp ON rp.role_id = ur.role_id
|
||||
WHERE e.is_current AND e.user_id IS NOT NULL
|
||||
AND rp.permission_id IN (SELECT id FROM target)
|
||||
UNION
|
||||
-- 2. Direct position grants. A delegate keeps their own position AND
|
||||
-- gains the one they stand in for, so both columns count.
|
||||
SELECT e.user_id
|
||||
FROM iam.employees e
|
||||
JOIN iam.employee_positions ep
|
||||
ON ep.employee_id = e.id AND ep.is_current
|
||||
JOIN iam.position_permissions pp
|
||||
ON pp.position_id IN (ep.position_id, ep.delegatee_position_id)
|
||||
WHERE e.is_current AND e.user_id IS NOT NULL
|
||||
AND pp.permission_id IN (SELECT id FROM target)
|
||||
UNION
|
||||
-- 3. Position TYPE grants — where admin-created positions keep theirs.
|
||||
SELECT e.user_id
|
||||
FROM iam.employees e
|
||||
JOIN iam.employee_positions ep
|
||||
ON ep.employee_id = e.id AND ep.is_current
|
||||
JOIN iam.positions p
|
||||
ON p.id IN (ep.position_id, ep.delegatee_position_id)
|
||||
JOIN iam.position_type_permissions ptp
|
||||
ON ptp.position_type_id = p.position_type_id
|
||||
WHERE e.is_current AND e.user_id IS NOT NULL
|
||||
AND ptp.permission_id IN (SELECT id FROM target)
|
||||
UNION
|
||||
-- 4. super_admin passes every freight permission check, so mirror that
|
||||
-- here or admins go blind on desks nobody else has been granted yet.
|
||||
SELECT e.user_id
|
||||
FROM iam.employees e
|
||||
JOIN iam.user_roles ur ON ur.user_id = e.user_id
|
||||
JOIN iam.roles r ON r.id = ur.role_id
|
||||
WHERE e.is_current AND e.user_id IS NOT NULL
|
||||
AND r.key = 'super_admin'`,
|
||||
[permissionKeys],
|
||||
);
|
||||
return rows.map((r) => r.userId);
|
||||
}
|
||||
|
||||
async createOrganizationUser(
|
||||
|
||||
@@ -1,26 +1,44 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiConsumes,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from "@nestjs/swagger";
|
||||
import type { Response } from "express";
|
||||
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||
|
||||
import { BookingStaff } from "../../common/booking-guards";
|
||||
import { resolveAuthUserId } from "../../common/resolve-auth-user-id";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { actorLabel } from "../warehouses/current-actor.util";
|
||||
import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service";
|
||||
import { BillingService } from "./billing.service";
|
||||
import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
|
||||
|
||||
@ApiTags("billing")
|
||||
@Controller("billing")
|
||||
@BookingStaff(FREIGHT_PERMS.invoices.view)
|
||||
// Class gate lists every key its routes use: Nest runs class AND method
|
||||
// guards, so a key missing here would deny before the route's own key runs.
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.invoices.view,
|
||||
FREIGHT_PERMS.invoices.export,
|
||||
FREIGHT_PERMS.invoices.confirmOffline,
|
||||
])
|
||||
@ApiBearerAuth()
|
||||
export class BillingController {
|
||||
constructor(
|
||||
@@ -51,6 +69,36 @@ export class BillingController {
|
||||
return this.billingService.findById(id);
|
||||
}
|
||||
|
||||
@Get("offline-usd")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Finance worklist: USD invoices settled offline by bank transfer, with booking pay-window context",
|
||||
})
|
||||
findOfflineUsd(@Query() query: FilterInvoiceDto) {
|
||||
return this.billingService.findOfflineUsdPaginated(query);
|
||||
}
|
||||
|
||||
@Post("invoices/:id/confirm-offline")
|
||||
@BookingStaff(FREIGHT_PERMS.invoices.confirmOffline)
|
||||
@UseInterceptors(FileInterceptor("file"))
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Finance confirms a USD invoice paid by bank transfer — slip file required, settles the full balance",
|
||||
})
|
||||
confirmOffline(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@UploadedFile() file: Express.Multer.File | undefined,
|
||||
@Body("reference") reference: string | undefined,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.billingService.confirmOfflinePayment(id, file, {
|
||||
reference: reference?.trim() || null,
|
||||
userId: resolveAuthUserId(user),
|
||||
userName: actorLabel(user) ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
@Get("invoices/:id/document")
|
||||
@BookingStaff(FREIGHT_PERMS.invoices.export)
|
||||
@ApiOperation({ summary: "Download the sealed invoice PDF" })
|
||||
|
||||
@@ -13,6 +13,7 @@ import { InvoiceRepository } from "./invoice.repository";
|
||||
import { InvoiceLineRepository } from "./invoice-line.repository";
|
||||
import { PaymentModule } from "../payment/payment.module";
|
||||
import { CompaniesModule } from "../companies/companies.module";
|
||||
import { FilesModule } from "../files/files.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -21,6 +22,7 @@ import { CompaniesModule } from "../companies/companies.module";
|
||||
CompaniesModule,
|
||||
DocumentsModule,
|
||||
UserTradeAccessModule,
|
||||
FilesModule,
|
||||
],
|
||||
controllers: [BillingController, PortalBillingController, PaymentController],
|
||||
providers: [BillingService, InvoiceRepository, InvoiceLineRepository],
|
||||
|
||||
@@ -79,6 +79,7 @@ describe("BillingService.generateInvoice", () => {
|
||||
{} as never, // payment
|
||||
{} as never, // companies
|
||||
{} as never, // invoiceDocuments
|
||||
{} as never, // files
|
||||
);
|
||||
});
|
||||
|
||||
@@ -140,6 +141,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
||||
{} as never, // payment
|
||||
{} as never, // companies
|
||||
{} as never, // invoiceDocuments
|
||||
{} as never, // files
|
||||
);
|
||||
|
||||
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
||||
@@ -193,6 +195,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
||||
{} as never, // payment
|
||||
{} as never, // companies
|
||||
{} as never, // invoiceDocuments
|
||||
{} as never, // files
|
||||
);
|
||||
|
||||
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
||||
@@ -236,6 +239,7 @@ describe("BillingService.settleByPaymentId", () => {
|
||||
{} as never, // payment
|
||||
{} as never, // companies
|
||||
{} as never, // invoiceDocuments
|
||||
{} as never, // files
|
||||
);
|
||||
return { service, mg, events };
|
||||
}
|
||||
@@ -347,6 +351,7 @@ describe("BillingService.recordPayment", () => {
|
||||
{} as never, // payment
|
||||
{} as never, // companies
|
||||
{} as never, // invoiceDocuments
|
||||
{} as never, // files
|
||||
);
|
||||
return { service, mg, events };
|
||||
}
|
||||
@@ -462,6 +467,7 @@ describe("BillingService.expirePayable — locked write runs in a transaction",
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, defaultManager, txManager, transaction };
|
||||
};
|
||||
@@ -533,6 +539,7 @@ describe("BillingService.issuePayable", () => {
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, manager };
|
||||
};
|
||||
@@ -622,6 +629,7 @@ describe("BillingService — CAC Bank (OTP debit)", () => {
|
||||
payment as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, repo };
|
||||
};
|
||||
@@ -703,6 +711,7 @@ describe("BillingService — CBE bill amounts round UP to whole birr", () => {
|
||||
payment as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, repo };
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ import { DataSource, EntityManager, In } from "typeorm";
|
||||
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { CompaniesService } from "../companies/companies.service";
|
||||
import { FilesService } from "../files/files.service";
|
||||
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
|
||||
import { PaymentService } from "../payment/payment.service";
|
||||
import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto";
|
||||
@@ -35,6 +36,14 @@ export interface PayInvoiceOptions {
|
||||
failureUrl?: string;
|
||||
}
|
||||
|
||||
/** Booking context attached to a finance offline-USD invoice row. */
|
||||
export interface OfflineUsdBookingInfo {
|
||||
id: string;
|
||||
reference: string;
|
||||
paymentDeadline: Date | null;
|
||||
paymentStatus: string;
|
||||
}
|
||||
|
||||
/** A single manual/offline settlement to record against an invoice. */
|
||||
export interface RecordPaymentInput {
|
||||
/** Amount settled by this payment; must be > 0. */
|
||||
@@ -150,6 +159,7 @@ export class BillingService {
|
||||
private readonly payment: PaymentService,
|
||||
private readonly companies: CompaniesService,
|
||||
private readonly invoiceDocuments: InvoiceDocumentService,
|
||||
private readonly files: FilesService,
|
||||
) { }
|
||||
|
||||
// ── Reads ──────────────────────────────────────────────────────────────────
|
||||
@@ -214,6 +224,146 @@ export class BillingService {
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Finance's offline-settlement worklist: USD invoices (paid by bank transfer,
|
||||
* never through the gateway), open ones by default or a single status when
|
||||
* filtered. Booking-sourced rows carry the booking's reference and pay-window
|
||||
* deadline so the UI can show the countdown and link to the booking.
|
||||
*/
|
||||
async findOfflineUsdPaginated(
|
||||
filter: {
|
||||
status?: Freight.InvoiceStatus;
|
||||
search?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
} = {},
|
||||
): Promise<{
|
||||
items: (Invoice & { booking: OfflineUsdBookingInfo | null })[];
|
||||
total: number;
|
||||
}> {
|
||||
const page = filter.page && filter.page > 0 ? filter.page : 1;
|
||||
const pageSize =
|
||||
filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20;
|
||||
|
||||
const qb = this.dataSource
|
||||
.getRepository(Invoice)
|
||||
.createQueryBuilder("invoice")
|
||||
.leftJoinAndSelect("invoice.company", "company")
|
||||
.where("UPPER(invoice.currency) = 'USD'")
|
||||
.orderBy("invoice.issuedAt", "DESC")
|
||||
.skip((page - 1) * pageSize)
|
||||
.take(pageSize);
|
||||
|
||||
if (filter.status) {
|
||||
qb.andWhere("invoice.status = :status", { status: filter.status });
|
||||
} else {
|
||||
qb.andWhere("invoice.status IN (:...open)", { open: OPEN_STATUSES });
|
||||
}
|
||||
if (filter.search) {
|
||||
qb.andWhere(
|
||||
"(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)",
|
||||
{ search: `%${filter.search}%` },
|
||||
);
|
||||
}
|
||||
|
||||
const [items, total] = await qb.getManyAndCount();
|
||||
|
||||
const bookingIds = items
|
||||
.filter((i) => i.source === "booking")
|
||||
.map((i) => i.sourceId);
|
||||
const bookings = bookingIds.length
|
||||
? await this.dataSource.getRepository(Booking).find({
|
||||
where: { id: In(bookingIds) },
|
||||
select: ["id", "reference", "paymentDeadline", "paymentStatus"],
|
||||
})
|
||||
: [];
|
||||
const byId = new Map(bookings.map((b) => [b.id, b]));
|
||||
|
||||
return {
|
||||
items: items.map((inv) => {
|
||||
const b = byId.get(inv.sourceId);
|
||||
return {
|
||||
...inv,
|
||||
booking: b
|
||||
? {
|
||||
id: b.id,
|
||||
reference: b.reference,
|
||||
paymentDeadline: b.paymentDeadline ?? null,
|
||||
paymentStatus: b.paymentStatus,
|
||||
}
|
||||
: null,
|
||||
} as Invoice & { booking: OfflineUsdBookingInfo | null };
|
||||
}),
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Finance confirms a USD invoice as paid by bank transfer: stores the slip
|
||||
* against the invoice and settles the FULL outstanding balance through
|
||||
* {@link recordPayment}, which flips the invoice to PAID and (for bookings)
|
||||
* emits `booking.invoice.paid` — the same event an online payment fires, so
|
||||
* the booking advances exactly as if it had been paid through the gateway.
|
||||
*
|
||||
* Guarded by the booking's pay window: past the deadline the booking expires
|
||||
* like any unpaid one, so confirmation is refused.
|
||||
*/
|
||||
async confirmOfflinePayment(
|
||||
invoiceId: string,
|
||||
file: Express.Multer.File | undefined,
|
||||
input: {
|
||||
reference?: string | null;
|
||||
userId?: string | null;
|
||||
userName?: string | null;
|
||||
},
|
||||
): Promise<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. */
|
||||
async findById(id: string): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||
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 { Freight } from "@edr/types";
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
import type { EimsInvoiceError, EimsInvoiceStatus } from "../../eims/eims-registration.types";
|
||||
import { PaymentEntity } from "../../payment/entities/payment.entity";
|
||||
import { Company } from "../../companies/entities/company.entity";
|
||||
import { CompanyProfile } from "../../companies/entities/company-profile.entity";
|
||||
@@ -105,4 +106,31 @@ export class Invoice extends BaseEntity {
|
||||
|
||||
@Column({ name: "due_at", type: "timestamptz" })
|
||||
dueAt!: Date;
|
||||
|
||||
/** MoR EIMS registration state. Set only by the EIMS module; billing never writes these. */
|
||||
@Column({ name: "eims_status", type: "varchar", length: 20, default: "NOT_SUBMITTED" })
|
||||
eimsStatus!: EimsInvoiceStatus;
|
||||
|
||||
/** Invoice Reference Number returned by EIMS. Unique across invoices (partial index). */
|
||||
@Column({ name: "eims_irn", type: "varchar", length: 64, nullable: true })
|
||||
eimsIrn?: string | null;
|
||||
|
||||
/** The numeric `DocumentDetails.DocumentNumber` filed for this invoice. */
|
||||
@Column({ name: "eims_document_number", type: "varchar", length: 16, nullable: true })
|
||||
eimsDocumentNumber?: string | null;
|
||||
|
||||
/** The `SourceSystem.InvoiceCounter` this invoice consumed. */
|
||||
@Column({ name: "eims_invoice_counter", type: "bigint", nullable: true })
|
||||
eimsInvoiceCounter?: number | null;
|
||||
|
||||
@Column({ name: "eims_submitted_at", type: "timestamptz", nullable: true })
|
||||
eimsSubmittedAt?: Date | null;
|
||||
|
||||
/** EIMS acknowledgement timestamp, stored verbatim — it is a Java ZonedDateTime string. */
|
||||
@Column({ name: "eims_ack_date", type: "varchar", length: 64, nullable: true })
|
||||
eimsAckDate?: string | null;
|
||||
|
||||
/** Sanitized last failure: the gateway's own error fields only, never our signed envelope. */
|
||||
@Column({ name: "eims_last_error", type: "jsonb", nullable: true })
|
||||
eimsLastError?: EimsInvoiceError | null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service';
|
||||
import type { Booking } from './entities/booking.entity';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
|
||||
/**
|
||||
* Who hears "Operations wants changes" depends on who owns the booking. A
|
||||
@@ -74,3 +75,39 @@ describe('BookingLifecycleNotifierService — operation changes requested', () =
|
||||
expect(inbox.notify.mock.calls[0][0].recipients).toEqual({ companyId: 'co-1' });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Staff notifications used to go to every employee in every organization. They
|
||||
* now target a desk — and the two desks are disjoint: the GL presets hold no
|
||||
* bookings:view and no intake keys, so intake pings would be noise they cannot
|
||||
* act on. Both branches run through the same `inAppStaff` helper, which is the
|
||||
* easy place to lose the distinction again.
|
||||
*/
|
||||
describe('BookingLifecycleNotifierService — staff desk targeting', () => {
|
||||
const booking = () =>
|
||||
({ id: 'b-1', reference: 'BKG-0001', companyId: 'co-1' }) as Booking;
|
||||
|
||||
let inbox: { notify: jest.Mock };
|
||||
let service: BookingLifecycleNotifierService;
|
||||
|
||||
beforeEach(() => {
|
||||
inbox = { notify: jest.fn().mockResolvedValue(undefined) };
|
||||
service = new BookingLifecycleNotifierService(
|
||||
{ directSend: jest.fn().mockResolvedValue(undefined) } as never,
|
||||
inbox as never,
|
||||
{ query: jest.fn().mockResolvedValue([]) } as never,
|
||||
);
|
||||
});
|
||||
|
||||
it('routes intake items to the booking desk and clearance items to the clearance desk', () => {
|
||||
service.submittedToStaff(booking());
|
||||
service.clearanceDocsUploadedToStaff(booking());
|
||||
|
||||
expect(inbox.notify.mock.calls[0][0].recipients).toEqual({
|
||||
permissionKeys: [FREIGHT_PERMS.bookings.getNotification],
|
||||
});
|
||||
expect(inbox.notify.mock.calls[1][0].recipients).toEqual({
|
||||
permissionKeys: [FREIGHT_PERMS.bookings.clearanceGetNotification],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,16 @@ import { Booking } from './entities/booking.entity';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
|
||||
/**
|
||||
* Clearance items are worked by the GL desks, which hold no bookings:view and
|
||||
* no intake keys — so they take their own selector rather than the booking
|
||||
* desk's. Every override using this deep-links to a clearance page.
|
||||
*/
|
||||
const CLEARANCE_DESK = {
|
||||
permissionKeys: [FREIGHT_PERMS.bookings.clearanceGetNotification],
|
||||
};
|
||||
|
||||
/**
|
||||
* Customer + staff notifications for the booking lifecycle: review, clearance
|
||||
@@ -89,7 +99,11 @@ export class BookingLifecycleNotifierService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Persist + push an in-app item to every backoffice staff user. */
|
||||
/**
|
||||
* Persist + push an in-app item to the booking desk — staff holding
|
||||
* `bookings:get_notification`. Callers whose item belongs to a different desk
|
||||
* override `recipients` (see {@link CLEARANCE_DESK}).
|
||||
*/
|
||||
private inAppStaff(
|
||||
b: Booking,
|
||||
title: string,
|
||||
@@ -97,7 +111,7 @@ export class BookingLifecycleNotifierService {
|
||||
overrides: Partial<NotifyInput> = {},
|
||||
): void {
|
||||
void this.inbox.notify({
|
||||
recipients: { allBackoffice: true },
|
||||
recipients: { permissionKeys: [FREIGHT_PERMS.bookings.getNotification] },
|
||||
audience: NotificationAudience.BACKOFFICE,
|
||||
type: NotificationType.REQUEST_SUBMITTED,
|
||||
title,
|
||||
@@ -274,6 +288,7 @@ export class BookingLifecycleNotifierService {
|
||||
`the customs declaration can be filed.${note ? ` Note: "${note}"` : ''}`;
|
||||
this.logger.log(`TRANSIT ASSIGNEE REQUESTED — ${this.ref(b)}`);
|
||||
this.inAppStaff(b, `Transit assignee needed — ${b.reference}`, msg, {
|
||||
recipients: CLEARANCE_DESK,
|
||||
type: NotificationType.CLEARANCE_REVIEW,
|
||||
link: `/dashboard/gl-djibouti/clearance/${b.id}`,
|
||||
});
|
||||
@@ -288,6 +303,7 @@ export class BookingLifecycleNotifierService {
|
||||
`The customs declaration can now be filed.`;
|
||||
this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${this.ref(b)}`);
|
||||
this.inAppStaff(b, `Transit assignee set — ${b.reference}`, msg, {
|
||||
recipients: CLEARANCE_DESK,
|
||||
type: NotificationType.CLEARANCE_REVIEW,
|
||||
link: `/dashboard/bookings/${b.id}/clearance`,
|
||||
});
|
||||
@@ -395,6 +411,7 @@ export class BookingLifecycleNotifierService {
|
||||
'Clearance documents uploaded',
|
||||
`Customer uploaded clearance documents for booking ${this.ref(b)} — review them in the clearance queue.`,
|
||||
{
|
||||
recipients: CLEARANCE_DESK,
|
||||
type: NotificationType.CLEARANCE_REVIEW,
|
||||
link: `/dashboard/bookings/${b.id}/clearance`,
|
||||
},
|
||||
@@ -422,6 +439,7 @@ export class BookingLifecycleNotifierService {
|
||||
`The customer requested a change to the draft declaration on booking ${this.ref(b)}: ` +
|
||||
`"${note}". Send a corrected draft from the clearance page.`;
|
||||
this.inAppStaff(b, `Draft declaration change requested — ${this.ref(b)}`, msg, {
|
||||
recipients: CLEARANCE_DESK,
|
||||
type: NotificationType.CLEARANCE_REVIEW,
|
||||
link: `/dashboard/bookings/${b.id}/clearance`,
|
||||
});
|
||||
@@ -440,6 +458,7 @@ export class BookingLifecycleNotifierService {
|
||||
'Payment slip uploaded',
|
||||
`Customer uploaded the ${label} payment slip for booking ${this.ref(b)}.`,
|
||||
{
|
||||
recipients: CLEARANCE_DESK,
|
||||
type: NotificationType.PAYMENT_RECEIVED,
|
||||
link: `/dashboard/bookings/${b.id}/clearance`,
|
||||
},
|
||||
|
||||
@@ -20,7 +20,7 @@ import { NotificationInboxService } from '../notification-inbox/notification-inb
|
||||
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
||||
import { Rate } from '../rule-engine/entities/rate.entity';
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
||||
import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
|
||||
import { WagonAllocationBulkLoad } from '../train-schedules/entities/wagon-allocation-bulk-load.entity';
|
||||
import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity';
|
||||
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
|
||||
@@ -1112,12 +1112,14 @@ export class BookingWagonCancellationService {
|
||||
|
||||
private notifyStaff(booking: Booking, title: string, body: string): void {
|
||||
void this.inbox.notify({
|
||||
recipients: { allBackoffice: true },
|
||||
recipients: { permissionKeys: [FREIGHT_PERMS.bookings.getNotification] },
|
||||
audience: NotificationAudience.BACKOFFICE,
|
||||
type: NotificationType.BOOKING_STATUS,
|
||||
title,
|
||||
body,
|
||||
link: `/bookings/${booking.id}`,
|
||||
// The portal path `/bookings/:id` used to be sent here, which 404s in the
|
||||
// dashboard. The staff view of these lives on the queue page.
|
||||
link: '/dashboard/wagon-cancellations',
|
||||
data: { bookingId: booking.id, reference: booking.reference },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ import { LastMileService } from '../last-mile/last-mile.service';
|
||||
import { GenerateGrnDto } from './dto/generate-grn.dto';
|
||||
import { ContainerReceiptService } from './container-receipt.service';
|
||||
import { SignContractDto } from './dto/sign-contract.dto';
|
||||
import { SetExportHandoverModeDto } from './dto/set-export-handover-mode.dto';
|
||||
import { UpdateBookingDto } from './dto/update-booking.dto';
|
||||
import { BookingWagonCancellationService } from './booking-wagon-cancellation.service';
|
||||
import {
|
||||
@@ -757,6 +758,18 @@ export class BookingsController {
|
||||
return this.customerTruckService.getLoadableContainers(id);
|
||||
}
|
||||
|
||||
@Patch(':id/export-handover-mode')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||
@ApiOperation({
|
||||
summary: 'Export only: choose direct truck-to-train (no warehouse, no GRN) or warehouse first',
|
||||
})
|
||||
setExportHandoverMode(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: SetExportHandoverModeDto,
|
||||
) {
|
||||
return this.bookingsService.setExportHandoverMode(id, dto.exportHandoverMode);
|
||||
}
|
||||
|
||||
@Post(':id/customer-trucks/:assignmentId/load')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||
@ApiOperation({ summary: 'Truck_dispatch: load selected containers onto a truck (staff)' })
|
||||
|
||||
@@ -13,7 +13,7 @@ import { insertWithGeneratedReference } from '@edr/api-common';
|
||||
// import { CustomersService } from '../customers/customers.service';
|
||||
import { CompaniesService } from '../companies/companies.service';
|
||||
import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity';
|
||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
||||
import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
|
||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { MinioService } from '../minio/minio.service';
|
||||
@@ -28,7 +28,7 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { DataSource, In } from 'typeorm';
|
||||
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
import { assertExportReceivedWithGrn } from '../../common/export-received-gate';
|
||||
import { assertExportReceivedWithGrn, DIRECT_TO_TRAIN } from '../../common/export-received-gate';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
@@ -285,10 +285,24 @@ export class BookingsService {
|
||||
// Receipt is proven by the warehouse GRN, but the GRN is warehouse paperwork
|
||||
// and never appears on this sheet — it is only the signal that EDR has taken
|
||||
// the cargo, which is what the customer's sheet attests to.
|
||||
const isDirectExport =
|
||||
booking.tradeDirection === 'EXPORT' && booking.exportHandoverMode === DIRECT_TO_TRAIN;
|
||||
const pendingWagons = wagons.length === 0;
|
||||
if (pendingWagons) {
|
||||
const receivedLines: CarriageAcceptanceReceivedRow[] =
|
||||
booking.tradeDirection === 'EXPORT'
|
||||
// Direct truck-to-train cargo never enters the warehouse, so there is no
|
||||
// GRN'd inventory to build the sheet from. Choosing direct handover is
|
||||
// itself the acceptance, so the sheet issues off the booking's own
|
||||
// containers (or its VGM weight when the cargo is bulk).
|
||||
const receivedLines: CarriageAcceptanceReceivedRow[] = isDirectExport
|
||||
? await this.dataSource.query(
|
||||
`SELECT NULL::numeric AS "allocatedWeightTons",
|
||||
c.container_number AS "containerNumbers"
|
||||
FROM freight.containers c
|
||||
WHERE c.booking_id = $1 AND c.deleted_at IS NULL
|
||||
ORDER BY c.container_number`,
|
||||
[bookingId],
|
||||
)
|
||||
: booking.tradeDirection === 'EXPORT'
|
||||
? await this.dataSource.query(
|
||||
`SELECT inv.weight AS "allocatedWeightTons",
|
||||
c.container_number AS "containerNumbers"
|
||||
@@ -304,6 +318,15 @@ export class BookingsService {
|
||||
[bookingId],
|
||||
)
|
||||
: [];
|
||||
// Bulk direct cargo has no containers — one line carrying the booking's
|
||||
// declared weight still makes a valid sheet.
|
||||
if (isDirectExport && receivedLines.length === 0) {
|
||||
receivedLines.push({
|
||||
allocatedWeightTons:
|
||||
booking.bulkTotalWeightTons == null ? null : String(booking.bulkTotalWeightTons),
|
||||
containerNumbers: null,
|
||||
});
|
||||
}
|
||||
if (receivedLines.length === 0) {
|
||||
throw new BadRequestException(
|
||||
booking.tradeDirection === 'EXPORT'
|
||||
@@ -424,7 +447,10 @@ export class BookingsService {
|
||||
|
||||
const departureStation = booking.originYard?.label ?? booking.originYard?.code ?? '-';
|
||||
const arrivalStation = booking.destinationYard?.label ?? booking.destinationYard?.code ?? '-';
|
||||
const cargoName = booking.cargoType?.cargoTypeName ?? booking.cargoFreeText ?? '-';
|
||||
// Container bookings carry no cargo type or free text — name the freight type
|
||||
// rather than printing a dash in the Cargo Name column.
|
||||
const cargoName =
|
||||
booking.cargoType?.cargoTypeName ?? booking.cargoFreeText ?? booking.freightType ?? '-';
|
||||
const currency = booking.paymentCurrency ?? 'ETB';
|
||||
const totalAmount = Number(booking.adjustedTotalAmount ?? booking.totalAmount) || 0;
|
||||
const prices = this.splitAmountAcrossWagons(
|
||||
@@ -467,6 +493,28 @@ export class BookingsService {
|
||||
)
|
||||
.join('');
|
||||
|
||||
// The totals belong in <tbody>, not <tfoot>: the Chromium-less fallback
|
||||
// renderer only parses tbody rows, so a <tfoot> silently drops every footer
|
||||
// figure from the printed sheet.
|
||||
const totalsRow = `<tr class="totals">
|
||||
<td>TOT</td>
|
||||
<td>${wagons.length} ${pendingWagons ? 'received lines' : 'wagons'}</td>
|
||||
<td>${
|
||||
pendingWagons
|
||||
? 'pending marshalling'
|
||||
: `full ${fullWagons} / empty ${wagons.length - fullWagons}`
|
||||
}</td>
|
||||
<td class="num">${num(totals.tare, 2)}</td>
|
||||
<td class="num">${num(totals.length)}</td>
|
||||
<td class="num">${num(totals.capacity)}</td>
|
||||
<td></td>
|
||||
<td>Gross ${num(totals.tare + totals.load)} T</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td class="num">${money(totalAmount)}</td>
|
||||
</tr>`;
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
@@ -490,7 +538,7 @@ export class BookingsService {
|
||||
th { background: #f8fafc; color: #475569; text-align: left; }
|
||||
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
|
||||
.num { text-align: right; }
|
||||
tfoot td { background: #f8fafc; font-weight: 700; }
|
||||
tr.totals td { background: #f8fafc; font-weight: 700; }
|
||||
.notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; }
|
||||
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; }
|
||||
.line { border-top: 1px solid #334155; padding-top: 7px; font-size: 9px; color: #475569; min-height: 34px; }
|
||||
@@ -538,21 +586,8 @@ export class BookingsService {
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows}
|
||||
${totalsRow}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="3">${
|
||||
pendingWagons
|
||||
? `Received lines: ${wagons.length} — wagons pending marshalling`
|
||||
: `Total wagons: ${wagons.length} (full ${fullWagons} / empty ${wagons.length - fullWagons})`
|
||||
}</td>
|
||||
<td class="num">${num(totals.tare, 2)}</td>
|
||||
<td class="num">${num(totals.length)}</td>
|
||||
<td class="num">${num(totals.capacity)}</td>
|
||||
<td colspan="5">Gross weight (tare + load): ${num(totals.tare + totals.load)} T</td>
|
||||
<td class="num">${money(totalAmount)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
<div class="notice">
|
||||
@@ -1985,6 +2020,47 @@ export class BookingsService {
|
||||
}
|
||||
|
||||
/** Get a single booking by ID with files. */
|
||||
/**
|
||||
* EXPORT only. Choose how the cargo reaches the train. DIRECT_TO_TRAIN takes
|
||||
* the booking out of the warehouse flow entirely — no receipt, no GRN, and the
|
||||
* carriage acceptance sheet becomes issuable straight away.
|
||||
*
|
||||
* Switching to direct is refused once the goods are already in the shed:
|
||||
* inventory exists, so the cargo demonstrably went the warehouse route and its
|
||||
* GRN paperwork must stand.
|
||||
*/
|
||||
async setExportHandoverMode(
|
||||
bookingId: string,
|
||||
mode: string,
|
||||
): Promise<{ bookingId: string; exportHandoverMode: string }> {
|
||||
const booking = await this.bookingsRepository.findById(bookingId);
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
}
|
||||
if ((booking.tradeDirection ?? '').toUpperCase() !== 'EXPORT') {
|
||||
throw new BadRequestException('Handover mode applies to export bookings only');
|
||||
}
|
||||
if (mode === DIRECT_TO_TRAIN) {
|
||||
const [stored]: Array<{ one: number }> = await this.dataSource.query(
|
||||
`SELECT 1 AS one
|
||||
FROM freight.warehouse_inventory
|
||||
WHERE booking_id = $1 AND deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[bookingId],
|
||||
);
|
||||
if (stored) {
|
||||
throw new BadRequestException(
|
||||
'This booking already has cargo in the warehouse, so it cannot be switched to direct truck-to-train',
|
||||
);
|
||||
}
|
||||
}
|
||||
await this.dataSource.query(
|
||||
`UPDATE freight.bookings SET export_handover_mode = $2, updated_at = NOW() WHERE id = $1`,
|
||||
[bookingId, mode],
|
||||
);
|
||||
return { bookingId, exportHandoverMode: mode };
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Booking> {
|
||||
const booking = await this.bookingsRepository.findByIdWithFiles(id);
|
||||
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 })
|
||||
customerTruckArrivedAt?: Date | null;
|
||||
|
||||
/**
|
||||
* EXPORT only. How the cargo reaches the train:
|
||||
* - DIRECT_TO_TRAIN — the customer's truck loads straight onto the wagon. No
|
||||
* warehouse, so no GRN is ever raised and the carriage acceptance sheet is
|
||||
* the only document handed over.
|
||||
* - WAREHOUSE (also null) — received into the warehouse and GRN'd first.
|
||||
*
|
||||
* Null is treated as WAREHOUSE so existing bookings keep the GRN gate.
|
||||
*/
|
||||
@Column({ name: 'export_handover_mode', type: 'varchar', length: 20, nullable: true })
|
||||
exportHandoverMode?: string | null;
|
||||
|
||||
/**
|
||||
* Did the goods need re-handling in the warehouse? Recorded by warehouse
|
||||
* staff after unloading. Only `true` bills the DOUBLE_HANDLING_FEE rule;
|
||||
|
||||
@@ -20,7 +20,14 @@ import { CargoesService } from './cargoes.service';
|
||||
|
||||
@ApiTags('cargoes')
|
||||
@Controller('cargoes')
|
||||
@FleetView(FREIGHT_PERMS.cargoes.view)
|
||||
// Class gate lists every key its routes use: Nest runs class AND method
|
||||
// guards, so a key missing here would deny before the route's own key runs.
|
||||
@FleetView([
|
||||
FREIGHT_PERMS.cargoes.view,
|
||||
FREIGHT_PERMS.cargoes.create,
|
||||
FREIGHT_PERMS.cargoes.update,
|
||||
FREIGHT_PERMS.cargoes.delete,
|
||||
])
|
||||
export class CargoesController {
|
||||
constructor(private readonly cargoesService: CargoesService) {}
|
||||
|
||||
|
||||
@@ -421,7 +421,10 @@ export class CompaniesController {
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Body() dto: CompleteIdentityVerificationDto,
|
||||
): 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")
|
||||
@@ -434,7 +437,10 @@ export class CompaniesController {
|
||||
async setGmSameAsOwner(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
): Promise<CompanyIdentityStateDto> {
|
||||
return this.companiesService.setGmSameAsOwner(user.id);
|
||||
return this.companiesService.setGmSameAsOwner(user.id, {
|
||||
email: user.email,
|
||||
phoneNumber: user.phoneNumber,
|
||||
});
|
||||
}
|
||||
|
||||
@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
|
||||
// shape: a sub, no contact details.
|
||||
it("keeps company contact details a Fayda verification never supplied", async () => {
|
||||
const { service, deps } = makeService({
|
||||
const { deps } = makeService({
|
||||
attributes: { ...OWNER_VERIFIED },
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.updateProfile("user-1", {
|
||||
companyEmail: "account@example.com",
|
||||
companyPhone: "+251911777777",
|
||||
} as never),
|
||||
).resolves.toBeDefined();
|
||||
const [, patch] = deps.companiesRepo.update.mock.calls.at(-1)!;
|
||||
expect(patch.email).toBe("account@example.com");
|
||||
expect(patch.phone).toBe("+251911777777");
|
||||
});
|
||||
|
||||
it("overwrites company contact details the verification did supply", async () => {
|
||||
const { service, deps } = makeService({
|
||||
attributes: {
|
||||
...OWNER_VERIFIED,
|
||||
ownerEmail: "abebe@example.com",
|
||||
ownerPhone: "+251911000000",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.updateProfile("user-1", {
|
||||
companyEmail: "someone-else@example.com",
|
||||
companyPhone: "+251911999999",
|
||||
} as never),
|
||||
).resolves.toBeDefined();
|
||||
const [, patch] = deps.companiesRepo.update.mock.calls.at(-1)!;
|
||||
expect(patch.email).toBe("abebe@example.com");
|
||||
expect(patch.phone).toBe("+251911000000");
|
||||
});
|
||||
|
||||
// "Same as owner" copies `ownerEmail ?? null` onto the GM while setting
|
||||
// `gmFaydaSub`. Locking that null made generalManagerEmail required by
|
||||
// onboarding, hidden by the portal's link card and unwritable at once.
|
||||
|
||||
@@ -231,41 +231,39 @@ export class CompaniesService {
|
||||
label: string;
|
||||
get: (company: Company) => unknown;
|
||||
}[] = [
|
||||
{
|
||||
key: "tinNumber",
|
||||
label: "Company TIN",
|
||||
get: (c) => (c.tin && !c.tin.startsWith("D") ? c.tin : null),
|
||||
},
|
||||
{ key: "companyEmail", label: "Company email", get: (c) => c.email },
|
||||
{ key: "companyPhone", label: "Company phone", get: (c) => c.phone },
|
||||
{ key: "companyAddress", label: "Company address", get: (c) => c.address },
|
||||
{ key: "fanNumber", label: "FAN number", get: (c) => c.fanNumber },
|
||||
{
|
||||
key: "contactPersonName",
|
||||
label: "Contact person name",
|
||||
get: (c) => c.attributes?.contactPersonName,
|
||||
},
|
||||
{
|
||||
key: "contactPersonPhone",
|
||||
label: "Contact person phone",
|
||||
get: (c) => c.attributes?.contactPersonPhone,
|
||||
},
|
||||
{
|
||||
key: "generalManagerName",
|
||||
label: "General manager name",
|
||||
get: (c) => c.attributes?.generalManagerName,
|
||||
},
|
||||
{
|
||||
key: "generalManagerEmail",
|
||||
label: "General manager email",
|
||||
get: (c) => c.attributes?.generalManagerEmail,
|
||||
},
|
||||
{
|
||||
key: "generalManagerPhone",
|
||||
label: "General manager phone",
|
||||
get: (c) => c.attributes?.generalManagerPhone,
|
||||
},
|
||||
];
|
||||
{
|
||||
key: "tinNumber",
|
||||
label: "Company TIN",
|
||||
get: (c) => (c.tin && !c.tin.startsWith("D") ? c.tin : null),
|
||||
},
|
||||
{ key: "companyAddress", label: "Company address", get: (c) => c.address },
|
||||
{ key: "fanNumber", label: "FAN number", get: (c) => c.fanNumber },
|
||||
{
|
||||
key: "contactPersonName",
|
||||
label: "Contact person name",
|
||||
get: (c) => c.attributes?.contactPersonName,
|
||||
},
|
||||
{
|
||||
key: "contactPersonPhone",
|
||||
label: "Contact person phone",
|
||||
get: (c) => c.attributes?.contactPersonPhone,
|
||||
},
|
||||
{
|
||||
key: "generalManagerName",
|
||||
label: "General manager name",
|
||||
get: (c) => c.attributes?.generalManagerName,
|
||||
},
|
||||
{
|
||||
key: "generalManagerEmail",
|
||||
label: "General manager email",
|
||||
get: (c) => c.attributes?.generalManagerEmail,
|
||||
},
|
||||
{
|
||||
key: "generalManagerPhone",
|
||||
label: "General manager phone",
|
||||
get: (c) => c.attributes?.generalManagerPhone,
|
||||
},
|
||||
];
|
||||
|
||||
/** The nationality-based document setting code for a company. */
|
||||
private documentSettingCodeFor(
|
||||
@@ -314,8 +312,6 @@ export class CompaniesService {
|
||||
fanNumber: dto.fanNumber ?? null,
|
||||
country: dto.companyLocation ?? "Ethiopia",
|
||||
address: dto.companyAddress ?? null,
|
||||
phone: normalizeE164(dto.companyPhone) ?? null,
|
||||
email: dto.companyEmail ?? null,
|
||||
attributes: dto.attributes ?? null,
|
||||
});
|
||||
|
||||
@@ -492,7 +488,8 @@ export class CompaniesService {
|
||||
async findCompanyById(id: string): Promise<Company> {
|
||||
const company = await this.companiesRepo.findById(id);
|
||||
if (!company) throw new NotFoundException(`Company ${id} not found`);
|
||||
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(id);
|
||||
company.companyProfiles =
|
||||
await this.companyProfilesRepo.findByCompanyId(id);
|
||||
// External profiles carry the onboarding flag the backoffice gates
|
||||
// approval decisions on (see ResponseCompanyDto.onboardingCompleted).
|
||||
company.profiles = await this.profilesRepo.findByCompanyId(id);
|
||||
@@ -515,9 +512,7 @@ export class CompaniesService {
|
||||
);
|
||||
}
|
||||
if (profile.status !== ProfileStatus.Active) {
|
||||
throw new BadRequestException(
|
||||
"Selected company profile is not active",
|
||||
);
|
||||
throw new BadRequestException("Selected company profile is not active");
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
@@ -760,11 +755,6 @@ export class CompaniesService {
|
||||
};
|
||||
|
||||
const keys: string[] = [];
|
||||
if (attrs.ownerFaydaSub) {
|
||||
// The Company-column mirrors of the owner's verified contact details.
|
||||
if (held("ownerEmail")) keys.push("companyEmail");
|
||||
if (held("ownerPhone")) keys.push("companyPhone");
|
||||
}
|
||||
for (const subject of IDENTITY_SUBJECTS) {
|
||||
if (!attrs[`${IDENTITY_PREFIX[subject]}FaydaSub`]) continue;
|
||||
keys.push(...IDENTITY_OWNED_FIELDS[subject].filter(held));
|
||||
@@ -789,9 +779,6 @@ export class CompaniesService {
|
||||
if (dto.nationality !== undefined)
|
||||
companyUpdates.nationality = dto.nationality;
|
||||
if (dto.companyName !== undefined) companyUpdates.name = dto.companyName;
|
||||
if (dto.companyEmail !== undefined) companyUpdates.email = dto.companyEmail;
|
||||
if (dto.companyPhone !== undefined)
|
||||
companyUpdates.phone = normalizeE164(dto.companyPhone);
|
||||
if (dto.companyLocation !== undefined)
|
||||
companyUpdates.country = dto.companyLocation;
|
||||
if (dto.companyAddress !== undefined)
|
||||
@@ -809,7 +796,9 @@ export class CompaniesService {
|
||||
if (dto.contactPersonPhone !== undefined)
|
||||
attrUpdates.contactPersonPhone = normalizeE164(dto.contactPersonPhone);
|
||||
if (dto.contactVerifiedPhone !== undefined)
|
||||
attrUpdates.contactVerifiedPhone = normalizeE164(dto.contactVerifiedPhone);
|
||||
attrUpdates.contactVerifiedPhone = normalizeE164(
|
||||
dto.contactVerifiedPhone,
|
||||
);
|
||||
if (dto.generalManagerName !== undefined)
|
||||
attrUpdates.generalManagerName = dto.generalManagerName;
|
||||
if (dto.generalManagerEmail !== undefined)
|
||||
@@ -820,7 +809,8 @@ export class CompaniesService {
|
||||
if (dto.poaPhone !== undefined)
|
||||
attrUpdates.poaPhone = normalizeE164(dto.poaPhone);
|
||||
if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail;
|
||||
if (dto.poaLocation !== undefined) attrUpdates.poaLocation = dto.poaLocation;
|
||||
if (dto.poaLocation !== undefined)
|
||||
attrUpdates.poaLocation = dto.poaLocation;
|
||||
if (dto.poaAddress !== undefined) attrUpdates.poaAddress = dto.poaAddress;
|
||||
|
||||
if (dto.licenceNumber !== undefined)
|
||||
@@ -857,12 +847,6 @@ export class CompaniesService {
|
||||
Object.assign(attrUpdates, dto.faydaIdentity);
|
||||
}
|
||||
|
||||
// companyEmail/companyPhone are the Company-column mirrors of the owner's
|
||||
// verified contact details (the portal derives and submits them, it never
|
||||
// lets the customer type them once verified) — lock them the same way
|
||||
// ownerEmail/ownerPhone themselves are locked below, once there is a
|
||||
// verified owner to lock them to.
|
||||
//
|
||||
// Keyed on the verified VALUE, not on `ownerFaydaSub`: Fayda's email and
|
||||
// phone claims are optional, so a verification can prove the person while
|
||||
// supplying neither (see completeIdentityVerification's conditional
|
||||
@@ -872,9 +856,8 @@ export class CompaniesService {
|
||||
// forever, and re-verifying could never clear it because Fayda still has
|
||||
// nothing to return.
|
||||
if (attrUpdates.ownerFaydaSub) {
|
||||
if (attrUpdates.ownerEmail && dto.companyEmail !== undefined)
|
||||
companyUpdates.email = attrUpdates.ownerEmail;
|
||||
if (attrUpdates.ownerPhone && dto.companyPhone !== undefined)
|
||||
if (attrUpdates.ownerEmail) companyUpdates.email = attrUpdates.ownerEmail;
|
||||
if (attrUpdates.ownerPhone)
|
||||
companyUpdates.phone = normalizeE164(String(attrUpdates.ownerPhone));
|
||||
}
|
||||
|
||||
@@ -1086,9 +1069,7 @@ export class CompaniesService {
|
||||
}
|
||||
|
||||
/** List a company's change requests, newest first (backoffice review). */
|
||||
async listChangeRequests(
|
||||
companyId: string,
|
||||
): Promise<CompanyChangeRequest[]> {
|
||||
async listChangeRequests(companyId: string): Promise<CompanyChangeRequest[]> {
|
||||
await this.findCompanyById(companyId);
|
||||
return this.changeRequestRepo.findByCompanyId(companyId);
|
||||
}
|
||||
@@ -1150,8 +1131,7 @@ export class CompaniesService {
|
||||
reviewerId?: string,
|
||||
): Promise<CompanyChangeRequest> {
|
||||
const request = await this.changeRequestRepo.findById(id);
|
||||
if (!request)
|
||||
throw new NotFoundException(`Change request ${id} not found`);
|
||||
if (!request) throw new NotFoundException(`Change request ${id} not found`);
|
||||
if (request.status !== ChangeRequestStatus.Pending) {
|
||||
throw new BadRequestException(
|
||||
`Change request ${id} is already ${request.status}`,
|
||||
@@ -1164,7 +1144,10 @@ export class CompaniesService {
|
||||
|
||||
const snapshot = (request.snapshot ?? {}) as Partial<UpdateProfileDto>;
|
||||
await this.assertTinAvailable(company, snapshot.tin);
|
||||
const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, snapshot);
|
||||
const companyUpdates = this.mapProfileDtoToCompanyUpdates(
|
||||
company,
|
||||
snapshot,
|
||||
);
|
||||
await this.companiesRepo.update(company.id, companyUpdates);
|
||||
await this.applyLicenseChanges(request);
|
||||
await this.applyDocumentChanges(request);
|
||||
@@ -1294,7 +1277,12 @@ export class CompaniesService {
|
||||
);
|
||||
}
|
||||
if (documentChanges.length > 0) {
|
||||
await this.recordCompanyRevision(company, {}, submittedBy, documentChanges);
|
||||
await this.recordCompanyRevision(
|
||||
company,
|
||||
{},
|
||||
submittedBy,
|
||||
documentChanges,
|
||||
);
|
||||
}
|
||||
return uploaded;
|
||||
}
|
||||
@@ -1414,7 +1402,11 @@ export class CompaniesService {
|
||||
status: ChangeRequestStatus.Pending,
|
||||
});
|
||||
if (company) {
|
||||
this.companyNotifier.changeRequestSubmitted(company, existing.id, false);
|
||||
this.companyNotifier.changeRequestSubmitted(
|
||||
company,
|
||||
existing.id,
|
||||
false,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const history = await this.changeRequestRepo.findByCompanyId(companyId);
|
||||
@@ -1446,8 +1438,7 @@ export class CompaniesService {
|
||||
reviewerId?: string,
|
||||
): Promise<CompanyChangeRequest> {
|
||||
const request = await this.changeRequestRepo.findById(id);
|
||||
if (!request)
|
||||
throw new NotFoundException(`Change request ${id} not found`);
|
||||
if (!request) throw new NotFoundException(`Change request ${id} not found`);
|
||||
if (request.status !== ChangeRequestStatus.Pending) {
|
||||
throw new BadRequestException(
|
||||
`Change request ${id} is already ${request.status}`,
|
||||
@@ -1486,8 +1477,7 @@ export class CompaniesService {
|
||||
reviewerId?: string,
|
||||
): Promise<CompanyChangeRequest> {
|
||||
const request = await this.changeRequestRepo.findById(id);
|
||||
if (!request)
|
||||
throw new NotFoundException(`Change request ${id} not found`);
|
||||
if (!request) throw new NotFoundException(`Change request ${id} not found`);
|
||||
if (request.status !== ChangeRequestStatus.Pending) {
|
||||
throw new BadRequestException(
|
||||
`Change request ${id} is already ${request.status}`,
|
||||
@@ -1569,10 +1559,7 @@ export class CompaniesService {
|
||||
const reactivating =
|
||||
status === ProfileStatus.Active &&
|
||||
existing.status === ProfileStatus.Suspended;
|
||||
if (
|
||||
(status === ProfileStatus.Suspended || reactivating) &&
|
||||
!note?.trim()
|
||||
) {
|
||||
if ((status === ProfileStatus.Suspended || reactivating) && !note?.trim()) {
|
||||
throw new BadRequestException(
|
||||
status === ProfileStatus.Suspended
|
||||
? "A message explaining the suspension is required — the customer will see it."
|
||||
@@ -1594,7 +1581,9 @@ export class CompaniesService {
|
||||
existing.status === ProfileStatus.Pending ||
|
||||
existing.status === ProfileStatus.Rejected;
|
||||
if (awaitingReview) {
|
||||
const owners = await this.profilesRepo.findByCompanyId(existing.companyId);
|
||||
const owners = await this.profilesRepo.findByCompanyId(
|
||||
existing.companyId,
|
||||
);
|
||||
if (owners.length > 0 && !owners.some((o) => o.onboardingCompleted)) {
|
||||
throw new BadRequestException(
|
||||
"This customer hasn't finished onboarding yet. Their roles can be reviewed once they submit their application.",
|
||||
@@ -1652,11 +1641,17 @@ export class CompaniesService {
|
||||
const names = pending.map((f) => f.name).join(", ");
|
||||
throw new BadRequestException(
|
||||
`This role has ${pending.length} document(s) awaiting customer correction (${names}). ` +
|
||||
`Approve it once the customer has re-uploaded them, or withdraw the change request first.`,
|
||||
`Approve it once the customer has re-uploaded them, or withdraw the change request first.`,
|
||||
);
|
||||
}
|
||||
|
||||
return this.applyProfileStatus(manager, existing, status, note, reviewerId);
|
||||
return this.applyProfileStatus(
|
||||
manager,
|
||||
existing,
|
||||
status,
|
||||
note,
|
||||
reviewerId,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1992,7 +1987,9 @@ export class CompaniesService {
|
||||
.map((f) => ({ key: f.key, label: f.label }));
|
||||
|
||||
// 2. Nationality-based company documents + which are already uploaded.
|
||||
const documentSettingCode = this.documentSettingCodeFor(company.nationality);
|
||||
const documentSettingCode = this.documentSettingCodeFor(
|
||||
company.nationality,
|
||||
);
|
||||
const [setting, uploadedFiles] = await Promise.all([
|
||||
this.fileUploadSettingsService
|
||||
.getByCode(documentSettingCode)
|
||||
@@ -2074,7 +2071,9 @@ export class CompaniesService {
|
||||
? [`Upload the ${POA_DELEGATION_LABEL} for your Power of Attorney`]
|
||||
: []),
|
||||
...(flaggedDelegation
|
||||
? [`Re-upload your ${POA_DELEGATION_LABEL} — EDR asked for a correction`]
|
||||
? [
|
||||
`Re-upload your ${POA_DELEGATION_LABEL} — EDR asked for a correction`,
|
||||
]
|
||||
: []),
|
||||
...(identity.faydaRequired && !identity.owner.verified
|
||||
? ["Verify the company owner's identity with Fayda"]
|
||||
@@ -2088,10 +2087,10 @@ export class CompaniesService {
|
||||
// verification its representative may have no way to obtain.
|
||||
...((poaRequired || poaProvided) && !poaProven
|
||||
? [
|
||||
identity.faydaRequired
|
||||
? "Verify your Power of Attorney's identity with Fayda"
|
||||
: "Name your Power of Attorney, or verify them with Fayda",
|
||||
]
|
||||
identity.faydaRequired
|
||||
? "Verify your Power of Attorney's identity with Fayda"
|
||||
: "Name your Power of Attorney, or verify them with Fayda",
|
||||
]
|
||||
: []),
|
||||
...(identity.passportRequired && !identity.owner.passportNumber
|
||||
? ["Add the company owner's passport number"]
|
||||
@@ -2137,7 +2136,10 @@ export class CompaniesService {
|
||||
return new OnboardingRequirementsResponseDto({
|
||||
documentSettingCode,
|
||||
nationality: company.nationality ?? CompanyNationality.Ethiopian,
|
||||
companyInfo: { complete: missingInfo.length === 0, missingFields: missingInfo },
|
||||
companyInfo: {
|
||||
complete: missingInfo.length === 0,
|
||||
missingFields: missingInfo,
|
||||
},
|
||||
documents,
|
||||
licenseProfiles,
|
||||
poa: {
|
||||
@@ -2179,7 +2181,7 @@ export class CompaniesService {
|
||||
if (!requirements.isComplete) {
|
||||
throw new BadRequestException(
|
||||
requirements.outstanding[0] ??
|
||||
"Your onboarding is incomplete. Please complete all required steps before submitting.",
|
||||
"Your onboarding is incomplete. Please complete all required steps before submitting.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2188,7 +2190,10 @@ export class CompaniesService {
|
||||
const profiles = await this.companyProfilesRepo.findByCompanyId(companyId);
|
||||
for (const cp of profiles) {
|
||||
if (cp.status !== ProfileStatus.Pending) {
|
||||
await this.companyProfilesRepo.updateStatus(cp.id, ProfileStatus.Pending);
|
||||
await this.companyProfilesRepo.updateStatus(
|
||||
cp.id,
|
||||
ProfileStatus.Pending,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2214,12 +2219,12 @@ export class CompaniesService {
|
||||
case CompanyStatus.Suspended:
|
||||
throw new ForbiddenException(
|
||||
`Your company account is suspended — you can't create ${action} right now. ` +
|
||||
`Please contact EDR support for details.`,
|
||||
`Please contact EDR support for details.`,
|
||||
);
|
||||
case CompanyStatus.Blacklisted:
|
||||
throw new ForbiddenException(
|
||||
`Your company account is blacklisted — you can't create ${action}. ` +
|
||||
`Please contact EDR support.`,
|
||||
`Please contact EDR support.`,
|
||||
);
|
||||
default:
|
||||
throw new ForbiddenException(
|
||||
@@ -2248,8 +2253,7 @@ export class CompaniesService {
|
||||
switch (profile.status) {
|
||||
case ProfileStatus.Suspended:
|
||||
throw new ForbiddenException(
|
||||
`Your ${role} role is suspended${
|
||||
profile.reviewNote ? ` — ${profile.reviewNote}` : ""
|
||||
`Your ${role} role is suspended${profile.reviewNote ? ` — ${profile.reviewNote}` : ""
|
||||
}. Your other roles are unaffected. Please contact EDR support to resolve this.`,
|
||||
);
|
||||
case ProfileStatus.Blacklisted:
|
||||
@@ -2258,8 +2262,7 @@ export class CompaniesService {
|
||||
);
|
||||
case ProfileStatus.Rejected:
|
||||
throw new ForbiddenException(
|
||||
`Your ${role} role was rejected${
|
||||
profile.reviewNote ? ` — ${profile.reviewNote}` : ""
|
||||
`Your ${role} role was rejected${profile.reviewNote ? ` — ${profile.reviewNote}` : ""
|
||||
}. Amend and resubmit it from your settings page.`,
|
||||
);
|
||||
default:
|
||||
@@ -2518,9 +2521,7 @@ export class CompaniesService {
|
||||
LICENSE_RESOURCE,
|
||||
);
|
||||
return records
|
||||
.filter(
|
||||
(r) => r.code === LICENSE_CODE || r.code === LICENSE_PENDING_CODE,
|
||||
)
|
||||
.filter((r) => r.code === LICENSE_CODE || r.code === LICENSE_PENDING_CODE)
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
@@ -2687,7 +2688,7 @@ export class CompaniesService {
|
||||
if (missing.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`A freight forwarder acts on other companies' behalf, so a Power of Attorney is required. ` +
|
||||
`Add the ${missing.map((f) => f.label.toLowerCase()).join(", ")} first.`,
|
||||
`Add the ${missing.map((f) => f.label.toLowerCase()).join(", ")} first.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2699,13 +2700,13 @@ export class CompaniesService {
|
||||
if (!onFile) {
|
||||
throw new BadRequestException(
|
||||
`Upload the ${POA_DELEGATION_LABEL} for the Power of Attorney` +
|
||||
(opts.requirePoa ? " — it is required for freight forwarders." : "."),
|
||||
(opts.requirePoa ? " — it is required for freight forwarders." : "."),
|
||||
);
|
||||
}
|
||||
if (flagged) {
|
||||
throw new BadRequestException(
|
||||
`The ${POA_DELEGATION_LABEL} on file needs to be corrected. ` +
|
||||
`Re-upload it before continuing.`,
|
||||
`Re-upload it before continuing.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2748,6 +2749,12 @@ export class CompaniesService {
|
||||
async completeIdentityVerification(
|
||||
userId: string,
|
||||
dto: CompleteIdentityVerificationDto,
|
||||
/**
|
||||
* The signed-in account, used as the owner's fallback contact details.
|
||||
* Optional so the callers that only have a user id keep compiling — they
|
||||
* simply get no fallback.
|
||||
*/
|
||||
account?: { email?: string; phoneNumber?: string },
|
||||
): Promise<CompanyIdentityStateDto> {
|
||||
const { company } = await this.getCompanyInfoByUserId(userId);
|
||||
const prefix = IDENTITY_PREFIX[dto.subject];
|
||||
@@ -2769,7 +2776,8 @@ export class CompaniesService {
|
||||
// of this check entirely.
|
||||
if (dto.subject === "owner" || dto.subject === "poa") {
|
||||
const other: IdentitySubject = dto.subject === "poa" ? "owner" : "poa";
|
||||
const otherSub = company.attributes?.[`${IDENTITY_PREFIX[other]}FaydaSub`];
|
||||
const otherSub =
|
||||
company.attributes?.[`${IDENTITY_PREFIX[other]}FaydaSub`];
|
||||
if (otherSub && otherSub === result.sub) {
|
||||
throw new BadRequestException(
|
||||
`This identity is already registered as the company's ${IDENTITY_LABEL[other]}. The Power of Attorney must be a different person from the owner.`,
|
||||
@@ -2778,6 +2786,22 @@ export class CompaniesService {
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Fayda's email and phone claims are optional and routinely come back empty.
|
||||
// For the owner that leaves the company with no contact details at all: the
|
||||
// step renders no input for them (they are the verification's output), and
|
||||
// "same as owner" then copies those blanks onto `generalManagerEmail` /
|
||||
// `generalManagerPhone`, which `REQUIRED_COMPANY_INFO` demands at submit —
|
||||
// an unfixable dead end. The account doing the onboarding is the one contact
|
||||
// we always have, and it is already OTP-proven, so it stands in.
|
||||
//
|
||||
// Owner only: the PoA and the GM are other people, and the registering
|
||||
// account's address is not theirs to wear.
|
||||
const isOwner = dto.subject === "owner";
|
||||
const email = result.email || (isOwner ? account?.email : undefined);
|
||||
const phone =
|
||||
result.phoneNumber || (isOwner ? account?.phoneNumber : undefined);
|
||||
|
||||
const identity: VerifiedIdentityAttributes = {
|
||||
[`${prefix}FaydaSub`]: result.sub,
|
||||
[`${prefix}FaydaVerifiedAt`]: now,
|
||||
@@ -2785,14 +2809,12 @@ export class CompaniesService {
|
||||
[`${prefix}Gender`]: result.gender ?? null,
|
||||
// The verified payload owns the person's details from here on.
|
||||
...(result.fullName ? { [`${prefix}Name`]: result.fullName } : {}),
|
||||
...(result.email ? { [`${prefix}Email`]: result.email } : {}),
|
||||
...(email ? { [`${prefix}Email`]: email } : {}),
|
||||
// Fayda returns whatever the national registry holds, which is routinely a
|
||||
// local number ("0911223344"). Every typed phone in this service is stored
|
||||
// E.164, and `@IsValidPhone()` rejects anything else — so a raw claim here
|
||||
// becomes a value the portal reads back and cannot resubmit.
|
||||
...(result.phoneNumber
|
||||
? { [`${prefix}Phone`]: normalizeE164(result.phoneNumber) }
|
||||
: {}),
|
||||
...(phone ? { [`${prefix}Phone`]: normalizeE164(phone) } : {}),
|
||||
...(result.address ? { [`${prefix}Address`]: result.address } : {}),
|
||||
};
|
||||
|
||||
@@ -2842,7 +2864,13 @@ export class CompaniesService {
|
||||
* proven identity to copy, only typed text that would arrive wearing a
|
||||
* verified badge.
|
||||
*/
|
||||
async setGmSameAsOwner(userId: string): Promise<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 attrs = company.attributes ?? {};
|
||||
const ownerSub = attrs.ownerFaydaSub as string | undefined;
|
||||
@@ -2852,20 +2880,23 @@ export class CompaniesService {
|
||||
);
|
||||
}
|
||||
|
||||
const ownerEmail = (attrs.ownerEmail as string | undefined) || account?.email || null;
|
||||
const ownerPhone = (attrs.ownerPhone as string | undefined) || account?.phoneNumber || null;
|
||||
|
||||
const copied: Record<string, unknown> = {
|
||||
gmSameAsOwner: true,
|
||||
gmFaydaSub: ownerSub,
|
||||
gmFaydaVerifiedAt: attrs.ownerFaydaVerifiedAt ?? new Date().toISOString(),
|
||||
gmName: attrs.ownerName ?? null,
|
||||
gmEmail: attrs.ownerEmail ?? null,
|
||||
gmPhone: attrs.ownerPhone ?? null,
|
||||
gmEmail: ownerEmail,
|
||||
gmPhone: ownerPhone ? normalizeE164(ownerPhone) : null,
|
||||
gmAddress: attrs.ownerAddress ?? null,
|
||||
gmBirthdate: attrs.ownerBirthdate ?? null,
|
||||
gmGender: attrs.ownerGender ?? null,
|
||||
// Kept in step for the notifiers, same as a GM verification does.
|
||||
generalManagerName: attrs.ownerName ?? null,
|
||||
generalManagerEmail: attrs.ownerEmail ?? null,
|
||||
generalManagerPhone: attrs.ownerPhone ?? null,
|
||||
generalManagerEmail: ownerEmail,
|
||||
generalManagerPhone: ownerPhone ? normalizeE164(ownerPhone) : null,
|
||||
};
|
||||
|
||||
const updated = await this.companiesRepo.update(company.id, {
|
||||
@@ -2976,8 +3007,8 @@ export class CompaniesService {
|
||||
const snapshot = {
|
||||
...(existing?.snapshot ?? {}),
|
||||
faydaIdentity: {
|
||||
...(((existing?.snapshot ?? {}) as Record<string, any>)
|
||||
.faydaIdentity ?? {}),
|
||||
...(((existing?.snapshot ?? {}) as Record<string, any>).faydaIdentity ??
|
||||
{}),
|
||||
...identity,
|
||||
},
|
||||
};
|
||||
@@ -3389,7 +3420,10 @@ export class CompaniesService {
|
||||
"We couldn't find a business license for this TIN with eTrade. Please double-check the number and try again.",
|
||||
);
|
||||
}
|
||||
return this.etradeService.extractRegistrationData(businessInfo, companyInfo);
|
||||
return this.etradeService.extractRegistrationData(
|
||||
businessInfo,
|
||||
companyInfo,
|
||||
);
|
||||
}
|
||||
|
||||
async fetchETradeData(tin: string, excludeCompanyId?: string) {
|
||||
@@ -3421,7 +3455,9 @@ export class CompaniesService {
|
||||
|
||||
const tin = dto.tin ?? company.tin;
|
||||
const registration = await this.resolveEtradeRegistration(tin);
|
||||
const fresh: Partial<Record<(typeof ETRADE_SOURCED_FIELDS)[number], string>> = {
|
||||
const fresh: Partial<
|
||||
Record<(typeof ETRADE_SOURCED_FIELDS)[number], string>
|
||||
> = {
|
||||
companyName: registration.companyName,
|
||||
licenceNumber: registration.licenceNumber,
|
||||
statusDescription: registration.statusDescription,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Company, CompanyStatus } from "./entities/company.entity";
|
||||
import { NotificationsService } from "../notifications/notifications.service";
|
||||
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
|
||||
import { resolveCompanyNotifyPhone } from "../notifications/resolve-company-phone.util";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
|
||||
/** Account statuses that lock the customer out and therefore must be told to them. */
|
||||
const PUNITIVE_STATUSES: readonly CompanyStatus[] = [
|
||||
@@ -175,12 +176,9 @@ export class CompanyNotifierService {
|
||||
// ── Backoffice-facing: work has arrived back in the review queue ────────────
|
||||
|
||||
/**
|
||||
* Persist + push an in-app item to every backoffice staff user, deep-linked to
|
||||
* the customer's detail page.
|
||||
*
|
||||
* The recipient resolver has no role/permission targeting (see
|
||||
* `notification-recipients.service.ts`) — `allBackoffice` is the narrowest
|
||||
* selector available, so marketing is reached by notifying all staff.
|
||||
* Persist + push an in-app item to the customer desk — staff holding
|
||||
* `customers:get_notification` — deep-linked to the customer's detail page,
|
||||
* which is itself gated on `customers:view`.
|
||||
*/
|
||||
private notifyStaff(
|
||||
company: Company,
|
||||
@@ -189,7 +187,7 @@ export class CompanyNotifierService {
|
||||
data: Record<string, unknown> = {},
|
||||
): void {
|
||||
void this.inbox.notify({
|
||||
recipients: { allBackoffice: true },
|
||||
recipients: { permissionKeys: [FREIGHT_PERMS.customers.getNotification] },
|
||||
audience: NotificationAudience.BACKOFFICE,
|
||||
type: NotificationType.REQUEST_SUBMITTED,
|
||||
title,
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsEnum, IsArray, ValidateNested, ArrayMinSize } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { CompanyType } from '../entities/company.entity';
|
||||
import { ProfileType } from '../entities/company-profile.entity';
|
||||
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
||||
import { IsTin } from '../../../common/validators/is-tin.validator';
|
||||
import {
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
MaxLength,
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsArray,
|
||||
ValidateNested,
|
||||
ArrayMinSize,
|
||||
} from "class-validator";
|
||||
import { Type } from "class-transformer";
|
||||
import { CompanyType } from "../entities/company.entity";
|
||||
import { ProfileType } from "../entities/company-profile.entity";
|
||||
import { IsTin } from "../../../common/validators/is-tin.validator";
|
||||
|
||||
export class CompanyProfileInputDto {
|
||||
@IsEnum(ProfileType)
|
||||
@@ -24,17 +33,6 @@ export class CreateCompanyWithProfileDto {
|
||||
@MaxLength(200)
|
||||
companyName!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
@MaxLength(150)
|
||||
companyEmail?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
@IsValidPhone()
|
||||
companyPhone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
@@ -46,7 +44,7 @@ export class CreateCompanyWithProfileDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsTin({ message: 'TIN must be exactly 10 digits' })
|
||||
@IsTin({ message: "TIN must be exactly 10 digits" })
|
||||
tin?: string;
|
||||
|
||||
@IsOptional()
|
||||
|
||||
@@ -2,21 +2,19 @@ import {
|
||||
buildCompanyIdentityState,
|
||||
CompanyIdentityStateDto,
|
||||
} from "./complete-identity-verification.dto";
|
||||
import { Company } from '../entities/company.entity';
|
||||
import { ExternalProfile } from '../entities/external-profile.entity';
|
||||
import { Company } from "../entities/company.entity";
|
||||
import { ExternalProfile } from "../entities/external-profile.entity";
|
||||
import {
|
||||
ChangeRequestStatus,
|
||||
CompanyChangeRequest,
|
||||
} from '../entities/company-change-request.entity';
|
||||
import { ResponseCompanyProfileDto } from './response-company.dto';
|
||||
} from "../entities/company-change-request.entity";
|
||||
import { ResponseCompanyProfileDto } from "./response-company.dto";
|
||||
|
||||
export class ProfileResponseDto {
|
||||
companyId: string;
|
||||
companyName: string;
|
||||
companyType: string;
|
||||
nationality: string | null;
|
||||
companyEmail: string | null;
|
||||
companyPhone: string | null;
|
||||
companyLocation: string;
|
||||
companyAddress: string | null;
|
||||
tinNumber: string;
|
||||
@@ -89,8 +87,6 @@ export class ProfileResponseDto {
|
||||
this.companyProfiles =
|
||||
company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)) ??
|
||||
[];
|
||||
this.companyEmail = company.email ?? null;
|
||||
this.companyPhone = company.phone ?? null;
|
||||
this.companyLocation = company.country;
|
||||
this.companyAddress = company.address ?? null;
|
||||
this.tinNumber = company.tin;
|
||||
@@ -128,9 +124,9 @@ export class ProfileResponseDto {
|
||||
|
||||
const openReview =
|
||||
changeRequest &&
|
||||
(changeRequest.status === ChangeRequestStatus.Pending ||
|
||||
changeRequest.status === ChangeRequestStatus.Rejected ||
|
||||
changeRequest.status === ChangeRequestStatus.ChangesRequested)
|
||||
(changeRequest.status === ChangeRequestStatus.Pending ||
|
||||
changeRequest.status === ChangeRequestStatus.Rejected ||
|
||||
changeRequest.status === ChangeRequestStatus.ChangesRequested)
|
||||
? changeRequest
|
||||
: null;
|
||||
this.reviewStatus =
|
||||
|
||||
@@ -6,11 +6,11 @@ import {
|
||||
IsEnum,
|
||||
IsIn,
|
||||
Matches,
|
||||
} from 'class-validator';
|
||||
import { ETHIOPIAN_REGIONS, type EthiopianRegion } from '@edr/types';
|
||||
import { CompanyNationality } from '../entities/company.entity';
|
||||
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
||||
import { IsTin } from '../../../common/validators/is-tin.validator';
|
||||
} from "class-validator";
|
||||
import { ETHIOPIAN_REGIONS, type EthiopianRegion } from "@edr/types";
|
||||
import { CompanyNationality } from "../entities/company.entity";
|
||||
import { IsValidPhone } from "../../../common/validators/is-phone-number.validator";
|
||||
import { IsTin } from "../../../common/validators/is-tin.validator";
|
||||
|
||||
export class UpdateProfileDto {
|
||||
@IsOptional()
|
||||
@@ -22,17 +22,6 @@ export class UpdateProfileDto {
|
||||
@MaxLength(200)
|
||||
companyName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
@MaxLength(150)
|
||||
companyEmail?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
@IsValidPhone()
|
||||
companyPhone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
@@ -44,7 +33,7 @@ export class UpdateProfileDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsTin({ message: 'TIN must be exactly 10 digits' })
|
||||
@IsTin({ message: "TIN must be exactly 10 digits" })
|
||||
tin?: string;
|
||||
|
||||
// Ethiopian VAT registration numbers are 10 digits, the same shape as the
|
||||
@@ -53,7 +42,7 @@ export class UpdateProfileDto {
|
||||
// column may hold.
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^\d{10}$/, { message: 'VAT number must be exactly 10 digits' })
|
||||
@Matches(/^\d{10}$/, { message: "VAT number must be exactly 10 digits" })
|
||||
vatNumber?: string;
|
||||
|
||||
// `fanNumber` is deliberately absent: the FAN is the Fayda number of the
|
||||
|
||||
@@ -11,7 +11,12 @@ import { ComplianceType } from './entities/compliance-record.entity';
|
||||
|
||||
@ApiTags('Vehicle Compliance')
|
||||
@Controller('compliance')
|
||||
@BookingStaff(FREIGHT_PERMS.compliance.view)
|
||||
// Class gate lists every key its routes use: Nest runs class AND method
|
||||
// guards, so a key missing here would deny before the route's own key runs.
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.compliance.view,
|
||||
FREIGHT_PERMS.compliance.manage,
|
||||
])
|
||||
export class ComplianceController {
|
||||
constructor(private readonly complianceService: ComplianceService) {}
|
||||
|
||||
|
||||
@@ -17,7 +17,12 @@ import { FilterConsignmentDto } from "./dto/filter-consignment.dto";
|
||||
|
||||
@ApiTags("consignments")
|
||||
@Controller("consignments")
|
||||
@FleetView(FREIGHT_PERMS.consignments.view)
|
||||
// Class gate lists every key its routes use: Nest runs class AND method
|
||||
// guards, so a key missing here would deny before the route's own key runs.
|
||||
@FleetView([
|
||||
FREIGHT_PERMS.consignments.view,
|
||||
FREIGHT_PERMS.consignments.create,
|
||||
])
|
||||
export class ConsignmentsController {
|
||||
constructor(private readonly consignmentsService: ConsignmentsService) {}
|
||||
|
||||
|
||||
@@ -19,7 +19,14 @@ import { ContainersService } from './containers.service';
|
||||
|
||||
@ApiTags('containers')
|
||||
@Controller('containers')
|
||||
@FleetView(FREIGHT_PERMS.containers.view)
|
||||
// Class gate lists every key its routes use: Nest runs class AND method
|
||||
// guards, so a key missing here would deny before the route's own key runs.
|
||||
@FleetView([
|
||||
FREIGHT_PERMS.containers.view,
|
||||
FREIGHT_PERMS.containers.create,
|
||||
FREIGHT_PERMS.containers.update,
|
||||
FREIGHT_PERMS.containers.delete,
|
||||
])
|
||||
export class ContainersController {
|
||||
constructor(private readonly containersService: ContainersService) {}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ export class Container extends BaseEntity {
|
||||
type: 'varchar',
|
||||
nullable: true,
|
||||
})
|
||||
sealNumber!: string | null;
|
||||
sealNumber!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', default: 'AVAILABLE' })
|
||||
status!: string; // AVAILABLE, LOADED, IN_TRANSIT, MAINTENANCE, DAMAGED
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
@@ -15,55 +17,85 @@ import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { ContractTemplatesService } from "./contract-templates.service";
|
||||
import {
|
||||
CreateArticleDto,
|
||||
CreateContractTemplateDto,
|
||||
PreviewContractTemplateDto,
|
||||
ReplaceArticlesDto,
|
||||
UpdateArticleDto,
|
||||
UpdateContractTemplateDto,
|
||||
} from "./dto/contract-template.dto";
|
||||
|
||||
// `view` opens the Templates page; `read` is API-read-only for other pages
|
||||
// that show template data; create/update/delete gate each write. `manage` is
|
||||
// the legacy write key and keeps working for roles that already hold it.
|
||||
const TEMPLATE_READ = [
|
||||
FREIGHT_PERMS.settings.contractTemplates.view,
|
||||
FREIGHT_PERMS.settings.contractTemplates.read,
|
||||
FREIGHT_PERMS.settings.contractTemplates.update,
|
||||
FREIGHT_PERMS.settings.contractTemplates.manage,
|
||||
FREIGHT_PERMS.admin,
|
||||
];
|
||||
|
||||
const TEMPLATE_UPDATE = [
|
||||
FREIGHT_PERMS.settings.contractTemplates.update,
|
||||
FREIGHT_PERMS.settings.contractTemplates.manage,
|
||||
FREIGHT_PERMS.admin,
|
||||
];
|
||||
|
||||
@ApiTags("contract-templates")
|
||||
@Controller("contract-templates")
|
||||
export class ContractTemplatesController {
|
||||
constructor(private readonly service: ContractTemplatesService) {}
|
||||
|
||||
// Reads are staff-only (the backoffice Templates tab is the only consumer);
|
||||
// writes are admin-guarded like other freight configuration resources.
|
||||
|
||||
@Get()
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.settings.contractTemplates.view,
|
||||
FREIGHT_PERMS.settings.contractTemplates.manage,
|
||||
FREIGHT_PERMS.admin,
|
||||
])
|
||||
@ApiOperation({ summary: "List the six contract document templates" })
|
||||
@BookingStaff(TEMPLATE_READ)
|
||||
@ApiOperation({ summary: "List contract templates (system container + staff-created bulk)" })
|
||||
list() {
|
||||
return this.service.list();
|
||||
}
|
||||
|
||||
@Get(":code")
|
||||
@Post()
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.settings.contractTemplates.view,
|
||||
FREIGHT_PERMS.settings.contractTemplates.create,
|
||||
FREIGHT_PERMS.settings.contractTemplates.manage,
|
||||
FREIGHT_PERMS.admin,
|
||||
])
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Create a bulk contract template for a (cargo type, customs option) pair",
|
||||
})
|
||||
create(@Body() dto: CreateContractTemplateDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Get(":code")
|
||||
@BookingStaff(TEMPLATE_READ)
|
||||
@ApiOperation({ summary: "Get one contract template by code" })
|
||||
getByCode(@Param("code") code: string) {
|
||||
return this.service.getByCode(code);
|
||||
}
|
||||
|
||||
@Patch(":code")
|
||||
@BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin])
|
||||
@BookingStaff(TEMPLATE_UPDATE)
|
||||
@ApiOperation({ summary: "Update template metadata (name, title, recitals, active flag)" })
|
||||
update(@Param("code") code: string, @Body() dto: UpdateContractTemplateDto) {
|
||||
return this.service.update(code, dto);
|
||||
}
|
||||
|
||||
@Post(":code/preview")
|
||||
@Delete(":code")
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.settings.contractTemplates.view,
|
||||
FREIGHT_PERMS.settings.contractTemplates.manage,
|
||||
FREIGHT_PERMS.settings.contractTemplates.delete,
|
||||
FREIGHT_PERMS.admin,
|
||||
])
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({
|
||||
summary: "Delete a staff-created bulk template (system templates refuse)",
|
||||
})
|
||||
remove(@Param("code") code: string) {
|
||||
return this.service.remove(code);
|
||||
}
|
||||
|
||||
@Post(":code/preview")
|
||||
@BookingStaff(TEMPLATE_READ)
|
||||
@ApiOperation({
|
||||
summary: "Render an HTML preview of the template against mock contract data",
|
||||
})
|
||||
@@ -77,21 +109,21 @@ export class ContractTemplatesController {
|
||||
/* ------------------------- article routes ------------------------- */
|
||||
|
||||
@Put(":code/articles")
|
||||
@BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin])
|
||||
@BookingStaff(TEMPLATE_UPDATE)
|
||||
@ApiOperation({ summary: "Replace the full ordered article list (used for reorder)" })
|
||||
replaceArticles(@Param("code") code: string, @Body() dto: ReplaceArticlesDto) {
|
||||
return this.service.replaceArticles(code, dto.articles);
|
||||
}
|
||||
|
||||
@Post(":code/articles")
|
||||
@BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin])
|
||||
@BookingStaff(TEMPLATE_UPDATE)
|
||||
@ApiOperation({ summary: "Add an article to the template" })
|
||||
addArticle(@Param("code") code: string, @Body() dto: CreateArticleDto) {
|
||||
return this.service.addArticle(code, dto);
|
||||
}
|
||||
|
||||
@Patch(":code/articles/:articleId")
|
||||
@BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin])
|
||||
@BookingStaff(TEMPLATE_UPDATE)
|
||||
@ApiOperation({ summary: "Update an article's title or body" })
|
||||
updateArticle(
|
||||
@Param("code") code: string,
|
||||
@@ -102,7 +134,7 @@ export class ContractTemplatesController {
|
||||
}
|
||||
|
||||
@Delete(":code/articles/:articleId")
|
||||
@BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin])
|
||||
@BookingStaff(TEMPLATE_UPDATE)
|
||||
@ApiOperation({ summary: "Remove an article from the template" })
|
||||
removeArticle(
|
||||
@Param("code") code: string,
|
||||
|
||||
@@ -3,10 +3,8 @@ import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
import {
|
||||
ContractTemplate,
|
||||
ContractTemplateCode,
|
||||
} from "./entities/contract-template.entity";
|
||||
import { CargoType } from "../rule-engine/entities/cargo-type.entity";
|
||||
import { ContractTemplate } from "./entities/contract-template.entity";
|
||||
|
||||
@Injectable()
|
||||
export class ContractTemplatesRepository extends BaseRepository<ContractTemplate> {
|
||||
@@ -17,12 +15,51 @@ export class ContractTemplatesRepository extends BaseRepository<ContractTemplate
|
||||
super(repository);
|
||||
}
|
||||
|
||||
findByCode(code: ContractTemplateCode): Promise<ContractTemplate | null> {
|
||||
findByCode(code: string): Promise<ContractTemplate | null> {
|
||||
return this.repository.findOne({ where: { code } });
|
||||
}
|
||||
|
||||
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> {
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { ContractRendererService } from "../../contracts/contract-renderer.service";
|
||||
@@ -11,6 +16,7 @@ import {
|
||||
import { ContractTemplatesRepository } from "./contract-templates.repository";
|
||||
import {
|
||||
CreateArticleDto,
|
||||
CreateContractTemplateDto,
|
||||
PreviewContractTemplateDto,
|
||||
ReplaceArticleDto,
|
||||
UpdateArticleDto,
|
||||
@@ -53,13 +59,17 @@ export class ContractTemplatesService {
|
||||
async list(): Promise<ContractTemplate[]> {
|
||||
const templates = await this.repository.findAll();
|
||||
const rank = new Map(CONTRACT_TEMPLATE_CODES.map((code, i) => [code, i] as const));
|
||||
return templates.sort(
|
||||
(a, b) => (rank.get(a.code) ?? 99) - (rank.get(b.code) ?? 99),
|
||||
);
|
||||
// Seeded container templates first in canonical order, then staff-created
|
||||
// bulk templates alphabetically.
|
||||
return templates.sort((a, b) => {
|
||||
const ra = rank.get(a.code as ContractTemplateCode) ?? 99;
|
||||
const rb = rank.get(b.code as ContractTemplateCode) ?? 99;
|
||||
return ra !== rb ? ra - rb : a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
async getByCode(code: string): Promise<ContractTemplate> {
|
||||
const template = await this.repository.findByCode(this.assertCode(code));
|
||||
const template = await this.repository.findByCode(code?.toUpperCase() ?? "");
|
||||
if (!template) {
|
||||
throw new NotFoundException(`Contract template ${code} not found`);
|
||||
}
|
||||
@@ -67,15 +77,93 @@ export class ContractTemplatesService {
|
||||
}
|
||||
|
||||
/**
|
||||
* The active template used when generating a contract document for the given
|
||||
* direction/freight/customs triple; null when missing or deactivated (the
|
||||
* renderer then falls back to the built-in generic layout).
|
||||
* Staff-created bulk template for one (cargo type, customs option) pair.
|
||||
* The cargo type must have hasContractTemplate enabled and the combination
|
||||
* must not already exist — the same commodity + customs pairing is edited,
|
||||
* never duplicated.
|
||||
*/
|
||||
async create(dto: CreateContractTemplateDto): Promise<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(
|
||||
tradeDirection?: string | null,
|
||||
freightType?: string | null,
|
||||
customsClearingEnabled?: boolean | null,
|
||||
cargoTypeId?: string | 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(
|
||||
tradeDirection,
|
||||
freightType,
|
||||
@@ -180,16 +268,32 @@ export class ContractTemplatesService {
|
||||
: this.sorted(template.articles),
|
||||
};
|
||||
|
||||
const view = this.buildMockView(template.code, dynamicTemplate);
|
||||
const view = this.buildMockView(template, dynamicTemplate);
|
||||
return { html: this.renderer.render(view) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry key the mock preview renders against. Staff-created bulk
|
||||
* templates aren't in the fixed code map — they preview against the
|
||||
* representative bulk import pack matching their customs option.
|
||||
*/
|
||||
private previewKeyFor(template: ContractTemplate): string {
|
||||
if (template.cargoTypeId) {
|
||||
return template.withCustoms
|
||||
? "IMP_BULK_USD_FORWARDING"
|
||||
: "IMP_BULK_USD_TRANSPORT_ONLY";
|
||||
}
|
||||
return PREVIEW_TEMPLATE_KEYS[template.code as ContractTemplateCode];
|
||||
}
|
||||
|
||||
private buildMockView(
|
||||
code: ContractTemplateCode,
|
||||
template: ContractTemplate,
|
||||
dynamicTemplate: ContractDynamicTemplateView,
|
||||
): ContractViewModel {
|
||||
const meta = getTemplateMeta(PREVIEW_TEMPLATE_KEYS[code]);
|
||||
const isBulk = code.endsWith("_BULK");
|
||||
const code = template.code;
|
||||
const previewKey = this.previewKeyFor(template);
|
||||
const meta = getTemplateMeta(previewKey);
|
||||
const isBulk = Boolean(template.cargoTypeId) || code.includes("BULK");
|
||||
const now = new Date();
|
||||
|
||||
// Representative rate schedule so the admin preview shows the live-rate
|
||||
@@ -200,7 +304,7 @@ export class ContractTemplatesService {
|
||||
bookingId: "00000000-0000-0000-0000-000000000000",
|
||||
reference: "EDR/CT/2026/0042",
|
||||
status: "CONTRACT_READY",
|
||||
templateKey: PREVIEW_TEMPLATE_KEYS[code],
|
||||
templateKey: previewKey,
|
||||
template: { ...meta, title: dynamicTemplate.name, templateFile: "edr-dynamic.hbs" },
|
||||
contractDate: now.toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
@@ -275,7 +379,7 @@ export class ContractTemplatesService {
|
||||
}
|
||||
|
||||
/** Static, representative rate schedule for the admin preview only. */
|
||||
private mockRateSchedule(code: ContractTemplateCode, isBulk: boolean): RateSchedule {
|
||||
private mockRateSchedule(code: string, isBulk: boolean): RateSchedule {
|
||||
const dir = code.startsWith("IMPORT")
|
||||
? "import"
|
||||
: code.startsWith("EXPORT")
|
||||
@@ -311,16 +415,6 @@ export class ContractTemplatesService {
|
||||
};
|
||||
}
|
||||
|
||||
private assertCode(code: string): ContractTemplateCode {
|
||||
const upper = code?.toUpperCase() as ContractTemplateCode;
|
||||
if (!CONTRACT_TEMPLATE_CODES.includes(upper)) {
|
||||
throw new BadRequestException(
|
||||
`Unknown contract template code "${code}". Valid codes: ${CONTRACT_TEMPLATE_CODES.join(", ")}`,
|
||||
);
|
||||
}
|
||||
return upper;
|
||||
}
|
||||
|
||||
private sorted(articles: ContractTemplateArticle[]): ContractTemplateArticle[] {
|
||||
return [...(articles ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
}
|
||||
|
||||
@@ -6,12 +6,41 @@ import {
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
|
||||
export class CreateContractTemplateDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Bulk cargo type this template is written for (must have hasContractTemplate enabled)",
|
||||
format: "uuid",
|
||||
})
|
||||
@IsUUID()
|
||||
cargoTypeId!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Whether this is the with-customs-clearing variant",
|
||||
})
|
||||
@IsBoolean()
|
||||
withCustoms!: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: "Display name (derived from the cargo type when omitted)" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
@MaxLength(200)
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Short description shown on the template card" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export class UpdateContractTemplateDto {
|
||||
@ApiPropertyOptional({ description: "Display name of the template" })
|
||||
@IsOptional()
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity, Index } from "typeorm";
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
|
||||
import { CargoType } from "../../rule-engine/entities/cargo-type.entity";
|
||||
|
||||
/**
|
||||
* The ten canonical contract document templates. Import and export split by
|
||||
* customs clearing (× freight type = 8); intercity does not, because it is a
|
||||
* purely domestic Ethiopian movement that crosses no border and therefore has
|
||||
* no customs leg at all (× freight type = 2).
|
||||
* The five seeded container templates (import/export split by customs
|
||||
* clearing; intercity is domestic, crosses no border, so it has a single
|
||||
* template). These are system rows: always present, never deletable.
|
||||
*
|
||||
* Bulk templates are NOT seeded — staff create them per bulk cargo type
|
||||
* (`cargoTypeId`) and customs option (`withCustoms`), one template per
|
||||
* combination. Their codes are generated as BULK_<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
|
||||
* those INTERCITY to match the commercial vocabulary used on the printed
|
||||
@@ -76,10 +83,12 @@ export function contractTemplateCodeFor(
|
||||
}
|
||||
|
||||
@Entity({ schema: "freight", name: "contract_templates" })
|
||||
@Index(["code"], { unique: true })
|
||||
// Uniqueness lives in partial DB indexes (live rows only): code, and
|
||||
// (cargo_type_id, with_customs) for staff-created bulk templates.
|
||||
@Index(["code"])
|
||||
export class ContractTemplate extends BaseEntity {
|
||||
@Column({ name: "code", type: "varchar", length: 40, unique: true })
|
||||
code!: ContractTemplateCode;
|
||||
@Column({ name: "code", type: "varchar", length: 80 })
|
||||
code!: string;
|
||||
|
||||
@Column({ name: "name", type: "varchar", length: 200 })
|
||||
name!: string;
|
||||
@@ -100,4 +109,20 @@ export class ContractTemplate extends BaseEntity {
|
||||
|
||||
@Column({ name: "is_active", type: "boolean", default: true })
|
||||
isActive!: boolean;
|
||||
|
||||
/** Bulk templates only: the cargo type this template is written for. */
|
||||
@Column({ name: "cargo_type_id", type: "uuid", nullable: true })
|
||||
cargoTypeId?: string | null;
|
||||
|
||||
@ManyToOne(() => CargoType, { nullable: true })
|
||||
@JoinColumn({ name: "cargo_type_id" })
|
||||
cargoType?: CargoType | null;
|
||||
|
||||
/** Bulk templates only: whether this is the with-customs-clearing variant. */
|
||||
@Column({ name: "with_customs", type: "boolean", nullable: true })
|
||||
withCustoms?: boolean | null;
|
||||
|
||||
/** The five seeded container templates — cannot be deleted. */
|
||||
@Column({ name: "is_system", type: "boolean", default: false })
|
||||
isSystem!: boolean;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
|
||||
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
|
||||
import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
|
||||
import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity';
|
||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
||||
import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ContractExpiryService } from './contract-expiry.service';
|
||||
import type { Contract } from './entities/contract.entity';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
|
||||
/**
|
||||
* The reminder must warn each customer once, ten days out, and must never let a
|
||||
@@ -60,4 +61,18 @@ describe('ContractExpiryService — expiry reminder', () => {
|
||||
inbox.notify.mockRejectedValue(new Error('inbox down'));
|
||||
await expect(service.remindExpiringContracts()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
// The sweep-failure alert is staff-facing. It used to go to every employee;
|
||||
// it belongs to the people who would notice expired contracts still listed
|
||||
// as active, i.e. the contract desk.
|
||||
it('alerts the contract desk when the sweep itself fails', async () => {
|
||||
repo.expireLapsedContracts.mockRejectedValue(new Error('deadlock'));
|
||||
|
||||
await service.expireLapsedContracts();
|
||||
|
||||
expect(inbox.notify).toHaveBeenCalledTimes(1);
|
||||
expect(inbox.notify.mock.calls[0][0].recipients).toEqual({
|
||||
permissionKeys: [FREIGHT_PERMS.contracts.getNotification],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { NotificationAudience, NotificationType } from '@edr/types';
|
||||
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
|
||||
/**
|
||||
* How many days before a contract lapses the customer is reminded. Mirrored by
|
||||
@@ -79,7 +80,11 @@ export class ContractExpiryService {
|
||||
);
|
||||
try {
|
||||
await this.inbox.notify({
|
||||
recipients: { allBackoffice: true },
|
||||
// The people who would notice expired contracts still listed as
|
||||
// active are the ones working the contract desk.
|
||||
recipients: {
|
||||
permissionKeys: [FREIGHT_PERMS.contracts.getNotification],
|
||||
},
|
||||
audience: NotificationAudience.BACKOFFICE,
|
||||
type: NotificationType.GENERIC,
|
||||
title: 'Contract expiry sweep failed',
|
||||
|
||||
@@ -11,6 +11,16 @@ import { Contract } from './entities/contract.entity';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
|
||||
/**
|
||||
* Clearance items are worked by the GL desks, which hold no intake keys — so
|
||||
* they take their own selector rather than the contract desk's. Every override
|
||||
* using this deep-links to a clearance or shipment-request page.
|
||||
*/
|
||||
const CLEARANCE_DESK = {
|
||||
permissionKeys: [FREIGHT_PERMS.contracts.clearanceGetNotification],
|
||||
};
|
||||
|
||||
/**
|
||||
* Customer + staff notifications for the contract lifecycle. Every customer
|
||||
@@ -86,7 +96,11 @@ export class ContractNotifierService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Persist + push an in-app item to every backoffice staff user. */
|
||||
/**
|
||||
* Persist + push an in-app item to the contract desk — staff holding
|
||||
* `contracts:get_notification`. Callers whose item belongs to a different
|
||||
* desk override `recipients` (see {@link CLEARANCE_DESK}).
|
||||
*/
|
||||
private inAppStaff(
|
||||
c: Contract,
|
||||
title: string,
|
||||
@@ -94,7 +108,7 @@ export class ContractNotifierService {
|
||||
overrides: Partial<NotifyInput> = {},
|
||||
): void {
|
||||
void this.inbox.notify({
|
||||
recipients: { allBackoffice: true },
|
||||
recipients: { permissionKeys: [FREIGHT_PERMS.contracts.getNotification] },
|
||||
audience: NotificationAudience.BACKOFFICE,
|
||||
type: NotificationType.REQUEST_SUBMITTED,
|
||||
title,
|
||||
@@ -230,6 +244,7 @@ export class ContractNotifierService {
|
||||
`the customs declaration can be filed.${note ? ` Note: "${note}"` : ''}`;
|
||||
this.logger.log(`TRANSIT ASSIGNEE REQUESTED — ${c.reference}`);
|
||||
this.inAppStaff(c, `Transit assignee needed — ${c.reference}`, msg, {
|
||||
recipients: CLEARANCE_DESK,
|
||||
type: NotificationType.CLEARANCE_REVIEW,
|
||||
link: `/dashboard/gl-djibouti/clearance/${c.id}`,
|
||||
});
|
||||
@@ -248,6 +263,7 @@ export class ContractNotifierService {
|
||||
`The customs declaration can now be filed.`;
|
||||
this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${c.reference}`);
|
||||
this.inAppStaff(c, `Transit assignee set — ${c.reference}`, msg, {
|
||||
recipients: CLEARANCE_DESK,
|
||||
type: NotificationType.CLEARANCE_REVIEW,
|
||||
link: `/dashboard/contracts/clearance/${c.id}`,
|
||||
});
|
||||
@@ -264,6 +280,7 @@ export class ContractNotifierService {
|
||||
`"${note}". Review and re-advise the amount on the clearance page.`;
|
||||
this.logger.log(`DUTY DISPUTED — ${c.reference}`);
|
||||
this.inAppStaff(c, `Duty disputed on ${c.reference}`, msg, {
|
||||
recipients: CLEARANCE_DESK,
|
||||
type: NotificationType.CLEARANCE_REVIEW,
|
||||
link: `/dashboard/contracts/clearance/${c.id}`,
|
||||
});
|
||||
@@ -321,6 +338,7 @@ export class ContractNotifierService {
|
||||
'Clearance documents uploaded',
|
||||
`Customer uploaded clearance documents for contract ${this.ref(c)} — review them in the clearance queue.`,
|
||||
{
|
||||
recipients: CLEARANCE_DESK,
|
||||
type: NotificationType.CLEARANCE_REVIEW,
|
||||
link: `/dashboard/contracts/clearance/${c.id}`,
|
||||
},
|
||||
@@ -334,6 +352,7 @@ export class ContractNotifierService {
|
||||
'Duty slip uploaded',
|
||||
`Customer uploaded the duty & tax payment slip for contract ${this.ref(c)}.`,
|
||||
{
|
||||
recipients: CLEARANCE_DESK,
|
||||
type: NotificationType.PAYMENT_RECEIVED,
|
||||
link: `/dashboard/contracts/clearance/${c.id}`,
|
||||
},
|
||||
@@ -347,6 +366,9 @@ export class ContractNotifierService {
|
||||
'New shipment request',
|
||||
`Shipment request ${requestRef} was filed under contract ${this.ref(c)} and awaits GL review.`,
|
||||
{
|
||||
// GL reviews these, and the shipment-requests page is gated on
|
||||
// contracts:create_booking — a key only the GL Ethiopia preset holds.
|
||||
recipients: CLEARANCE_DESK,
|
||||
link: `/dashboard/shipment-requests/${requestId}`,
|
||||
data: { contractId: c.id, requestId, reference: requestRef },
|
||||
},
|
||||
|
||||
@@ -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.freightType,
|
||||
contract.customsClearingEnabled,
|
||||
// Bulk templates are keyed by the contract's cargo type.
|
||||
(contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId,
|
||||
);
|
||||
if (!active) return null;
|
||||
return {
|
||||
|
||||
@@ -1074,16 +1074,23 @@ export class ContractsController {
|
||||
// ── Booking under contract (Path A customer / Path B GL ET) ────────────────
|
||||
|
||||
@Post(':id/bookings')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.createBooking)
|
||||
// Path A is a customer flow — both audiences must reach the service, whose
|
||||
// assertGate decides per role. Staff still need contracts:create_booking.
|
||||
@MixedAudience(FREIGHT_PERMS.contracts.createBooking)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Create a shipment booking under a contract — Path A (customer) or Path B (GL Ethiopia).',
|
||||
})
|
||||
createBooking(
|
||||
async createBooking(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: CreateBookingUnderContractDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@CurrentUser() user: TCurrentUser & { sub?: string },
|
||||
) {
|
||||
// Customer callers may only book on their own contract.
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking)) {
|
||||
const contract = await this.contractsService.findById(id);
|
||||
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
|
||||
}
|
||||
// The service decides the execution path from the contract:
|
||||
// Path A (customs disabled) → customer/staff create; status checks apply.
|
||||
// Path B (customs enabled) → GL Ethiopia only, once clearance is ready.
|
||||
@@ -1096,16 +1103,25 @@ export class ContractsController {
|
||||
}
|
||||
|
||||
@Post(':id/bookings/initiate')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.createBooking)
|
||||
// Customer initiates their own ONE_TIME instance; GL initiates on customs
|
||||
// contracts — the service's assertGate decides per role, so both audiences
|
||||
// must reach it. Staff still need contracts:create_booking.
|
||||
@MixedAudience(FREIGHT_PERMS.contracts.createBooking)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Initiate a bare booking instance under an import/export contract (ONE_TIME or GENERAL) — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS). ONE_TIME customs instances are opened by the customer (or GL); GENERAL customs comes from a shipment request.',
|
||||
})
|
||||
initiateBooking(
|
||||
async initiateBooking(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: CreateBookingUnderContractDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@CurrentUser() user: TCurrentUser & { sub?: string },
|
||||
) {
|
||||
// Customer callers may only initiate on their own contract; the service's
|
||||
// assertGate then decides what a customer may do on it.
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking)) {
|
||||
const contract = await this.contractsService.findById(id);
|
||||
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
|
||||
}
|
||||
return this.contractBookingService.initiateUnderContract(
|
||||
id,
|
||||
{ contractRouteId: dto?.contractRouteId },
|
||||
@@ -1115,17 +1131,24 @@ export class ContractsController {
|
||||
}
|
||||
|
||||
@Post(':id/bookings/:bookingId/complete')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.createBooking)
|
||||
// Customers complete their own initiated (non-customs) instances; the
|
||||
// service keeps customs completion GL-only via the actor's permissions.
|
||||
@MixedAudience(FREIGHT_PERMS.contracts.createBooking)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Complete an initiated booking after Operations finalized its clearance — cargo + binding day, window and departure checks, pricing and invoicing.',
|
||||
})
|
||||
completeBooking(
|
||||
async completeBooking(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@Body() dto: CreateBookingUnderContractDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@CurrentUser() user: TCurrentUser & { sub?: string },
|
||||
) {
|
||||
// Customer callers may only complete bookings on their own contract.
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking)) {
|
||||
const contract = await this.contractsService.findById(id);
|
||||
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
|
||||
}
|
||||
// Customs (Path B) instances may only be completed by GL Ethiopia — the
|
||||
// service checks the actor's contracts:create_booking permission.
|
||||
return this.contractBookingService.completeUnderContract(
|
||||
|
||||
@@ -104,15 +104,19 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
.andWhere('contract.status NOT IN (:...terminal)', {
|
||||
terminal: TERMINAL_CONTRACT_STATUSES,
|
||||
})
|
||||
// A ONE_TIME contract allows a single booking, so once that booking
|
||||
// exists the contract is spent and can never carry another shipment.
|
||||
// A ONE_TIME contract allows a single booking, so once that booking is
|
||||
// PAID the contract is spent and can never carry another shipment.
|
||||
// Without this it kept blocking new requests on the same service type +
|
||||
// route until its validity lapsed — locking a customer out of a lane for
|
||||
// the rest of the term after one completed shipment.
|
||||
// Payment is the gate, not the booking row: a DRAFT or abandoned unpaid
|
||||
// booking must keep the contract blocking, otherwise a customer holds an
|
||||
// unpaid booking and requests an identical contract alongside it.
|
||||
.andWhere(
|
||||
`(contract.contract_kind <> 'ONE_TIME' OR NOT EXISTS (
|
||||
SELECT 1 FROM freight.bookings b
|
||||
WHERE b.contract_id = contract.id AND b.deleted_at IS NULL
|
||||
AND b.payment_status = 'PAID'
|
||||
))`,
|
||||
)
|
||||
.getMany();
|
||||
|
||||
@@ -24,7 +24,14 @@ import { FleetHistoryService } from '../fleet-history/fleet-history.service';
|
||||
@ApiTags('drivers')
|
||||
@ApiBearerAuth()
|
||||
@Controller('drivers')
|
||||
@BookingStaff(FREIGHT_PERMS.drivers.view)
|
||||
// Class gate lists every key its routes use: Nest runs class AND method
|
||||
// guards, so a key missing here would deny before the route's own key runs.
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.drivers.view,
|
||||
FREIGHT_PERMS.drivers.create,
|
||||
FREIGHT_PERMS.drivers.update,
|
||||
FREIGHT_PERMS.drivers.delete,
|
||||
])
|
||||
export class DriversController {
|
||||
constructor(
|
||||
private readonly driversService: DriversService,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsBoolean, IsOptional, IsString, Length } from "class-validator";
|
||||
|
||||
/**
|
||||
* Manual reconciliation of a submission that was never acknowledged. Exactly one of the two is
|
||||
* meaningful: supply the IRN confirmed with MoR, or discard the attempt.
|
||||
*/
|
||||
export class ResolveEimsRegistrationDto {
|
||||
@ApiPropertyOptional({
|
||||
description: "IRN confirmed in the MoR portal. Records the registration and resumes the chain.",
|
||||
example: "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 64)
|
||||
irn?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Abandon the submission: the invoice is marked FAILED and the chain is unchanged.",
|
||||
example: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
discard?: boolean;
|
||||
}
|
||||
256
apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts
Normal file
256
apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { HttpService } from "@nestjs/axios";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { AxiosError, AxiosHeaders } from "axios";
|
||||
import { of, throwError } from "rxjs";
|
||||
import { EimsConfig } from "../../config/eims.config";
|
||||
import { eimsConfig, eimsToken } from "./eims-test-fixtures";
|
||||
import { EimsAuthService } from "./eims-auth.service";
|
||||
import { EimsSignerService } from "./eims-signer.service";
|
||||
|
||||
const CLIENT_SECRET = "super-secret-value";
|
||||
const API_KEY = "super-secret-apikey";
|
||||
|
||||
const cfg = (over: Partial<EimsConfig> = {}): EimsConfig => eimsConfig(over);
|
||||
|
||||
const TOKEN_1 = eimsToken({ jti: "one" });
|
||||
const TOKEN_2 = eimsToken({ jti: "two" });
|
||||
|
||||
const loginBody = (accessToken: string, expiresIn = 3600) => ({
|
||||
data: { accessToken, refreshToken: "refresh-1", encryptionKey: null, expiresIn },
|
||||
status: "SUCCESS",
|
||||
});
|
||||
|
||||
/** Stub signer: the real signing path has its own spec and needs no key material here. */
|
||||
const signer = {
|
||||
signRequest: <T>(request: T) => ({ request, signature: "SIGNATURE", certificate: "CERTIFICATE" }),
|
||||
} as unknown as EimsSignerService;
|
||||
|
||||
const build = (post: jest.Mock, config: EimsConfig = cfg()) =>
|
||||
new EimsAuthService(
|
||||
{ post } as unknown as HttpService,
|
||||
{ get: () => config } as unknown as ConfigService,
|
||||
signer,
|
||||
);
|
||||
|
||||
const axiosErr = (status: number, data: unknown) =>
|
||||
new AxiosError("Request failed", undefined, undefined, undefined, {
|
||||
status,
|
||||
statusText: "",
|
||||
data,
|
||||
headers: new AxiosHeaders(),
|
||||
config: { headers: new AxiosHeaders() },
|
||||
});
|
||||
|
||||
describe("EimsAuthService.getValidAccessToken", () => {
|
||||
it("posts the signed login envelope to /auth/login with no Authorization header", async () => {
|
||||
const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) }));
|
||||
|
||||
await build(post).getValidAccessToken();
|
||||
|
||||
expect(post).toHaveBeenCalledTimes(1);
|
||||
const [url, body, options] = post.mock.calls[0];
|
||||
expect(url).toBe("https://core.mor.gov.et/auth/login");
|
||||
expect(options.headers).toEqual({ "Content-Type": "application/json" });
|
||||
expect(options.headers.Authorization).toBeUndefined();
|
||||
|
||||
expect(typeof body).toBe("string");
|
||||
expect(JSON.parse(body)).toEqual({
|
||||
request: { clientId: "cid", clientSecret: CLIENT_SECRET, apikey: API_KEY, tin: "0000034558" },
|
||||
signature: "SIGNATURE",
|
||||
certificate: "CERTIFICATE",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the access token from data.accessToken", async () => {
|
||||
const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) }));
|
||||
await expect(build(post).getValidAccessToken()).resolves.toBe(TOKEN_1);
|
||||
});
|
||||
|
||||
it("reuses a cached token instead of logging in again", async () => {
|
||||
const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) }));
|
||||
const auth = build(post);
|
||||
|
||||
await auth.getValidAccessToken();
|
||||
await expect(auth.getValidAccessToken()).resolves.toBe(TOKEN_1);
|
||||
expect(post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("re-authenticates a skew-window before the token actually expires", async () => {
|
||||
const post = jest
|
||||
.fn()
|
||||
.mockReturnValueOnce(of({ data: loginBody(TOKEN_1, 100) })) // 100s ttl, 45s skew ⇒ usable 55s
|
||||
.mockReturnValueOnce(of({ data: loginBody(TOKEN_2) }));
|
||||
const auth = build(post);
|
||||
const start = Date.now();
|
||||
const clock = jest.spyOn(Date, "now");
|
||||
|
||||
try {
|
||||
clock.mockReturnValue(start);
|
||||
await expect(auth.getValidAccessToken()).resolves.toBe(TOKEN_1);
|
||||
|
||||
clock.mockReturnValue(start + 50_000); // inside the window: still cached
|
||||
await expect(auth.getValidAccessToken()).resolves.toBe(TOKEN_1);
|
||||
expect(post).toHaveBeenCalledTimes(1);
|
||||
|
||||
clock.mockReturnValue(start + 56_000); // past ttl-minus-skew, before the real 100s expiry
|
||||
await expect(auth.getValidAccessToken()).resolves.toBe(TOKEN_2);
|
||||
expect(post).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
clock.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("logs in again after invalidate()", async () => {
|
||||
const post = jest
|
||||
.fn()
|
||||
.mockReturnValueOnce(of({ data: loginBody(TOKEN_1) }))
|
||||
.mockReturnValueOnce(of({ data: loginBody(TOKEN_2) }));
|
||||
const auth = build(post);
|
||||
|
||||
await auth.getValidAccessToken();
|
||||
auth.invalidate();
|
||||
await expect(auth.getValidAccessToken()).resolves.toBe(TOKEN_2);
|
||||
expect(post).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("performs exactly one login for many concurrent callers", async () => {
|
||||
const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) }));
|
||||
const auth = build(post);
|
||||
|
||||
const tokens = await Promise.all(Array.from({ length: 20 }, () => auth.getValidAccessToken()));
|
||||
|
||||
expect(post).toHaveBeenCalledTimes(1);
|
||||
expect(new Set(tokens)).toEqual(new Set([TOKEN_1]));
|
||||
});
|
||||
|
||||
it("does not put the access token in its own log line", async () => {
|
||||
const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) }));
|
||||
const logged: string[] = [];
|
||||
const auth = build(post);
|
||||
jest
|
||||
.spyOn(auth["logger"], "log")
|
||||
.mockImplementation((message: unknown) => void logged.push(String(message)));
|
||||
|
||||
await auth.getValidAccessToken();
|
||||
|
||||
expect(logged.join("\n")).not.toContain(TOKEN_1);
|
||||
expect(logged.join("\n")).toContain("B0360154BA");
|
||||
});
|
||||
|
||||
it("refuses to call the gateway when EIMS is disabled", async () => {
|
||||
const post = jest.fn();
|
||||
await expect(build(post, cfg({ enabled: false })).getValidAccessToken()).rejects.toThrow(
|
||||
/EIMS integration is disabled/,
|
||||
);
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a 200 response that carries no access token", async () => {
|
||||
const post = jest.fn().mockReturnValue(of({ data: { data: {}, status: "SUCCESS" } }));
|
||||
await expect(build(post).getValidAccessToken()).rejects.toThrow(/returned no accessToken/);
|
||||
});
|
||||
|
||||
it("surfaces gateway errors without leaking credentials or the envelope", async () => {
|
||||
const post = jest.fn().mockReturnValue(
|
||||
throwError(() =>
|
||||
axiosErr(401, {
|
||||
message: "GATEWAY ERROR",
|
||||
statusCode: 401,
|
||||
code: "4400",
|
||||
details: [{ errorMessage: "Invalid Credentials" }],
|
||||
// Fields the gateway must never echo back into our logs or exceptions:
|
||||
signature: "SIGNATURE",
|
||||
certificate: "CERTIFICATE",
|
||||
accessToken: "leaked-token",
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const error = (await build(post)
|
||||
.getValidAccessToken()
|
||||
.catch((e: Error) => e)) as Error & { response?: unknown };
|
||||
const serialized = JSON.stringify({ message: error.message, response: error.response });
|
||||
|
||||
expect(error.message).toContain("EIMS login failed (401)");
|
||||
expect(error.message).toContain("Invalid Credentials");
|
||||
for (const secret of [CLIENT_SECRET, API_KEY, "SIGNATURE", "CERTIFICATE", "leaked-token"]) {
|
||||
expect(serialized).not.toContain(secret);
|
||||
}
|
||||
});
|
||||
|
||||
it("maps a timeout to a TIMEOUT failure without a status", async () => {
|
||||
const timeout = new AxiosError("timeout of 30000ms exceeded", "ECONNABORTED");
|
||||
const post = jest.fn().mockReturnValue(throwError(() => timeout));
|
||||
|
||||
await expect(build(post).getValidAccessToken()).rejects.toThrow(/EIMS login timed out/);
|
||||
});
|
||||
|
||||
it("maps an unreachable gateway to a NETWORK failure", async () => {
|
||||
const refused = new AxiosError("connect ECONNREFUSED", "ECONNREFUSED");
|
||||
const post = jest.fn().mockReturnValue(throwError(() => refused));
|
||||
|
||||
await expect(build(post).getValidAccessToken()).rejects.toThrow(/could not reach the gateway/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("EimsAuthService.getSessionContext", () => {
|
||||
it("takes the source system from the token's claims", async () => {
|
||||
const post = jest
|
||||
.fn()
|
||||
.mockReturnValue(
|
||||
of({ data: loginBody(eimsToken({ systemNumber: "FROM-TOKEN", systemType: "POS" })) }),
|
||||
);
|
||||
|
||||
// Env deliberately left empty: with nothing to check against, the token is simply believed.
|
||||
await expect(
|
||||
build(post, cfg({ systemNumber: "", systemType: "" })).getSessionContext(),
|
||||
).resolves.toEqual({ systemNumber: "FROM-TOKEN", systemType: "POS" });
|
||||
});
|
||||
|
||||
it("serves the session from the cached login rather than re-authenticating", async () => {
|
||||
const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) }));
|
||||
const auth = build(post);
|
||||
|
||||
await auth.getSessionContext();
|
||||
await expect(auth.getSessionContext()).resolves.toEqual({
|
||||
systemNumber: "B0360154BA",
|
||||
systemType: "SYS",
|
||||
});
|
||||
expect(post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each(["systemNumber", "systemType"])("rejects a token with no %s claim", async (claim) => {
|
||||
const post = jest
|
||||
.fn()
|
||||
.mockReturnValue(of({ data: loginBody(eimsToken({ [claim]: undefined })) }));
|
||||
|
||||
await expect(
|
||||
build(post, cfg({ systemNumber: "", systemType: "" })).getSessionContext(),
|
||||
).rejects.toThrow(new RegExp(`no ${claim} claim`));
|
||||
});
|
||||
|
||||
it("rejects an access token that is not a decodable JWT", async () => {
|
||||
const post = jest.fn().mockReturnValue(of({ data: loginBody("not-a-jwt") }));
|
||||
|
||||
await expect(build(post).getSessionContext()).rejects.toThrow(/not a JWT/);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["systemNumber", { systemNumber: "SOMETHING-ELSE" }, /EIMS_SYSTEM_NUMBER=B0360154BA/],
|
||||
["systemType", { systemType: "POS" }, /EIMS_SYSTEM_TYPE=SYS/],
|
||||
])("fails fast when the configured %s disagrees with the token", async (_name, over, pattern) => {
|
||||
const post = jest.fn().mockReturnValue(of({ data: loginBody(eimsToken(over)) }));
|
||||
|
||||
// cfg() sets EIMS_SYSTEM_NUMBER=B0360154BA and EIMS_SYSTEM_TYPE=SYS as expectations.
|
||||
await expect(build(post).getSessionContext()).rejects.toThrow(pattern);
|
||||
});
|
||||
|
||||
it("accepts a configured value that matches the token", async () => {
|
||||
const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) }));
|
||||
|
||||
await expect(build(post).getSessionContext()).resolves.toEqual({
|
||||
systemNumber: "B0360154BA",
|
||||
systemType: "SYS",
|
||||
});
|
||||
});
|
||||
});
|
||||
212
apps/edr-freight-api/src/modules/eims/eims-auth.service.ts
Normal file
212
apps/edr-freight-api/src/modules/eims/eims-auth.service.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { HttpService } from "@nestjs/axios";
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { EimsConfig } from "../../config/eims.config";
|
||||
import { EimsSignerService, toSignedBody } from "./eims-signer.service";
|
||||
import { EimsApiException, EimsConfigException, toEimsApiException } from "./eims.errors";
|
||||
import { EimsLoginRequest, EimsLoginResponse } from "./eims.types";
|
||||
|
||||
interface TokenCache {
|
||||
accessToken: string;
|
||||
/** Epoch ms, already reduced by the configured skew. */
|
||||
expiresAt: number;
|
||||
session: EimsSessionContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Source-system identity, taken from the access token MoR issues us.
|
||||
*
|
||||
* The gateway stamps `systemNumber` and `systemType` into the token for the credentials that
|
||||
* authenticated, which makes the token the authority on them — not our environment file. Anything
|
||||
* we configured locally can only ever disagree with what MoR believes.
|
||||
*/
|
||||
export interface EimsSessionContext {
|
||||
systemNumber: string;
|
||||
systemType: string;
|
||||
}
|
||||
|
||||
/** Decode a JWT payload without verifying it: this is MoR's token, signed with MoR's key. */
|
||||
function decodeTokenClaims(accessToken: string): Record<string, unknown> {
|
||||
const payload = accessToken.split(".")[1];
|
||||
if (!payload) {
|
||||
throw new EimsApiException("UNKNOWN", "EIMS access token is not a JWT (no payload segment)");
|
||||
}
|
||||
try {
|
||||
return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as Record<string, unknown>;
|
||||
} catch (err) {
|
||||
// The token itself is never included — only that its payload would not parse.
|
||||
throw new EimsApiException(
|
||||
"UNKNOWN",
|
||||
`EIMS access token payload could not be decoded: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const claimString = (claims: Record<string, unknown>, name: string): string => {
|
||||
const value = claims[name];
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
};
|
||||
|
||||
/** Used when the gateway omits `expiresIn`; the observed value is 3600. */
|
||||
const FALLBACK_EXPIRES_IN_SECONDS = 3600;
|
||||
|
||||
/**
|
||||
* EIMS authentication: signed `POST /auth/login`, plus an in-memory access-token cache.
|
||||
*
|
||||
* Login is the one EIMS call that carries no bearer token, which is why it lives here rather than
|
||||
* in the generic client. Tokens are held in memory only — never persisted, never logged, never
|
||||
* returned to a frontend.
|
||||
*/
|
||||
@Injectable()
|
||||
export class EimsAuthService {
|
||||
private readonly logger = new Logger(EimsAuthService.name);
|
||||
private cache: TokenCache | null = null;
|
||||
private loginInFlight: Promise<string> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly http: HttpService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly signer: EimsSignerService,
|
||||
) {}
|
||||
|
||||
private get cfg(): EimsConfig {
|
||||
return this.config.get<EimsConfig>("eims")!;
|
||||
}
|
||||
|
||||
/**
|
||||
* A non-expired access token, logging in if needed. Concurrent callers share one login: the
|
||||
* first caller stores the in-flight promise and everyone else awaits it.
|
||||
*/
|
||||
async getValidAccessToken(): Promise<string> {
|
||||
if (this.cache && Date.now() < this.cache.expiresAt) {
|
||||
return this.cache.accessToken;
|
||||
}
|
||||
if (this.loginInFlight) return this.loginInFlight;
|
||||
|
||||
this.loginInFlight = this.login();
|
||||
try {
|
||||
return await this.loginInFlight;
|
||||
} finally {
|
||||
this.loginInFlight = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The source-system identity MoR issued this session, refreshing the login if needed.
|
||||
*
|
||||
* This is the authority for `SourceSystem.SystemNumber` / `SystemType`: the gateway stamps both
|
||||
* into the access token for the authenticating credentials, so a local env value could only ever
|
||||
* disagree with it.
|
||||
*/
|
||||
async getSessionContext(): Promise<EimsSessionContext> {
|
||||
await this.getValidAccessToken();
|
||||
return this.cache!.session;
|
||||
}
|
||||
|
||||
/** Drop the cached token — called after a 401 so the next request re-authenticates. */
|
||||
invalidate(): void {
|
||||
this.cache = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the source-system claims out of the token, and cross-check anything configured locally.
|
||||
*
|
||||
* `EIMS_SYSTEM_NUMBER` / `EIMS_SYSTEM_TYPE` are optional expectations, not inputs: when set they
|
||||
* are compared and a mismatch fails immediately rather than one silently winning. Registering
|
||||
* under the wrong source system is not something to discover from a rejected invoice.
|
||||
*/
|
||||
private readSessionContext(accessToken: string, cfg: EimsConfig): EimsSessionContext {
|
||||
const claims = decodeTokenClaims(accessToken);
|
||||
const systemNumber = claimString(claims, "systemNumber");
|
||||
const systemType = claimString(claims, "systemType");
|
||||
|
||||
const missing = [
|
||||
!systemNumber && "systemNumber",
|
||||
!systemType && "systemType",
|
||||
].filter(Boolean);
|
||||
if (missing.length > 0) {
|
||||
throw new EimsApiException(
|
||||
"UNKNOWN",
|
||||
`EIMS access token carries no ${missing.join(" or ")} claim; cannot identify the source system`,
|
||||
);
|
||||
}
|
||||
|
||||
const mismatches = [
|
||||
cfg.systemNumber && cfg.systemNumber !== systemNumber
|
||||
? `EIMS_SYSTEM_NUMBER=${cfg.systemNumber} but the token says ${systemNumber}`
|
||||
: null,
|
||||
cfg.systemType && cfg.systemType !== systemType
|
||||
? `EIMS_SYSTEM_TYPE=${cfg.systemType} but the token says ${systemType}`
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
if (mismatches.length > 0) {
|
||||
throw new EimsConfigException(
|
||||
`EIMS source-system configuration disagrees with the issued token: ${mismatches.join("; ")}. ` +
|
||||
"Correct the environment or the credentials — neither value is assumed to win.",
|
||||
);
|
||||
}
|
||||
|
||||
return { systemNumber, systemType };
|
||||
}
|
||||
|
||||
private async login(): Promise<string> {
|
||||
const cfg = this.cfg;
|
||||
if (!cfg.enabled) {
|
||||
throw new EimsConfigException("EIMS integration is disabled; set EIMS_ENABLED=true to use it");
|
||||
}
|
||||
|
||||
const request: EimsLoginRequest = {
|
||||
clientId: cfg.clientId,
|
||||
clientSecret: cfg.clientSecret,
|
||||
apikey: cfg.apiKey,
|
||||
tin: cfg.tin,
|
||||
};
|
||||
const body = toSignedBody(this.signer.signRequest(request));
|
||||
|
||||
let response: EimsLoginResponse;
|
||||
try {
|
||||
const res = await firstValueFrom(
|
||||
this.http.post<EimsLoginResponse>(`${cfg.baseUrl}/auth/login`, body, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
timeout: cfg.httpTimeoutMs,
|
||||
}),
|
||||
);
|
||||
response = res.data;
|
||||
} catch (err) {
|
||||
const mapped = toEimsApiException(err, "login");
|
||||
this.logger.error(mapped.message);
|
||||
throw mapped;
|
||||
}
|
||||
|
||||
const accessToken = response?.data?.accessToken;
|
||||
if (!accessToken) {
|
||||
throw new EimsApiException("UNKNOWN", "EIMS login returned no accessToken");
|
||||
}
|
||||
|
||||
const expiresIn =
|
||||
Number.isFinite(response.data.expiresIn) && response.data.expiresIn > 0
|
||||
? response.data.expiresIn
|
||||
: FALLBACK_EXPIRES_IN_SECONDS;
|
||||
|
||||
// TODO: implement `POST /auth/refresh-token` and hold `response.data.refreshToken`. The
|
||||
// collection shows a bare `{refreshToken}` body with no envelope, but it also carries unsigned
|
||||
// examples of calls that do require signing, so whether refresh must be signed is unconfirmed.
|
||||
// Until MoR confirms it, an expired token just triggers a fresh login — `expiresIn` is 3600s,
|
||||
// so that is one extra call an hour.
|
||||
// Reject the session before caching it: a token we cannot identify a source system from is
|
||||
// useless for registration, and a configured expectation that disagrees is a deployment fault.
|
||||
const session = this.readSessionContext(accessToken, cfg);
|
||||
|
||||
this.cache = {
|
||||
accessToken,
|
||||
expiresAt: Date.now() + Math.max(expiresIn * 1000 - cfg.tokenSkewMs, 1000),
|
||||
session,
|
||||
};
|
||||
this.logger.log(
|
||||
`EIMS login succeeded; token cached for ~${expiresIn}s ` +
|
||||
`(system ${session.systemNumber}, type ${session.systemType})`,
|
||||
);
|
||||
return accessToken;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { DataSource } from "typeorm";
|
||||
|
||||
import { EimsConfig } from "../../config/eims.config";
|
||||
import { EimsAutoSubmitService } from "./eims-auto-submit.service";
|
||||
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
|
||||
import { EimsInvoiceStatus } from "./eims-registration.types";
|
||||
import { eimsConfig } from "./eims-test-fixtures";
|
||||
|
||||
const INVOICE_ID = "11111111-1111-4111-8111-111111111111";
|
||||
|
||||
/**
|
||||
* `query` is answered by shape: the first call is the system-state guard, the second is the
|
||||
* candidate lookup. Keeps the fake honest about the order the service actually asks in.
|
||||
*/
|
||||
const build = (
|
||||
opts: {
|
||||
cfg?: Partial<EimsConfig>;
|
||||
state?: { in_flight_invoice_id?: string | null; blocked_reason?: string | null };
|
||||
candidate?: { id: string; invoiceNumber: string } | null;
|
||||
register?: jest.Mock;
|
||||
} = {},
|
||||
) => {
|
||||
const register =
|
||||
opts.register ??
|
||||
jest.fn().mockResolvedValue({ eimsStatus: EimsInvoiceStatus.Registered, eimsIrn: "IRN-1" });
|
||||
|
||||
const query = jest.fn().mockImplementation((sql: string) => {
|
||||
if (sql.includes("eims_system_state")) {
|
||||
return Promise.resolve(
|
||||
opts.state ? [{ in_flight_invoice_id: null, blocked_reason: null, ...opts.state }] : [],
|
||||
);
|
||||
}
|
||||
return Promise.resolve(opts.candidate === undefined ? [] : opts.candidate ? [opts.candidate] : []);
|
||||
});
|
||||
|
||||
const service = new EimsAutoSubmitService(
|
||||
{ query } as unknown as DataSource,
|
||||
{ get: () => eimsConfig({ autoSubmit: true, ...opts.cfg }) } as unknown as ConfigService,
|
||||
{ registerInvoiceWithEims: register } as unknown as EimsInvoiceRegistrationService,
|
||||
);
|
||||
return { service, register, query };
|
||||
};
|
||||
|
||||
const candidate = { id: INVOICE_ID, invoiceNumber: "INV-20260807-00006" };
|
||||
|
||||
describe("EimsAutoSubmitService.tick", () => {
|
||||
it("files the oldest eligible invoice through the registration service", async () => {
|
||||
const { service, register } = build({ candidate });
|
||||
|
||||
await service.tick();
|
||||
|
||||
expect(register).toHaveBeenCalledTimes(1);
|
||||
expect(register).toHaveBeenCalledWith(INVOICE_ID);
|
||||
});
|
||||
|
||||
it("files nothing when EIMS_AUTO_SUBMIT is off", async () => {
|
||||
const { service, register, query } = build({ cfg: { autoSubmit: false }, candidate });
|
||||
|
||||
await service.tick();
|
||||
|
||||
expect(register).not.toHaveBeenCalled();
|
||||
expect(query).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("files nothing when EIMS itself is disabled, even with auto-submit on", async () => {
|
||||
const { service, register, query } = build({ cfg: { enabled: false }, candidate });
|
||||
|
||||
await service.tick();
|
||||
|
||||
expect(register).not.toHaveBeenCalled();
|
||||
expect(query).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not submit while another submission is in flight", async () => {
|
||||
const { service, register } = build({
|
||||
state: { in_flight_invoice_id: "22222222-2222-4222-8222-222222222222" },
|
||||
candidate,
|
||||
});
|
||||
|
||||
await service.tick();
|
||||
|
||||
expect(register).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not submit while the system number is blocked", async () => {
|
||||
const { service, register } = build({
|
||||
state: { blocked_reason: "never acknowledged" },
|
||||
candidate,
|
||||
});
|
||||
|
||||
await service.tick();
|
||||
|
||||
expect(register).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing when no invoice is eligible", async () => {
|
||||
const { service, register } = build({ candidate: null });
|
||||
|
||||
await service.tick();
|
||||
|
||||
expect(register).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("asks only for NOT_SUBMITTED invoices, so UNKNOWN and FAILED are never retried", async () => {
|
||||
const { service, query } = build({ candidate });
|
||||
|
||||
await service.tick();
|
||||
|
||||
const [sql, params] = query.mock.calls.find(([s]: [string]) => s.includes("freight.invoices"))!;
|
||||
expect(sql).toContain("i.eims_status = $1");
|
||||
expect(params[0]).toBe(EimsInvoiceStatus.NotSubmitted);
|
||||
expect(sql).toContain("i.issued_at IS NOT NULL");
|
||||
});
|
||||
|
||||
it("survives a filing failure so the job keeps running", async () => {
|
||||
const register = jest.fn().mockRejectedValue(new Error("EIMS register failed (406)"));
|
||||
const { service } = build({ candidate, register });
|
||||
|
||||
await expect(service.tick()).resolves.toBeUndefined();
|
||||
expect(register).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not start a second tick while one is still filing", async () => {
|
||||
let release: () => void = () => {};
|
||||
const register = jest.fn().mockImplementation(
|
||||
() => new Promise((resolve) => (release = () => resolve({ eimsStatus: "REGISTERED" }))),
|
||||
);
|
||||
const { service } = build({ candidate, register });
|
||||
|
||||
const first = service.tick();
|
||||
await new Promise((r) => setImmediate(r));
|
||||
await service.tick(); // overlapping tick, must be a no-op
|
||||
|
||||
expect(register).toHaveBeenCalledTimes(1);
|
||||
release();
|
||||
await first;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Cron } from "@nestjs/schedule";
|
||||
import { InjectDataSource } from "@nestjs/typeorm";
|
||||
import { DataSource } from "typeorm";
|
||||
|
||||
import { EimsConfig } from "../../config/eims.config";
|
||||
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
|
||||
import { EimsInvoiceStatus } from "./eims-registration.types";
|
||||
|
||||
/**
|
||||
* Files issued invoices with MoR EIMS on a timer.
|
||||
*
|
||||
* Invoices are produced by the freight workflow rather than by a person, so this — not the manual
|
||||
* endpoint — is the production path. It is a sweep rather than a hook on the eleven places an
|
||||
* invoice can be created or issued, which buys three things: the workflow is untouched, the HTTP
|
||||
* call is by construction outside the invoice's transaction, and an invoice missed through a crash
|
||||
* or a restart is picked up on the next tick.
|
||||
*
|
||||
* `invoices.eims_status` is the queue — nothing new is persisted. Only `NOT_SUBMITTED` is eligible:
|
||||
* `UNKNOWN` must never be retried automatically (the document may already be filed), and `FAILED`
|
||||
* waits for an explicit retry policy rather than a timer's guess.
|
||||
*
|
||||
* Off unless **both** `EIMS_ENABLED` and `EIMS_AUTO_SUBMIT` are true. Enabling it starts filing
|
||||
* real documents with the tax authority, and a registration cannot be undone from this side.
|
||||
*/
|
||||
@Injectable()
|
||||
export class EimsAutoSubmitService {
|
||||
private readonly logger = new Logger(EimsAutoSubmitService.name);
|
||||
/** Guards against a tick starting while the previous one is still filing. */
|
||||
private running = false;
|
||||
|
||||
constructor(
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
private readonly config: ConfigService,
|
||||
private readonly registration: EimsInvoiceRegistrationService,
|
||||
) {}
|
||||
|
||||
private get cfg(): EimsConfig {
|
||||
return this.config.get<EimsConfig>("eims")!;
|
||||
}
|
||||
|
||||
/**
|
||||
* One invoice per tick.
|
||||
*
|
||||
* Deliberately not a batch: each filing consumes a counter and advances the IRN chain, an
|
||||
* ambiguous result blocks the system number until a human resolves it, and a misconfiguration
|
||||
* should cost one rejected document rather than a burst of them.
|
||||
*/
|
||||
@Cron(process.env.EIMS_AUTO_SUBMIT_CRON ?? "0 */5 * * * *", { name: "eims-auto-submit" })
|
||||
async tick(): Promise<void> {
|
||||
const cfg = this.cfg;
|
||||
if (!cfg.enabled || !cfg.autoSubmit) return;
|
||||
if (this.running) return;
|
||||
|
||||
this.running = true;
|
||||
try {
|
||||
// Rule of the chain: nothing may be filed while a submission is in flight or the system is
|
||||
// blocked. The reservation would refuse anyway — checking first keeps the log quiet and
|
||||
// avoids burning a tick on a guaranteed conflict.
|
||||
const blocked = await this.systemBlockReason();
|
||||
if (blocked) {
|
||||
this.logger.warn(`EIMS auto-submit paused: ${blocked}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const candidate = await this.nextCandidate();
|
||||
if (!candidate) return;
|
||||
|
||||
const view = await this.registration.registerInvoiceWithEims(candidate.id);
|
||||
this.logger.log(
|
||||
`EIMS auto-submit: invoice ${candidate.invoiceNumber} -> ${view.eimsStatus}` +
|
||||
(view.eimsIrn ? ` (IRN ${view.eimsIrn})` : ""),
|
||||
);
|
||||
} catch (err) {
|
||||
// Never let a filing failure kill the job. The outcome is already persisted on the invoice
|
||||
// (FAILED or UNKNOWN with the gateway's own message), and a blocked system number stops the
|
||||
// next tick at the guard above.
|
||||
this.logger.error(`EIMS auto-submit tick failed: ${(err as Error).message}`);
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Why filing is currently impossible for this system number, or null when it is free. */
|
||||
private async systemBlockReason(): Promise<string | null> {
|
||||
const rows: { in_flight_invoice_id: string | null; blocked_reason: string | null }[] =
|
||||
await this.dataSource.query(
|
||||
`SELECT in_flight_invoice_id, blocked_reason
|
||||
FROM freight.eims_system_state
|
||||
WHERE system_number = $1 AND deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[this.cfg.systemNumber],
|
||||
);
|
||||
const state = rows[0];
|
||||
if (!state) return null;
|
||||
if (state.blocked_reason) return state.blocked_reason;
|
||||
if (state.in_flight_invoice_id) {
|
||||
return `a submission for invoice ${state.in_flight_invoice_id} is still in flight`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Oldest never-submitted invoice that is issued, still inside MoR's document-age window, and
|
||||
* carries at least one line.
|
||||
*/
|
||||
private async nextCandidate(): Promise<{ id: string; invoiceNumber: string } | null> {
|
||||
const rows: { id: string; invoiceNumber: string }[] = await this.dataSource.query(
|
||||
`SELECT i.id, i.invoice_number AS "invoiceNumber"
|
||||
FROM freight.invoices i
|
||||
WHERE i.eims_status = $1
|
||||
AND i.issued_at IS NOT NULL
|
||||
AND i.deleted_at IS NULL
|
||||
AND i.issued_at > now() - ($2 || ' days')::interval
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM freight.invoice_lines l
|
||||
WHERE l.invoice_id = i.id AND l.deleted_at IS NULL
|
||||
)
|
||||
ORDER BY i.issued_at ASC
|
||||
LIMIT 1`,
|
||||
[EimsInvoiceStatus.NotSubmitted, this.cfg.autoSubmitMaxAgeDays],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
}
|
||||
80
apps/edr-freight-api/src/modules/eims/eims-client.service.ts
Normal file
80
apps/edr-freight-api/src/modules/eims/eims-client.service.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { HttpService } from "@nestjs/axios";
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { EimsConfig } from "../../config/eims.config";
|
||||
import { EimsAuthService } from "./eims-auth.service";
|
||||
import { EimsSignerService, toSignedBody } from "./eims-signer.service";
|
||||
import { toEimsApiException } from "./eims.errors";
|
||||
|
||||
/**
|
||||
* Foundation for EIMS's bearer-authenticated endpoints (`/v1/register`, `/v1/verify`, …).
|
||||
*
|
||||
* Login is not routed through here: `/auth/login` carries no bearer token and lives in
|
||||
* `EimsAuthService`. Nothing calls `postSigned` yet — invoice registration is a later phase.
|
||||
*/
|
||||
@Injectable()
|
||||
export class EimsClientService {
|
||||
private readonly logger = new Logger(EimsClientService.name);
|
||||
|
||||
constructor(
|
||||
private readonly http: HttpService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly auth: EimsAuthService,
|
||||
private readonly signer: EimsSignerService,
|
||||
) {}
|
||||
|
||||
private get cfg(): EimsConfig {
|
||||
return this.config.get<EimsConfig>("eims")!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign `request`, POST it to `path` with a valid bearer token, and return the parsed response.
|
||||
* A 401 invalidates the cached token and retries exactly once.
|
||||
*/
|
||||
async postSigned<TRequest, TResponse>(path: string, request: TRequest): Promise<TResponse> {
|
||||
return this.send<TRequest, TResponse>(path, request, false, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST `request` verbatim — bearer-authenticated but **not** wrapped in a signed envelope.
|
||||
*
|
||||
* `/v1/verify` is the only endpoint observed to work this way: the supplied collection sends a
|
||||
* raw `{"irn":"…"}` body with no `signature`/`certificate` siblings. Kept as its own entry point
|
||||
* so that if the live gateway turns out to require signing after all, exactly one call site
|
||||
* changes — `postSigned` is already the alternative.
|
||||
*/
|
||||
async postBearer<TRequest, TResponse>(path: string, request: TRequest): Promise<TResponse> {
|
||||
return this.send<TRequest, TResponse>(path, request, false, false);
|
||||
}
|
||||
|
||||
private async send<TRequest, TResponse>(
|
||||
path: string,
|
||||
request: TRequest,
|
||||
isRetry: boolean,
|
||||
signed: boolean,
|
||||
): Promise<TResponse> {
|
||||
const cfg = this.cfg;
|
||||
const token = await this.auth.getValidAccessToken();
|
||||
const body = signed ? toSignedBody(this.signer.signRequest(request)) : request;
|
||||
|
||||
try {
|
||||
const res = await firstValueFrom(
|
||||
this.http.post<TResponse>(`${cfg.baseUrl}${path}`, body, {
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
||||
timeout: cfg.httpTimeoutMs,
|
||||
}),
|
||||
);
|
||||
return res.data;
|
||||
} catch (err) {
|
||||
const mapped = toEimsApiException(err, `POST ${path}`);
|
||||
if (mapped.kind === "AUTH" && !isRetry) {
|
||||
this.logger.warn(`EIMS rejected the token on ${path}; re-authenticating once`);
|
||||
this.auth.invalidate();
|
||||
return this.send<TRequest, TResponse>(path, request, true, signed);
|
||||
}
|
||||
this.logger.error(mapped.message);
|
||||
throw mapped;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { KeyObject, createPrivateKey } from "node:crypto";
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { EimsConfig } from "../../config/eims.config";
|
||||
import { EimsConfigException } from "./eims.errors";
|
||||
|
||||
/**
|
||||
* Loads the INSA-issued EIMS credentials from disk, once, and keeps them in memory.
|
||||
*
|
||||
* The certificate is sent as base64 of the **exact bytes of the issued file** — it is deliberately
|
||||
* never parsed, re-encoded or re-exported, because that is what produced a working live login.
|
||||
* The private key never leaves this process: it is only ever used to produce a signature.
|
||||
*/
|
||||
@Injectable()
|
||||
export class EimsCredentialsProvider {
|
||||
private readonly logger = new Logger(EimsCredentialsProvider.name);
|
||||
private privateKey: KeyObject | null = null;
|
||||
private certificateBase64: string | null = null;
|
||||
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
private get cfg(): EimsConfig {
|
||||
return this.config.get<EimsConfig>("eims")!;
|
||||
}
|
||||
|
||||
/** RSA private key, parsed once. Throws a config error if the path is missing or unusable. */
|
||||
getPrivateKey(): KeyObject {
|
||||
if (this.privateKey) return this.privateKey;
|
||||
|
||||
const path = this.cfg.privateKeyPath;
|
||||
if (!path) throw new EimsConfigException("EIMS_PRIVATE_KEY_PATH is not set");
|
||||
|
||||
let key: KeyObject;
|
||||
try {
|
||||
key = createPrivateKey(readFileSync(path));
|
||||
} catch (err) {
|
||||
// The path is operational information, not a secret; the key material never appears.
|
||||
throw new EimsConfigException(
|
||||
`EIMS private key at ${path} could not be read or parsed: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
if (key.asymmetricKeyType !== "rsa") {
|
||||
throw new EimsConfigException(
|
||||
`EIMS private key at ${path} is ${key.asymmetricKeyType ?? "of unknown type"}; EIMS requires RSA`,
|
||||
);
|
||||
}
|
||||
|
||||
this.privateKey = key;
|
||||
this.logger.log(`EIMS private key loaded (RSA-${key.asymmetricKeyDetails?.modulusLength ?? "?"})`);
|
||||
return key;
|
||||
}
|
||||
|
||||
/** Base64 of the certificate file's exact bytes. No parsing, no re-encoding. */
|
||||
getCertificateBase64(): string {
|
||||
if (this.certificateBase64) return this.certificateBase64;
|
||||
|
||||
const path = this.cfg.certificatePath;
|
||||
if (!path) throw new EimsConfigException("EIMS_CERTIFICATE_PATH is not set");
|
||||
|
||||
let bytes: Buffer;
|
||||
try {
|
||||
bytes = readFileSync(path);
|
||||
} catch (err) {
|
||||
throw new EimsConfigException(
|
||||
`EIMS certificate at ${path} could not be read: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
if (bytes.length === 0) {
|
||||
throw new EimsConfigException(`EIMS certificate at ${path} is empty`);
|
||||
}
|
||||
|
||||
this.certificateBase64 = bytes.toString("base64");
|
||||
this.logger.log(`EIMS certificate bundle loaded (${bytes.length} bytes)`);
|
||||
return this.certificateBase64;
|
||||
}
|
||||
}
|
||||
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')
|
||||
@Controller('facilities')
|
||||
@BookingStaff(FREIGHT_PERMS.facilities.view)
|
||||
// Class gate lists every key its routes use: Nest runs class AND method
|
||||
// guards, so a key missing here would deny before the route's own key runs.
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.facilities.view,
|
||||
FREIGHT_PERMS.facilities.manage,
|
||||
])
|
||||
export class FacilitiesController {
|
||||
constructor(private readonly facilitiesService: FacilitiesService) {}
|
||||
|
||||
|
||||
@@ -19,7 +19,12 @@ import { RegisterDeviceDto, UpdateDeviceDto } from './dto/gps-device.dto';
|
||||
@ApiTags('gps-tracking')
|
||||
@ApiBearerAuth()
|
||||
@Controller('gps')
|
||||
@BookingStaff(FREIGHT_PERMS.tracking.view)
|
||||
// Class gate lists every key its routes use: Nest runs class AND method
|
||||
// guards, so a key missing here would deny before the route's own key runs.
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.tracking.view,
|
||||
FREIGHT_PERMS.tracking.manage,
|
||||
])
|
||||
export class GpsTrackingController {
|
||||
constructor(private readonly gps: GpsTrackingService) {}
|
||||
|
||||
|
||||
@@ -22,7 +22,14 @@ import { IncidentStatus, IncidentType } from './entities/incident.entity';
|
||||
// No incidents-specific permission exists in the registry, so this reuses the
|
||||
// (real) drivers.* fleet-road keys — incident records are driver-safety data
|
||||
// (driver stats / incident history). TODO: add a dedicated incidents:* key.
|
||||
@BookingStaff(FREIGHT_PERMS.drivers.view)
|
||||
// Class gate lists every key its routes use: Nest runs class AND method
|
||||
// guards, so a key missing here would deny before the route's own key runs.
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.drivers.view,
|
||||
FREIGHT_PERMS.drivers.create,
|
||||
FREIGHT_PERMS.drivers.update,
|
||||
FREIGHT_PERMS.drivers.delete,
|
||||
])
|
||||
export class IncidentsController {
|
||||
constructor(private readonly incidentsService: IncidentsService) {}
|
||||
|
||||
|
||||
@@ -15,7 +15,14 @@ import { InterchangeDocumentsService } from './interchange-documents.service';
|
||||
@ApiBearerAuth()
|
||||
@Controller('interchange-documents')
|
||||
// Class-level view guard; each write route adds its own manage permission below.
|
||||
@BookingStaff(FREIGHT_PERMS.interchangeDocuments.view)
|
||||
// Class gate lists every key its routes use: Nest runs class AND method
|
||||
// guards, so a key missing here would deny before the route's own key runs.
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.interchangeDocuments.view,
|
||||
FREIGHT_PERMS.interchangeDocuments.generate,
|
||||
FREIGHT_PERMS.interchangeDocuments.acknowledge,
|
||||
FREIGHT_PERMS.interchangeDocuments.dispute,
|
||||
])
|
||||
export class InterchangeDocumentsController {
|
||||
constructor(private readonly service: InterchangeDocumentsService) {}
|
||||
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsNumber, Min } from 'class-validator';
|
||||
import { IsNumber, IsOptional, Min } from 'class-validator';
|
||||
|
||||
export class ApproveLastMileRequestDto {
|
||||
// The approve dialog prefills this from GET :id/price-estimate (rule-based),
|
||||
// but the chief can still override — the typed value is what's invoiced.
|
||||
@ApiProperty({ description: 'Advance amount the customer must pay before execution proceeds', example: 3000 })
|
||||
@Transform(({ value }) => Number(value))
|
||||
// Omitted = the rule-based last-mile rate estimate is the advance. The chief
|
||||
// can still override with an explicit amount (required when no rate covers
|
||||
// the job).
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Advance override. Omitted = the amount comes from the live last-mile rates (km × rate).',
|
||||
example: 3000,
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (value === null || value === undefined || value === '' ? undefined : Number(value)))
|
||||
@IsNumber()
|
||||
@Min(0.01)
|
||||
advanceAmount!: number;
|
||||
advanceAmount?: number;
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ export class LastMileRequestsController {
|
||||
|
||||
@Post(':id/approve')
|
||||
@BookingStaff(FREIGHT_PERMS.lastMile.requestApprove)
|
||||
@ApiOperation({ summary: 'Truck & Machinery chief approves the request — LM contract becomes signable; the advance invoice follows the customer signature' })
|
||||
@ApiOperation({ summary: 'Truck & Machinery chief approves the request — the advance defaults to the live last-mile rate; LM contract becomes signable and the advance invoice follows the customer signature' })
|
||||
approve(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: ApproveLastMileRequestDto,
|
||||
|
||||
@@ -299,7 +299,11 @@ export class LastMileRequestsService {
|
||||
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);
|
||||
if (request.status !== LastMileRequestStatus.Submitted) {
|
||||
throw new BadRequestException(`Only a submitted request can be approved (current status: ${request.status})`);
|
||||
@@ -307,6 +311,18 @@ export class LastMileRequestsService {
|
||||
const booking = request.booking ?? (await this.bookingsRepository.findById(request.bookingId));
|
||||
if (!booking) throw new NotFoundException(`Booking ${request.bookingId} not found`);
|
||||
|
||||
// The live last-mile rates are the authority on the advance (km × rate);
|
||||
// the chief's typed amount is only an override — and the only path when no
|
||||
// rate covers the job. Snapshotted so the contract and invoice stay immune
|
||||
// to later rate edits.
|
||||
const estimate = await this.priceEstimate(id);
|
||||
const advanceAmount = advanceOverride ?? estimate.total;
|
||||
if (!advanceAmount || advanceAmount <= 0) {
|
||||
throw new BadRequestException(
|
||||
'No live last-mile rate covers this job — enter the advance amount manually.',
|
||||
);
|
||||
}
|
||||
|
||||
// Idempotent per booking — reuses the record if one already exists.
|
||||
const lastMile = await this.lastMileService.create({
|
||||
bookingId: request.bookingId,
|
||||
@@ -316,10 +332,6 @@ export class LastMileRequestsService {
|
||||
|
||||
// No invoice yet: the advance is invoiced by LastMileContractService.sign()
|
||||
// once the customer has signed the LM contract — doc first, then payment.
|
||||
// Snapshot the rate estimate now so the contract shows the numbers the
|
||||
// chief actually approved against, immune to later rate edits.
|
||||
const estimate = await this.priceEstimate(id);
|
||||
|
||||
await this.requestsRepository.update(id, {
|
||||
status: LastMileRequestStatus.Approved,
|
||||
reviewedByStaffId: staffId,
|
||||
@@ -364,7 +376,10 @@ export class LastMileRequestsService {
|
||||
type: 'LAST_MILE_ADVANCE',
|
||||
companyId: booking.companyId,
|
||||
companyProfileId: booking.companyProfileId || '',
|
||||
currency: booking.paymentCurrency || 'ETB',
|
||||
// The advance is priced by the last-mile rate, so it bills in that
|
||||
// rate's currency (birr for domestic trucking) — the booking's payment
|
||||
// currency is only the fallback when the amount was a manual override.
|
||||
currency: request.contractSummary?.currency || booking.paymentCurrency || 'ETB',
|
||||
lines: [
|
||||
{
|
||||
chargeType: 'LAST_MILE_ADVANCE',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NotificationAudience } from '@edr/types';
|
||||
|
||||
import { MaintenanceService } from './maintenance.service';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
|
||||
/**
|
||||
* The daily due-alert: a SCHEDULED item that crossed its km or date threshold
|
||||
@@ -37,6 +38,10 @@ describe('MaintenanceService.sendDueAlerts', () => {
|
||||
expect(notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
audience: NotificationAudience.BACKOFFICE,
|
||||
// The fleet desk, not every employee in the company.
|
||||
recipients: {
|
||||
permissionKeys: [FREIGHT_PERMS.maintenance.getNotification],
|
||||
},
|
||||
title: 'Maintenance due — ET-9875',
|
||||
body: expect.stringContaining('driven 50200 km (due at 50000 km)'),
|
||||
}),
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
UpsertMaintenanceIntervalDto,
|
||||
} from './dto/create-maintenance.dto';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
|
||||
@Injectable()
|
||||
export class MaintenanceService {
|
||||
@@ -53,7 +54,9 @@ export class MaintenanceService {
|
||||
? `driven ${item.currentKm} km (due at ${item.nextDueKm} km)`
|
||||
: `due ${new Date(item.nextDueDate as Date).toLocaleDateString()}`;
|
||||
await this.inbox.notify({
|
||||
recipients: { allBackoffice: true },
|
||||
recipients: {
|
||||
permissionKeys: [FREIGHT_PERMS.maintenance.getNotification],
|
||||
},
|
||||
audience: NotificationAudience.BACKOFFICE,
|
||||
type: NotificationType.GENERIC,
|
||||
title: `Maintenance due — ${item.plateNumber}`,
|
||||
|
||||
@@ -14,7 +14,9 @@ import { ExternalProfileRepository } from "../companies/external-profile.reposit
|
||||
* - `companyProfileId` → resolved to its company, then to that company's users.
|
||||
* - `organizationId` → all current employees of the org (backoffice staff).
|
||||
* - `permissionKeys` → current employees (any org) holding any of these
|
||||
* permission keys (e.g. department/role-scoped targeting).
|
||||
* permission keys — how every staff-facing notification is targeted. There is
|
||||
* deliberately no "all backoffice" selector: staff notifications belong to a
|
||||
* desk, and the `<module>:get_notification` keys name which one.
|
||||
*/
|
||||
@Injectable()
|
||||
export class NotificationRecipientsService {
|
||||
@@ -69,18 +71,6 @@ export class NotificationRecipientsService {
|
||||
}
|
||||
}
|
||||
|
||||
if (recipients.allBackoffice) {
|
||||
try {
|
||||
for (const uid of await this.backoffice.getAllCurrentEmployeeUserIds()) {
|
||||
ids.add(uid);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Failed to resolve allBackoffice recipients: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (recipients.permissionKeys?.length) {
|
||||
try {
|
||||
for (const uid of await this.backoffice.getEmployeeUserIdsByPermission(
|
||||
|
||||
@@ -60,14 +60,38 @@ export class RefundDto {
|
||||
}
|
||||
|
||||
export class ClientActionDto {
|
||||
// INVOKE_BRIDGE (SuperApp mini-app payload) is part of the shared ClientAction union and so
|
||||
// must be assignable here, but freight never requests platform=inapp and therefore never
|
||||
// receives one. Passenger owns that flow — see docs/telebirr-miniapp/.
|
||||
@ApiProperty({
|
||||
enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP", "SHOW_BILL_REFERENCE"],
|
||||
enum: [
|
||||
"REDIRECT",
|
||||
"LAUNCH_APP",
|
||||
"INVOKE_BRIDGE",
|
||||
"COLLECT_OTP",
|
||||
"SHOW_BILL_REFERENCE",
|
||||
],
|
||||
})
|
||||
type!: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP" | "SHOW_BILL_REFERENCE";
|
||||
type!:
|
||||
| "REDIRECT"
|
||||
| "LAUNCH_APP"
|
||||
| "INVOKE_BRIDGE"
|
||||
| "COLLECT_OTP"
|
||||
| "SHOW_BILL_REFERENCE";
|
||||
|
||||
@ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" })
|
||||
url?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Set when type=INVOKE_BRIDGE (SuperApp mini app) — not used by freight",
|
||||
})
|
||||
bridge?: "TELEBIRR";
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Set when type=INVOKE_BRIDGE (SuperApp mini app) — not used by freight",
|
||||
})
|
||||
rawRequest?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" })
|
||||
appId?: string;
|
||||
|
||||
|
||||
@@ -13,7 +13,14 @@ import {
|
||||
|
||||
@ApiTags('Procurement & Asset Lifecycle')
|
||||
@Controller('procurement')
|
||||
@BookingStaff(FREIGHT_PERMS.procurement.view)
|
||||
// Class gate lists every key its routes use: Nest runs class AND method
|
||||
// guards, so a key missing here would deny before the route's own key runs.
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.procurement.view,
|
||||
FREIGHT_PERMS.procurement.vendorManage,
|
||||
FREIGHT_PERMS.procurement.acquisitionManage,
|
||||
FREIGHT_PERMS.procurement.disposalManage,
|
||||
])
|
||||
export class ProcurementController {
|
||||
constructor(private readonly procurementService: ProcurementService) {}
|
||||
|
||||
|
||||
@@ -27,7 +27,15 @@ import { RoutesService } from './routes.service';
|
||||
@ApiTags('routes')
|
||||
@ApiBearerAuth()
|
||||
@Controller('routes')
|
||||
@FleetView(FREIGHT_PERMS.routes.view)
|
||||
// Class gate lists every key its routes use: Nest runs class AND method
|
||||
// guards, so a key missing here would deny before the route's own key runs.
|
||||
@FleetView([
|
||||
FREIGHT_PERMS.routes.view,
|
||||
FREIGHT_PERMS.routes.create,
|
||||
FREIGHT_PERMS.routes.update,
|
||||
FREIGHT_PERMS.routes.hardDelete,
|
||||
FREIGHT_PERMS.routes.delete,
|
||||
])
|
||||
export class RoutesController {
|
||||
constructor(private readonly routesService: RoutesService) {}
|
||||
|
||||
|
||||
@@ -70,6 +70,16 @@ export class CreateCargoTypeDto {
|
||||
@IsBoolean()
|
||||
hasLashing?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
default: false,
|
||||
description:
|
||||
'Allow staff to write bulk contract templates for this cargo type. ' +
|
||||
'Mutually exclusive with the parent group / children having it.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
hasContractTemplate?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
|
||||
@@ -82,6 +82,14 @@ export class CargoType extends BaseEntity {
|
||||
@Column({ name: 'has_lashing', type: 'boolean', default: false })
|
||||
hasLashing!: boolean;
|
||||
|
||||
/**
|
||||
* Whether staff may write bulk contract templates against this cargo type.
|
||||
* Mutually exclusive between a parent group and its children: if the parent
|
||||
* provides the template, no child may, and vice versa.
|
||||
*/
|
||||
@Column({ name: 'has_contract_template', type: 'boolean', default: false })
|
||||
hasContractTemplate!: boolean;
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive!: boolean;
|
||||
|
||||
|
||||
@@ -135,6 +135,35 @@ export class CargoTypesService {
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* A cargo type and its parent group may not BOTH offer a contract template —
|
||||
* the template would be ambiguous for bookings of the child. To enable the
|
||||
* child, the parent must be turned off first (and vice versa).
|
||||
*/
|
||||
private async assertContractTemplateExclusive(input: {
|
||||
id?: string;
|
||||
parentGroupId?: string | null;
|
||||
}): Promise<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. */
|
||||
async create(dto: CreateCargoTypeDto): Promise<CargoType> {
|
||||
const code = generateCode(dto.cargoTypeName);
|
||||
@@ -144,6 +173,9 @@ export class CargoTypesService {
|
||||
const parent = await this.repository.findById(dto.parentGroupId);
|
||||
if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`);
|
||||
}
|
||||
if (dto.hasContractTemplate) {
|
||||
await this.assertContractTemplateExclusive({ parentGroupId: dto.parentGroupId });
|
||||
}
|
||||
|
||||
const displayOrder = await this.displayOrder.resolveCreateOrder(CargoType, 'displayOrder', {
|
||||
explicitOrder: dto.displayOrder,
|
||||
@@ -160,6 +192,7 @@ export class CargoTypesService {
|
||||
code,
|
||||
cargoTypeName: dto.cargoTypeName,
|
||||
parentGroupId: dto.parentGroupId ?? null,
|
||||
hasContractTemplate: dto.hasContractTemplate ?? false,
|
||||
requiresDirectorApproval: dto.requiresDirectorApproval ?? false,
|
||||
isActive: dto.isActive ?? true,
|
||||
unitOfMeasure: dto.unitOfMeasure ?? null,
|
||||
@@ -183,6 +216,18 @@ export class CargoTypesService {
|
||||
const parent = await this.repository.findById(dto.parentGroupId);
|
||||
if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`);
|
||||
}
|
||||
// Re-check the parent/child template exclusivity whenever the flag or the
|
||||
// parent moves and the row ends up flagged.
|
||||
const willHaveTemplate = dto.hasContractTemplate ?? existing.hasContractTemplate;
|
||||
if (
|
||||
willHaveTemplate &&
|
||||
(dto.hasContractTemplate !== undefined || dto.parentGroupId !== undefined)
|
||||
) {
|
||||
await this.assertContractTemplateExclusive({
|
||||
id,
|
||||
parentGroupId: dto.parentGroupId ?? existing.parentGroupId,
|
||||
});
|
||||
}
|
||||
const {
|
||||
wagonTypeIds,
|
||||
itemsPerWagonMap,
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
PriorityRuleChangeStatus,
|
||||
} from '../entities/priority-rule-change-request.entity';
|
||||
import { PriorityConfigsService } from './priority-configs.service';
|
||||
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
|
||||
|
||||
/** Backoffice rule-engine page — where both queue and rules live. */
|
||||
const RULES_LINK = '/dashboard/rules/priority-configs';
|
||||
@@ -221,7 +222,9 @@ export class PriorityRuleChangeRequestsService {
|
||||
): void {
|
||||
void this.inbox
|
||||
.notify({
|
||||
recipients: { allBackoffice: true },
|
||||
recipients: {
|
||||
permissionKeys: [FREIGHT_PERMS.ruleEngine.getNotification],
|
||||
},
|
||||
audience: NotificationAudience.BACKOFFICE,
|
||||
type: NotificationType.REQUEST_SUBMITTED,
|
||||
title,
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from '../entities/rate-change-request.entity';
|
||||
import { Rate } from '../entities/rate.entity';
|
||||
import { RatesService } from './rates.service';
|
||||
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
|
||||
|
||||
/** Backoffice page where both the queue and the rates live. */
|
||||
const RATES_LINK = '/dashboard/rules/rates';
|
||||
@@ -234,7 +235,9 @@ export class RateChangeRequestsService {
|
||||
private notifyTeam(title: string, body: string, request: RateChangeRequest): void {
|
||||
void this.inbox
|
||||
.notify({
|
||||
recipients: { allBackoffice: true },
|
||||
recipients: {
|
||||
permissionKeys: [FREIGHT_PERMS.ruleEngine.getNotification],
|
||||
},
|
||||
audience: NotificationAudience.BACKOFFICE,
|
||||
type: NotificationType.REQUEST_SUBMITTED,
|
||||
title,
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { compareSchedulingPriority } from '../scheduling/compare-scheduling-priority.util';
|
||||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
||||
import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
|
||||
import { BookingNotifierService } from '../train-scheduling/booking-notifier.service';
|
||||
import { ExecuteRescheduleDto, PreviewRescheduleDto } from './dto/preview-reschedule.dto';
|
||||
import { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto';
|
||||
|
||||
@@ -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