mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev' into alpha
This commit is contained in:
@@ -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
|
||||
@@ -122,3 +128,79 @@ FAYDA_SESSION_TTL_MINUTES=10
|
||||
EXPIRATION_TIME=15
|
||||
ALGORITHM=RS256
|
||||
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,11 +50,11 @@ 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";
|
||||
import { BackofficeModule } from "./modules/backoffice/backoffice.module";
|
||||
import { DemoPermissionsModule } from "./modules/demo-permissions/demo-permissions.module";
|
||||
import { FreightAuthModule } from "./modules/auth/freight-auth.module";
|
||||
import {
|
||||
EDR_FREIGHT_APPLICATION,
|
||||
@@ -67,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";
|
||||
@@ -77,13 +79,15 @@ 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";
|
||||
import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-demo.seeder";
|
||||
// import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
|
||||
// import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-demo.seeder";
|
||||
//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";
|
||||
@@ -100,6 +104,7 @@ import { MaintenanceModule } from "./modules/maintenance/maintenance.module";
|
||||
import { ComplianceModule } from "./modules/compliance/compliance.module";
|
||||
import { IncidentsModule } from "./modules/incidents/incidents.module";
|
||||
import { ProcurementModule } from "./modules/procurement/procurement.module";
|
||||
import { FacilitiesModule } from "./modules/facilities/facilities.module";
|
||||
import { GpsTrackingModule } from "./modules/gps-tracking/gps-tracking.module";
|
||||
import { FirstMileModule } from "./modules/first-mile/first-mile.module";
|
||||
import { LastMileModule } from "./modules/last-mile/last-mile.module";
|
||||
@@ -110,6 +115,7 @@ import { AiModule } from "./modules/ai/ai.module";
|
||||
import { AuditModule } from "./modules/audit/audit.module";
|
||||
import { LoggerMiddleware } from "./logger.middleware";
|
||||
import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware";
|
||||
import { PositionTypePermissionsCache } from "./common/position-type-permissions.cache";
|
||||
|
||||
if (!process.env.APPLICATION_NAME) {
|
||||
process.env.APPLICATION_NAME = "freight";
|
||||
@@ -125,6 +131,7 @@ if (!process.env.APPLICATION_NAME) {
|
||||
telebirrConfig,
|
||||
rabbitmqConfig,
|
||||
faydaConfig,
|
||||
eimsConfig,
|
||||
],
|
||||
}),
|
||||
ScheduleModule.forRoot(),
|
||||
@@ -215,11 +222,11 @@ if (!process.env.APPLICATION_NAME) {
|
||||
DropdownSettingsModule,
|
||||
ExchangeSettingsModule,
|
||||
ContractTemplatesModule,
|
||||
SupportContentModule,
|
||||
OtpModule,
|
||||
HealthModule,
|
||||
RuleEngineModule,
|
||||
BackofficeModule,
|
||||
DemoPermissionsModule,
|
||||
FreightAuthModule,
|
||||
PaymentModule,
|
||||
//New Modules
|
||||
@@ -239,6 +246,7 @@ if (!process.env.APPLICATION_NAME) {
|
||||
ComplianceModule,
|
||||
IncidentsModule,
|
||||
ProcurementModule,
|
||||
FacilitiesModule,
|
||||
GpsTrackingModule,
|
||||
FirstMileModule,
|
||||
LastMileModule,
|
||||
@@ -246,6 +254,7 @@ if (!process.env.APPLICATION_NAME) {
|
||||
InterchangeDocumentsModule,
|
||||
ImportOperationsModule,
|
||||
VerifaydaModule,
|
||||
EimsModule,
|
||||
FleetHistoryModule,
|
||||
AiModule,
|
||||
AuditModule,
|
||||
@@ -254,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,
|
||||
@@ -270,9 +281,12 @@ if (!process.env.APPLICATION_NAME) {
|
||||
// WarehouseDemoSeeder,
|
||||
// ExportDjiboutiInterchangeDemoSeeder,
|
||||
// MarshallingDemoTrainsSeeder,
|
||||
ApprovedFirstLastMileDemoBookingsSeeder,
|
||||
PaidImportExportMileDemoSeeder,
|
||||
// ApprovedFirstLastMileDemoBookingsSeeder,
|
||||
// PaidImportExportMileDemoSeeder,
|
||||
LoginAudienceMiddleware,
|
||||
// Feeds position-TYPE grants to the synchronous permission checks — without
|
||||
// it, staff whose permissions live on their position type resolve to none.
|
||||
PositionTypePermissionsCache,
|
||||
],
|
||||
})
|
||||
export class AppModule implements OnApplicationBootstrap {
|
||||
@@ -281,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,
|
||||
@@ -324,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();
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { applyDecorators, UseGuards } from '@nestjs/common';
|
||||
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||||
|
||||
import { FreightPermissionGuard } from './freight-permission.guard';
|
||||
import {
|
||||
FreightPermissionGuard,
|
||||
MixedAudienceGuard,
|
||||
PortalCustomerGuard,
|
||||
} from './freight-permission.guard';
|
||||
import { FREIGHT_PERMS } from '../seed/freight-permissions.registry';
|
||||
|
||||
export const BookingStaff = (permission: string | string[]) =>
|
||||
@@ -18,8 +22,30 @@ export const BookingStaff = (permission: string | string[]) =>
|
||||
* Read-only reference data (yard dropdowns, search filters): any signed-in
|
||||
* staff. Menu/page visibility stays permission-gated in the frontend — this
|
||||
* only lets forms populate their lookups.
|
||||
* Deprecated for new routes — it never checked the caller was staff. Prefer
|
||||
* BookingStaff(<view key>) or MixedAudience(); kept for routes not yet swept.
|
||||
*/
|
||||
export const StaffReference = () => applyDecorators(UseGuards(JwtGuard));
|
||||
export const StaffReference = () =>
|
||||
applyDecorators(UseGuards(JwtGuard, FreightPermissionGuard([])));
|
||||
|
||||
/** Portal routes: customer accounts only; ownership scoping stays in services. */
|
||||
export const PortalCustomer = () =>
|
||||
applyDecorators(UseGuards(JwtGuard, PortalCustomerGuard));
|
||||
|
||||
/**
|
||||
* Routes both audiences call (sign, shared document reads, handover): staff
|
||||
* need one of the given permissions, customers pass through to the service's
|
||||
* ownership checks.
|
||||
*/
|
||||
export const MixedAudience = (permission: string | string[]) =>
|
||||
applyDecorators(
|
||||
UseGuards(
|
||||
JwtGuard,
|
||||
MixedAudienceGuard(
|
||||
Array.isArray(permission) ? permission : [permission],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view);
|
||||
|
||||
@@ -31,6 +57,18 @@ export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view);
|
||||
export const BookingDocReviewAlert = () =>
|
||||
BookingStaff(FREIGHT_PERMS.bookings.docReviewAlert);
|
||||
|
||||
/** Staff wagon-cancellation history list (admin side). */
|
||||
export const WagonCancellationView = () =>
|
||||
BookingStaff(FREIGHT_PERMS.bookings.wagonCancellationView);
|
||||
|
||||
/** Staff void of a customer's pending (fee-unpaid) wagon cancellation. */
|
||||
export const WagonCancellationVoid = () =>
|
||||
BookingStaff(FREIGHT_PERMS.bookings.wagonCancellationVoid);
|
||||
|
||||
/** Staff rebook of a customer's wagon-cancellation credit on their behalf. */
|
||||
export const WagonCancellationRebook = () =>
|
||||
BookingStaff(FREIGHT_PERMS.bookings.wagonCancellationRebook);
|
||||
|
||||
export const TrainSchedulingView = () =>
|
||||
BookingStaff(FREIGHT_PERMS.trainScheduling.view);
|
||||
|
||||
@@ -57,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
|
||||
|
||||
@@ -8,7 +8,48 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
|
||||
import { hasFreightPermission } from './freight-permission.util';
|
||||
import { hasFreightPermission, isSuperAdmin } from './freight-permission.util';
|
||||
import { readTwinOf } from '../seed/freight-permissions.registry';
|
||||
|
||||
// String literals on purpose (same reasoning as login-audience.middleware.ts):
|
||||
// the values are wire-format constants from iam.users.user_type, and importing
|
||||
// the vendored enum couples us to its package layout for no gain.
|
||||
const CUSTOMER_USER_TYPES = ['individual', 'external_organization'];
|
||||
|
||||
const userTypeOf = (user: TCurrentUser): string | undefined =>
|
||||
(user as { userType?: string }).userType;
|
||||
|
||||
/** Staff routes are employee-only; a missing userType (stale session) also fails. */
|
||||
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[],
|
||||
@@ -16,15 +57,20 @@ export function FreightPermissionGuard(
|
||||
@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 (!permissions?.length) return true;
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Authentication required');
|
||||
}
|
||||
if (!isEmployee(user)) {
|
||||
throw new ForbiddenException('Staff account required');
|
||||
}
|
||||
|
||||
if (permissions.some((p) => hasFreightPermission(user, p))) {
|
||||
if (!permissions?.length) return true;
|
||||
if (permissions.some((p) => satisfiedBy(user, p, request.method))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -36,3 +82,59 @@ export function FreightPermissionGuard(
|
||||
|
||||
return FreightPermissionsGuard;
|
||||
}
|
||||
|
||||
/** Portal routes: customer accounts only (individual / external organization). */
|
||||
@Injectable()
|
||||
export class PortalCustomerGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest<{ user?: TCurrentUser }>();
|
||||
const user = request.user;
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Authentication required');
|
||||
}
|
||||
if (!CUSTOMER_USER_TYPES.includes(userTypeOf(user) ?? '')) {
|
||||
throw new ForbiddenException('Customer account required');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes both audiences legitimately call (contract sign, shared document
|
||||
* reads, warehouse handover). Staff callers must hold one of the given
|
||||
* permissions; customer callers pass here and are scoped by the service's
|
||||
* ownership checks.
|
||||
*/
|
||||
export function MixedAudienceGuard(permissions: string[]): Type<CanActivate> {
|
||||
@Injectable()
|
||||
class MixedAudiencesGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context
|
||||
.switchToHttp()
|
||||
.getRequest<{ user?: TCurrentUser; method: string }>();
|
||||
const user = request.user;
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Authentication required');
|
||||
}
|
||||
if (CUSTOMER_USER_TYPES.includes(userTypeOf(user) ?? '')) {
|
||||
return true;
|
||||
}
|
||||
if (!isEmployee(user)) {
|
||||
throw new ForbiddenException('Unrecognized account type');
|
||||
}
|
||||
if (
|
||||
!permissions?.length ||
|
||||
permissions.some((p) => satisfiedBy(user, p, request.method))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
throw new ForbiddenException(
|
||||
`Missing permission. Required one of: ${permissions.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return MixedAudiencesGuard;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import {
|
||||
assertCanApproveContractStep,
|
||||
canEditContractStep,
|
||||
collectPermissionKeys,
|
||||
hasFreightPermission,
|
||||
setPositionTypePermissionResolver,
|
||||
} from './freight-permission.util';
|
||||
import { FREIGHT_PERMS } from '../seed/freight-permissions.registry';
|
||||
|
||||
@@ -49,3 +52,72 @@ describe('canEditContractStep (strict per-step edit gate)', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The GL lockout regression: positions created through the admin UI keep their
|
||||
* grants on the position TYPE, and the JWT only ever snapshots DIRECT position
|
||||
* permissions. Without the type resolver those staff resolved to zero
|
||||
* permissions, so every gated route rejected them — which is what kept GL
|
||||
* officers out of their own clearance detail pages.
|
||||
*/
|
||||
describe('collectPermissionKeys — position-type grants', () => {
|
||||
const CLEARANCE = FREIGHT_PERMS.contracts.clearanceReview;
|
||||
|
||||
afterEach(() => {
|
||||
setPositionTypePermissionResolver(() => []);
|
||||
});
|
||||
|
||||
const glOfficer = {
|
||||
roles: [],
|
||||
permissions: [],
|
||||
employee: {
|
||||
position: {
|
||||
permissions: [], // admin-created position carries NO direct grants
|
||||
positionType: { key: 'commercial-global-logistics-(et)-officer' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it('resolves permissions carried by the position type', () => {
|
||||
setPositionTypePermissionResolver((key) =>
|
||||
key === 'commercial-global-logistics-(et)-officer' ? [CLEARANCE] : [],
|
||||
);
|
||||
|
||||
expect(collectPermissionKeys(glOfficer)).toContain(CLEARANCE);
|
||||
expect(hasFreightPermission(glOfficer, CLEARANCE)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles the array-shaped employee payload too', () => {
|
||||
setPositionTypePermissionResolver(() => [CLEARANCE]);
|
||||
|
||||
const arrayShaped = {
|
||||
roles: [],
|
||||
permissions: [],
|
||||
employee: [
|
||||
{
|
||||
positions: [
|
||||
{ permissions: [], positionType: { key: 'djibouti-gl-officer' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(hasFreightPermission(arrayShaped, CLEARANCE)).toBe(true);
|
||||
});
|
||||
|
||||
it('still rejects when neither the position nor its type grants it', () => {
|
||||
setPositionTypePermissionResolver(() => []);
|
||||
|
||||
expect(hasFreightPermission(glOfficer, CLEARANCE)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps direct position permissions working with no resolver installed', () => {
|
||||
const direct = {
|
||||
roles: [],
|
||||
permissions: [],
|
||||
employee: { position: { permissions: [{ key: CLEARANCE }] } },
|
||||
};
|
||||
|
||||
expect(hasFreightPermission(direct, CLEARANCE)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,12 +42,41 @@ export function isFreightApprovalAdmin(user: MeLikeUser | null | undefined): boo
|
||||
return isSuperAdmin(user) || isOrganizationAdmin(user);
|
||||
}
|
||||
|
||||
/** Flat permission keys from JWT / session user (roles + position permissions). */
|
||||
/**
|
||||
* Permissions carried by a position TYPE rather than the position itself.
|
||||
*
|
||||
* The JWT snapshots only DIRECT position permissions, so type-level grants —
|
||||
* which is where admin-created positions keep theirs — are absent from the
|
||||
* token entirely. This resolver is installed at startup
|
||||
* (see `PositionTypePermissionsCache`) so the synchronous permission checks
|
||||
* below can still see them. Left as a no-op resolver until then, which
|
||||
* degrades to the old position-only behaviour rather than throwing.
|
||||
*/
|
||||
let positionTypePermissionResolver: (positionTypeKey: string) => string[] = () =>
|
||||
[];
|
||||
|
||||
export function setPositionTypePermissionResolver(
|
||||
resolver: (positionTypeKey: string) => string[],
|
||||
): void {
|
||||
positionTypePermissionResolver = resolver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flat permission keys from JWT / session user: roles, position permissions,
|
||||
* and the grants held by each position's TYPE.
|
||||
*/
|
||||
export function collectPermissionKeys(user: MeLikeUser | null | undefined): string[] {
|
||||
if (!user) return [];
|
||||
|
||||
const keys = new Set<string>();
|
||||
|
||||
const addTypePermissions = (positionType: PositionTypeLike | null | undefined) => {
|
||||
if (!positionType?.key) return;
|
||||
for (const key of positionTypePermissionResolver(positionType.key)) {
|
||||
keys.add(key);
|
||||
}
|
||||
};
|
||||
|
||||
for (const p of user.permissions ?? []) {
|
||||
if (p.key) keys.add(p.key);
|
||||
}
|
||||
@@ -63,6 +92,7 @@ export function collectPermissionKeys(user: MeLikeUser | null | undefined): stri
|
||||
for (const p of pos.permissions ?? []) {
|
||||
if (p.key) keys.add(p.key);
|
||||
}
|
||||
addTypePermissions(pos.positionType);
|
||||
}
|
||||
}
|
||||
return [...keys];
|
||||
@@ -71,6 +101,7 @@ export function collectPermissionKeys(user: MeLikeUser | null | undefined): stri
|
||||
for (const p of employee.position?.permissions ?? []) {
|
||||
if (p.key) keys.add(p.key);
|
||||
}
|
||||
addTypePermissions(employee.position?.positionType);
|
||||
for (const delegated of employee.delegatedPositions ?? []) {
|
||||
for (const p of delegated.permissions ?? []) {
|
||||
if (p.key) keys.add(p.key);
|
||||
|
||||
@@ -20,8 +20,12 @@ export class ServiceAuthGuard implements CanActivate {
|
||||
private warned = false;
|
||||
|
||||
constructor() {
|
||||
if (!this.token && process.env.NODE_ENV === "production") {
|
||||
throw new Error("SERVICE_AUTH_TOKEN must be set in production");
|
||||
// Fail closed everywhere: a missing secret must never silently open the
|
||||
// internal payment surface. Local dev can opt out explicitly.
|
||||
if (!this.token && process.env.ALLOW_UNAUTH_INTERNAL !== "true") {
|
||||
throw new Error(
|
||||
"SERVICE_AUTH_TOKEN must be set (or ALLOW_UNAUTH_INTERNAL=true for local dev)",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +33,7 @@ export class ServiceAuthGuard implements CanActivate {
|
||||
if (!this.token) {
|
||||
if (!this.warned) {
|
||||
this.logger.warn(
|
||||
"SERVICE_AUTH_TOKEN unset — internal endpoints are UNGUARDED (dev only)",
|
||||
"ALLOW_UNAUTH_INTERNAL=true — internal endpoints are UNGUARDED (dev only)",
|
||||
);
|
||||
this.warned = true;
|
||||
}
|
||||
|
||||
@@ -85,6 +85,48 @@ describe('computeLastMileCharge', () => {
|
||||
expect(charge).toMatchObject({ mode: 'BULK', total: 60 * 26 * 25, currency: 'ETB' });
|
||||
});
|
||||
|
||||
it('picks the bulk rate whose distance band holds the km (half-open boundary)', () => {
|
||||
const bulkNear = rate({ rateUnit: 'PER_TON_KM', rateValue: 30, minKm: 0, maxKm: 30 });
|
||||
const bulkFar = rate({ rateUnit: 'PER_TON_KM', rateValue: 22, minKm: 30, maxKm: null });
|
||||
const near = computeLastMileCharge({
|
||||
freightType: 'BULK',
|
||||
tons: 10,
|
||||
km: 12,
|
||||
containers: [],
|
||||
liveRates: [bulkNear, bulkFar],
|
||||
});
|
||||
expect(near).toMatchObject({ mode: 'BULK', total: 10 * 12 * 30 });
|
||||
const boundary = computeLastMileCharge({
|
||||
freightType: 'BULK',
|
||||
tons: 10,
|
||||
km: 30,
|
||||
containers: [],
|
||||
liveRates: [bulkNear, bulkFar],
|
||||
});
|
||||
expect(boundary).toMatchObject({ total: 10 * 30 * 22 });
|
||||
});
|
||||
|
||||
it('bulk falls back to the legacy bandless rate when no band holds the km, null when nothing covers it', () => {
|
||||
const bulkNear = rate({ rateUnit: 'PER_TON_KM', rateValue: 30, minKm: 0, maxKm: 30 });
|
||||
const fallback = computeLastMileCharge({
|
||||
freightType: 'BULK',
|
||||
tons: 10,
|
||||
km: 50,
|
||||
containers: [],
|
||||
liveRates: [bulkNear, bulkRate], // bulkRate has no band
|
||||
});
|
||||
expect(fallback).toMatchObject({ total: 10 * 50 * 25 });
|
||||
expect(
|
||||
computeLastMileCharge({
|
||||
freightType: 'BULK',
|
||||
tons: 10,
|
||||
km: 50,
|
||||
containers: [],
|
||||
liveRates: [bulkNear],
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null on mixed currencies, unknown km, and uncovered freight types', () => {
|
||||
const usd40 = rate({ ...band40a, currency: 'USD' });
|
||||
expect(
|
||||
|
||||
@@ -30,10 +30,12 @@ const round2 = (n: number): number => Math.round(n * 100) / 100;
|
||||
/**
|
||||
* Price a last-mile leg off the LIVE rate rules. Pure — pass the live rates in.
|
||||
*
|
||||
* BULK: one PER_TON_KM rate → price = tons × km × rate.
|
||||
* BULK: the PER_TON_KM rate whose distance band holds the km (a legacy
|
||||
* bandless row — NULL minKm — is the fallback and prices every distance) →
|
||||
* price = tons × km × rate.
|
||||
* CONTAINER: per container size, the PER_KM rate whose distance band holds the
|
||||
* km (bands are half-open [minKm, maxKm), NULL maxKm = open-ended) → price =
|
||||
* km × rate × quantity, summed across sizes.
|
||||
* km → price = km × rate × quantity, summed across sizes.
|
||||
* Bands are half-open [minKm, maxKm), NULL maxKm = open-ended.
|
||||
*
|
||||
* Returns null whenever the rules don't fully cover the shipment (no rate, a
|
||||
* container size without a matching band, mixed currencies, km/tons unknown) —
|
||||
@@ -56,7 +58,15 @@ export function computeLastMileCharge(input: {
|
||||
|
||||
if (freightType === 'BULK') {
|
||||
if (!tons || tons <= 0) return null;
|
||||
const rate = candidates.find((r) => r.rateUnit === 'PER_TON_KM');
|
||||
const bulkRates = candidates.filter((r) => r.rateUnit === 'PER_TON_KM');
|
||||
const rate =
|
||||
bulkRates.find(
|
||||
(r) =>
|
||||
r.minKm !== null &&
|
||||
r.minKm !== undefined &&
|
||||
Number(r.minKm) <= km &&
|
||||
(r.maxKm === null || r.maxKm === undefined || km < Number(r.maxKm)),
|
||||
) ?? bulkRates.find((r) => r.minKm === null || r.minKm === undefined);
|
||||
if (!rate) return null;
|
||||
const unitRate = Number(rate.rateValue);
|
||||
const amount = round2(tons * km * unitRate);
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { setPositionTypePermissionResolver } from './freight-permission.util';
|
||||
|
||||
/**
|
||||
* Permissions granted to a position TYPE (`iam.position_type_permissions`).
|
||||
*
|
||||
* A position type is the platform's notion of a role, and positions created
|
||||
* through the admin UI carry their grants there rather than on the position
|
||||
* itself. The JWT only ever snapshots DIRECT position permissions, so those
|
||||
* grants are invisible to `collectPermissionKeys` — staff on such a position
|
||||
* resolve to zero permissions and every permission-gated route rejects them.
|
||||
*
|
||||
* The permission checks (`hasFreightPermission`, `FreightPermissionGuard`) are
|
||||
* synchronous and sit on the request path, so the mapping is held in memory and
|
||||
* refreshed periodically rather than queried per request. The dataset is tiny
|
||||
* (tens of types, a few hundred rows), so a full reload is cheaper than any
|
||||
* incremental scheme.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PositionTypePermissionsCache implements OnModuleInit {
|
||||
private readonly logger = new Logger(PositionTypePermissionsCache.name);
|
||||
|
||||
/** position_type key → permission keys. Empty until the first load lands. */
|
||||
private byPositionTypeKey = new Map<string, string[]>();
|
||||
|
||||
// ponytail: fixed 5-min refresh, no invalidation hook. A permission granted
|
||||
// in the admin UI takes up to one interval to reach the guards. Wire the
|
||||
// grant mutation to call `refresh()` if that lag ever matters.
|
||||
private static readonly REFRESH_INTERVAL_MS = 5 * 60 * 1000;
|
||||
|
||||
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
await this.refresh();
|
||||
// Hand the lookup to the permission utils, whose checks are synchronous and
|
||||
// therefore cannot query IAM themselves.
|
||||
setPositionTypePermissionResolver((positionTypeKey) =>
|
||||
this.get(positionTypeKey),
|
||||
);
|
||||
const timer = setInterval(() => {
|
||||
void this.refresh();
|
||||
}, PositionTypePermissionsCache.REFRESH_INTERVAL_MS);
|
||||
// Never hold the process open for a cache refresh.
|
||||
timer.unref?.();
|
||||
}
|
||||
|
||||
/** Permission keys for a position-type key ([] when unknown/not loaded). */
|
||||
get(positionTypeKey: string | undefined | null): string[] {
|
||||
if (!positionTypeKey) return [];
|
||||
return this.byPositionTypeKey.get(positionTypeKey) ?? [];
|
||||
}
|
||||
|
||||
/** Reload the whole mapping. Failures keep the previous snapshot in place. */
|
||||
async refresh(): Promise<void> {
|
||||
try {
|
||||
const rows: { position_type_key: string; permission_key: string }[] =
|
||||
await this.dataSource.query(
|
||||
`SELECT pt.key AS position_type_key, perm.key AS permission_key
|
||||
FROM iam.position_type_permissions ptp
|
||||
JOIN iam.position_types pt ON pt.id = ptp.position_type_id
|
||||
JOIN iam.permissions perm ON perm.id = ptp.permission_id`,
|
||||
);
|
||||
|
||||
const next = new Map<string, string[]>();
|
||||
for (const row of rows) {
|
||||
if (!row.position_type_key || !row.permission_key) continue;
|
||||
const keys = next.get(row.position_type_key);
|
||||
if (keys) keys.push(row.permission_key);
|
||||
else next.set(row.position_type_key, [row.permission_key]);
|
||||
}
|
||||
this.byPositionTypeKey = next;
|
||||
} catch (err) {
|
||||
// iam schema unreachable — keep serving the previous snapshot rather than
|
||||
// dropping every type-derived permission and locking staff out.
|
||||
this.logger.warn(
|
||||
`Position-type permission refresh failed: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,65 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Partial wagon cancellation with rebooking credit.
|
||||
*
|
||||
* One row per cancellation cycle on a PAID booking: the customer asks to drop
|
||||
* N wagons, pays a per-wagon cancellation fee (rates row
|
||||
* rate_type = 'CANCELLATION_FEE', rate_unit = 'PER_WAGON'), and the dropped cargo becomes a
|
||||
* rebookable credit. The credit is redeemed by creating a fresh booking
|
||||
* through the normal under-contract create path (which re-checks contract
|
||||
* validity and caps), immediately marked PAID — the freight was already paid
|
||||
* on the original booking, only the fee is new money.
|
||||
*
|
||||
* cancelled_quantities carries what was cut, in the booking's own terms:
|
||||
* `{ bulkTons }` for bulk, `{ bySize: { "20": 4, "40": 3 } }` for container.
|
||||
* Container numbers are NOT stored here — they are recovered at rebook time
|
||||
* from the unit rows the reduction soft-deleted (same hybrid pattern as
|
||||
* RemainderPlacementService).
|
||||
*/
|
||||
export class BookingWagonCancellations3300000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.booking_wagon_cancellations (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
booking_id uuid NOT NULL REFERENCES freight.bookings(id),
|
||||
rebooked_booking_id uuid REFERENCES freight.bookings(id),
|
||||
wagons_cancelled numeric(6,2) NOT NULL CHECK (wagons_cancelled > 0),
|
||||
weight_tons numeric(12,3) NOT NULL DEFAULT 0,
|
||||
cancelled_quantities jsonb NOT NULL,
|
||||
credit_amount numeric(14,2) NOT NULL DEFAULT 0,
|
||||
fee_rate_id uuid REFERENCES freight.rates(id),
|
||||
fee_amount numeric(14,2) NOT NULL CHECK (fee_amount >= 0),
|
||||
fee_currency varchar(8) NOT NULL DEFAULT 'ETB',
|
||||
fee_invoice_id uuid REFERENCES freight.invoices(id),
|
||||
fee_paid_at timestamptz,
|
||||
status varchar(30) NOT NULL DEFAULT 'FEE_PENDING',
|
||||
reason text,
|
||||
requested_by_user_id uuid,
|
||||
rebooked_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
)
|
||||
`);
|
||||
// One open (fee-unpaid) cancellation per booking — closes the double-click
|
||||
// race without app-level locking.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_open_wagon_cancellation_per_booking
|
||||
ON freight.booking_wagon_cancellations (booking_id)
|
||||
WHERE status = 'FEE_PENDING' AND deleted_at IS NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_bwc_booking
|
||||
ON freight.booking_wagon_cancellations (booking_id)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_bwc_status
|
||||
ON freight.booking_wagon_cancellations (status)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_wagon_cancellations`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Folds each help section's `media[]` array into its markdown body.
|
||||
*
|
||||
* Attachments used to hang off the section as a separate list, rendered after
|
||||
* the text — which meant an author could not put a picture next to the sentence
|
||||
* it illustrates, and had two different places to manage media. They are now
|
||||
* embedded with markdown's image syntax, and the renderer picks `<img>` or
|
||||
* `<video>` from the file extension.
|
||||
*
|
||||
* Existing entries are converted rather than dropped: an uploaded object
|
||||
* becomes ``, and a shipped asset path or external URL
|
||||
* is kept verbatim. Sections are left alone once they have no `media` key, so
|
||||
* re-running does nothing.
|
||||
*/
|
||||
interface LegacyMedia {
|
||||
src: string;
|
||||
caption?: string | null;
|
||||
}
|
||||
|
||||
interface LegacySection {
|
||||
id: string;
|
||||
heading: string;
|
||||
body: string;
|
||||
media?: LegacyMedia[];
|
||||
}
|
||||
|
||||
/** Uploaded objects are stored as keys; the `minio:` ref is signed on read. */
|
||||
function toMarkdown(item: LegacyMedia): string {
|
||||
const isStoredObject = !/^(https?:\/\/|\/)/.test(item.src);
|
||||
const target = isStoredObject ? `minio:${item.src}` : item.src;
|
||||
return ``;
|
||||
}
|
||||
|
||||
export class SupportHelpInlineMedia3370000000000 implements MigrationInterface {
|
||||
name = "SupportHelpInlineMedia3370000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const rows: {
|
||||
id: string;
|
||||
version: number;
|
||||
payload: { sections?: LegacySection[] } & Record<string, unknown>;
|
||||
}[] = await queryRunner.query(`
|
||||
SELECT id, version, payload
|
||||
FROM freight.support_documents
|
||||
WHERE slug = 'HELP'
|
||||
`);
|
||||
|
||||
for (const row of rows) {
|
||||
const sections = row.payload.sections ?? [];
|
||||
if (!sections.some((section) => section.media !== undefined)) continue;
|
||||
|
||||
const payload = {
|
||||
...row.payload,
|
||||
sections: sections.map(({ media, ...section }) => {
|
||||
const embeds = (media ?? []).map(toMarkdown);
|
||||
return {
|
||||
...section,
|
||||
body: [section.body, ...embeds].filter(Boolean).join("\n\n"),
|
||||
};
|
||||
}),
|
||||
};
|
||||
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),
|
||||
"Moved attachments inline into the section text",
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Not reversible — see the note on SupportHelpSections3360000000000. */
|
||||
public async down(): Promise<void> {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,13 @@
|
||||
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
|
||||
import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { Public } from '@edr/api-common';
|
||||
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { AiBookingRequestDto } from './dto/ai-booking-request.dto';
|
||||
import { AiBookingResult } from './types/ai-booking-result.type';
|
||||
import { MockAiService } from './mock-ai.service';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
|
||||
// @Public() — TODO: swap for real guard when this leaves dev/testing.
|
||||
// Safe while public: extracts + validates text only, never creates or
|
||||
// dispatches anything.
|
||||
@Public()
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.view)
|
||||
@ApiTags('AI Assistant (mock)')
|
||||
@Controller('ai')
|
||||
export class AiController {
|
||||
|
||||
@@ -35,10 +35,57 @@ export class FreightMeService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Permissions granted to the position's TYPE (`iam.position_type_permissions`).
|
||||
* A position type is the platform's notion of a role, and admin-created
|
||||
* positions carry their grants there rather than on the position itself — but
|
||||
* the JWT only ever snapshots direct position permissions. Without this, staff
|
||||
* on such a position resolve to zero permissions and every permission-gated
|
||||
* route rejects them (this is what locked GL officers out of their clearance
|
||||
* detail pages). Resolved live from IAM, same as the position type above.
|
||||
*/
|
||||
private async lookupPositionTypePermissions(
|
||||
positionId: string | undefined,
|
||||
): Promise<string[]> {
|
||||
if (!positionId) return [];
|
||||
try {
|
||||
const rows: { key: string }[] = await this.dataSource.query(
|
||||
`SELECT DISTINCT perm.key
|
||||
FROM iam.positions p
|
||||
JOIN iam.position_type_permissions ptp
|
||||
ON ptp.position_type_id = p.position_type_id
|
||||
JOIN iam.permissions perm ON perm.id = ptp.permission_id
|
||||
WHERE p.id = $1`,
|
||||
[positionId],
|
||||
);
|
||||
return rows.map((r) => r.key).filter(Boolean);
|
||||
} catch {
|
||||
return []; // iam schema unreachable — degrade to position-only permissions
|
||||
}
|
||||
}
|
||||
|
||||
async getEnrichedProfile(user: TCurrentUser) {
|
||||
const positionType = await this.lookupPositionType(
|
||||
user.employee?.position?.id,
|
||||
const positionId = user.employee?.position?.id;
|
||||
const [positionType, positionTypePermissionKeys] = await Promise.all([
|
||||
this.lookupPositionType(positionId),
|
||||
this.lookupPositionTypePermissions(positionId),
|
||||
]);
|
||||
|
||||
// Merge the type-level grants into the position's own permission list so
|
||||
// BOTH consumers see them: `collectPermissionKeys` below, and the
|
||||
// backoffice's `getPermissionKeys`, which walks this same nested array.
|
||||
const positionPermissions = [
|
||||
...(user.employee?.position?.permissions ?? []),
|
||||
];
|
||||
const seenPermissionKeys = new Set(
|
||||
positionPermissions.map((p) => p?.key).filter(Boolean),
|
||||
);
|
||||
for (const key of positionTypePermissionKeys) {
|
||||
if (!seenPermissionKeys.has(key)) {
|
||||
seenPermissionKeys.add(key);
|
||||
positionPermissions.push({ key } as (typeof positionPermissions)[number]);
|
||||
}
|
||||
}
|
||||
|
||||
const employee = user.employee
|
||||
? [
|
||||
@@ -56,7 +103,7 @@ export class FreightMeService {
|
||||
name: user.employee.position.name,
|
||||
isDelegate: user.employee.position.isDelegate,
|
||||
parentPositionId: user.employee.position.parentPositionId,
|
||||
permissions: user.employee.position.permissions ?? [],
|
||||
permissions: positionPermissions,
|
||||
positionType,
|
||||
},
|
||||
]
|
||||
@@ -65,7 +112,15 @@ export class FreightMeService {
|
||||
]
|
||||
: [];
|
||||
|
||||
const permissionKeys = collectPermissionKeys(user);
|
||||
// `collectPermissionKeys` reads the raw token (position-level only), so
|
||||
// union the type-level grants in — the backoffice prefers this flat list
|
||||
// over the nested array and would otherwise still see none of them.
|
||||
const permissionKeys = [
|
||||
...new Set([
|
||||
...collectPermissionKeys(user),
|
||||
...positionTypePermissionKeys,
|
||||
]),
|
||||
];
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
|
||||
@@ -3,7 +3,8 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { ListUsersQueryDto } from './dto/list-users-query.dto';
|
||||
import { ListUsersService } from './list-users.service';
|
||||
import { StaffReference } from '../../common/booking-guards';
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('staff/users')
|
||||
@@ -12,7 +13,7 @@ export class ListUsersController {
|
||||
constructor(private readonly service: ListUsersService) {}
|
||||
|
||||
@Get()
|
||||
@StaffReference()
|
||||
@BookingStaff(FREIGHT_PERMS.staff.users.view)
|
||||
@ApiOperation({
|
||||
summary: 'List IAM users (paginated) for backoffice pickers',
|
||||
})
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -10,18 +10,19 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { FreightAdmin } from "../../common/booking-guards";
|
||||
import { BookingStaff } from "../../common/booking-guards";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { BackofficeService } from "./backoffice.service";
|
||||
import { CreateOrganizationUserDto } from "./dto/create-organization-user.dto";
|
||||
import { UpdateEmployeeUserRolesDto } from "./dto/update-employee-user-roles.dto";
|
||||
|
||||
@ApiTags("backoffice")
|
||||
@Controller("backoffice")
|
||||
@FreightAdmin()
|
||||
export class BackofficeController {
|
||||
constructor(private readonly backofficeService: BackofficeService) {}
|
||||
|
||||
@Post("organizations/:orgId/users")
|
||||
@BookingStaff([FREIGHT_PERMS.staff.employeeRegistration.create, FREIGHT_PERMS.admin])
|
||||
@ApiOperation({ summary: "Create an organization user without assigning positions" })
|
||||
createOrganizationUser(
|
||||
@Param("orgId", ParseUUIDPipe) organizationId: string,
|
||||
@@ -31,6 +32,7 @@ export class BackofficeController {
|
||||
}
|
||||
|
||||
@Get("organizations/:orgId/employees")
|
||||
@BookingStaff([FREIGHT_PERMS.staff.roleAssignment.view, FREIGHT_PERMS.admin])
|
||||
@ApiOperation({ summary: "Get deduplicated organization employees for backoffice" })
|
||||
getOrganizationEmployees(
|
||||
@Param("orgId", ParseUUIDPipe) organizationId: string,
|
||||
@@ -44,6 +46,7 @@ export class BackofficeController {
|
||||
}
|
||||
|
||||
@Get("organizations/:orgId/employee-users/:userId/roles")
|
||||
@BookingStaff([FREIGHT_PERMS.staff.roleAssignment.view, FREIGHT_PERMS.admin])
|
||||
@ApiOperation({ summary: "Get org-scoped roles assigned to an employee user" })
|
||||
getEmployeeUserRoles(
|
||||
@Param("orgId", ParseUUIDPipe) organizationId: string,
|
||||
@@ -53,6 +56,7 @@ export class BackofficeController {
|
||||
}
|
||||
|
||||
@Put("organizations/:orgId/employee-users/:userId/roles")
|
||||
@BookingStaff([FREIGHT_PERMS.staff.roleAssignment.replace, FREIGHT_PERMS.admin])
|
||||
@ApiOperation({ summary: "Replace org-scoped roles assigned to an employee user" })
|
||||
replaceEmployeeUserRoles(
|
||||
@Param("orgId", ParseUUIDPipe) organizationId: string,
|
||||
|
||||
@@ -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,25 +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 { BookingView } from "../../common/booking-guards";
|
||||
import { BookingStaff } from "../../common/booking-guards";
|
||||
import { resolveAuthUserId } from "../../common/resolve-auth-user-id";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { 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")
|
||||
@BookingView()
|
||||
// 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(
|
||||
@@ -50,7 +69,38 @@ 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" })
|
||||
async document(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) {
|
||||
const { filename, buffer } = await this.billingService.document(id);
|
||||
@@ -58,6 +108,7 @@ export class BillingController {
|
||||
}
|
||||
|
||||
@Get("invoices/:id/receipt")
|
||||
@BookingStaff(FREIGHT_PERMS.invoices.export)
|
||||
@ApiOperation({ summary: "Download the sealed payment receipt PDF" })
|
||||
async receipt(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) {
|
||||
const { filename, buffer } = await this.billingService.receipt(id);
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -668,3 +676,67 @@ describe("BillingService — CAC Bank (OTP debit)", () => {
|
||||
expect(confirmOtp).toHaveBeenCalledWith("intent-1", "123456");
|
||||
});
|
||||
});
|
||||
|
||||
describe("BillingService — CBE bill amounts carry cents, never rounded", () => {
|
||||
// CBE settles to the cent (/cbe/payment gates on amountsMatchToTheCent), so the
|
||||
// bill must quote the exact balance. Rounding UP overcharged the payer by up to
|
||||
// a birr; rounding DOWN underpaid while markInvoiceAsPaid still wrote paidAmount
|
||||
// = totalAmount. payInvoice and billQuery must agree, or /cbe/payment mismatches.
|
||||
const invoice = {
|
||||
id: "inv-1",
|
||||
status: Freight.InvoiceStatus.Pending,
|
||||
source: Freight.InvoiceSource.Booking,
|
||||
sourceId: "booking-1",
|
||||
type: "PREPAID",
|
||||
invoiceNumber: "INV-20260101-00001",
|
||||
currency: "ETB",
|
||||
// .43 — cents that must survive all the way to the bill.
|
||||
balanceAmount: 12345.43,
|
||||
totalAmount: 12345.43,
|
||||
company: { name: "Acme PLC" },
|
||||
paymentId: null,
|
||||
dueAt: null,
|
||||
};
|
||||
|
||||
const build = (payment: Record<string, unknown> = {}) => {
|
||||
const repo = {
|
||||
findOne: jest.fn().mockResolvedValue(invoice),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const service = new BillingService(
|
||||
{ getRepository: () => repo } as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
makeEvents() as never,
|
||||
payment as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, repo };
|
||||
};
|
||||
|
||||
it("opens the intent for the exact balance, cents included", async () => {
|
||||
const initiate = jest.fn().mockResolvedValue({
|
||||
intentId: "intent-1",
|
||||
immediateSuccess: false,
|
||||
response: { intentId: "intent-1", status: "REQUIRES_ACTION" },
|
||||
});
|
||||
const { service } = build({ initiate });
|
||||
|
||||
await service.payInvoice("inv-1", { method: "CBE_BILL" });
|
||||
|
||||
expect(initiate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ amountMinor: 12345.43 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("quotes the same exact amount on bill-query as payInvoice opened", async () => {
|
||||
const { service } = build();
|
||||
|
||||
await expect(service.billQuery("booking-1")).resolves.toMatchObject({
|
||||
stillPayable: true,
|
||||
currentAmountMinor: 12345.43,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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, {
|
||||
@@ -1191,7 +1341,11 @@ export class BillingService {
|
||||
// service branches on a domain-specific reference type.
|
||||
referenceType: PaymentReferenceType.SHIPMENT,
|
||||
orderRef: invoice.invoiceNumber.replace(/-/g, "_"),
|
||||
amountMinor: Math.round(Number(invoice.balanceAmount)),
|
||||
// Exact balance, cents included. CBE bills this verbatim and /cbe/payment
|
||||
// matches the debited amount to the cent (amountsMatchToTheCent), so any
|
||||
// rounding here would overcharge the payer and leave the invoice balance
|
||||
// non-zero. billQuery quotes the same unrounded value.
|
||||
amountMinor: round2(Number(invoice.balanceAmount)),
|
||||
currency: invoice.currency,
|
||||
reason: `Payment for invoice ${invoice.invoiceNumber}`,
|
||||
method: opts.method ?? "TELEBIRR",
|
||||
@@ -1336,7 +1490,10 @@ export class BillingService {
|
||||
});
|
||||
|
||||
if (open) {
|
||||
const balance = Math.round(Number(open.balanceAmount ?? open.totalAmount));
|
||||
// Unrounded, matching payInvoice — the amount CBE quotes at the counter has
|
||||
// to be the amount the intent was opened for, to the cent, or /cbe/payment
|
||||
// sees a mismatch.
|
||||
const balance = round2(Number(open.balanceAmount ?? open.totalAmount));
|
||||
const expired = open.dueAt && open.dueAt.getTime() < Date.now();
|
||||
return {
|
||||
stillPayable: balance > 0 && !expired,
|
||||
@@ -1371,7 +1528,7 @@ export class BillingService {
|
||||
return {
|
||||
stillPayable: false,
|
||||
payerName: latest.company?.name ?? null,
|
||||
currentAmountMinor: Math.round(Number(latest.totalAmount)),
|
||||
currentAmountMinor: round2(Number(latest.totalAmount)),
|
||||
currency: latest.currency,
|
||||
paymentReason: `Freight invoice ${latest.invoiceNumber}`,
|
||||
reason: closedInvoiceReason(latest.status),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import type { Response } from "express";
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
|
||||
import { PortalCustomer } from "../../common/booking-guards";
|
||||
import {
|
||||
type AuthUserPayload,
|
||||
resolveAuthUserId,
|
||||
@@ -28,6 +29,7 @@ import { ConfirmOtpDto, PayInvoiceDto } from "./dto/pay-invoice.dto";
|
||||
@ApiTags("billing")
|
||||
@ApiBearerAuth()
|
||||
@Controller("billing")
|
||||
@PortalCustomer()
|
||||
export class PortalBillingController {
|
||||
constructor(private readonly billingService: BillingService) {}
|
||||
|
||||
|
||||
@@ -20,6 +20,10 @@ import { FirstMileService } from "../first-mile/first-mile.service";
|
||||
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
|
||||
import { PriceLineItemDto } from "./dto/generate-price-response.dto";
|
||||
import { BookingsRepository } from "./bookings.repository";
|
||||
import {
|
||||
BookingWagonCancellationService,
|
||||
WAGON_CANCEL_FEE_INVOICE_TYPE,
|
||||
} from "./booking-wagon-cancellation.service";
|
||||
import { Booking } from "./entities/booking.entity";
|
||||
|
||||
/** Snapshot written onto `booking.pricingBreakdown` by the pricing service. */
|
||||
@@ -58,6 +62,8 @@ export class BookingInvoiceService {
|
||||
private readonly firstMile: FirstMileService,
|
||||
@Inject(forwardRef(() => BookingBatchService))
|
||||
private readonly bookingBatch: BookingBatchService,
|
||||
@Inject(forwardRef(() => BookingWagonCancellationService))
|
||||
private readonly wagonCancellations: BookingWagonCancellationService,
|
||||
) { }
|
||||
|
||||
/**
|
||||
@@ -90,6 +96,21 @@ export class BookingInvoiceService {
|
||||
return this.billing.generateInvoice(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the booking's open PREPAID invoice, if any — used when a
|
||||
* changes-requested resubmit restates the cargo, so the re-priced booking can
|
||||
* be re-invoiced. Throws when the invoice already has payments recorded
|
||||
* (cargo must not change out from under recorded money).
|
||||
*/
|
||||
async cancelUnpaidInvoiceForBooking(bookingId: string): Promise<void> {
|
||||
const existing = await this.billing.findPayable(
|
||||
Freight.InvoiceSource.Booking,
|
||||
bookingId,
|
||||
"PREPAID",
|
||||
);
|
||||
if (existing) await this.billing.cancelInvoice(existing.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* React to a booking invoice being paid — the settlement branch point. Per-type
|
||||
* reactions live here (not in the payment process): each invoice type advances
|
||||
@@ -108,6 +129,11 @@ export class BookingInvoiceService {
|
||||
await this.bookingBatch.reviveOfferForInvoice(payload.invoiceId);
|
||||
await this.advanceBookingOnPayment(payload.sourceId);
|
||||
break;
|
||||
case WAGON_CANCEL_FEE_INVOICE_TYPE:
|
||||
// Partial wagon cancellation: the fee settled — reduce the booking and
|
||||
// release the cancelled wagons (T2 of the cancellation cycle).
|
||||
await this.wagonCancellations.onFeePaid(payload.invoiceId);
|
||||
break;
|
||||
default:
|
||||
this.logger.warn(
|
||||
`Unhandled booking invoice type "${payload.type}" paid (${payload.invoiceId})`,
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,17 @@ import {
|
||||
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 { resolveCompanyNotifyContact } 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
|
||||
@@ -45,10 +55,13 @@ export class BookingLifecycleNotifierService {
|
||||
logLabel: string,
|
||||
): Promise<void> {
|
||||
this.logger.log(`${logLabel} — ${this.ref(b)}`);
|
||||
const phone = b.companyId
|
||||
? await resolveCompanyNotifyPhone(this.dataSource, b.companyId)
|
||||
: null;
|
||||
const email = b.company?.email ?? b.company?.generalManagerEmail ?? null;
|
||||
// Both channels come from the same resolver: the company row's own columns
|
||||
// are only half the story (see companyNotifyEmailExpr), and reading them off
|
||||
// the loaded entity silently dropped every mail to a company whose address
|
||||
// lives in `attributes`.
|
||||
const { phone, email } = b.companyId
|
||||
? await resolveCompanyNotifyContact(this.dataSource, b.companyId)
|
||||
: { phone: null, email: null };
|
||||
|
||||
if (phone) {
|
||||
try {
|
||||
@@ -89,7 +102,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 +114,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 +291,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 +306,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 +414,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 +442,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 +461,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`,
|
||||
},
|
||||
|
||||
@@ -279,7 +279,10 @@ export class BookingPricingService {
|
||||
|
||||
return {
|
||||
lineItems,
|
||||
totalAmount: total,
|
||||
// Grand total keeps its cents, matching the line items it sums — rounding
|
||||
// to whole birr made the total disagree with the breakdown (135,375.61 of
|
||||
// lines shown as a 135,376.00 total) and CBE bills this figure to the cent.
|
||||
totalAmount: round2(total),
|
||||
currency: booking.paymentCurrency,
|
||||
usedRates: [...usedRatesMap.values()],
|
||||
appliedModifiers: ruleResult.appliedModifiers,
|
||||
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
Optional,
|
||||
} from "@nestjs/common";
|
||||
import { EventEmitter2, OnEvent } from "@nestjs/event-emitter";
|
||||
import { DataSource } from "typeorm";
|
||||
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
|
||||
import {
|
||||
BookingBatchService,
|
||||
@@ -56,11 +59,18 @@ export class BookingTransitionService {
|
||||
private readonly bookingClearanceService: BookingClearanceService,
|
||||
@Inject(forwardRef(() => ClearanceWorkflowService))
|
||||
private readonly workflowService: ClearanceWorkflowService,
|
||||
private readonly invoiceService: BookingInvoiceService,
|
||||
// forwardRef: booking-invoice.service now pulls in the wagon-cancellation
|
||||
// service, whose cross-module imports close a require cycle through this
|
||||
// file — without it the class is undefined at decorator time.
|
||||
@Inject(forwardRef(() => BookingInvoiceService))
|
||||
private readonly invoiceService: BookingInvoiceService,
|
||||
private readonly containerValidationService: ContainerValidationService,
|
||||
private readonly notifier: BookingLifecycleNotifierService,
|
||||
private readonly events: EventEmitter2,
|
||||
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||
// Optional + last so the hand-constructed service in *.spec.ts files keeps
|
||||
// compiling; Nest injects it normally at runtime.
|
||||
@Optional() private readonly dataSource?: DataSource,
|
||||
) {}
|
||||
|
||||
private isPhasedCustoms(booking: Booking): boolean {
|
||||
@@ -429,6 +439,20 @@ export class BookingTransitionService {
|
||||
return fresh;
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer self-service cancel, allowed only before payment — no fee.
|
||||
* SELECTED_FOR_BATCH releases the wagon hold immediately; earlier statuses
|
||||
* take the plain cancel path (open invoices expired, nothing reserved yet).
|
||||
* Anything past payment falls through to cancel()'s status assertion.
|
||||
*/
|
||||
async customerCancel(bookingId: string, reason?: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
if (booking.status === "SELECTED_FOR_BATCH") {
|
||||
return this.cancelHold(bookingId, reason);
|
||||
}
|
||||
return this.cancel(bookingId, reason ?? "Customer cancelled before payment");
|
||||
}
|
||||
|
||||
async cancel(bookingId: string, reason: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, [
|
||||
@@ -978,6 +1002,15 @@ export class BookingTransitionService {
|
||||
// booking through the space checks below AND is persisted so the accept /
|
||||
// reserve path locks onto that train (pickExportSchedule honors it).
|
||||
const requestedId = isExportTrain ? (requestedTrainScheduleId ?? null) : null;
|
||||
// Export rail rides the exact train the customer picked — never an
|
||||
// auto-assigned one. Both portal flows (clearance + contract completion)
|
||||
// surface a picker, so a missing id is an invalid submission, not a
|
||||
// legitimate "let the system choose".
|
||||
if (isExportTrain && !requestedId) {
|
||||
throw new BadRequestException(
|
||||
"Select a train for the chosen shipment day.",
|
||||
);
|
||||
}
|
||||
const scheduledBooking = {
|
||||
...booking,
|
||||
scheduledDate: date,
|
||||
@@ -1244,6 +1277,12 @@ export class BookingTransitionService {
|
||||
/** Flat list of physical container numbers on this booking (for the
|
||||
* customer truck-assignment container picker). */
|
||||
containerNumbers: string[];
|
||||
/** The allocated train, when the booking is placed on a schedule. */
|
||||
trainSchedule?: {
|
||||
trainNumber: string | null;
|
||||
reference: string | null;
|
||||
scheduledDepartureDate: Date | null;
|
||||
} | null;
|
||||
}
|
||||
> {
|
||||
// This enrichment runs AFTER the transition has committed. A failure here
|
||||
@@ -1296,6 +1335,32 @@ export class BookingTransitionService {
|
||||
`enrichBookingResponse: batch-offer lookup failed for ${booking.id}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
// Allocated train: number + schedule reference for the detail headers
|
||||
// (portal and backoffice). Degrades to null like every fragile field here.
|
||||
let trainSchedule: {
|
||||
trainNumber: string | null;
|
||||
reference: string | null;
|
||||
scheduledDepartureDate: Date | null;
|
||||
} | null = null;
|
||||
if (booking.trainScheduleId && this.dataSource) {
|
||||
try {
|
||||
const s = await this.dataSource.getRepository(TrainSchedule).findOne({
|
||||
where: { id: booking.trainScheduleId },
|
||||
});
|
||||
if (s) {
|
||||
trainSchedule = {
|
||||
trainNumber: s.trainNumber ?? null,
|
||||
reference: s.reference ?? null,
|
||||
scheduledDepartureDate: s.scheduledDepartureDate ?? null,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`enrichBookingResponse: train-schedule lookup failed for ${booking.id}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Physical container numbers entered at booking time (booking_container
|
||||
// units), flattened for the customer truck-assignment container picker.
|
||||
const containerNumbers = (booking.bookingContainers ?? [])
|
||||
@@ -1310,6 +1375,7 @@ export class BookingTransitionService {
|
||||
nextStep,
|
||||
activeBatchOffer,
|
||||
containerNumbers,
|
||||
trainSchedule,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,85 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { BookingWagonCancellation } from './entities/booking-wagon-cancellation.entity';
|
||||
|
||||
export interface WagonCancellationListFilter {
|
||||
status?: string[];
|
||||
/** Booking reference / company name search (staff list). */
|
||||
search?: string;
|
||||
companyId?: string;
|
||||
bookingId?: string;
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BookingWagonCancellationsRepository extends BaseRepository<BookingWagonCancellation> {
|
||||
constructor(
|
||||
@InjectRepository(BookingWagonCancellation)
|
||||
repository: Repository<BookingWagonCancellation>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
/** The one open (fee-unpaid) cancellation of a booking, if any. */
|
||||
findOpenForBooking(bookingId: string): Promise<BookingWagonCancellation | null> {
|
||||
return this.repository.findOne({
|
||||
where: { bookingId, status: 'FEE_PENDING' },
|
||||
});
|
||||
}
|
||||
|
||||
findByFeeInvoiceId(feeInvoiceId: string): Promise<BookingWagonCancellation | null> {
|
||||
return this.repository.findOne({ where: { feeInvoiceId } });
|
||||
}
|
||||
|
||||
/** Paged history — staff see everything, customers are scoped by companyId. */
|
||||
async list(
|
||||
filter: WagonCancellationListFilter,
|
||||
): Promise<{ items: BookingWagonCancellation[]; total: number }> {
|
||||
const page = Math.max(1, filter.page ?? 1);
|
||||
const pageSize = Math.min(100, Math.max(1, filter.pageSize ?? 10));
|
||||
|
||||
const qb = this.baseQuery();
|
||||
if (filter.bookingId) {
|
||||
qb.andWhere('(bwc.booking_id = :bookingId OR bwc.rebooked_booking_id = :bookingId)', {
|
||||
bookingId: filter.bookingId,
|
||||
});
|
||||
}
|
||||
if (filter.companyId) {
|
||||
qb.andWhere('booking.company_id = :companyId', { companyId: filter.companyId });
|
||||
}
|
||||
if (filter.status?.length) {
|
||||
qb.andWhere('bwc.status IN (:...statuses)', { statuses: filter.status });
|
||||
}
|
||||
if (filter.search) {
|
||||
qb.andWhere('(booking.reference ILIKE :search OR company.name ILIKE :search)', {
|
||||
search: `%${filter.search}%`,
|
||||
});
|
||||
}
|
||||
if (filter.from) qb.andWhere('bwc.created_at >= :from', { from: filter.from });
|
||||
if (filter.to) qb.andWhere('bwc.created_at <= :to', { to: filter.to });
|
||||
|
||||
// Property path (not raw column): skip/take builds a distinct-id subquery
|
||||
// and the ORDER BY must resolve inside it.
|
||||
const [items, total] = await qb
|
||||
.orderBy('bwc.createdAt', 'DESC')
|
||||
.skip((page - 1) * pageSize)
|
||||
.take(pageSize)
|
||||
.getManyAndCount();
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
private baseQuery(): SelectQueryBuilder<BookingWagonCancellation> {
|
||||
return this.repository
|
||||
.createQueryBuilder('bwc')
|
||||
.leftJoinAndSelect('bwc.booking', 'booking')
|
||||
.leftJoinAndSelect('booking.company', 'company')
|
||||
.leftJoinAndSelect('bwc.rebookedBooking', 'rebookedBooking')
|
||||
.leftJoinAndSelect('bwc.feeInvoice', 'feeInvoice');
|
||||
}
|
||||
}
|
||||
@@ -15,13 +15,17 @@ import {
|
||||
UnauthorizedException,
|
||||
UploadedFile,
|
||||
UploadedFiles,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||||
import { BookingStaff, BookingView } from '../../common/booking-guards';
|
||||
import {
|
||||
BookingStaff,
|
||||
BookingView,
|
||||
MixedAudience,
|
||||
PortalCustomer,
|
||||
WagonCancellationView,
|
||||
} from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express';
|
||||
import {
|
||||
@@ -73,7 +77,14 @@ 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 {
|
||||
FilterWagonCancellationsDto,
|
||||
RebookCancelledWagonsDto,
|
||||
RequestWagonCancellationDto,
|
||||
} from './dto/wagon-cancellation.dto';
|
||||
import {
|
||||
type AuthUserPayload,
|
||||
resolveAuthUserId,
|
||||
@@ -153,9 +164,11 @@ export class BookingsController {
|
||||
private readonly firstMileService: FirstMileService,
|
||||
private readonly lastMileService: LastMileService,
|
||||
private readonly userTradeAccessService: UserTradeAccessService,
|
||||
private readonly wagonCancellationService: BookingWagonCancellationService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@MixedAudience([])
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({ summary: "Create a new freight booking (DRAFT)" })
|
||||
@@ -196,6 +209,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
@MixedAudience([])
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
@@ -212,6 +226,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Get()
|
||||
@MixedAudience(FREIGHT_PERMS.bookings.view)
|
||||
@ApiOperation({ summary: "List freight bookings (paginated)" })
|
||||
async findAll(
|
||||
@Query() filter: FilterBookingDto,
|
||||
@@ -287,6 +302,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Get("my")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary: "List the current customer's bookings ready for payment",
|
||||
description:
|
||||
@@ -317,6 +333,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Get("reference-data")
|
||||
@MixedAudience([])
|
||||
@ApiOperation({ summary: "Booking form catalog" })
|
||||
@ApiOkResponse({ type: BookingReferenceDataDto })
|
||||
getReferenceData(): Promise<BookingReferenceDataDto> {
|
||||
@@ -324,6 +341,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Get("by-reference/:reference")
|
||||
@MixedAudience(FREIGHT_PERMS.bookings.view)
|
||||
@ApiOperation({ summary: "Get booking by reference" })
|
||||
async findByReference(
|
||||
@Param("reference") reference: string,
|
||||
@@ -341,6 +359,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@MixedAudience(FREIGHT_PERMS.bookings.view)
|
||||
@ApiOperation({ summary: "Get booking by ID" })
|
||||
async findOne(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@@ -362,6 +381,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Get(':id/available-days')
|
||||
@MixedAudience([])
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Days bookable for THIS booking (cargo-aware wagon-TYPE gate; days only, no capacity counts)',
|
||||
@@ -384,6 +404,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Get(':id/day-availability')
|
||||
@MixedAudience([])
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Advisory free-wagon count for a shipment day (planning hint, not enforced). ' +
|
||||
@@ -409,6 +430,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Get(':id/mile-summary')
|
||||
@MixedAudience(FREIGHT_PERMS.bookings.view)
|
||||
@ApiOperation({
|
||||
summary: 'First/last-mile operational summary for a booking (customer-safe)',
|
||||
})
|
||||
@@ -436,6 +458,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Post(':id/customer-truck-assignment')
|
||||
@PortalCustomer()
|
||||
@ApiOperation({ summary: 'Customer assigns external truck and driver for terminal pickup' })
|
||||
async assignCustomerTruck(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -451,6 +474,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Get(':id/customer-truck-assignment/freight-order')
|
||||
@MixedAudience(FREIGHT_PERMS.bookings.view)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Download freight order copies. The 2 gate copies always print; ?copies=1,2,8 adds waybill-style copies (catalog indexes 1-8).',
|
||||
@@ -477,6 +501,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Get(':id/carriage-acceptance-sheet')
|
||||
@MixedAudience(FREIGHT_PERMS.bookings.view)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Download the carriage acceptance sheet (one per booking, lists every allocated wagon)',
|
||||
@@ -496,7 +521,156 @@ export class BookingsController {
|
||||
res.send(buffer);
|
||||
}
|
||||
|
||||
@Get(':id/wagons')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Allocated wagons for a booking (JSON) — empty until the paid booking is placed on a train',
|
||||
})
|
||||
async wagonAllocations(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||
}
|
||||
return this.bookingsService.wagonAllocations(id);
|
||||
}
|
||||
|
||||
// ── Partial wagon cancellation (paid bookings) ────────────────────────────
|
||||
// Customer endpoints are ownership-scoped (no portal permission keys); the
|
||||
// staff history/void/rebook variants are permission-gated below.
|
||||
|
||||
@Post(':id/wagon-cancellations/preview')
|
||||
@ApiOperation({ summary: 'Preview the fee/credit of a partial wagon cancellation (no writes)' })
|
||||
async previewWagonCancellation(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: RequestWagonCancellationDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||
}
|
||||
return this.wagonCancellationService.previewCancellation(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/wagon-cancellations')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Request a partial wagon cancellation on a PAID booking — opens the cancellation-fee invoice; wagons are released only once the fee settles',
|
||||
})
|
||||
async requestWagonCancellation(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: RequestWagonCancellationDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||
}
|
||||
return this.wagonCancellationService.requestCancellation(id, dto, user?.id);
|
||||
}
|
||||
|
||||
@Get(':id/wagon-cancellations')
|
||||
@ApiOperation({ summary: 'Wagon-cancellation history of one booking (owner or staff)' })
|
||||
async listBookingWagonCancellations(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
const staff =
|
||||
hasFreightPermission(user, FREIGHT_PERMS.bookings.view) ||
|
||||
hasFreightPermission(user, FREIGHT_PERMS.bookings.wagonCancellationView);
|
||||
if (!staff) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||
}
|
||||
return this.wagonCancellationService.list({ bookingId: id, pageSize: 100 });
|
||||
}
|
||||
|
||||
@Get('wagon-cancellations/my')
|
||||
@ApiOperation({ summary: 'Wagon-cancellation history of the calling customer (paginated, filterable)' })
|
||||
async listMyWagonCancellations(
|
||||
@Query() filter: FilterWagonCancellationsDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const companyId = await this.bookingsService.resolveCustomerCompanyId(user?.id ?? '');
|
||||
if (!companyId) throw new ForbiddenException('No customer company for this user.');
|
||||
return this.wagonCancellationService.list({
|
||||
companyId,
|
||||
status: filter.statuses,
|
||||
search: filter.search,
|
||||
from: filter.from ? new Date(filter.from) : undefined,
|
||||
to: filter.to ? new Date(filter.to) : undefined,
|
||||
page: filter.page,
|
||||
pageSize: filter.pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('wagon-cancellations/history')
|
||||
@WagonCancellationView()
|
||||
@ApiOperation({ summary: 'All wagon cancellations (staff, paginated, filterable)' })
|
||||
async listAllWagonCancellations(@Query() filter: FilterWagonCancellationsDto) {
|
||||
return this.wagonCancellationService.list({
|
||||
status: filter.statuses,
|
||||
search: filter.search,
|
||||
from: filter.from ? new Date(filter.from) : undefined,
|
||||
to: filter.to ? new Date(filter.to) : undefined,
|
||||
page: filter.page,
|
||||
pageSize: filter.pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
@Post('wagon-cancellations/:cancellationId/withdraw')
|
||||
@ApiOperation({ summary: 'Withdraw a fee-pending wagon cancellation (owner, or staff with the void permission)' })
|
||||
async withdrawWagonCancellation(
|
||||
@Param('cancellationId', ParseUUIDPipe) cancellationId: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
await this.assertWagonCancellationActor(
|
||||
cancellationId,
|
||||
user,
|
||||
FREIGHT_PERMS.bookings.wagonCancellationVoid,
|
||||
);
|
||||
return this.wagonCancellationService.withdraw(cancellationId);
|
||||
}
|
||||
|
||||
@Post('wagon-cancellations/:cancellationId/rebook')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Rebook a wagon-cancellation credit: pick a shipment day only — the new booking is created under the contract and marked PAID (freight already paid; contract must still be valid)',
|
||||
})
|
||||
async rebookWagonCancellation(
|
||||
@Param('cancellationId', ParseUUIDPipe) cancellationId: string,
|
||||
@Body() dto: RebookCancelledWagonsDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
await this.assertWagonCancellationActor(
|
||||
cancellationId,
|
||||
user,
|
||||
FREIGHT_PERMS.bookings.wagonCancellationRebook,
|
||||
);
|
||||
return this.wagonCancellationService.rebook(cancellationId, dto, user?.id);
|
||||
}
|
||||
|
||||
/** Owner-or-staff gate shared by the per-cancellation actions. */
|
||||
private async assertWagonCancellationActor(
|
||||
cancellationId: string,
|
||||
user: TCurrentUser,
|
||||
staffPermission: string,
|
||||
): Promise<void> {
|
||||
if (hasFreightPermission(user, staffPermission)) return;
|
||||
const row = await this.wagonCancellationService.findById(cancellationId);
|
||||
const booking = await this.bookingsService.findById(row.bookingId);
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||
}
|
||||
|
||||
@Get(':id/customer-trucks')
|
||||
@MixedAudience([
|
||||
FREIGHT_PERMS.bookings.view,
|
||||
FREIGHT_PERMS.bookings.operations,
|
||||
FREIGHT_PERMS.warehouseInventory.view,
|
||||
])
|
||||
@ApiOperation({ summary: 'List customer self-haul trucks (multi-truck) for a booking' })
|
||||
async listCustomerTrucks(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -510,6 +684,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Post(':id/customer-trucks')
|
||||
@PortalCustomer()
|
||||
@ApiOperation({ summary: 'Add a customer self-haul truck carrying 1–2 of the booking containers' })
|
||||
async addCustomerTruck(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -524,6 +699,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Post(':id/customer-trucks/bulk')
|
||||
@PortalCustomer()
|
||||
@ApiOperation({ summary: 'Bulk add customer trucks from array payload (Excel parsed)' })
|
||||
async bulkAddCustomerTrucks(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -538,6 +714,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Patch(':id/customer-trucks/:assignmentId')
|
||||
@PortalCustomer()
|
||||
@ApiOperation({ summary: 'Edit a not-yet-arrived customer truck (plate/driver/type + containers)' })
|
||||
async updateCustomerTruck(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -553,6 +730,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Delete(':id/customer-trucks/:assignmentId')
|
||||
@PortalCustomer()
|
||||
@ApiOperation({ summary: 'Remove a not-yet-arrived customer truck from a booking' })
|
||||
async removeCustomerTruck(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -567,6 +745,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Get(':id/customer-trucks/loadable-containers')
|
||||
@MixedAudience(FREIGHT_PERMS.bookings.view)
|
||||
@ApiOperation({ summary: 'Booking containers not yet loaded onto a truck' })
|
||||
async loadableContainers(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -579,7 +758,20 @@ 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)' })
|
||||
async loadCustomerTruck(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -594,6 +786,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Post(':id/customer-trucks/:assignmentId/depart')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||
@ApiOperation({
|
||||
summary: 'Register an import truck leaving: containers loaded + weighed gross (staff)',
|
||||
})
|
||||
@@ -611,6 +804,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Get(':id/received-pending-grn')
|
||||
@MixedAudience(FREIGHT_PERMS.bookings.view)
|
||||
@ApiOperation({ summary: 'Containers received into port but not yet on a GRN' })
|
||||
async receivedPendingGrn(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -624,6 +818,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Post(':id/generate-grn')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Generate a GRN over the received containers (all received, or a subset) — one GRN per batch',
|
||||
@@ -641,6 +836,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Get(':id/tracking')
|
||||
@MixedAudience(FREIGHT_PERMS.bookings.view)
|
||||
@ApiOperation({
|
||||
summary: "Shipment tracking timeline for a booking",
|
||||
description:
|
||||
@@ -663,6 +859,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@MixedAudience([])
|
||||
@HttpCode(204)
|
||||
@ApiOperation({ summary: "Soft-delete DRAFT booking" })
|
||||
remove(@Param("id", ParseUUIDPipe) id: string) {
|
||||
@@ -670,6 +867,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Post(":id/documents")
|
||||
@MixedAudience([])
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({ summary: "Upload documents for a booking (DRAFT only)" })
|
||||
@@ -682,6 +880,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Post(":id/generate-price")
|
||||
@MixedAudience([])
|
||||
@ApiOperation({
|
||||
summary: "Generate price preview (DRAFT or CHANGES_REQUESTED)",
|
||||
description:
|
||||
@@ -693,6 +892,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Post(":id/submit")
|
||||
@MixedAudience([])
|
||||
@ApiOperation({
|
||||
summary: "Customer submit booking",
|
||||
description:
|
||||
@@ -704,6 +904,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Post(":id/confirm-submit")
|
||||
@MixedAudience([])
|
||||
@ApiOperation({
|
||||
summary: "Confirm submit after price change",
|
||||
description:
|
||||
@@ -715,6 +916,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Post(":id/reject")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary: "Customer reject price estimate",
|
||||
description:
|
||||
@@ -745,6 +947,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Get(':id/clearance')
|
||||
@MixedAudience([FREIGHT_PERMS.bookings.clearanceView, FREIGHT_PERMS.bookings.reviewDocuments])
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Document-clearance grid (required docs + upload + GL review status)",
|
||||
@@ -754,6 +957,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Post(":id/clearance/documents")
|
||||
@PortalCustomer()
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
@@ -770,7 +974,10 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
// Customer requests the operation; GL ET also resubmits here on the
|
||||
// customer's behalf after operations requests changes (BookingChangesRequestedAlert).
|
||||
@Post(":id/clearance/proceed")
|
||||
@MixedAudience(FREIGHT_PERMS.contracts.createBooking)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Customer requests operation with a schedule day " +
|
||||
@@ -789,6 +996,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Get(":id/export-trains")
|
||||
@MixedAudience([])
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Export train picker: the day's export trains on the booking's corridor " +
|
||||
@@ -990,6 +1198,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Post(':id/clearance/draft-declaration/accept')
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia',
|
||||
@@ -1000,6 +1209,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Post(':id/clearance/draft-declaration/change')
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Customer requests a change to the draft customs declaration with a reason — GL Ethiopia sends a corrected draft (repeatable)',
|
||||
@@ -1026,6 +1236,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Post(':id/clearance/duty-slip')
|
||||
@PortalCustomer()
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Customer uploads duty/tax payment slip on booking' })
|
||||
@@ -1177,7 +1388,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Post(":id/government-expedite")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.governmentExpedite)
|
||||
@ApiOperation({
|
||||
summary: "Expedite government booking to PAID / ELIGIBLE for scheduling",
|
||||
})
|
||||
@@ -1201,6 +1412,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Get(":id/contract/view")
|
||||
@MixedAudience(FREIGHT_PERMS.bookings.view)
|
||||
@ApiOkResponse({ type: ContractViewDto })
|
||||
@ApiOperation({ summary: "Contract HTML view for portal and backoffice" })
|
||||
getContractView(
|
||||
@@ -1212,6 +1424,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Get(":id/contract/document")
|
||||
@MixedAudience(FREIGHT_PERMS.bookings.view)
|
||||
@ApiOperation({ summary: "Download contract PDF" })
|
||||
async downloadContractDocument(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@@ -1227,6 +1440,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Get(":id/contract")
|
||||
@MixedAudience(FREIGHT_PERMS.bookings.view)
|
||||
@ApiOperation({ summary: "Download contract file (alias)" })
|
||||
async downloadContract(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@@ -1236,7 +1450,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Post(":id/contract/sign")
|
||||
@UseGuards(JwtGuard)
|
||||
@MixedAudience(FREIGHT_PERMS.bookings.signStaff)
|
||||
@ApiOperation({ summary: "Apply digital signature (customer or staff)" })
|
||||
async signContract(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@@ -1257,18 +1471,21 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Get(":id/contract/signatures")
|
||||
@MixedAudience(FREIGHT_PERMS.bookings.view)
|
||||
@ApiOperation({ summary: "List contract signatures" })
|
||||
getContractSignatures(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.contractService.getSignatures(id);
|
||||
}
|
||||
|
||||
@Get(":id/summary")
|
||||
@MixedAudience(FREIGHT_PERMS.bookings.view)
|
||||
@ApiOperation({ summary: "Contract summary string for dashboard" })
|
||||
getSummary(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.contractService.getSummary(id);
|
||||
}
|
||||
|
||||
@Post(":id/customer/sign")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary: "Customer digital signature (deprecated — use POST contract/sign)",
|
||||
})
|
||||
@@ -1335,7 +1552,21 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(":id/customer-cancel")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Customer cancels their own booking before payment — no cancellation fee",
|
||||
})
|
||||
async customerCancel(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: RejectBookingDto,
|
||||
) {
|
||||
const booking = await this.transitionService.customerCancel(id, dto.reason);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(":id/cancel-hold")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Customer cancels an unpaid hold (SELECTED_FOR_BATCH → CANCELLED); " +
|
||||
@@ -1350,18 +1581,21 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Post(":id/consolidation")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({ summary: "Request freight consolidation" })
|
||||
requestConsolidation(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.bookingsService.requestConsolidation(id);
|
||||
}
|
||||
|
||||
@Delete(":id/consolidation")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({ summary: "Remove consolidation pairing" })
|
||||
removeConsolidation(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.bookingsService.removeConsolidation(id);
|
||||
}
|
||||
|
||||
@Get(":id/consolidation")
|
||||
@MixedAudience(FREIGHT_PERMS.bookings.view)
|
||||
@ApiOperation({ summary: "Get consolidation details" })
|
||||
getConsolidationDetails(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.bookingsService.getConsolidationDetails(id);
|
||||
|
||||
@@ -25,6 +25,7 @@ import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.se
|
||||
import { BookingTransitionService } from './booking-transition.service';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
|
||||
import { BookingAllocationController } from './booking-allocation.controller';
|
||||
import { BookingsController } from './bookings.controller';
|
||||
// import { PayController } from './pay.controller';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
@@ -38,6 +39,9 @@ import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
||||
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
|
||||
import { BookingReviewNote } from './entities/booking-review-note.entity';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { BookingWagonCancellation } from './entities/booking-wagon-cancellation.entity';
|
||||
import { BookingWagonCancellationsRepository } from './booking-wagon-cancellations.repository';
|
||||
import { BookingWagonCancellationService } from './booking-wagon-cancellation.service';
|
||||
import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity';
|
||||
import { CustomerTruckContainer } from './entities/customer-truck-container.entity';
|
||||
import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository';
|
||||
@@ -65,6 +69,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
BookingReviewNote,
|
||||
BookingContractSignature,
|
||||
BookingContainerAllocation,
|
||||
BookingWagonCancellation,
|
||||
CustomerTruckAssignment,
|
||||
CustomerTruckContainer,
|
||||
]),
|
||||
@@ -88,7 +93,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
SignaturesModule,
|
||||
registerExchangeModule(),
|
||||
],
|
||||
controllers: [BookingsController],
|
||||
controllers: [BookingsController, BookingAllocationController],
|
||||
providers: [
|
||||
BookingsService,
|
||||
BookingsRepository,
|
||||
@@ -109,6 +114,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
CustomerTruckAssignmentsRepository,
|
||||
CustomerTruckService,
|
||||
ContainerReceiptService,
|
||||
BookingWagonCancellationsRepository,
|
||||
BookingWagonCancellationService,
|
||||
],
|
||||
exports: [
|
||||
BookingsService,
|
||||
@@ -120,6 +127,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
ConsolidationService,
|
||||
CustomerTruckService,
|
||||
ContainerReceiptService,
|
||||
BookingWagonCancellationService,
|
||||
],
|
||||
})
|
||||
export class BookingsModule { }
|
||||
|
||||
@@ -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';
|
||||
@@ -92,6 +92,7 @@ interface CarriageAcceptanceWagonRow {
|
||||
interface CarriageAcceptanceReceivedRow {
|
||||
allocatedWeightTons: string | null;
|
||||
containerNumbers: string | null;
|
||||
sealNumbers?: string | null;
|
||||
}
|
||||
|
||||
const URGENT_PRIORITY_THRESHOLD = 1000;
|
||||
@@ -285,10 +286,28 @@ 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 containers the
|
||||
// customer declared on the booking — freight.containers only gains rows at
|
||||
// allocation, by which point the wagon query above already serves.
|
||||
const receivedLines: CarriageAcceptanceReceivedRow[] = isDirectExport
|
||||
? await this.dataSource.query(
|
||||
`SELECT NULL::numeric AS "allocatedWeightTons",
|
||||
unit.container_number AS "containerNumbers",
|
||||
unit.seal_number AS "sealNumbers"
|
||||
FROM freight.booking_container_units unit
|
||||
JOIN freight.booking_container line
|
||||
ON line.id = unit.booking_container_id AND line.deleted_at IS NULL
|
||||
WHERE line.booking_id = $1 AND unit.deleted_at IS NULL
|
||||
ORDER BY unit.container_number`,
|
||||
[bookingId],
|
||||
)
|
||||
: booking.tradeDirection === 'EXPORT'
|
||||
? await this.dataSource.query(
|
||||
`SELECT inv.weight AS "allocatedWeightTons",
|
||||
c.container_number AS "containerNumbers"
|
||||
@@ -304,6 +323,17 @@ export class BookingsService {
|
||||
[bookingId],
|
||||
)
|
||||
: [];
|
||||
// Bulk direct cargo has no containers — one line carrying the booking's
|
||||
// declared weight still makes a valid sheet. bulkTotalWeightTons only
|
||||
// holds the real tonnage for PER_ITEM break-bulk; everywhere else (PER_TON
|
||||
// bulk and every container booking) the VGM column is the weight.
|
||||
if (isDirectExport && receivedLines.length === 0) {
|
||||
const totalWeight = booking.bulkTotalWeightTons ?? booking.cargoTotalWeightVgm;
|
||||
receivedLines.push({
|
||||
allocatedWeightTons: totalWeight == null ? null : String(totalWeight),
|
||||
containerNumbers: null,
|
||||
});
|
||||
}
|
||||
if (receivedLines.length === 0) {
|
||||
throw new BadRequestException(
|
||||
booking.tradeDirection === 'EXPORT'
|
||||
@@ -324,7 +354,7 @@ export class BookingsService {
|
||||
marshalledAt: null,
|
||||
arrivalAt: null,
|
||||
containerNumbers: row.containerNumbers,
|
||||
sealNumbers: null,
|
||||
sealNumbers: row.sealNumbers ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -339,6 +369,64 @@ export class BookingsService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocated wagons of a booking as JSON — the portal's "Wagons" tab. Same
|
||||
* join chain as the carriage acceptance sheet, but structured (containers as
|
||||
* an array per wagon, bulk load description when the wagon carries bulk).
|
||||
* Empty array until the booking has been allocated onto a train.
|
||||
*/
|
||||
async wagonAllocations(bookingId: string): Promise<unknown[]> {
|
||||
return this.dataSource.query(
|
||||
`SELECT a.id AS "allocationId",
|
||||
tsw.sequence_no AS "sequenceNo",
|
||||
w.wagon_number AS "wagonNumber",
|
||||
COALESCE(wt.name, wt.code) AS "wagonType",
|
||||
wt.code AS "wagonTypeCode",
|
||||
wt.tare_weight_tons AS "tareWeightTons",
|
||||
tsw.capacity_tons AS "capacityTons",
|
||||
tsw.length_meters AS "lengthMeters",
|
||||
a.allocated_weight_tons AS "allocatedWeightTons",
|
||||
a.load_type AS "loadType",
|
||||
a.status AS "status",
|
||||
s.train_number AS "trainNumber",
|
||||
s.scheduled_departure_date AS "departureAt",
|
||||
so.label AS "originStation",
|
||||
sd.label AS "destinationStation",
|
||||
bl.cargo_description AS "bulkCargoDescription",
|
||||
bl.quantity AS "bulkQuantity",
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'containerNumber', ci.container_number,
|
||||
'sealNumber', ci.seal_number,
|
||||
'positionOnWagon', ci.position_on_wagon,
|
||||
'grossWeightTons', ci.gross_weight_tons
|
||||
) ORDER BY ci.position_on_wagon, ci.container_number
|
||||
) FILTER (WHERE ci.id IS NOT NULL),
|
||||
'[]'
|
||||
) AS "containers"
|
||||
FROM freight.wagon_booking_allocations a
|
||||
JOIN freight.train_set_wagons tsw
|
||||
ON tsw.id = a.train_set_wagon_id AND tsw.deleted_at IS NULL
|
||||
LEFT JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id
|
||||
LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id
|
||||
LEFT JOIN freight.train_schedules s
|
||||
ON s.train_set_id = tsw.train_set_id AND s.deleted_at IS NULL
|
||||
LEFT JOIN freight.yards so ON so.id = s.origin_station_id
|
||||
LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id
|
||||
LEFT JOIN freight.wagon_allocation_container_items ci
|
||||
ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL
|
||||
LEFT JOIN freight.wagon_allocation_bulk_loads bl
|
||||
ON bl.wagon_booking_allocation_id = a.id AND bl.deleted_at IS NULL
|
||||
WHERE a.booking_id = $1 AND a.deleted_at IS NULL
|
||||
GROUP BY tsw.id, a.id, w.wagon_number, wt.name, wt.code, wt.tare_weight_tons,
|
||||
s.train_number, s.scheduled_departure_date, so.label, sd.label,
|
||||
bl.cargo_description, bl.quantity
|
||||
ORDER BY tsw.sequence_no`,
|
||||
[bookingId],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Split the booking amount across its wagons, proportional to allocated weight
|
||||
* (equal shares when no weights are recorded). The last row absorbs the rounding
|
||||
@@ -366,7 +454,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(
|
||||
@@ -409,6 +500,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>
|
||||
@@ -432,7 +545,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; }
|
||||
@@ -480,21 +593,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">
|
||||
@@ -1425,7 +1525,46 @@ export class BookingsService {
|
||||
tradeDirection,
|
||||
);
|
||||
}
|
||||
if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate);
|
||||
// Re-pinning the departure day on an edit (e.g. fixing a CHANGES_REQUESTED
|
||||
// booking) must obey the same gate as creation: the route needs an OPEN
|
||||
// departure on that EAT day that can carry the cargo. Skipped when the day
|
||||
// didn't change, for general contracts (period-based, no pinned day) and
|
||||
// for intercity (staff assign a passing train later).
|
||||
if (dto.scheduledDate) {
|
||||
const day = eatDay(new Date(dto.scheduledDate));
|
||||
const dayChanged =
|
||||
!existing.scheduledDate || eatDay(existing.scheduledDate) !== day;
|
||||
if (
|
||||
dayChanged &&
|
||||
existing.bookingType !== 'GENERAL_CONTRACT' &&
|
||||
tradeDirection !== 'DOMESTIC'
|
||||
) {
|
||||
const { hasDeparture, hasCompatible } =
|
||||
await this.trainSchedulingService.checkDayCargoCompatibility(
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
day,
|
||||
{
|
||||
freightType: freightType as 'CONTAINER' | 'BULK',
|
||||
cargoTypeId,
|
||||
containerTypeIds: containers
|
||||
.map((c) => c.containerTypeId)
|
||||
.filter((cid): cid is string => Boolean(cid)),
|
||||
},
|
||||
);
|
||||
if (!hasDeparture) {
|
||||
throw new BadRequestException(
|
||||
'No departures available on the selected day for this route',
|
||||
);
|
||||
}
|
||||
if (!hasCompatible) {
|
||||
throw new BadRequestException(
|
||||
'No wagon on the selected day can carry this cargo type — please choose another day',
|
||||
);
|
||||
}
|
||||
}
|
||||
updates.scheduledDate = new Date(dto.scheduledDate);
|
||||
}
|
||||
if (dto.estimatedShipmentDate)
|
||||
updates.estimatedShipmentDate = new Date(dto.estimatedShipmentDate);
|
||||
if (dto.startDate) updates.startDate = new Date(dto.startDate);
|
||||
@@ -1888,6 +2027,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;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayNotEmpty,
|
||||
IsArray,
|
||||
IsDateString,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
import { WAGON_CANCELLATION_STATUSES } from '../entities/booking-wagon-cancellation.entity';
|
||||
|
||||
export class CancelContainerLineDto {
|
||||
@ApiProperty({ description: 'Container size (ft) as stored on the booking line, e.g. "20", "40"' })
|
||||
@IsString()
|
||||
containerSize!: string;
|
||||
|
||||
@ApiProperty({ description: 'How many units of this size to cancel' })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
quantity!: number;
|
||||
}
|
||||
|
||||
export class RequestWagonCancellationDto {
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Cancel SPECIFIC allocated wagons: wagon_booking_allocation ids from GET /bookings/:id/wagons. ' +
|
||||
'When set, wagons/containers are derived from the selected wagons and the other fields are ignored.',
|
||||
type: [String],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsUUID('4', { each: true })
|
||||
wagonAllocationIds?: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'BULK bookings: number of wagons to cancel (tons derived proportionally)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0.5)
|
||||
wagons?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'CONTAINER bookings: units to cancel per size (wagons derived per size)',
|
||||
type: [CancelContainerLineDto],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CancelContainerLineDto)
|
||||
containers?: CancelContainerLineDto[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Customer reason for the cancellation' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(1000)
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export class RebookCancelledWagonsDto {
|
||||
@ApiProperty({ description: 'Shipment day the credit is rebooked onto (ISO date)' })
|
||||
@IsDateString()
|
||||
scheduledDate!: string;
|
||||
}
|
||||
|
||||
export class FilterWagonCancellationsDto {
|
||||
@ApiPropertyOptional({ enum: WAGON_CANCELLATION_STATUSES, isArray: true })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsIn(WAGON_CANCELLATION_STATUSES as readonly string[], { each: true })
|
||||
statuses?: string[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Booking reference / company name search' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
search?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
from?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
to?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional({ default: 10 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
pageSize?: number;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
|
||||
import { Invoice } from '../../billing/entities/invoice.entity';
|
||||
import { Rate } from '../../rule-engine/entities/rate.entity';
|
||||
import { Booking } from './booking.entity';
|
||||
|
||||
export const WAGON_CANCELLATION_STATUSES = [
|
||||
// Requested; fee invoice open; wagons still allocated to the customer.
|
||||
'FEE_PENDING',
|
||||
// Fee settled; booking reduced, wagons freed; credit waiting for a rebook.
|
||||
'CREDIT_AVAILABLE',
|
||||
// Credit redeemed into a new PAID booking (rebookedBookingId).
|
||||
'REBOOKED',
|
||||
// Customer/staff voided the request before paying the fee. Nothing changed.
|
||||
'WITHDRAWN',
|
||||
// Reserved for a future expiry policy; not set by code today.
|
||||
'EXPIRED',
|
||||
] as const;
|
||||
|
||||
export type WagonCancellationStatus = (typeof WAGON_CANCELLATION_STATUSES)[number];
|
||||
|
||||
/** Snapshot of one physical container unit cut by the cancellation. */
|
||||
export interface CancelledUnitSnapshot {
|
||||
containerSize: string;
|
||||
containerNumber: string;
|
||||
sealNumber?: string | null;
|
||||
vgmTons: number;
|
||||
isHazardous: boolean;
|
||||
isReefer: boolean;
|
||||
}
|
||||
|
||||
/** What the cancellation cut, in the booking's own quantity terms. */
|
||||
export interface CancelledQuantities {
|
||||
/** Bulk bookings: tons cut (PER_ITEM cargo: item count, matching cargoTotalWeightVgm). */
|
||||
bulkTons?: number;
|
||||
/** Container bookings: units cut per container size. */
|
||||
bySize?: Record<string, number>;
|
||||
/**
|
||||
* Container bookings: the exact physical units cut. Snapshotted at request
|
||||
* time when the customer picked specific wagons, otherwise at fee settlement
|
||||
* (LIFO trim). The rebook reconstructs the new booking from THESE — never
|
||||
* from a soft-deleted-row scan, which could pick up units dropped by an
|
||||
* unrelated batch split on the same booking.
|
||||
*/
|
||||
units?: CancelledUnitSnapshot[];
|
||||
/**
|
||||
* Specific-wagon cancellation: the wagon_booking_allocation ids the customer
|
||||
* picked in the Wagons tab. T2 releases exactly these (fallback to
|
||||
* newest-first for any id that no longer exists, e.g. after a re-batch).
|
||||
*/
|
||||
allocationIds?: string[];
|
||||
/**
|
||||
* The wagon allocations were already released from the schedule at REQUEST
|
||||
* time (policy: wagons free up immediately; the fee is still owed before the
|
||||
* credit can be rebooked). Tells T2 to skip its release step so it never
|
||||
* deletes wagons the batch engine re-assigned in between.
|
||||
*/
|
||||
releasedAtRequest?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* One partial-wagon-cancellation cycle on a PAID booking — the audit trail and
|
||||
* the state machine. The credit itself is not a wallet balance: redeeming it
|
||||
* creates a real booking through the under-contract create path and marks it
|
||||
* PAID (see BookingWagonCancellationService).
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'booking_wagon_cancellations' })
|
||||
@Index(['bookingId'])
|
||||
@Index(['status'])
|
||||
export class BookingWagonCancellation extends BaseEntity {
|
||||
@Column({ name: 'booking_id', type: 'uuid' })
|
||||
bookingId!: string;
|
||||
|
||||
@ManyToOne(() => Booking)
|
||||
@JoinColumn({ name: 'booking_id' })
|
||||
booking?: Booking;
|
||||
|
||||
@Column({ name: 'rebooked_booking_id', type: 'uuid', nullable: true })
|
||||
rebookedBookingId?: string | null;
|
||||
|
||||
@ManyToOne(() => Booking, { nullable: true })
|
||||
@JoinColumn({ name: 'rebooked_booking_id' })
|
||||
rebookedBooking?: Booking | null;
|
||||
|
||||
@Column({ name: 'wagons_cancelled', type: 'numeric', precision: 6, scale: 2 })
|
||||
wagonsCancelled!: number;
|
||||
|
||||
@Column({ name: 'weight_tons', type: 'numeric', precision: 12, scale: 3, default: 0 })
|
||||
weightTons!: number;
|
||||
|
||||
@Column({ name: 'cancelled_quantities', type: 'jsonb' })
|
||||
cancelledQuantities!: CancelledQuantities;
|
||||
|
||||
/**
|
||||
* The freight value of the cancelled part at the ORIGINAL booking's price —
|
||||
* informational (shown to the customer as "credit worth"); no refund is ever
|
||||
* issued from it, the credit is redeemed by rebooking.
|
||||
*/
|
||||
@Column({ name: 'credit_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
|
||||
creditAmount!: number;
|
||||
|
||||
@Column({ name: 'fee_rate_id', type: 'uuid', nullable: true })
|
||||
feeRateId?: string | null;
|
||||
|
||||
@ManyToOne(() => Rate, { nullable: true })
|
||||
@JoinColumn({ name: 'fee_rate_id' })
|
||||
feeRate?: Rate | null;
|
||||
|
||||
@Column({ name: 'fee_amount', type: 'numeric', precision: 14, scale: 2 })
|
||||
feeAmount!: number;
|
||||
|
||||
@Column({ name: 'fee_currency', type: 'varchar', length: 8, default: 'ETB' })
|
||||
feeCurrency!: string;
|
||||
|
||||
@Column({ name: 'fee_invoice_id', type: 'uuid', nullable: true })
|
||||
feeInvoiceId?: string | null;
|
||||
|
||||
@ManyToOne(() => Invoice, { nullable: true })
|
||||
@JoinColumn({ name: 'fee_invoice_id' })
|
||||
feeInvoice?: Invoice | null;
|
||||
|
||||
@Column({ name: 'fee_paid_at', type: 'timestamptz', nullable: true })
|
||||
feePaidAt?: Date | null;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 30, default: 'FEE_PENDING' })
|
||||
status!: string;
|
||||
|
||||
@Column({ name: 'reason', type: 'text', nullable: true })
|
||||
reason?: string | null;
|
||||
|
||||
@Column({ name: 'requested_by_user_id', type: 'uuid', nullable: true })
|
||||
requestedByUserId?: string | null;
|
||||
|
||||
@Column({ name: 'rebooked_at', type: 'timestamptz', nullable: true })
|
||||
rebookedAt?: Date | null;
|
||||
}
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseInterceptors,
|
||||
UseGuards,
|
||||
UploadedFiles,
|
||||
BadRequestException,
|
||||
NotFoundException,
|
||||
@@ -20,8 +19,7 @@ import { AnyFilesInterceptor } from "@nestjs/platform-express";
|
||||
import { ApiOperation, ApiTags, ApiConsumes } from "@nestjs/swagger";
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||
import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard";
|
||||
import { BookingStaff } from "../../common/booking-guards";
|
||||
import { BookingStaff, MixedAudience, PortalCustomer } from "../../common/booking-guards";
|
||||
import {
|
||||
assertFreightPermission,
|
||||
hasFreightPermission,
|
||||
@@ -118,6 +116,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Get("getInfo")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({ summary: "Get company info for the current user" })
|
||||
async getInfo(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@@ -131,6 +130,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Get("profile")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({ summary: "Get flattened profile for the settings page" })
|
||||
async getProfile(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@@ -146,6 +146,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Get("profile/change-request")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary: "Current user's open profile change request (pending/rejected)",
|
||||
})
|
||||
@@ -161,6 +162,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Post("company-profiles/:profileId/reapply")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary: "Resubmit a rejected operational role for approval (→ pending)",
|
||||
})
|
||||
@@ -176,6 +178,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Get("dashboard")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Get portal dashboard KPIs (delivered, spend, freight volume) for the current user",
|
||||
@@ -191,6 +194,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Post("fetch-etrade-info")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({ summary: "Fetch company info from eTrade by TIN" })
|
||||
async fetchETradeInfo(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@@ -211,6 +215,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Patch("profile")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({ summary: "Update profile (flattened settings page)" })
|
||||
async updateProfile(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@@ -220,6 +225,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Post("company-profiles")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Add operational profile(s) (importer/exporter/forwarder) to the current user's company",
|
||||
@@ -236,6 +242,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Post("onboarding/start")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Begin onboarding: create a draft company + profile + role(s) so later steps can save incrementally",
|
||||
@@ -261,6 +268,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Post("company-profile")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Create a single operational profile for the current user's company. The role starts pending and does not become the active mode",
|
||||
@@ -278,6 +286,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Post("company-profiles/:profileId/license")
|
||||
@PortalCustomer()
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
@@ -298,6 +307,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Post("company-profiles/:profileId/license/:fileId/replace")
|
||||
@PortalCustomer()
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
@@ -324,6 +334,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Delete("company-profiles/:profileId/license/:fileId")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Remove a business-license file (staged for review on an approved company).",
|
||||
@@ -341,6 +352,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Get("company-profiles/:profileId/license")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary: "List business-license documents (with review state) for a profile",
|
||||
})
|
||||
@@ -352,6 +364,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Get("poa-delegation")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"List the Power of Attorney delegation letter (with review state) for the current user's company",
|
||||
@@ -363,6 +376,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Post("poa-delegation")
|
||||
@PortalCustomer()
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
@@ -383,6 +397,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Delete("poa-delegation/:fileId")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Remove the Power of Attorney delegation letter (staged for review on an approved company).",
|
||||
@@ -395,6 +410,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Post("identity/fayda/complete")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Bind a completed Fayda verification to the company's owner or Power of Attorney. " +
|
||||
@@ -405,10 +421,14 @@ 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")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Declare the General Manager is the company's owner, copying the owner's verified identity across. " +
|
||||
@@ -417,10 +437,14 @@ 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")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Clear the General Manager's identity — the \"same as owner\" declaration or a verification, and the details either wrote. " +
|
||||
@@ -432,7 +456,38 @@ export class CompaniesController {
|
||||
return this.companiesService.clearGmIdentity(user.id);
|
||||
}
|
||||
|
||||
@Post("identity/poa/same-as-owner")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Declare the Power of Attorney is the company's owner, copying the owner's identity across. " +
|
||||
"Waives the DARS delegation paper — nobody delegates to themselves. " +
|
||||
"Refused for an Ethiopian company whose owner is not Fayda-verified yet: its representative must be verified, and there would be nothing proven to copy.",
|
||||
})
|
||||
async setPoaSameAsOwner(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
): Promise<CompanyIdentityStateDto> {
|
||||
return this.companiesService.setPoaSameAsOwner(user.id, {
|
||||
email: user.email,
|
||||
phoneNumber: user.phoneNumber,
|
||||
});
|
||||
}
|
||||
|
||||
@Delete("identity/poa/same-as-owner")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Undo the Power of Attorney \"same as owner\" declaration and the identity it copied, leaving the representative open to be verified in their own right. " +
|
||||
"Unlike DELETE identity/fayda/poa this is allowed for a freight forwarder — it is how they change who represents them — and leaves the delegation paper on file.",
|
||||
})
|
||||
async clearPoaSameAsOwner(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
): Promise<CompanyIdentityStateDto> {
|
||||
return this.companiesService.clearPoaSameAsOwner(user.id);
|
||||
}
|
||||
|
||||
@Delete("identity/fayda/poa")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Remove the company's Power of Attorney — the verified identity, its details and the delegation paper together. " +
|
||||
@@ -445,6 +500,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Patch("onboarding-step")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({ summary: "Persist the user's current onboarding wizard step" })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async setOnboardingStep(
|
||||
@@ -455,6 +511,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Get("onboarding/requirements")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"What the current user's company still needs to finish onboarding (server-driven documents + outstanding items)",
|
||||
@@ -466,6 +523,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Post("onboarding/complete")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({ summary: "Mark the current user's onboarding as complete" })
|
||||
async completeOnboarding(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@@ -477,6 +535,7 @@ export class CompaniesController {
|
||||
|
||||
// Used by portal
|
||||
@Post("create")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Create a company with its associated external profile (onboarding)",
|
||||
@@ -591,7 +650,11 @@ export class CompaniesController {
|
||||
* permission still needs the applicant's documents.
|
||||
*/
|
||||
@Get(":companyId/documents")
|
||||
@UseGuards(JwtGuard)
|
||||
@MixedAudience([
|
||||
FREIGHT_PERMS.customers.view,
|
||||
FREIGHT_PERMS.contracts.view,
|
||||
FREIGHT_PERMS.bookings.view,
|
||||
])
|
||||
@ApiOperation({ summary: "List documents uploaded for a company" })
|
||||
async listDocuments(
|
||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||
@@ -661,6 +724,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Post(":companyId/documents")
|
||||
@MixedAudience(FREIGHT_PERMS.customers.update)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({ summary: "Upload documents for a company (onboarding)" })
|
||||
|
||||
@@ -147,6 +147,10 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
||||
verifayda: {
|
||||
completeVerification: jest.fn(async () => ctx.verification),
|
||||
},
|
||||
// Onboarding requirements resolve the nationality's document set through
|
||||
// this; no setting means no company documents, which keeps those cases
|
||||
// focused on the identity/PoA half of the list.
|
||||
fileUploadSettings: { getByCode: jest.fn(async () => null) },
|
||||
};
|
||||
|
||||
const service = new CompaniesService(
|
||||
@@ -157,7 +161,7 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
||||
deps.profilesRepo as never,
|
||||
{} as never,
|
||||
deps.filesService as never,
|
||||
{} as never,
|
||||
deps.fileUploadSettings as never,
|
||||
{} as never,
|
||||
deps.companyNotifier as never,
|
||||
{} as never,
|
||||
@@ -225,8 +229,10 @@ describe("Fayda identity verification binds a person to the company", () => {
|
||||
expect(state.owner.verified).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses to make one identity both owner and PoA", async () => {
|
||||
const { service } = makeService({
|
||||
it("lets one identity be both owner and PoA", async () => {
|
||||
// An owner who represents their own company is the ordinary small-business
|
||||
// case, not a conflict — the same answer the GM has always been allowed.
|
||||
const { service, ctx } = makeService({
|
||||
attributes: { ownerFaydaSub: "same-person" },
|
||||
verification: {
|
||||
purpose: "VERIFY",
|
||||
@@ -236,13 +242,14 @@ describe("Fayda identity verification binds a person to the company", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.completeIdentityVerification("user-1", {
|
||||
subject: "poa",
|
||||
code: "c",
|
||||
state: "s",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
const state = await service.completeIdentityVerification("user-1", {
|
||||
subject: "poa",
|
||||
code: "c",
|
||||
state: "s",
|
||||
});
|
||||
|
||||
expect(state.poa.verified).toBe(true);
|
||||
expect(ctx.attributes.poaFaydaSub).toBe("same-person");
|
||||
});
|
||||
|
||||
it("stages an owner re-verification for review on an approved company", async () => {
|
||||
@@ -322,41 +329,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.
|
||||
@@ -380,6 +361,93 @@ describe("Fayda identity verification binds a person to the company", () => {
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
// Fayda's email and phone claims are optional and routinely absent. The owner
|
||||
// is who the company is reached through and the step renders no input for
|
||||
// their contact details, so the onboarding account — already OTP-proven —
|
||||
// stands in rather than leaving the company unreachable.
|
||||
describe("account contact details stand in for absent Fayda claims", () => {
|
||||
const noContactClaims = {
|
||||
purpose: "VERIFY",
|
||||
verified: true,
|
||||
sub: "new-sub",
|
||||
fullName: "Haile Gebrselassie",
|
||||
address: "Addis Ababa",
|
||||
};
|
||||
const account = {
|
||||
email: "account@example.com",
|
||||
phoneNumber: "+251911777777",
|
||||
};
|
||||
|
||||
it("falls back to the account for an owner Fayda gave no email or phone", async () => {
|
||||
const { service, ctx } = makeService({ verification: noContactClaims });
|
||||
|
||||
await service.completeIdentityVerification(
|
||||
"user-1",
|
||||
{ subject: "owner", code: "c", state: "s" },
|
||||
account,
|
||||
);
|
||||
|
||||
expect(ctx.attributes.ownerEmail).toBe("account@example.com");
|
||||
expect(ctx.attributes.ownerPhone).toBe("+251911777777");
|
||||
});
|
||||
|
||||
it("prefers the Fayda claim over the account when there is one", async () => {
|
||||
const { service, ctx } = makeService();
|
||||
|
||||
await service.completeIdentityVerification(
|
||||
"user-1",
|
||||
{ subject: "owner", code: "c", state: "s" },
|
||||
account,
|
||||
);
|
||||
|
||||
expect(ctx.attributes.ownerEmail).toBe("haile@example.com");
|
||||
expect(ctx.attributes.ownerPhone).toBe("+251922000000");
|
||||
});
|
||||
|
||||
it("leaves the PoA alone — the account is not that person", async () => {
|
||||
const { service, ctx } = makeService({ verification: noContactClaims });
|
||||
|
||||
await service.completeIdentityVerification(
|
||||
"user-1",
|
||||
{ subject: "poa", code: "c", state: "s" },
|
||||
account,
|
||||
);
|
||||
|
||||
expect(ctx.attributes.poaEmail).toBeUndefined();
|
||||
expect(ctx.attributes.poaPhone).toBeUndefined();
|
||||
});
|
||||
|
||||
// Owners verified before the fallback existed hold blank contacts. Copying
|
||||
// those blanks onto the GM makes generalManagerEmail required by onboarding
|
||||
// with no field anywhere to satisfy it.
|
||||
it("fills the GM copy from the account when the stored owner has no contacts", async () => {
|
||||
const { service, ctx } = makeService({
|
||||
attributes: { ...OWNER_VERIFIED },
|
||||
});
|
||||
|
||||
await service.setGmSameAsOwner("user-1", account);
|
||||
|
||||
expect(ctx.attributes.gmEmail).toBe("account@example.com");
|
||||
expect(ctx.attributes.generalManagerEmail).toBe("account@example.com");
|
||||
expect(ctx.attributes.generalManagerPhone).toBe("+251911777777");
|
||||
});
|
||||
|
||||
it("keeps the stored owner contacts when the GM copy has them", async () => {
|
||||
const { service, ctx } = makeService({
|
||||
attributes: {
|
||||
...OWNER_VERIFIED,
|
||||
ownerEmail: "abebe@example.com",
|
||||
ownerPhone: "+251911000111",
|
||||
},
|
||||
});
|
||||
|
||||
await service.setGmSameAsOwner("user-1", account);
|
||||
|
||||
expect(ctx.attributes.generalManagerEmail).toBe("abebe@example.com");
|
||||
expect(ctx.attributes.generalManagerPhone).toBe("+251911000111");
|
||||
});
|
||||
});
|
||||
|
||||
it("never locks or gates the general manager — it is not the verified subject", async () => {
|
||||
// GM is a plain typed role; the portal offers a "same as owner" copy, but
|
||||
// the backend must not treat it as identity-owned or require it verified.
|
||||
@@ -636,9 +704,8 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
|
||||
});
|
||||
|
||||
it("lets the GM verify as the same human as the owner", async () => {
|
||||
// The owner/PoA collision check exists because self-delegation is not
|
||||
// delegation. It must not fire here: the GM being the owner is a supported
|
||||
// answer, so verifying with the owner's own Fayda sub has to succeed.
|
||||
// One human in every role is the ordinary small-business shape, so
|
||||
// verifying with the owner's own Fayda sub has to succeed.
|
||||
const { service, ctx } = makeService({
|
||||
attributes: { ...OWNER_VERIFIED },
|
||||
verification: {
|
||||
@@ -662,25 +729,42 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
|
||||
expect(ctx.attributes.generalManagerName).toBe("Abebe Bikila");
|
||||
});
|
||||
|
||||
it("still refuses a PoA who is the owner", async () => {
|
||||
// The GM exemption above must not have widened into the PoA.
|
||||
const { service } = makeService({
|
||||
attributes: { ...OWNER_VERIFIED },
|
||||
verification: {
|
||||
purpose: "VERIFY",
|
||||
verified: true,
|
||||
sub: "owner-sub",
|
||||
fullName: "Abebe Bikila",
|
||||
},
|
||||
it("declares the PoA is the owner, copying the verified identity across", async () => {
|
||||
const { service, ctx } = makeService({ attributes: { ...OWNER_VERIFIED } });
|
||||
|
||||
const state = await service.setPoaSameAsOwner("user-1");
|
||||
|
||||
expect(state.poaSameAsOwner).toBe(true);
|
||||
expect(state.poa.verified).toBe(true);
|
||||
expect(ctx.attributes.poaFaydaSub).toBe(OWNER_VERIFIED.ownerFaydaSub);
|
||||
expect(ctx.attributes.poaName).toBe(OWNER_VERIFIED.ownerName);
|
||||
});
|
||||
|
||||
it("refuses to declare the PoA is the owner while an Ethiopian owner is unverified", async () => {
|
||||
// Its representative must be Fayda-verified, so a declaration here would
|
||||
// record one that could never satisfy the gate.
|
||||
const { service } = makeService({ attributes: {} });
|
||||
|
||||
await expect(service.setPoaSameAsOwner("user-1")).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it("undoes the PoA \"same as owner\" declaration without touching a real verification", async () => {
|
||||
const { service, ctx } = makeService({
|
||||
attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED },
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.completeIdentityVerification("user-1", {
|
||||
subject: "poa",
|
||||
code: "c",
|
||||
state: "s",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
// No declaration in place: the verified representative must survive.
|
||||
await service.clearPoaSameAsOwner("user-1");
|
||||
expect(ctx.attributes.poaFaydaSub).toBe(POA_VERIFIED.poaFaydaSub);
|
||||
|
||||
await service.setPoaSameAsOwner("user-1");
|
||||
const state = await service.clearPoaSameAsOwner("user-1");
|
||||
|
||||
expect(state.poaSameAsOwner).toBe(false);
|
||||
expect(state.poa.verified).toBe(false);
|
||||
expect(ctx.attributes.poaFaydaSub).toBeNull();
|
||||
});
|
||||
|
||||
it("reports a pre-existing typed GM as unverified rather than blank", async () => {
|
||||
@@ -702,6 +786,73 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
|
||||
expect(state.gm.email).toBe("legacy@example.com");
|
||||
});
|
||||
|
||||
// The mirror image, and the reason the fallback above is gated on the GM
|
||||
// being unverified: a manager Fayda proved but supplied no email for types
|
||||
// one instead, and the portal decides whether to render that input by asking
|
||||
// whether the identity holds one. Reading the typed column back as part of
|
||||
// the verified identity would answer "yes" the moment it was saved — the
|
||||
// input would vanish and a typo could never be corrected.
|
||||
it("keeps a verified GM's typed email out of the verified identity", async () => {
|
||||
const { service, company } = makeService({
|
||||
attributes: {
|
||||
...OWNER_VERIFIED,
|
||||
gmFaydaSub: "gm-sub",
|
||||
gmFaydaVerifiedAt: "2026-07-03T00:00:00.000Z",
|
||||
gmName: "Derartu Tulu",
|
||||
generalManagerName: "Derartu Tulu",
|
||||
generalManagerEmail: "typed@example.com",
|
||||
},
|
||||
});
|
||||
|
||||
const state = service.getCompanyIdentityState(company() as never);
|
||||
|
||||
expect(state.gm.verified).toBe(true);
|
||||
expect(state.gm.name).toBe("Derartu Tulu");
|
||||
expect(state.gm.email).toBeNull();
|
||||
});
|
||||
|
||||
it("never stands the account in for a GM Fayda gave no email", async () => {
|
||||
// Deliberate: the account is the person onboarding, not necessarily the
|
||||
// manager. The portal asks for the email instead.
|
||||
const { service, ctx } = makeService({
|
||||
attributes: { ...OWNER_VERIFIED },
|
||||
verification: {
|
||||
purpose: "VERIFY",
|
||||
verified: true,
|
||||
sub: "gm-sub",
|
||||
fullName: "Derartu Tulu",
|
||||
phoneNumber: "+251911222333",
|
||||
},
|
||||
});
|
||||
|
||||
const state = await service.completeIdentityVerification(
|
||||
"user-1",
|
||||
{ subject: "gm", code: "c", state: "s" },
|
||||
{ email: "account@example.com", phoneNumber: "+251911777777" },
|
||||
);
|
||||
|
||||
expect(ctx.attributes.gmEmail).toBeUndefined();
|
||||
expect(state.gm.verified).toBe(true);
|
||||
expect(state.gm.email).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts the email typed for a GM whose verification carried none", async () => {
|
||||
const { service, ctx } = makeService({
|
||||
attributes: {
|
||||
...OWNER_VERIFIED,
|
||||
gmFaydaSub: "gm-sub",
|
||||
gmName: "Derartu Tulu",
|
||||
generalManagerName: "Derartu Tulu",
|
||||
},
|
||||
});
|
||||
|
||||
await service.updateProfile("user-1", {
|
||||
generalManagerEmail: "gm@example.com",
|
||||
} as never);
|
||||
|
||||
expect(ctx.attributes.generalManagerEmail).toBe("gm@example.com");
|
||||
});
|
||||
|
||||
it("does not let an unproven GM block the company from trading", async () => {
|
||||
// The GM names who to talk to, not what the company may do. Capturing it
|
||||
// through Fayda changed how it is collected, not whether it gates.
|
||||
@@ -714,3 +865,94 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Fayda's email and phone claims are optional, so a *verified* representative
|
||||
* can still be missing the details `REQUIRED_POA_FIELDS` demands. The PoA step
|
||||
* renders an input for whatever the verification did not supply — so onboarding
|
||||
* has to report them outstanding, rather than letting a freight forwarder
|
||||
* submit an incomplete representative and be refused its next PoA edit for it.
|
||||
*/
|
||||
describe("onboarding requirements name the PoA details Fayda did not supply", () => {
|
||||
const POA_VERIFIED_NO_CONTACTS = {
|
||||
poaFaydaSub: "poa-sub",
|
||||
poaFaydaVerifiedAt: "2026-07-02T00:00:00.000Z",
|
||||
poaName: "Tirunesh Dibaba",
|
||||
};
|
||||
|
||||
it("reports the missing email and phone for a freight forwarder", async () => {
|
||||
const { service } = makeService({
|
||||
attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED_NO_CONTACTS },
|
||||
profileTypes: [ProfileType.freightForwarder],
|
||||
files: [paper()],
|
||||
});
|
||||
|
||||
const req = await service.getOnboardingRequirements("user-1");
|
||||
|
||||
expect(req.poa.missingFields.map((f) => f.key)).toEqual([
|
||||
"poaEmail",
|
||||
"poaPhone",
|
||||
]);
|
||||
expect(req.poa.complete).toBe(false);
|
||||
expect(req.outstanding).toEqual(
|
||||
expect.arrayContaining(["Add your poa email", "Add your poa phone"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("clears once they are typed", async () => {
|
||||
const { service } = makeService({
|
||||
attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED },
|
||||
profileTypes: [ProfileType.freightForwarder],
|
||||
files: [paper()],
|
||||
});
|
||||
|
||||
const req = await service.getOnboardingRequirements("user-1");
|
||||
|
||||
expect(req.poa.missingFields).toEqual([]);
|
||||
expect(req.poa.complete).toBe(true);
|
||||
expect(req.outstanding).not.toContain("Add your poa email");
|
||||
});
|
||||
|
||||
// Fayda's email claim is optional and the GM's verification has no account to
|
||||
// fall back on, so demanding one blocked a manager the government had already
|
||||
// proved. `companyNotifyEmailExpr` resolves the address from the contact
|
||||
// person or the registering account instead, so nothing needs this filled.
|
||||
it("does not hold a company back for a general manager with no email", async () => {
|
||||
const { service } = makeService({
|
||||
attributes: {
|
||||
...OWNER_VERIFIED,
|
||||
gmFaydaSub: "gm-sub",
|
||||
generalManagerName: "Derartu Tulu",
|
||||
generalManagerPhone: "+251911222333",
|
||||
},
|
||||
});
|
||||
|
||||
const req = await service.getOnboardingRequirements("user-1");
|
||||
|
||||
expect(req.companyInfo.missingFields.map((f) => f.key)).not.toContain(
|
||||
"generalManagerEmail",
|
||||
);
|
||||
expect(req.outstanding).not.toContain("Add your general manager email");
|
||||
});
|
||||
|
||||
it("still holds it back for the manager's name and phone", async () => {
|
||||
const { service } = makeService({ attributes: { ...OWNER_VERIFIED } });
|
||||
|
||||
const req = await service.getOnboardingRequirements("user-1");
|
||||
|
||||
expect(req.companyInfo.missingFields.map((f) => f.key)).toEqual(
|
||||
expect.arrayContaining(["generalManagerName", "generalManagerPhone"]),
|
||||
);
|
||||
});
|
||||
|
||||
// An importer that never named a representative owes nothing here — the step
|
||||
// is one it may walk straight past.
|
||||
it("asks nothing of a company with no PoA at all", async () => {
|
||||
const { service } = makeService({ attributes: { ...OWNER_VERIFIED } });
|
||||
|
||||
const req = await service.getOnboardingRequirements("user-1");
|
||||
|
||||
expect(req.poa.missingFields).toEqual([]);
|
||||
expect(req.poa.complete).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -174,6 +174,31 @@ describe("PoA delegation paper is enforced wherever PoA state changes", () => {
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("waives the paper when the owner represents the company themselves", async () => {
|
||||
// Nobody delegates to themselves, so a self-declared PoA owes no DARS
|
||||
// paper — the representative's own details are still required.
|
||||
const { service } = makeService({
|
||||
attributes: { ...VERIFIED_IDENTITIES, poaSameAsOwner: true },
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.updateProfile("user-1", POA as never),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("grants the forwarder role to a self-represented company with no paper", async () => {
|
||||
const { service } = makeService({
|
||||
attributes: { ...VERIFIED_IDENTITIES, ...POA, poaSameAsOwner: true },
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.createCompanyProfileForUser(
|
||||
"user-1",
|
||||
ProfileType.freightForwarder,
|
||||
),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("rejects a paper the reviewer sent back for correction", async () => {
|
||||
const { service } = makeService({ files: [paper("change_requested")] });
|
||||
|
||||
|
||||
@@ -133,12 +133,6 @@ const IDENTITY_PREFIX: Record<IdentitySubject, string> = {
|
||||
gm: "gm",
|
||||
};
|
||||
|
||||
const IDENTITY_LABEL: Record<IdentitySubject, string> = {
|
||||
owner: "owner",
|
||||
poa: "Power of Attorney",
|
||||
gm: "General Manager",
|
||||
};
|
||||
|
||||
/**
|
||||
* Typed GM columns a GM verification also writes. Three notifier services mail
|
||||
* `company.generalManagerEmail` directly, so leaving these behind would mean a
|
||||
@@ -231,41 +225,42 @@ 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,
|
||||
},
|
||||
// The manager's EMAIL is deliberately absent. It was demanded because the
|
||||
// notifiers were believed to mail it, and Fayda's email claim is optional
|
||||
// — so a manager the government proved without one blocked the whole
|
||||
// submission over an address nothing could produce. `companyNotifyEmailExpr`
|
||||
// now resolves the address itself and falls through to the contact
|
||||
// person's, then to the registering account's (which signup guarantees),
|
||||
// so nothing depends on this being filled. It is still collected and still
|
||||
// preferred when present; it just no longer holds the company hostage.
|
||||
{
|
||||
key: "generalManagerPhone",
|
||||
label: "General manager phone",
|
||||
get: (c) => c.attributes?.generalManagerPhone,
|
||||
},
|
||||
];
|
||||
|
||||
/** The nationality-based document setting code for a company. */
|
||||
private documentSettingCodeFor(
|
||||
@@ -314,8 +309,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 +485,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 +509,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 +752,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 +776,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 +793,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 +806,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 +844,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 +853,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 +1066,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 +1128,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 +1141,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 +1274,12 @@ export class CompaniesService {
|
||||
);
|
||||
}
|
||||
if (documentChanges.length > 0) {
|
||||
await this.recordCompanyRevision(company, {}, submittedBy, documentChanges);
|
||||
await this.recordCompanyRevision(
|
||||
company,
|
||||
{},
|
||||
submittedBy,
|
||||
documentChanges,
|
||||
);
|
||||
}
|
||||
return uploaded;
|
||||
}
|
||||
@@ -1414,7 +1399,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 +1435,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 +1474,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 +1556,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 +1578,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 +1638,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 +1984,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)
|
||||
@@ -2045,18 +2039,37 @@ export class CompaniesService {
|
||||
const poaProvided = POA_ATTRIBUTES.some((k) =>
|
||||
(company.attributes?.[k] as string | undefined)?.trim(),
|
||||
);
|
||||
// No company types its PoA details — they arrive from the Fayda
|
||||
// verification whatever the nationality — so reporting them as missing
|
||||
// fields would ask for something no form offers. The identity block below
|
||||
// reports "verify your PoA" instead.
|
||||
const missingPoaFields: typeof REQUIRED_POA_FIELDS = [];
|
||||
const delegation = await this.getPoaDelegationState(company.id);
|
||||
const delegationDue = poaRequired || poaProvided;
|
||||
// "There is a representative" and "a paper is owed for them" used to be the
|
||||
// same condition. They part company once the owner represents the company
|
||||
// themselves: the representative's details are still required, but nobody
|
||||
// delegates to themselves, so no DARS paper is due (`assertPoaDelegationSatisfied`
|
||||
// returns on the same flag — the two must agree).
|
||||
const poaDue = poaRequired || poaProvided;
|
||||
const delegationDue = poaDue && !identity.poaSameAsOwner;
|
||||
// The representative's details normally arrive from their Fayda
|
||||
// verification — but Fayda's email and phone claims are optional and
|
||||
// routinely come back empty, and the PoA step renders an input for whatever
|
||||
// the verification did not supply. So these are askable after all, and are
|
||||
// reported outstanding once a PoA is required or provided; reporting
|
||||
// nothing here let a freight forwarder finish onboarding with a
|
||||
// representative the API's own `REQUIRED_POA_FIELDS` calls incomplete, then
|
||||
// 400'd their next PoA edit for it.
|
||||
const missingPoaFields = poaDue
|
||||
? REQUIRED_POA_FIELDS.filter(
|
||||
(f) => !(company.attributes?.[f.key] as string | undefined)?.trim(),
|
||||
)
|
||||
: [];
|
||||
const missingDelegation = delegationDue && !delegation.onFile;
|
||||
// A paper the reviewer sent back is not evidence — the customer has to
|
||||
// replace it before the application counts as complete.
|
||||
const flaggedDelegation = delegationDue && delegation.flagged;
|
||||
|
||||
// Mirrors `poaProven` in buildCompanyIdentityState — see the note there.
|
||||
const poaProven = identity.faydaRequired
|
||||
? identity.poa.verified
|
||||
: identity.poa.verified || Boolean(identity.poa.name?.trim());
|
||||
|
||||
const outstanding = [
|
||||
...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`),
|
||||
...missingDocs.map((d) => `Upload your ${d.fileLabel}`),
|
||||
@@ -2069,13 +2082,26 @@ 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"]
|
||||
: []),
|
||||
...((poaRequired || poaProvided) && !identity.poa.verified
|
||||
? ["Verify your Power of Attorney's identity with Fayda"]
|
||||
// Nationality-aware, exactly like `poaProven` in
|
||||
// buildCompanyIdentityState and the check in `assertIdentityVerified`:
|
||||
// Fayda is an Ethiopian national ID, so a foreign company's typed
|
||||
// representative has to count. Demanding a verification here regardless
|
||||
// made this list disagree with the rule actually enforced, and left a
|
||||
// foreign freight forwarder unable to submit — asked for a Fayda
|
||||
// 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.passportRequired && !identity.owner.passportNumber
|
||||
? ["Add the company owner's passport number"]
|
||||
@@ -2086,20 +2112,26 @@ export class CompaniesService {
|
||||
// fields, required documents, one license per operational profile, and the
|
||||
// PoA details/paper whenever those are mandatory.
|
||||
const requiredDocCount = documents.filter((d) => d.isRequired).length;
|
||||
const poaItemCount = delegationDue ? 1 : 0;
|
||||
// The delegation paper plus the representative's own required details —
|
||||
// `completed` below subtracts every one of those it is still missing, so
|
||||
// leaving them out of the total would make the bar understate progress.
|
||||
const poaItemCount =
|
||||
(poaDue ? REQUIRED_POA_FIELDS.length : 0) + (delegationDue ? 1 : 0);
|
||||
// One item per identity credential the company has to prove: the owner
|
||||
// always (Fayda for Ethiopian, passport for foreign), plus the PoA once
|
||||
// there is one — that one is Fayda whatever the nationality.
|
||||
// there is one — Fayda for an Ethiopian company, a named representative
|
||||
// for a foreign one, same rule as `poaProven` above. Counting a foreign
|
||||
// company's typed PoA as unproven here left the progress bar permanently
|
||||
// short of 100% on an item it had already satisfied.
|
||||
const ownerCredentialDue =
|
||||
identity.faydaRequired || identity.passportRequired;
|
||||
const ownerCredentialProven = identity.faydaRequired
|
||||
? identity.owner.verified
|
||||
: Boolean(identity.owner.passportNumber);
|
||||
const identityItemCount =
|
||||
(ownerCredentialDue ? 1 : 0) + (delegationDue ? 1 : 0);
|
||||
const identityItemCount = (ownerCredentialDue ? 1 : 0) + (poaDue ? 1 : 0);
|
||||
const missingIdentityCount =
|
||||
(ownerCredentialDue && !ownerCredentialProven ? 1 : 0) +
|
||||
(delegationDue && !identity.poa.verified ? 1 : 0);
|
||||
(poaDue && !poaProven ? 1 : 0);
|
||||
const total =
|
||||
requiredInfo.length +
|
||||
requiredDocCount +
|
||||
@@ -2118,12 +2150,16 @@ 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: {
|
||||
required: poaRequired,
|
||||
provided: poaProvided,
|
||||
delegationLetterRequired: delegationDue,
|
||||
delegationLetterUploaded: delegation.onFile,
|
||||
delegationLetterFlagged: delegation.flagged,
|
||||
missingFields: missingPoaFields,
|
||||
@@ -2160,7 +2196,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.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2169,7 +2205,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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2195,12 +2234,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(
|
||||
@@ -2229,8 +2268,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:
|
||||
@@ -2239,8 +2277,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:
|
||||
@@ -2499,9 +2536,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,
|
||||
@@ -2668,11 +2703,17 @@ 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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Nobody delegates to themselves: an owner representing their own company
|
||||
// has no delegation to evidence, so the DARS paper is not owed. The
|
||||
// representative's own details are still required above — a forwarder's
|
||||
// counterparties need someone to contact either way.
|
||||
if (attributes?.poaSameAsOwner) return;
|
||||
|
||||
const { onFile, flagged } = await this.getPoaDelegationState(
|
||||
companyId,
|
||||
opts.ignoreFileIds,
|
||||
@@ -2680,13 +2721,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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2729,6 +2770,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];
|
||||
@@ -2743,22 +2790,29 @@ export class CompaniesService {
|
||||
);
|
||||
}
|
||||
|
||||
// The owner delegating power of attorney to themselves is not a
|
||||
// delegation — it would let one identity satisfy both halves of the check.
|
||||
// Only owner/PoA collide this way: the GM is very often the owner, and
|
||||
// saying so is a supported answer rather than a conflict, so it is left out
|
||||
// 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`];
|
||||
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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// An owner who is also the company's representative is a supported answer,
|
||||
// not a conflict — the same way the GM is very often the owner. Small
|
||||
// companies routinely have one human in all three roles, and the portal's
|
||||
// "same as owner" cards exist precisely so they can say so. No identity
|
||||
// here is refused for colliding with another.
|
||||
|
||||
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,
|
||||
@@ -2766,8 +2820,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 } : {}),
|
||||
...(result.phoneNumber ? { [`${prefix}Phone`]: result.phoneNumber } : {}),
|
||||
...(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.
|
||||
...(phone ? { [`${prefix}Phone`]: normalizeE164(phone) } : {}),
|
||||
...(result.address ? { [`${prefix}Address`]: result.address } : {}),
|
||||
};
|
||||
|
||||
@@ -2776,6 +2834,12 @@ export class CompaniesService {
|
||||
// mail `generalManagerEmail`), and clears any earlier "same as owner"
|
||||
// declaration — verifying in their own right is the GM answering for
|
||||
// themselves.
|
||||
// Verifying the representative in their own right answers the question the
|
||||
// "same as owner" declaration answered, so the declaration goes.
|
||||
if (dto.subject === "poa") {
|
||||
identity.poaSameAsOwner = false;
|
||||
}
|
||||
|
||||
if (dto.subject === "gm") {
|
||||
identity.gmSameAsOwner = false;
|
||||
if (result.fullName) identity.generalManagerName = result.fullName;
|
||||
@@ -2817,7 +2881,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;
|
||||
@@ -2827,20 +2897,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, {
|
||||
@@ -2883,6 +2956,118 @@ export class CompaniesService {
|
||||
return this.getCompanyIdentityState(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Declare that the company's Power of Attorney is its owner.
|
||||
*
|
||||
* An owner representing their own company is the ordinary case for a small
|
||||
* business, so this is a supported answer rather than the conflict it used to
|
||||
* be refused as. Two shapes, matching {@link setGmSameAsOwner}:
|
||||
*
|
||||
* - A Fayda-verified owner is a proven identity, so it is copied outright —
|
||||
* the representative inherits the verification instead of the same human
|
||||
* being sent through Fayda a second time.
|
||||
* - A foreign company's owner is backed by a typed passport, so there is
|
||||
* nothing proven to copy. The declaration is still recorded (it is what
|
||||
* waives the DARS paper) and whatever owner details exist come across; the
|
||||
* portal types the rest, which `poaProven` accepts for a foreign company.
|
||||
*
|
||||
* Refused for an Ethiopian company whose owner is not verified yet: Fayda is
|
||||
* mandatory for its representative, so a declaration there would record a
|
||||
* representative that could never satisfy the gate.
|
||||
*/
|
||||
async setPoaSameAsOwner(
|
||||
userId: string,
|
||||
/** Same fallback as {@link completeIdentityVerification} — an owner whose
|
||||
* Fayda claims carried no email/phone has none stored, and copying blanks
|
||||
* onto a freight forwarder's PoA would block the submit on
|
||||
* `REQUIRED_POA_FIELDS`. */
|
||||
account?: { email?: string; phoneNumber?: string },
|
||||
): Promise<CompanyIdentityStateDto> {
|
||||
const { company } = await this.getCompanyInfoByUserId(userId);
|
||||
const attrs = company.attributes ?? {};
|
||||
const state = buildCompanyIdentityState(company);
|
||||
const ownerSub = attrs.ownerFaydaSub as string | undefined;
|
||||
|
||||
if (state.faydaRequired && !ownerSub) {
|
||||
throw new BadRequestException(
|
||||
"Verify the company owner with Fayda first — there is no proven identity to reuse yet.",
|
||||
);
|
||||
}
|
||||
|
||||
const ownerEmail = (attrs.ownerEmail as string | undefined) || account?.email;
|
||||
const ownerPhone =
|
||||
(attrs.ownerPhone as string | undefined) || account?.phoneNumber;
|
||||
|
||||
// Only non-blank values are copied: a blank here would overwrite something
|
||||
// the portal typed for a foreign company, whose owner has no verified
|
||||
// claims to draw on.
|
||||
const copied: Record<string, unknown> = { poaSameAsOwner: true };
|
||||
const copy = (key: string, value: unknown) => {
|
||||
if (value !== null && value !== undefined && value !== "")
|
||||
copied[key] = value;
|
||||
};
|
||||
copy("poaName", attrs.ownerName);
|
||||
copy("poaEmail", ownerEmail);
|
||||
copy("poaPhone", ownerPhone ? normalizeE164(ownerPhone) : undefined);
|
||||
copy("poaAddress", attrs.ownerAddress);
|
||||
|
||||
if (ownerSub) {
|
||||
copied.poaFaydaSub = ownerSub;
|
||||
copied.poaFaydaVerifiedAt =
|
||||
attrs.ownerFaydaVerifiedAt ?? new Date().toISOString();
|
||||
copy("poaBirthdate", attrs.ownerBirthdate);
|
||||
copy("poaGender", attrs.ownerGender);
|
||||
}
|
||||
|
||||
const updated = await this.companiesRepo.update(company.id, {
|
||||
attributes: { ...attrs, ...copied },
|
||||
});
|
||||
if (!updated)
|
||||
throw new NotFoundException(`Company ${company.id} not found`);
|
||||
updated.companyProfiles = company.companyProfiles;
|
||||
return this.getCompanyIdentityState(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo the PoA "same as owner" declaration, clearing the identity it copied
|
||||
* so a different representative can be verified (or typed, for a foreign
|
||||
* company).
|
||||
*
|
||||
* Separate from {@link removePoaIdentity}, which drops the representative and
|
||||
* their paper and is refused to a freight forwarder. Undoing a declaration is
|
||||
* how a forwarder changes its mind about who represents it, so it must stay
|
||||
* open to them — the submit gate still refuses a forwarder that never names a
|
||||
* replacement. The delegation paper is left alone for the same reason: the
|
||||
* company still owes one, now for whoever comes next.
|
||||
*
|
||||
* A no-op when no declaration is in place: a stray call must not wipe a
|
||||
* representative who verified in their own right.
|
||||
*/
|
||||
async clearPoaSameAsOwner(userId: string): Promise<CompanyIdentityStateDto> {
|
||||
const { company } = await this.getCompanyInfoByUserId(userId);
|
||||
const attrs = { ...(company.attributes ?? {}) };
|
||||
if (!attrs.poaSameAsOwner) return this.getCompanyIdentityState(company);
|
||||
|
||||
attrs.poaSameAsOwner = false;
|
||||
for (const key of [
|
||||
...POA_ATTRIBUTES,
|
||||
"poaFaydaSub",
|
||||
"poaFaydaVerifiedAt",
|
||||
"poaBirthdate",
|
||||
"poaGender",
|
||||
]) {
|
||||
attrs[key] = null;
|
||||
}
|
||||
|
||||
const updated = await this.companiesRepo.update(company.id, {
|
||||
attributes: attrs,
|
||||
});
|
||||
if (!updated)
|
||||
throw new NotFoundException(`Company ${company.id} not found`);
|
||||
updated.companyProfiles = company.companyProfiles;
|
||||
return this.getCompanyIdentityState(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the Power of Attorney entirely — the verified identity, the details it
|
||||
* wrote and the delegation paper together.
|
||||
@@ -2904,7 +3089,7 @@ export class CompaniesService {
|
||||
);
|
||||
}
|
||||
|
||||
const cleared: Record<string, unknown> = {};
|
||||
const cleared: Record<string, unknown> = { poaSameAsOwner: false };
|
||||
for (const key of [
|
||||
...POA_ATTRIBUTES,
|
||||
"poaFaydaSub",
|
||||
@@ -2951,8 +3136,8 @@ export class CompaniesService {
|
||||
const snapshot = {
|
||||
...(existing?.snapshot ?? {}),
|
||||
faydaIdentity: {
|
||||
...(((existing?.snapshot ?? {}) as Record<string, any>)
|
||||
.faydaIdentity ?? {}),
|
||||
...(((existing?.snapshot ?? {}) as Record<string, any>).faydaIdentity ??
|
||||
{}),
|
||||
...identity,
|
||||
},
|
||||
};
|
||||
@@ -3364,7 +3549,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) {
|
||||
@@ -3396,7 +3584,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,
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
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 { resolveCompanyNotifyContact } 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[] = [
|
||||
@@ -37,8 +38,13 @@ export class CompanyNotifierService {
|
||||
|
||||
/** Send SMS + email to the company contact; log-only on failure. */
|
||||
private async notifyContact(company: Company, message: string): Promise<void> {
|
||||
const phone = await resolveCompanyNotifyPhone(this.dataSource, company.id);
|
||||
const email = company.email ?? company.generalManagerEmail ?? null;
|
||||
// One resolver for both channels — the company row's own email column is
|
||||
// only set for a Fayda-verified owner (see companyNotifyEmailExpr), which
|
||||
// left an approval/suspension notice unsent to everyone else.
|
||||
const { phone, email } = await resolveCompanyNotifyContact(
|
||||
this.dataSource,
|
||||
company.id,
|
||||
);
|
||||
|
||||
if (phone) {
|
||||
try {
|
||||
@@ -175,12 +181,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 +192,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,
|
||||
|
||||
@@ -75,6 +75,12 @@ export class CompanyIdentityStateDto {
|
||||
@ApiProperty({ type: IdentityVerificationStateDto })
|
||||
poa!: IdentityVerificationStateDto;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
"True when the Power of Attorney is the company's owner, declared through the portal's \"same as owner\" copy. Waives the DARS delegation paper — nobody delegates to themselves.",
|
||||
})
|
||||
poaSameAsOwner!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
type: IdentityVerificationStateDto,
|
||||
description:
|
||||
@@ -143,12 +149,18 @@ function stateFor(
|
||||
birthdate: read(`${p}Birthdate`),
|
||||
gender: read(`${p}Gender`),
|
||||
};
|
||||
if (subject !== "gm") return state;
|
||||
if (subject !== "gm" || state.verified) return state;
|
||||
|
||||
// Companies onboarded before the GM was verifiable have typed details and no
|
||||
// `gm*` attributes at all. Report those rather than a blank card — they are
|
||||
// still what the notifiers mail — leaving `verified` false so the portal
|
||||
// offers the upgrade instead of pretending the identity is proven.
|
||||
//
|
||||
// Only for such an unverified GM, which is the whole population this exists
|
||||
// for. Merging the typed columns into a *verified* manager's state would read
|
||||
// back the email the portal asked them to type when Fayda supplied none, and
|
||||
// the input offering it — keyed on that value being absent — would vanish the
|
||||
// moment it was saved, leaving a typo uncorrectable.
|
||||
return {
|
||||
...state,
|
||||
name: state.name ?? read(GM_TYPED_KEYS.name),
|
||||
@@ -190,6 +202,7 @@ export function buildCompanyIdentityState(
|
||||
|
||||
const gm = stateFor(attrs, "gm");
|
||||
const gmSameAsOwner = Boolean(attrs.gmSameAsOwner);
|
||||
const poaSameAsOwner = Boolean(attrs.poaSameAsOwner);
|
||||
|
||||
const ownerProven = faydaRequired
|
||||
? owner.verified
|
||||
@@ -214,6 +227,7 @@ export function buildCompanyIdentityState(
|
||||
passportRequired,
|
||||
owner,
|
||||
poa,
|
||||
poaSameAsOwner,
|
||||
gm,
|
||||
gmSameAsOwner,
|
||||
complete,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -42,6 +42,12 @@ export interface OnboardingPoaState {
|
||||
required: boolean;
|
||||
/** True once any PoA detail has been entered. */
|
||||
provided: boolean;
|
||||
/**
|
||||
* True when the DARS delegation paper is owed — a PoA exists (or is
|
||||
* mandatory) and is not the owner themselves. An owner representing their own
|
||||
* company delegates to nobody, so there is no delegation to evidence.
|
||||
*/
|
||||
delegationLetterRequired: boolean;
|
||||
/** True when the DARS delegation paper is stored for the company. */
|
||||
delegationLetterUploaded: boolean;
|
||||
/** True when a reviewer sent the paper back for correction. */
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { IsString, IsOptional, IsEmail, MaxLength, IsEnum, IsIn } 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';
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsEmail,
|
||||
MaxLength,
|
||||
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";
|
||||
|
||||
export class UpdateProfileDto {
|
||||
@IsOptional()
|
||||
@@ -14,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)
|
||||
@@ -36,12 +33,16 @@ 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
|
||||
// TIN. Both portal forms enforce that; without it here the API happily stored
|
||||
// whatever a stale client sent, and the two layers disagreed about what the
|
||||
// column may hold.
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
@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
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Controller, Post, Get, Patch, Delete, Body, Param, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { ComplianceService } from './compliance.service';
|
||||
import {
|
||||
CreateComplianceRecordDto,
|
||||
@@ -9,10 +11,17 @@ import { ComplianceType } from './entities/compliance-record.entity';
|
||||
|
||||
@ApiTags('Vehicle Compliance')
|
||||
@Controller('compliance')
|
||||
// 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) {}
|
||||
|
||||
@Post()
|
||||
@BookingStaff(FREIGHT_PERMS.compliance.manage)
|
||||
@ApiOperation({ summary: 'Create a compliance record' })
|
||||
create(@Body() dto: CreateComplianceRecordDto) {
|
||||
return this.complianceService.create(dto);
|
||||
@@ -40,12 +49,14 @@ export class ComplianceController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@BookingStaff(FREIGHT_PERMS.compliance.manage)
|
||||
@ApiOperation({ summary: 'Update a compliance record' })
|
||||
update(@Param('id') id: string, @Body() dto: UpdateComplianceRecordDto) {
|
||||
return this.complianceService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@BookingStaff(FREIGHT_PERMS.compliance.manage)
|
||||
@ApiOperation({ summary: 'Soft-delete a compliance record' })
|
||||
remove(@Param('id') id: string) {
|
||||
return this.complianceService.remove(id);
|
||||
|
||||
@@ -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,
|
||||
@@ -10,44 +12,90 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { FreightAdmin } from "../../common/booking-guards";
|
||||
import { BookingStaff } from "../../common/booking-guards";
|
||||
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 stay open to authenticated staff (the backoffice Templates tab);
|
||||
// writes are admin-guarded like other freight configuration resources.
|
||||
|
||||
@Get()
|
||||
@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();
|
||||
}
|
||||
|
||||
@Post()
|
||||
@BookingStaff([
|
||||
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")
|
||||
@FreightAdmin()
|
||||
@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);
|
||||
}
|
||||
|
||||
@Delete(":code")
|
||||
@BookingStaff([
|
||||
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",
|
||||
})
|
||||
@@ -61,21 +109,21 @@ export class ContractTemplatesController {
|
||||
/* ------------------------- article routes ------------------------- */
|
||||
|
||||
@Put(":code/articles")
|
||||
@FreightAdmin()
|
||||
@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")
|
||||
@FreightAdmin()
|
||||
@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")
|
||||
@FreightAdmin()
|
||||
@BookingStaff(TEMPLATE_UPDATE)
|
||||
@ApiOperation({ summary: "Update an article's title or body" })
|
||||
updateArticle(
|
||||
@Param("code") code: string,
|
||||
@@ -86,7 +134,7 @@ export class ContractTemplatesController {
|
||||
}
|
||||
|
||||
@Delete(":code/articles/:articleId")
|
||||
@FreightAdmin()
|
||||
@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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { ContractBookingService } from './contract-booking.service';
|
||||
|
||||
/**
|
||||
* OPERATION_CHANGES_REQUESTED resubmit with restated cargo. Operations can ask
|
||||
* for the cargo itself to change, so a completion payload that restates
|
||||
* containers must cancel the unpaid invoice, wipe the persisted cargo and
|
||||
* re-run the fresh-completion path (re-persist, re-price, re-invoice). A
|
||||
* payload without cargo keeps the day-only resubmit behavior.
|
||||
*/
|
||||
describe('ContractBookingService — changes-requested resubmit restating cargo', () => {
|
||||
const CONTRACT = {
|
||||
id: 'c-1',
|
||||
reference: 'CTR-1',
|
||||
contractKind: 'GENERAL',
|
||||
freightType: 'CONTAINER',
|
||||
tradeDirection: 'IMPORT',
|
||||
customsClearingEnabled: false,
|
||||
contractValidUntil: null,
|
||||
cargoScope: [],
|
||||
};
|
||||
|
||||
const bookingWithCargo = () => ({
|
||||
id: 'b-1',
|
||||
contractId: 'c-1',
|
||||
reference: 'BKG-1',
|
||||
status: 'OPERATION_CHANGES_REQUESTED',
|
||||
bookingContainers: [{ containerSize: '20FT', quantity: 4 }],
|
||||
cargoTotalWeightVgm: 80,
|
||||
originYardId: 'y-o',
|
||||
destinationYardId: 'y-d',
|
||||
});
|
||||
|
||||
function makeService() {
|
||||
const bookingsRepository = {
|
||||
findByIdWithFiles: jest.fn().mockResolvedValue(bookingWithCargo()),
|
||||
deleteContainers: jest.fn().mockResolvedValue(undefined),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const invoiceService = {
|
||||
cancelUnpaidInvoiceForBooking: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const trainSchedulingService = {
|
||||
assertBookingWindowOpen: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const contractsRepository = {
|
||||
findByIdWithRelations: jest.fn().mockResolvedValue(CONTRACT),
|
||||
};
|
||||
const service = new ContractBookingService(
|
||||
contractsRepository as never,
|
||||
bookingsRepository as never,
|
||||
{} as never, // bookingPricingService
|
||||
{} as never, // consolidationService
|
||||
{} as never, // containerTypesService
|
||||
{} as never, // ruleEngineService
|
||||
{} as never, // milestoneService
|
||||
invoiceService as never,
|
||||
{} as never, // bookingNotifier
|
||||
{} as never, // dataSource
|
||||
trainSchedulingService as never,
|
||||
{} as never, // bookingBatchService
|
||||
{} as never, // bookingTransitionService
|
||||
);
|
||||
return { service, bookingsRepository, invoiceService };
|
||||
}
|
||||
|
||||
// Both paths dead-end into a downstream private assert we replace with a
|
||||
// sentinel — which path threw tells us which branch the resubmit took.
|
||||
const SENTINEL = new Error('reached-branch');
|
||||
|
||||
it('restated cargo cancels the invoice, wipes cargo and re-runs fresh completion', async () => {
|
||||
const { service, bookingsRepository, invoiceService } = makeService();
|
||||
// First gate inside the fresh-completion (!hasCargo) path.
|
||||
jest
|
||||
.spyOn(
|
||||
service as never as { assertWithinQuantityCap: () => Promise<void> },
|
||||
'assertWithinQuantityCap',
|
||||
)
|
||||
.mockRejectedValue(SENTINEL);
|
||||
|
||||
await expect(
|
||||
service.completeUnderContract('c-1', 'b-1', {
|
||||
scheduledDate: new Date().toISOString(),
|
||||
containers: [{ containerSize: '20FT', quantity: 2 }],
|
||||
} as never),
|
||||
).rejects.toBe(SENTINEL);
|
||||
|
||||
expect(invoiceService.cancelUnpaidInvoiceForBooking).toHaveBeenCalledWith('b-1');
|
||||
expect(bookingsRepository.deleteContainers).toHaveBeenCalledWith('b-1');
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', {
|
||||
cargoTotalWeightVgm: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('a day-only resubmit keeps the persisted cargo and invoice untouched', async () => {
|
||||
const { service, bookingsRepository, invoiceService } = makeService();
|
||||
// First call inside the day-only (hasCargo) resubmit path.
|
||||
jest
|
||||
.spyOn(
|
||||
service as never as {
|
||||
assertPersistedContainersAvailable: () => Promise<void>;
|
||||
},
|
||||
'assertPersistedContainersAvailable',
|
||||
)
|
||||
.mockRejectedValue(SENTINEL);
|
||||
|
||||
await expect(
|
||||
service.completeUnderContract('c-1', 'b-1', {
|
||||
scheduledDate: new Date().toISOString(),
|
||||
} as never),
|
||||
).rejects.toBe(SENTINEL);
|
||||
|
||||
expect(invoiceService.cancelUnpaidInvoiceForBooking).not.toHaveBeenCalled();
|
||||
expect(bookingsRepository.deleteContainers).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
@@ -98,6 +98,9 @@ export class ContractBookingService {
|
||||
private readonly containerTypesService: ContainerTypesService,
|
||||
private readonly ruleEngineService: RuleEngineService,
|
||||
private readonly milestoneService: ClearanceMilestoneService,
|
||||
// forwardRef: part of the booking-invoice ⇄ wagon-cancellation ⇄ contracts
|
||||
// require cycle (see BookingTransitionService).
|
||||
@Inject(forwardRef(() => BookingInvoiceService))
|
||||
private readonly invoiceService: BookingInvoiceService,
|
||||
private readonly bookingNotifier: BookingLifecycleNotifierService,
|
||||
private readonly dataSource: DataSource,
|
||||
@@ -692,11 +695,30 @@ export class ContractBookingService {
|
||||
});
|
||||
|
||||
const freightType = contract.freightType;
|
||||
const hasCargo =
|
||||
let hasCargo =
|
||||
(booking.bookingContainers?.length ?? 0) > 0 ||
|
||||
Number(booking.cargoTotalWeightVgm) > 0;
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Operations may return a booking asking for the CARGO to change (fewer or
|
||||
// more containers), not just the day. A resubmit whose payload restates the
|
||||
// cargo therefore starts the completion over: cancel the unpaid invoice
|
||||
// first (it throws if money is already recorded — cargo must not change
|
||||
// under a paid invoice), then wipe the persisted cargo so the fresh-
|
||||
// completion path below re-persists, re-prices and re-invoices from the
|
||||
// payload. A resubmit without cargo keeps today's day-only behavior.
|
||||
const restatesCargo = Boolean(
|
||||
dto.containers?.length || dto.bulkLines?.length,
|
||||
);
|
||||
if (hasCargo && restatesCargo) {
|
||||
await this.invoiceService.cancelUnpaidInvoiceForBooking(booking.id);
|
||||
await this.bookingsRepository.deleteContainers(booking.id);
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
cargoTotalWeightVgm: 0,
|
||||
} as never);
|
||||
hasCargo = false;
|
||||
}
|
||||
|
||||
// EXPORT rides whole or not at all (no split concept): the chosen day must
|
||||
// have a single open train that carries the whole booking. First completion
|
||||
// sizes from the dto's cargo; a changes-requested resubmit (cargo already
|
||||
@@ -1863,6 +1885,9 @@ export class ContractBookingService {
|
||||
async validateShipment(
|
||||
contractId: string,
|
||||
dto: CreateBookingUnderContractDto,
|
||||
// Completion/resubmit preview: the booking being completed must not clash
|
||||
// with its own persisted containers.
|
||||
excludeBookingId?: string,
|
||||
): Promise<{
|
||||
overweightLines: Array<{
|
||||
containerTypeCode: string;
|
||||
@@ -2024,6 +2049,7 @@ export class ContractBookingService {
|
||||
originYardId: route?.originYardId,
|
||||
destinationYardId: route?.destinationYardId,
|
||||
},
|
||||
excludeBookingId,
|
||||
);
|
||||
containerClashErrors = clashes.map(
|
||||
(c) =>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -10,7 +10,17 @@ import {
|
||||
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 { resolveCompanyNotifyContact } 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
|
||||
@@ -42,10 +52,11 @@ export class ContractNotifierService {
|
||||
logLabel: string,
|
||||
): Promise<void> {
|
||||
this.logger.log(`${logLabel} — ${this.ref(c)}`);
|
||||
const phone = c.companyId
|
||||
? await resolveCompanyNotifyPhone(this.dataSource, c.companyId)
|
||||
: null;
|
||||
const email = c.company?.email ?? c.company?.generalManagerEmail ?? null;
|
||||
// One resolver for both channels — the company row's own email column is
|
||||
// only set for a Fayda-verified owner (see companyNotifyEmailExpr).
|
||||
const { phone, email } = c.companyId
|
||||
? await resolveCompanyNotifyContact(this.dataSource, c.companyId)
|
||||
: { phone: null, email: null };
|
||||
|
||||
if (phone) {
|
||||
try {
|
||||
@@ -86,7 +97,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 +109,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 +245,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 +264,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 +281,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 +339,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 +353,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 +367,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 {
|
||||
|
||||
@@ -14,12 +14,10 @@ import {
|
||||
UnauthorizedException,
|
||||
UploadedFiles,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||||
import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express';
|
||||
import type { Response } from 'express';
|
||||
import {
|
||||
@@ -32,7 +30,7 @@ import {
|
||||
} from '@nestjs/swagger';
|
||||
|
||||
import { actorLabel } from '../warehouses/current-actor.util';
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { BookingStaff, MixedAudience, PortalCustomer } from '../../common/booking-guards';
|
||||
import { ContractDocumentHistoryService } from './contract-document-history.service';
|
||||
import {
|
||||
FREIGHT_PERMS,
|
||||
@@ -127,6 +125,7 @@ export class ContractsController {
|
||||
}
|
||||
|
||||
@Get('booking-requests/:reqId')
|
||||
@MixedAudience(FREIGHT_PERMS.contracts.view)
|
||||
@ApiOperation({ summary: 'A single shipment request' })
|
||||
getBookingRequest(@Param('reqId', ParseUUIDPipe) reqId: string) {
|
||||
return this.bookingRequestService.findOne(reqId);
|
||||
@@ -159,6 +158,7 @@ export class ContractsController {
|
||||
}
|
||||
|
||||
@Post('booking-requests/:reqId/cancel')
|
||||
@PortalCustomer()
|
||||
@ApiOperation({ summary: 'Customer cancels their own pending shipment request' })
|
||||
cancelBookingRequest(
|
||||
@Param('reqId', ParseUUIDPipe) reqId: string,
|
||||
@@ -168,6 +168,7 @@ export class ContractsController {
|
||||
}
|
||||
|
||||
@Post(':id/booking-requests')
|
||||
@PortalCustomer()
|
||||
@ApiOperation({ summary: 'Customer submits a shipment request on a GENERAL customs contract' })
|
||||
submitBookingRequest(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -178,12 +179,14 @@ export class ContractsController {
|
||||
}
|
||||
|
||||
@Get(':id/booking-requests')
|
||||
@MixedAudience(FREIGHT_PERMS.contracts.view)
|
||||
@ApiOperation({ summary: 'List the shipment requests on a contract' })
|
||||
listBookingRequests(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.bookingRequestService.listForContract(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@MixedAudience([])
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Create a new contract (DRAFT) with routes + cargo scope' })
|
||||
@@ -203,6 +206,7 @@ export class ContractsController {
|
||||
}
|
||||
|
||||
@Get()
|
||||
@MixedAudience([])
|
||||
@ApiOperation({ summary: 'List contracts (paginated)' })
|
||||
async findAll(
|
||||
@Query() filter: FilterContractDto,
|
||||
@@ -247,6 +251,7 @@ export class ContractsController {
|
||||
}
|
||||
|
||||
@Get('my')
|
||||
@PortalCustomer()
|
||||
@ApiOperation({ summary: "List the current customer's contracts" })
|
||||
async findMy(
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@@ -301,6 +306,7 @@ export class ContractsController {
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@MixedAudience(FREIGHT_PERMS.contracts.view)
|
||||
@ApiOperation({ summary: 'Get contract by ID (routes, cargo scope, unit rates)' })
|
||||
async findOne(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -319,6 +325,7 @@ export class ContractsController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@MixedAudience([])
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({
|
||||
@@ -337,6 +344,7 @@ export class ContractsController {
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@MixedAudience([])
|
||||
@HttpCode(204)
|
||||
@ApiOperation({ summary: 'Soft-delete DRAFT contract' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
@@ -344,6 +352,7 @@ export class ContractsController {
|
||||
}
|
||||
|
||||
@Post(':id/documents')
|
||||
@MixedAudience([])
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Upload intake documents for a contract (DRAFT only)' })
|
||||
@@ -355,18 +364,21 @@ export class ContractsController {
|
||||
}
|
||||
|
||||
@Post(':id/generate-price')
|
||||
@MixedAudience([])
|
||||
@ApiOperation({ summary: 'Generate unit-rate breakdown (no totals at contract phase)' })
|
||||
generatePrice(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.pricingService.generatePrice(id);
|
||||
}
|
||||
|
||||
@Post(':id/submit')
|
||||
@MixedAudience([])
|
||||
@ApiOperation({ summary: 'Customer submit contract (freezes contract_rate_snapshots)' })
|
||||
submit(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.transitionService.submit(id);
|
||||
}
|
||||
|
||||
@Post(':id/confirm-submit')
|
||||
@MixedAudience([])
|
||||
@ApiOperation({ summary: 'Confirm submit after a price change' })
|
||||
confirmSubmit(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.transitionService.confirmSubmit(id);
|
||||
@@ -427,10 +439,7 @@ export class ContractsController {
|
||||
// real boundary: it admits only the approver whose step is currently pending
|
||||
// (edit rights hand off down the chain on each approval).
|
||||
@Put(':id/document/articles')
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.contracts.view,
|
||||
...bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept),
|
||||
])
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.editDocument)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Edit this contract\'s document articles only (per-contract; never touches the six shared templates)',
|
||||
@@ -515,6 +524,7 @@ export class ContractsController {
|
||||
}
|
||||
|
||||
@Post(':id/cancel')
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary: 'Customer cancels their own contract (blocked while a booking is live)',
|
||||
})
|
||||
@@ -591,6 +601,7 @@ export class ContractsController {
|
||||
}
|
||||
|
||||
@Get(':id/contract/view')
|
||||
@MixedAudience(FREIGHT_PERMS.contracts.view)
|
||||
@ApiOperation({ summary: 'Contract PDF view-model + rendered HTML for signing' })
|
||||
async getContractView(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -630,6 +641,7 @@ export class ContractsController {
|
||||
}
|
||||
|
||||
@Get(':id/contract/document')
|
||||
@MixedAudience(FREIGHT_PERMS.contracts.view)
|
||||
@ApiOperation({ summary: 'Download contract PDF' })
|
||||
async downloadContractDocument(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -653,7 +665,7 @@ export class ContractsController {
|
||||
}
|
||||
|
||||
@Post(':id/contract/send-signing-otp')
|
||||
@UseGuards(JwtGuard)
|
||||
@MixedAudience(bothFreightTypes(FREIGHT_PERMS.contracts.signStaff))
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Send the sudo-mode signing OTP to the contract company's registered phone (server picks the number)",
|
||||
@@ -666,7 +678,7 @@ export class ContractsController {
|
||||
}
|
||||
|
||||
@Post(':id/contract/sign')
|
||||
@UseGuards(JwtGuard)
|
||||
@MixedAudience(bothFreightTypes(FREIGHT_PERMS.contracts.signStaff))
|
||||
@ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' })
|
||||
async signContract(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -691,6 +703,7 @@ export class ContractsController {
|
||||
}
|
||||
|
||||
@Post(':id/renew')
|
||||
@PortalCustomer()
|
||||
@ApiOperation({ summary: 'Create a renewal draft linked via renewalOfId' })
|
||||
async renew(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -712,12 +725,14 @@ export class ContractsController {
|
||||
// ── Pre-booking clearance (Path B, doc §15.2.1) ────────────────────────────
|
||||
|
||||
@Get(':id/clearance')
|
||||
@MixedAudience(FREIGHT_PERMS.contracts.view)
|
||||
@ApiOperation({ summary: 'Pre-booking clearance document grid on the contract' })
|
||||
getClearance(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.clearanceService.getClearanceView(id);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/documents')
|
||||
@PortalCustomer()
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Customer uploads clearance documents (fieldname = document key)' })
|
||||
@@ -901,6 +916,7 @@ export class ContractsController {
|
||||
}
|
||||
|
||||
@Post(':id/clearance/duty/dispute')
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Customer disputes the advised duty/tax with a reason — reopens the step so GL Ethiopia can re-advise (repeatable)',
|
||||
@@ -914,6 +930,7 @@ export class ContractsController {
|
||||
}
|
||||
|
||||
@Post(':id/clearance/duty-slip')
|
||||
@PortalCustomer()
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Customer uploads duty/tax payment slip on contract' })
|
||||
@@ -1057,15 +1074,23 @@ export class ContractsController {
|
||||
// ── Booking under contract (Path A customer / Path B GL ET) ────────────────
|
||||
|
||||
@Post(':id/bookings')
|
||||
// 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.
|
||||
@@ -1078,15 +1103,25 @@ export class ContractsController {
|
||||
}
|
||||
|
||||
@Post(':id/bookings/initiate')
|
||||
// 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 },
|
||||
@@ -1096,16 +1131,24 @@ export class ContractsController {
|
||||
}
|
||||
|
||||
@Post(':id/bookings/:bookingId/complete')
|
||||
// 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(
|
||||
@@ -1117,6 +1160,7 @@ export class ContractsController {
|
||||
}
|
||||
|
||||
@Post(':id/validate-shipment')
|
||||
@MixedAudience([FREIGHT_PERMS.contracts.createBooking, FREIGHT_PERMS.contracts.view])
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Pre-create validation + authoritative price preview: full booking price breakdown (rail, first/last mile, surcharges), overweight lines and 20ft weight-pairing errors for a shipment payload (no booking created).',
|
||||
@@ -1124,11 +1168,15 @@ export class ContractsController {
|
||||
validateShipment(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: CreateBookingUnderContractDto,
|
||||
// Completion/resubmit preview: exclude this booking's own persisted
|
||||
// containers from the same-train clash check.
|
||||
@Query('bookingId') bookingId?: string,
|
||||
) {
|
||||
return this.contractBookingService.validateShipment(id, dto);
|
||||
return this.contractBookingService.validateShipment(id, dto, bookingId);
|
||||
}
|
||||
|
||||
@Get(':id/capacity')
|
||||
@MixedAudience(FREIGHT_PERMS.contracts.view)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Remaining bookable quantity per cargo line (GENERAL draw-down cap, or the outstanding remainder of a split ONE_TIME contract)',
|
||||
@@ -1141,12 +1189,14 @@ export class ContractsController {
|
||||
// ── Clearance milestones (doc §11.3, §12.2) ────────────────────────────────
|
||||
|
||||
@Get(':id/milestones')
|
||||
@MixedAudience(FREIGHT_PERMS.contracts.view)
|
||||
@ApiOperation({ summary: 'Pre-booking clearance milestones for a contract cycle' })
|
||||
listContractMilestones(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.milestoneService.listForContract(id);
|
||||
}
|
||||
|
||||
@Get('bookings/:bookingId/milestones')
|
||||
@MixedAudience(FREIGHT_PERMS.contracts.view)
|
||||
@ApiOperation({ summary: 'Post-booking clearance milestones for a shipment booking' })
|
||||
listBookingMilestones(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||
return this.milestoneService.listForBooking(bookingId);
|
||||
@@ -1280,7 +1330,7 @@ export class ContractsController {
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/final-invoice')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.finalInvoiceRaise)
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({
|
||||
@@ -1307,6 +1357,7 @@ export class ContractsController {
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/final-invoice/approve')
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary: 'Customer approves the drafted final invoice — unlocks the payment slip',
|
||||
})
|
||||
@@ -1321,6 +1372,7 @@ export class ContractsController {
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/final-invoice-slip')
|
||||
@PortalCustomer()
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Customer attaches the payment slip for the final invoice' })
|
||||
@@ -1332,10 +1384,7 @@ export class ContractsController {
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/final-invoice/confirm')
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.contracts.clearanceDjActions,
|
||||
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||
])
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.finalInvoiceConfirm)
|
||||
@ApiOperation({ summary: 'GL (ET or DJ) confirms the payment slip — settles the final invoice' })
|
||||
confirmFinalInvoicePaid(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@@ -1378,6 +1427,7 @@ export class ContractsController {
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/second-duty-slip')
|
||||
@PortalCustomer()
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Customer attaches the additional duty/tax payment slip' })
|
||||
@@ -1403,6 +1453,7 @@ export class ContractsController {
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/duty-slip')
|
||||
@PortalCustomer()
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Customer uploads the duty/tax payment slip' })
|
||||
@@ -1419,6 +1470,7 @@ export class ContractsController {
|
||||
}
|
||||
|
||||
@Get('bookings/:bookingId/incidents')
|
||||
@MixedAudience(FREIGHT_PERMS.contracts.view)
|
||||
@ApiOperation({ summary: 'List cargo exception/damage reports for a shipment' })
|
||||
listIncidents(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||
return this.glOperationsService.listIncidents(bookingId);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Controller, Get, UseGuards } from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { PermissionGuard } from "@tria-plc/api-common/modules/auth/services/permission.guard";
|
||||
|
||||
@ApiTags("demo-permissions")
|
||||
@Controller()
|
||||
export class DemoPermissionsController {
|
||||
@Get("test_user1")
|
||||
@ApiOperation({ summary: "Permission demo (can:demo:user1)" })
|
||||
@UseGuards(PermissionGuard(["can:demo:user1"]))
|
||||
testUser1() {
|
||||
return { ok: true, permission: "can:demo:user1" };
|
||||
}
|
||||
|
||||
@Get("test_user2")
|
||||
@ApiOperation({ summary: "Permission demo (can:demo:user2)" })
|
||||
@UseGuards(PermissionGuard(["can:demo:user2"]))
|
||||
testUser2() {
|
||||
return { ok: true, permission: "can:demo:user2" };
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
import { DemoPermissionsController } from "./demo-permissions.controller";
|
||||
|
||||
@Module({
|
||||
controllers: [DemoPermissionsController],
|
||||
})
|
||||
export class DemoPermissionsModule {}
|
||||
@@ -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,
|
||||
|
||||
@@ -14,7 +14,8 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { FreightAdmin } from "../../common/booking-guards";
|
||||
import { BookingStaff } from "../../common/booking-guards";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { CreateDropdownOptionDto } from "./dto/create-dropdown-option.dto";
|
||||
import { CreateDropdownSettingDto } from "./dto/create-dropdown-setting.dto";
|
||||
import { ListDropdownSettingsQueryDto } from "./dto/list-dropdown-settings-query.dto";
|
||||
@@ -58,14 +59,14 @@ export class DropdownSettingsController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@FreightAdmin()
|
||||
@BookingStaff([FREIGHT_PERMS.settings.dropdown.manage, FREIGHT_PERMS.admin])
|
||||
@ApiOperation({ summary: "Create a new dropdown setting" })
|
||||
create(@Body() dto: CreateDropdownSettingDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
@FreightAdmin()
|
||||
@BookingStaff([FREIGHT_PERMS.settings.dropdown.manage, FREIGHT_PERMS.admin])
|
||||
@ApiOperation({ summary: "Update a dropdown setting's metadata" })
|
||||
update(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@@ -75,7 +76,7 @@ export class DropdownSettingsController {
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@FreightAdmin()
|
||||
@BookingStaff([FREIGHT_PERMS.settings.dropdown.manage, FREIGHT_PERMS.admin])
|
||||
@ApiOperation({ summary: "Soft-delete a dropdown setting" })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
remove(@Param("id", ParseUUIDPipe) id: string) {
|
||||
@@ -85,7 +86,7 @@ export class DropdownSettingsController {
|
||||
/* ------------------------- option routes ------------------------- */
|
||||
|
||||
@Put(":id/options")
|
||||
@FreightAdmin()
|
||||
@BookingStaff([FREIGHT_PERMS.settings.dropdown.manage, FREIGHT_PERMS.admin])
|
||||
@ApiOperation({ summary: "Replace the full option list for a setting" })
|
||||
replaceOptions(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@@ -95,7 +96,7 @@ export class DropdownSettingsController {
|
||||
}
|
||||
|
||||
@Post(":id/options")
|
||||
@FreightAdmin()
|
||||
@BookingStaff([FREIGHT_PERMS.settings.dropdown.manage, FREIGHT_PERMS.admin])
|
||||
@ApiOperation({ summary: "Append a single option to a setting" })
|
||||
addOption(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@@ -105,7 +106,7 @@ export class DropdownSettingsController {
|
||||
}
|
||||
|
||||
@Patch("options/:optionId")
|
||||
@FreightAdmin()
|
||||
@BookingStaff([FREIGHT_PERMS.settings.dropdown.manage, FREIGHT_PERMS.admin])
|
||||
@ApiOperation({ summary: "Update a single option" })
|
||||
updateOption(
|
||||
@Param("optionId", ParseUUIDPipe) optionId: string,
|
||||
@@ -115,7 +116,7 @@ export class DropdownSettingsController {
|
||||
}
|
||||
|
||||
@Delete("options/:optionId")
|
||||
@FreightAdmin()
|
||||
@BookingStaff([FREIGHT_PERMS.settings.dropdown.manage, FREIGHT_PERMS.admin])
|
||||
@ApiOperation({ summary: "Soft-delete a single option" })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
removeOption(@Param("optionId", ParseUUIDPipe) optionId: string) {
|
||||
|
||||
@@ -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);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user