Merge branch 'dev' into alpha

This commit is contained in:
Abubeker Yasin
2026-08-10 14:38:44 +03:00
439 changed files with 42473 additions and 6778 deletions

View File

@@ -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

View File

@@ -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:*",

View File

@@ -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();

View File

@@ -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) =>

View File

@@ -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);
});
});

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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);
});
});

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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(

View File

@@ -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);

View File

@@ -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}`,
);
}
}
}

View File

@@ -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,
}),
);

View File

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

View File

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

View File

@@ -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`);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 `![caption](minio:<key>)`, 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 `![${item.caption ?? ""}](${target})`;
}
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
}
}

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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',
})

View File

@@ -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 });
}

View File

@@ -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,

View File

@@ -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(

View File

@@ -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);

View File

@@ -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],

View File

@@ -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,
});
});
});

View File

@@ -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),

View File

@@ -0,0 +1,287 @@
import {
EimsMapperContext,
EimsMapperInvoice,
EimsSellerDetails,
formatEimsDate,
toEimsInvoice,
} from "./eims-invoice.mapper";
const seller: EimsSellerDetails = {
City: null,
Email: "finance@edr.et",
HouseNumber: null,
LegalName: "Ethio-Djibouti Railway S.C.",
Locality: null,
Phone: "0911223344",
Region: "13",
SubCity: null,
Tin: "0016324478",
VatNumber: "3215840010",
Wereda: "574",
};
const invoice = (over: Partial<EimsMapperInvoice> = {}): EimsMapperInvoice => ({
invoiceNumber: "INV-20260807-00042",
currency: "ETB",
issuedAt: new Date(2026, 7, 7, 9, 5, 3),
totalAmount: "11000.00",
company: {
name: "ABC Trading PLC",
tin: "0999930000",
vatNumber: "123475885858",
phone: "0912345678",
email: "buyer@abc.et",
region: "13",
zone: "SHA",
woreda: "574",
kebele: "03",
houseNo: "NEW",
country: "Ethiopia",
},
lines: [
{ chargeType: "RAIL_FREIGHT", description: "Addis → Djibouti", quantity: "1.00", unitRate: "10000.00", amount: "10000.00" },
{ chargeType: "HAZARD_SURCHARGE", description: null, quantity: "2.00", unitRate: "500.00", amount: "1000.00", metadata: { unit: "CTR" } },
],
...over,
});
const context = (over: Partial<EimsMapperContext> = {}): EimsMapperContext => ({
systemNumber: "B0360154BA",
systemType: "SYS",
documentNumber: "24",
invoiceCounter: 7,
previousIrn: "",
cashierName: null,
salesPersonName: null,
transactionType: "B2B",
payment: { mode: "CASH", term: "IMMIDIATE" },
taxForLine: () => ({ code: "VAT15", ratePercent: 15, exciseTaxValue: 0 }),
natureOfSupplies: "Service",
unitDefault: "PCS",
incomeWithholdValue: 0,
transactionWithholdValue: 0,
buyerRegionCodes: { "Addis Ababa": "13" },
buyerWeredaCodes: {},
...over,
});
describe("toEimsInvoice", () => {
it("emits the ten EIMS sections with the collection's field names", () => {
const doc = toEimsInvoice(invoice(), seller, context());
expect(Object.keys(doc)).toEqual([
"BuyerDetails",
"DocumentDetails",
"ItemList",
"PaymentDetails",
"ReferenceDetails",
"SellerDetails",
"SourceSystem",
"TransactionType",
"ValueDetails",
"Version",
]);
expect(doc.Version).toBe("1");
expect(doc.DocumentDetails).toEqual({ DocumentNumber: "24", Date: "07-08-2026T09:05:03", Type: "INV" });
expect(doc.SourceSystem.InvoiceCounter).toBe(7);
expect(doc.SellerDetails).toBe(seller);
});
it("maps the buyer from the company row and leaves unmodelled fields null", () => {
const doc = toEimsInvoice(invoice(), seller, context());
expect(doc.BuyerDetails).toEqual({
City: null,
Email: "buyer@abc.et",
HouseNumber: "NEW",
IdNumber: null,
IdType: null,
Tin: "0999930000",
LegalName: "ABC Trading PLC",
Phone: "0912345678",
Region: "13",
Country: null,
Zone: "SHA",
Kebele: "03",
VatNumber: "123475885858",
Wereda: "574",
});
});
it("applies per-line tax and totals it into ValueDetails", () => {
const doc = toEimsInvoice(
invoice(),
seller,
context({
taxForLine: (line) =>
line.chargeType === "RAIL_FREIGHT"
? { code: "VAT15", ratePercent: 15, exciseTaxValue: 0 }
: { code: "EXEMPT", ratePercent: 0, exciseTaxValue: 50 },
}),
);
expect(doc.ItemList[0]).toMatchObject({
LineNumber: 1,
ItemCode: "RAIL_FREIGHT",
ProductDescription: "Addis → Djibouti",
Quantity: 1,
UnitPrice: 10000,
PreTaxValue: 10000,
TaxCode: "VAT15",
TaxAmount: 1500,
ExciseTaxValue: 0,
TotalLineAmount: 11500,
Unit: "PCS",
NatureOfSupplies: "service",
HarmonizationCode: null,
});
expect(doc.ItemList[1]).toMatchObject({
LineNumber: 2,
ProductDescription: "HAZARD_SURCHARGE",
TaxCode: "EXEMPT",
TaxAmount: 0,
ExciseTaxValue: 50,
TotalLineAmount: 1050,
Unit: "CTR",
});
expect(doc.ValueDetails).toEqual({
Discount: null,
ExciseValue: 50,
IncomeWithholdValue: 0,
TaxValue: 1500,
TotalValue: 12550,
TransactionWithholdValue: 0,
InvoiceCurrency: "ETB",
});
});
it("passes PreviousIrn through verbatim and defaults RelatedDocument to null", () => {
expect(toEimsInvoice(invoice(), seller, context()).ReferenceDetails).toEqual({
PreviousIrn: "",
RelatedDocument: null,
});
expect(
toEimsInvoice(invoice(), seller, context({ previousIrn: null, relatedDocument: "CN-9" }))
.ReferenceDetails,
).toEqual({ PreviousIrn: null, RelatedDocument: "CN-9" });
});
it("emits ExchangeRate only when supplied", () => {
expect(toEimsInvoice(invoice(), seller, context()).ValueDetails.ExchangeRate).toBeUndefined();
const usd = toEimsInvoice(
invoice({ currency: "USD" }),
seller,
context({ exchangeRate: 132.5 }),
);
expect(usd.ValueDetails).toMatchObject({ InvoiceCurrency: "USD", ExchangeRate: 132.5 });
});
it("honours a caller-supplied date formatter", () => {
const doc = toEimsInvoice(invoice(), seller, context({ formatDate: () => "2026-08-07T09:05:03Z" }));
expect(doc.DocumentDetails.Date).toBe("2026-08-07T09:05:03Z");
});
it("throws when tax treatment cannot be resolved for a line", () => {
expect(() =>
toEimsInvoice(
invoice(),
seller,
context({ taxForLine: () => ({ code: "", ratePercent: 15, exciseTaxValue: 0 }) }),
),
).toThrow(/unresolved tax treatment for line 1/);
});
it("throws on a missing buyer TIN, no lines, or an unissued invoice", () => {
expect(() => toEimsInvoice(invoice({ company: null }), seller, context())).toThrow(/buyer company TIN/);
expect(() => toEimsInvoice(invoice({ lines: [] }), seller, context())).toThrow(/has no lines/);
expect(() => toEimsInvoice(invoice({ issuedAt: null }), seller, context())).toThrow(/not issued/);
});
it("throws when the lines do not sum to the invoice total", () => {
expect(() => toEimsInvoice(invoice({ totalAmount: "9000.00" }), seller, context())).toThrow(
/lines sum to 11000 but the invoice total is 9000/,
);
});
it("throws on a non-ETB invoice with no exchange rate", () => {
expect(() => toEimsInvoice(invoice({ currency: "USD" }), seller, context())).toThrow(/needs an exchangeRate/);
});
});
describe("toEimsInvoice — MoR field constraints", () => {
it("passes a buyer region through when it is already a MoR code", () => {
const doc = toEimsInvoice(invoice(), seller, context());
expect(doc.BuyerDetails.Region).toBe("13");
});
it("maps a region name to its code, ignoring case and spacing", () => {
const doc = toEimsInvoice(
invoice({ company: { ...invoice().company!, region: " addis ababa " } }),
seller,
context({ buyerRegionCodes: { "Addis Ababa": "13" } }),
);
expect(doc.BuyerDetails.Region).toBe("13");
});
it("refuses to file a buyer whose region has no mapping", () => {
expect(() =>
toEimsInvoice(
invoice({ company: { ...invoice().company!, region: "Somewhere Else" } }),
seller,
context(),
),
).toThrow(/not a MoR Region code and has no mapping/);
});
it("refuses a buyer with no region at all rather than guessing one", () => {
expect(() =>
toEimsInvoice(
invoice({ company: { ...invoice().company!, region: null } }),
seller,
context(),
),
).toThrow(/buyer Region \(unset\)/);
});
it("passes a buyer wereda through when it is already a MoR code", () => {
const doc = toEimsInvoice(invoice(), seller, context());
expect(doc.BuyerDetails.Wereda).toBe("574");
});
it("maps a wereda name to its code", () => {
const doc = toEimsInvoice(
invoice({ company: { ...invoice().company!, woreda: "Yeka" } }),
seller,
context({ buyerWeredaCodes: { Yeka: "99" } }),
);
expect(doc.BuyerDetails.Wereda).toBe("99");
});
it("refuses to file a buyer whose wereda has no mapping", () => {
expect(() =>
toEimsInvoice(
invoice({ company: { ...invoice().company!, woreda: "Yeka" } }),
seller,
context({ buyerWeredaCodes: {} }),
),
).toThrow(/buyer Wereda "Yeka".*EIMS_BUYER_WEREDA_CODES/);
});
it("emits NatureOfSupplies lowercase, whatever case it was configured in", () => {
const doc = toEimsInvoice(invoice(), seller, context({ natureOfSupplies: "Service" }));
expect(doc.ItemList[0].NatureOfSupplies).toBe("service");
});
it("rejects a NatureOfSupplies MoR does not accept", () => {
expect(() =>
toEimsInvoice(invoice(), seller, context({ natureOfSupplies: "Services" })),
).toThrow(/must be one of goods, service/);
});
});
describe("formatEimsDate", () => {
it("renders the observed dd-MM-yyyyTHH:mm:ss shape with zero padding", () => {
expect(formatEimsDate(new Date(2025, 2, 21, 0, 0, 0))).toBe("21-03-2025T00:00:00");
});
});

View File

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

View File

@@ -1,6 +1,7 @@
import { BaseEntity } from "@edr/api-common";
import { 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;
}

View File

@@ -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) {}

View File

@@ -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})`,

View File

@@ -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],
});
});
});

View File

@@ -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`,
},

View File

@@ -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,

View File

@@ -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

View File

@@ -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');
}
}

View File

@@ -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 12 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);

View File

@@ -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 { }

View File

@@ -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) {

View File

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

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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) {}

View File

@@ -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)" })

View File

@@ -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);
});
});

View File

@@ -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")] });

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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()

View File

@@ -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. */

View File

@@ -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 =

View File

@@ -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

View File

@@ -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);

View File

@@ -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) {}

View File

@@ -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) {}

View File

@@ -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

View File

@@ -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,

View File

@@ -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> {

View File

@@ -1,4 +1,9 @@
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { randomUUID } from "node:crypto";
import { 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));
}

View File

@@ -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()

View File

@@ -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;
}

View File

@@ -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();
});
});

View File

@@ -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) =>

View File

@@ -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],
});
});
});

View File

@@ -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',

View File

@@ -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 },
},

View File

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

View File

@@ -424,6 +424,8 @@ export class ContractTransitionService {
contract.tradeDirection,
contract.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 {

View File

@@ -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);

View File

@@ -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();

View File

@@ -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" };
}
}

View File

@@ -1,8 +0,0 @@
import { Module } from "@nestjs/common";
import { DemoPermissionsController } from "./demo-permissions.controller";
@Module({
controllers: [DemoPermissionsController],
})
export class DemoPermissionsModule {}

View File

@@ -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,

View File

@@ -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) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,89 @@
import { EimsErrorResponse } from "./eims.types";
/**
* Registration state of one invoice at MoR EIMS.
*
* `UNKNOWN` is not a synonym for failure: the request left this process and no answer came back,
* so the invoice may or may not be registered at the gateway. It is never auto-retried — a resend
* would risk a duplicate registration.
*/
export enum EimsInvoiceStatus {
NotSubmitted = "NOT_SUBMITTED",
Submitting = "SUBMITTING",
Registered = "REGISTERED",
Failed = "FAILED",
Unknown = "UNKNOWN",
}
/** `body` of a successful `POST /v1/register`, as observed in the collection. */
export interface EimsRegisterResponseBody {
irn: string;
ackDate?: string;
signedQR?: string;
signedInvoice?: string;
status?: string;
documentNumber?: string;
errorMessage?: string | null;
}
export interface EimsRegisterResponse {
statusCode?: number;
message?: string;
body?: EimsRegisterResponseBody;
}
/**
* Inner request of `POST /v1/verify`. The wire property is lowercase `irn` and is required —
* omitting it yields a 400 "SCHEMA ERROR" reporting `$: required property 'irn' not found`.
*/
export interface EimsVerifyRequest {
irn: string;
}
/**
* `body` of a successful `POST /v1/verify` — the stored document echoed back. Note the casing
* flip against the request: the response spells the reference `Irn`.
*
* Only the fields we actually assert on are typed; the rest of the echoed document (SellerDetails,
* BuyerDetails, ItemList, …) is carried through untyped because nothing here reads it.
*/
export interface EimsVerifyResponseBody {
Irn?: string;
TransactionType?: string;
DocumentDetails?: {
Type?: string;
DocumentNumber?: string;
Date?: string;
};
Version?: string;
[section: string]: unknown;
}
export interface EimsVerifyResponse {
statusCode?: number;
message?: string;
body?: EimsVerifyResponseBody;
}
/** Persisted failure detail. Carries the gateway's own error fields only — never our envelope. */
export interface EimsInvoiceError {
kind: string;
message: string;
httpStatus?: number;
details?: EimsErrorResponse;
at: string;
}
/** What the status endpoint returns, and what a later invoice-detail panel will render. */
export interface EimsInvoiceStatusView {
invoiceId: string;
invoiceNumber: string;
eimsStatus: EimsInvoiceStatus;
eimsIrn: string | null;
/** The numeric DocumentNumber filed with MoR; not our own invoiceNumber. */
eimsDocumentNumber: string | null;
eimsInvoiceCounter: number | null;
eimsSubmittedAt: Date | null;
eimsAckDate: string | null;
eimsLastError: EimsInvoiceError | null;
}

View File

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

View File

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

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