diff --git a/.gitignore b/.gitignore
index b9dc16c3c..18fdae9c2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -49,3 +49,6 @@ test-results/
playwright-report/
blob-report/
RUNNING_LOCALLY.md
+
+# Generated per-shard compose file for the integration suite (it.mjs).
+integration/.it-shards.yaml
diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example
index 9338a958f..c579eb388 100644
--- a/apps/edr-freight-api/.env.example
+++ b/apps/edr-freight-api/.env.example
@@ -98,10 +98,10 @@ FAYDA_PRIVATE_KEY_BASE64=
# OAuth redirect_uri for MOBILE clients (must be registered with eSignet)
FAYDA_REDIRECT_URI=http://localhost:3001/api/fayda/verification/complete
# OAuth redirect_uri for WEB clients. Defaults to FAYDA_REDIRECT_URI when unset.
-FAYDA_WEB_REDIRECT_URI=http://localhost:3000/callback
+FAYDA_WEB_REDIRECT_URI=http://localhost:3000/fayda/callback
# OAuth redirect_uri for the customer portal (its own origin — must also be
# registered with eSignet). Defaults to FAYDA_WEB_REDIRECT_URI when unset.
-FAYDA_PORTAL_REDIRECT_URI=http://localhost:5173/callback
+FAYDA_PORTAL_REDIRECT_URI=http://localhost:5173/fayda/callback
CLIENT_ASSERTION_TYPE=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
FAYDA_SCOPE=openid profile email phone address
FAYDA_ACR_VALUES=mosip:idp:acr:generated-code
diff --git a/apps/edr-freight-api/docs/FREIGHT_MASTER_FLOW.md b/apps/edr-freight-api/docs/FREIGHT_MASTER_FLOW.md
index ea68e6938..98657843c 100644
--- a/apps/edr-freight-api/docs/FREIGHT_MASTER_FLOW.md
+++ b/apps/edr-freight-api/docs/FREIGHT_MASTER_FLOW.md
@@ -21,7 +21,7 @@ flowchart TD
S0(["Customer visits portal"]):::start
S0 --> S1["Signup via IAM GET /auth/check-availability @Public POST /otp/send + /otp/verify (P)"]:::port
S1 --> S2{"Identity proofing (VeriFayda)?"}:::dec
- S2 -->|"Yes"| S3["POST /fayda/verification/start → /callback → /complete upsert iam.users (verified_by=fayda) (P)"]:::port
+ S2 -->|"Yes"| S3["POST /fayda/verification/start → /fayda/callback → /complete upsert iam.users (verified_by=fayda) (P)"]:::port
S2 -->|"No"| S4
S3 --> S4["POST /companies/onboarding/start draft company (placeholder TIN, PENDING) (P)"]:::port
S4 --> S4b["Wizard: PATCH /profile, /onboarding-step, upload license + docs GET /onboarding/requirements (P)"]:::port
diff --git a/apps/edr-freight-api/docs/FREIGHT_SYSTEM_FLOW.md b/apps/edr-freight-api/docs/FREIGHT_SYSTEM_FLOW.md
index a6fd8bbeb..b29763060 100644
--- a/apps/edr-freight-api/docs/FREIGHT_SYSTEM_FLOW.md
+++ b/apps/edr-freight-api/docs/FREIGHT_SYSTEM_FLOW.md
@@ -131,7 +131,7 @@ sequenceDiagram
`HasActiveDelegationGuard` as **global `APP_GUARD`s** — *every* route is JWT-protected unless it
carries `@Public()`. Fine-grained `FreightPermissionGuard([perm])` decorators add permission checks
on staff routes. Explicitly **public** endpoints: `GET /api/files/:fileId`, `POST /api/otp/{send,verify}`,
-`GET /api/auth/check-availability`, the `fayda/verification/*` + `/callback` endpoints,
+`GET /api/auth/check-availability`, the `fayda/verification/*` + `/fayda/callback` endpoints,
`GET /api/payments/{checkout,receipt/:orderId}`, and the service-to-service `POST /api/internal/payments/mark-paid`.
Real login / JWT issuance lives in the **external IAM package**, not this repo. (Note: `@edr/api-common`'s
`@Public` and `@tria-plc/api-common`'s `@IsPublic` both set the same `"isPublic"` metadata key the guard reads.)
@@ -288,7 +288,7 @@ flowchart TD
chk --> otp["POST /otp/send + /otp/verify (P) @Public"]
otp --> fayda{"Identity proofing?"}
fayda -->|"VeriFayda 2.0"| fstart["POST /fayda/verification/start → eSignet authorize URL"]
- fstart --> fcb["Fayda redirect → GET /callback (ack) → GET /fayda/verification/complete (PKCE code exchange → upsert iam.users)"]
+ fstart --> fcb["Fayda redirect → GET /fayda/callback (ack) → GET /fayda/verification/complete (PKCE code exchange → upsert iam.users)"]
fcb --> onb
fayda -->|"skip"| onb
@@ -313,7 +313,7 @@ drives the required document set. Booking guards elsewhere `403` if the acting p
| POST | `/api/fayda/verification/start` | start eSignet session (PKCE) | `@Public` + OptionalJwt | (B) verifayda.service |
| GET | `/api/fayda/verification/complete` | code→identity, upsert `iam.users` | `@Public` | (B) verifayda.service |
| GET | `/api/fayda/verification/status` | current user's Fayda link | JwtGuard | — |
-| GET | `/callback` | passive Fayda redirect ack (no `/api`) | `@Public` | popup postMessage |
+| GET | `/fayda/callback` | passive Fayda redirect ack (no `/api`) | `@Public` | popup postMessage |
| GET·PUT | `/api/me/signature` | reusable signature (MinIO, base64) | JwtGuard | (P)(B) signatures.service |
| GET | `/api/test_user1` · `/api/test_user2` | permission-guard demo | `PermissionGuard` | (B) demo pages |
| GET | `/api/companies/getInfo` · `/profile` · `/dashboard` | company info / KPIs | JwtGuard | (P) companies.service |
diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts
index 0e7949d7e..73e19709e 100644
--- a/apps/edr-freight-api/src/app.module.ts
+++ b/apps/edr-freight-api/src/app.module.ts
@@ -46,6 +46,7 @@ import { NotificationInboxModule } from "./modules/notification-inbox/notificati
import { SupportChatModule } from "./modules/support-chat/support-chat.module";
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
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 { OtpModule } from "./modules/otp/otp.module";
import { HealthModule } from "./modules/health/health.module";
@@ -192,6 +193,7 @@ import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middlewar
SupportChatModule,
FileUploadSettingsModule,
DropdownSettingsModule,
+ ExchangeSettingsModule,
ContractTemplatesModule,
OtpModule,
HealthModule,
diff --git a/apps/edr-freight-api/src/config/app.config.ts b/apps/edr-freight-api/src/config/app.config.ts
index 050b07145..8e03ac3f0 100644
--- a/apps/edr-freight-api/src/config/app.config.ts
+++ b/apps/edr-freight-api/src/config/app.config.ts
@@ -24,12 +24,13 @@ export default registerAs("app", () => ({
},
// Consumed by @edr/api-common ExchangeModule.forRootAsync (see bookings.module.ts).
cbeExchange: {
- /** ethio.forex CBET page — scraped for USD buying/selling rates. */
+ /** CBE daily-exchange-rates JSON — USD `transactionalSelling` is used. */
scrapeUrl:
process.env.CBE_EXCHANGE_SCRAPE_URL ??
process.env.CBE_EXCHANGE_API_URL ??
- "https://ethio.forex/bank/CBET",
- fallbackRate: numberFromEnv("CBE_EXCHANGE_FALLBACK_RATE", 130),
+ "https://combanketh.et/cbeapi/daily-exchange-rates/?_limit=1&_sort=Date%3ADESC",
+ // No fallback env var: the fallback lives in freight.exchange_settings,
+ // maintained by the backoffice and by write-back on every successful fetch.
cacheTtlMs: numberFromEnv("CBE_EXCHANGE_CACHE_TTL_MS", 3_600_000),
},
}));
diff --git a/apps/edr-freight-api/src/contracts/contract-article.util.ts b/apps/edr-freight-api/src/contracts/contract-article.util.ts
index 92bef9dd3..ee7978e08 100644
--- a/apps/edr-freight-api/src/contracts/contract-article.util.ts
+++ b/apps/edr-freight-api/src/contracts/contract-article.util.ts
@@ -3,7 +3,10 @@ import Handlebars from 'handlebars';
/** One numbered clause of a dynamic article, with optional nested bullets. */
export interface RenderedClause {
text: string;
- /** Computed outline number, e.g. "3" or "2.1.4". */
+ /**
+ * Computed outline marker for this clause at its own level: "3" at depth 1,
+ * "b" at depth 2, "iv" at depth 3, cycling back to arabic at depth 4.
+ */
number: string;
/** Nesting level: 1 = clause, 2 = sub-clause (x.y), 3 = x.y.z, … */
depth: number;
@@ -35,6 +38,51 @@ const CLAUSE_NUMBER_RE = /^(?:(\d+(?:\.\d+)+)[.)]?|(\d+)[.)])(?:\s+|$)/;
/** Deepest supported sub-clause level (1.1.1.1.1.1). */
const MAX_CLAUSE_DEPTH = 6;
+/** 1 → "a", 2 → "b", … 27 → "aa". */
+function toAlpha(n: number): string {
+ let out = '';
+ let value = n;
+ while (value > 0) {
+ const rem = (value - 1) % 26;
+ out = String.fromCharCode(97 + rem) + out;
+ value = Math.floor((value - 1) / 26);
+ }
+ return out || 'a';
+}
+
+const ROMAN: Array<[number, string]> = [
+ [1000, 'm'], [900, 'cm'], [500, 'd'], [400, 'cd'],
+ [100, 'c'], [90, 'xc'], [50, 'l'], [40, 'xl'],
+ [10, 'x'], [9, 'ix'], [5, 'v'], [4, 'iv'], [1, 'i'],
+];
+
+/** 1 → "i", 4 → "iv", 9 → "ix". */
+function toRoman(n: number): string {
+ let value = n;
+ let out = '';
+ for (const [amount, numeral] of ROMAN) {
+ while (value >= amount) {
+ out += numeral;
+ value -= amount;
+ }
+ }
+ return out || 'i';
+}
+
+/**
+ * Word-processor outline markers, cycling by depth the way Quill's own list
+ * rendering does: 1. → a. → i. → 1. … Depth 1 keeps plain arabic numerals so
+ * top-level clauses read as "1.", "2." in the contract; the marker is the
+ * clause's own counter at its level, NOT a dotted path — "a" under clause 2 is
+ * "a", not "2.a".
+ */
+export function clauseMarker(counter: number, depth: number): string {
+ const style = (depth - 1) % 3;
+ if (style === 1) return toAlpha(counter);
+ if (style === 2) return toRoman(counter);
+ return String(counter);
+}
+
/**
* Parse a template article body into clauses. Format: one clause per line.
* A leading outline number ("2. ", "2.1 ", "2.1.3 ") nests the line as a
@@ -82,7 +130,7 @@ export function parseArticleBody(body: string): Pick s.containerSize ?? '').filter(Boolean)),
+ ].join(', ');
+ const cargoTypeName = [
+ ...new Set(
+ scope
+ .map((s) => s.cargoType?.cargoTypeName ?? s.cargoFreeText ?? '')
+ .filter(Boolean),
+ ),
+ ].join(', ');
+ const cargoSummary = scope
+ .map((s) => {
+ const name = s.cargoType?.cargoTypeName ?? s.cargoFreeText ?? null;
+ const size = s.containerSize ? `(${s.containerSize})` : null;
+ const cap = s.quantityCap ? `× ${Number(s.quantityCap)}` : null;
+ return [name, size, cap].filter(Boolean).join(' ');
+ })
+ .filter(Boolean)
+ .join('; ');
+
return {
originLabel: this.yardLabel(firstRoute?.originYard),
destinationLabel: this.yardLabel(firstRoute?.destinationYard),
@@ -302,6 +327,9 @@ export class ContractDocumentViewModelBuilder {
scheduledDate: this.formatDate(null),
contractType: this.valueOrDash(contract.contractType),
cargoDescription: this.valueOrDash(cargoName),
+ cargoTypeName: this.valueOrDash(cargoTypeName),
+ containerType: this.valueOrDash(containerType),
+ cargoSummary: this.valueOrDash(cargoSummary),
totalWeightVgm: '—',
equipmentReturn: this.valueOrDash(contract.equipmentReturn),
// A hazardous contract names the declared class + UN number on the
diff --git a/apps/edr-freight-api/src/contracts/contract-dynamic-template.spec.ts b/apps/edr-freight-api/src/contracts/contract-dynamic-template.spec.ts
index 765c41142..6cefe6423 100644
--- a/apps/edr-freight-api/src/contracts/contract-dynamic-template.spec.ts
+++ b/apps/edr-freight-api/src/contracts/contract-dynamic-template.spec.ts
@@ -27,18 +27,32 @@ describe('parseArticleBody', () => {
expect(parsed.clauses).toEqual([]);
});
- it('nests numbered sub-clauses by their outline token and renumbers sequentially', () => {
+ it('nests sub-clauses by outline token and marks each level 1. → a. → i.', () => {
const parsed = parseArticleBody(
'1. Scope\n5.1 Rail transport\n1.1.1 Wagon supply\n2. Payment',
);
expect(parsed.clauses.map((c) => [c.number, c.depth, c.text])).toEqual([
['1', 1, 'Scope'],
- ['1.1', 2, 'Rail transport'],
- ['1.1.1', 3, 'Wagon supply'],
+ ['a', 2, 'Rail transport'],
+ ['i', 3, 'Wagon supply'],
['2', 1, 'Payment'],
]);
});
+ it('cycles markers back to arabic at depth 4 and counts each level on its own', () => {
+ const parsed = parseArticleBody(
+ '1. One\n1.1 Alpha\n1.2 Beta\n1.2.1 Roman one\n1.2.2 Roman two\n1.2.2.1 Deep',
+ );
+ expect(parsed.clauses.map((c) => [c.number, c.depth])).toEqual([
+ ['1', 1],
+ ['a', 2],
+ ['b', 2],
+ ['i', 3],
+ ['ii', 3],
+ ['1', 4],
+ ]);
+ });
+
it('clamps a sub-clause with no open parent to the next available level', () => {
const parsed = parseArticleBody('1.1.1 Orphan sub-clause\nSecond clause.');
expect(parsed.clauses.map((c) => [c.number, c.depth])).toEqual([
@@ -90,6 +104,8 @@ describe('dynamic template rendering (edr-dynamic.hbs)', () => {
template: { ...meta, title: 'Bulk Import Contract', templateFile: 'edr-dynamic.hbs' },
contractDate: '1 January 2026',
contractYear: 2026,
+ contractStartDate: '1 January 2026',
+ contractEndDate: '31 December 2026',
client: {
companyName: 'Abyssinia Trading PLC',
companyAddress: 'Bole Sub-city, Addis Ababa',
@@ -117,6 +133,9 @@ describe('dynamic template rendering (edr-dynamic.hbs)', () => {
scheduledDate: '—',
contractType: 'GENERAL',
cargoDescription: 'Steel billets',
+ cargoTypeName: 'Steel billets',
+ containerType: '—',
+ cargoSummary: 'Steel billets × 2,800',
totalWeightVgm: '—',
equipmentReturn: '—',
hazardousLabel: 'No',
@@ -192,6 +211,42 @@ describe('dynamic template rendering (edr-dynamic.hbs)', () => {
expect(html).toContain('#1b9e7a');
});
+ it('shows the contract validity window in the commercial schedule annex', () => {
+ const html = renderer.render(dynamicView());
+ expect(html).toContain('Valid from');
+ expect(html).toContain('Valid until');
+ expect(html).toContain('1 January 2026');
+ expect(html).toContain('31 December 2026');
+ });
+
+ it('interpolates the start/end date placeholders inside article text', () => {
+ const view = dynamicView();
+ expect(
+ interpolateTemplateText(
+ 'In force {{contractStartDate}} to {{contractEndDate}}.',
+ view,
+ ),
+ ).toBe('In force 1 January 2026 to 31 December 2026.');
+ });
+
+ it('shows cargo type and container type in the commercial schedule annex', () => {
+ const html = renderer.render(dynamicView());
+ expect(html).toContain('Cargo type');
+ expect(html).toContain('Container type');
+ expect(html).toContain('Cargo scope');
+ expect(html).toContain('Steel billets × 2,800');
+ });
+
+ it('interpolates the cargo/container placeholders inside article text', () => {
+ const view = dynamicView();
+ const body =
+ 'Cargo: {{schedule.cargoTypeName}} in {{schedule.containerType}} ' +
+ '({{schedule.freightType}}). Scope: {{schedule.cargoSummary}}.';
+ expect(interpolateTemplateText(body, view)).toBe(
+ 'Cargo: Steel billets in — (BULK). Scope: Steel billets × 2,800.',
+ );
+ });
+
it('renders the live rate schedule lane under the pricing article', () => {
const html = renderer.render(dynamicView());
expect(html).toContain('Rate Schedule');
diff --git a/apps/edr-freight-api/src/contracts/contract-renderer.service.spec.ts b/apps/edr-freight-api/src/contracts/contract-renderer.service.spec.ts
index b0fc4c8ef..fc7e66e5b 100644
--- a/apps/edr-freight-api/src/contracts/contract-renderer.service.spec.ts
+++ b/apps/edr-freight-api/src/contracts/contract-renderer.service.spec.ts
@@ -16,6 +16,8 @@ describe('ContractRendererService', () => {
template,
contractDate: '1 January 2026',
contractYear: 2026,
+ contractStartDate: '1 January 2026',
+ contractEndDate: '31 December 2026',
client: {
companyName: 'Test Co',
companyAddress: 'Addis Ababa',
@@ -43,6 +45,9 @@ describe('ContractRendererService', () => {
scheduledDate: '1 January 2026',
contractType: 'NEW',
cargoDescription: 'Container cargo',
+ cargoTypeName: 'Coffee',
+ containerType: '40ft',
+ cargoSummary: 'Coffee (40ft) × 12',
totalWeightVgm: '24 tons',
equipmentReturn: 'RETURN',
hazardousLabel: 'No',
diff --git a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts
index 07f4d25d3..d1158d035 100644
--- a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts
+++ b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts
@@ -41,6 +41,13 @@ export interface ContractViewModel {
template: ContractTemplateMeta;
contractDate: string;
contractYear: number;
+ /**
+ * The contract's validity window (`contract_valid_from` / `_until`). Distinct
+ * from `contractDate`, which is the day the document is generated — these are
+ * the dates the contract is actually in force between. "—" when unset.
+ */
+ contractStartDate: string;
+ contractEndDate: string;
client: {
companyName: string;
companyAddress: string;
@@ -68,6 +75,16 @@ export interface ContractViewModel {
scheduledDate: string;
contractType: string;
cargoDescription: string;
+ /**
+ * The named cargo type on its own (e.g. "Coffee"), separate from
+ * `cargoDescription` which folds in free text and a container fallback.
+ * Lets a clause name the commodity without the surrounding prose.
+ */
+ cargoTypeName: string;
+ /** Container size alone, e.g. "20ft" / "40ft"; "—" for bulk. */
+ containerType: string;
+ /** Every cargo line on the contract, e.g. "Coffee (40ft) × 12". */
+ cargoSummary: string;
totalWeightVgm: string;
equipmentReturn: string;
hazardousLabel: string;
@@ -133,6 +150,8 @@ export class ContractViewModelBuilder {
year: 'numeric',
}),
contractYear: new Date().getFullYear(),
+ contractStartDate: this.formatDate(booking.contractValidFrom),
+ contractEndDate: this.formatDate(booking.contractValidUntil),
client: {
companyName: booking.company?.name ?? 'Client',
companyAddress: this.valueOrDash(booking.company?.address),
@@ -195,6 +214,21 @@ export class ContractViewModelBuilder {
'Bulk commodity'
: booking.cargoType?.cargoTypeName || 'Container cargo';
const totalWeight = Number(booking.cargoTotalWeightVgm || 0);
+ // A booking may carry both sizes; name each one once, in the order booked.
+ const containerType = [
+ ...new Set(
+ (booking.bookingContainers ?? [])
+ .map(
+ (line) =>
+ line.containerType?.label ??
+ (line.containerType?.sizeFt
+ ? `${line.containerType.sizeFt}ft`
+ : line.containerSize) ??
+ '',
+ )
+ .filter(Boolean),
+ ),
+ ].join(', ');
return {
originLabel: this.yardLabel(booking.originYard),
@@ -207,6 +241,13 @@ export class ContractViewModelBuilder {
scheduledDate: this.formatDate(booking.scheduledDate),
contractType: this.valueOrDash(booking.contractType),
cargoDescription: this.valueOrDash(cargoName),
+ cargoTypeName: this.valueOrDash(booking.cargoType?.cargoTypeName),
+ containerType: this.valueOrDash(containerType),
+ cargoSummary: this.valueOrDash(
+ [cargoName, containerType ? `(${containerType})` : null]
+ .filter(Boolean)
+ .join(' '),
+ ),
totalWeightVgm:
totalWeight > 0 ? `${totalWeight.toLocaleString()} tons` : '—',
equipmentReturn: this.valueOrDash(booking.equipmentReturn),
diff --git a/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs b/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs
index 9c4957b2b..6ba7c1610 100644
--- a/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs
+++ b/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs
@@ -125,12 +125,30 @@
Hazardous cargo
{{schedule.hazardousLabel}}
+
+
Cargo type
+
{{schedule.cargoTypeName}}
+
Container type
+
{{schedule.containerType}}
+
+
+
Cargo scope
+
{{schedule.cargoSummary}}
+
Freight type
+
{{schedule.freightType}}
+
Equipment return
{{schedule.equipmentReturn}}
Payment currency
{{paymentArticle}}
+
+
Valid from
+
{{contractStartDate}}
+
Valid until
+
{{contractEndDate}}
+
diff --git a/apps/edr-freight-api/src/main.ts b/apps/edr-freight-api/src/main.ts
index f13c596d2..a4fbefdd2 100644
--- a/apps/edr-freight-api/src/main.ts
+++ b/apps/edr-freight-api/src/main.ts
@@ -19,7 +19,7 @@ import { AppModule } from "./app.module";
* ~13.4MB on the wire. Express defaults to 100kb, which rejected any real stamp
* image with a 413 "request entity too large".
*/
-const JSON_BODY_LIMIT = '20mb';
+const JSON_BODY_LIMIT = "20mb";
/**
* Static /etc/hosts-style overrides from `DNS_HOST_OVERRIDES`, formatted as
@@ -44,7 +44,9 @@ function applyDnsHostOverrides(): void {
}
if (overrides.size === 0) return;
- const dns = createRequire(__filename)("node:dns") as typeof import("node:dns");
+ const dns = createRequire(__filename)(
+ "node:dns",
+ ) as typeof import("node:dns");
const originalLookup = dns.lookup.bind(dns);
// `dns.lookup` is overloaded (options optional, all/family variants); the
// cast keeps that surface intact while we intercept only mapped hostnames.
@@ -63,7 +65,9 @@ function applyDnsHostOverrides(): void {
) => void;
const family = ip.includes(":") ? 6 : 4;
const wantsAll =
- typeof options === "object" && options !== null && (options as { all?: boolean }).all;
+ typeof options === "object" &&
+ options !== null &&
+ (options as { all?: boolean }).all;
process.nextTick(() =>
wantsAll ? done(null, [{ address: ip, family }]) : done(null, ip, family),
@@ -77,7 +81,15 @@ function applyDnsHostOverrides(): void {
applyDnsHostOverrides();
-async function bootstrap() {
+/**
+ * Build the app with every global the production process applies, but do NOT
+ * listen. Exported so a test harness can boot the REAL app in its own process
+ * (integration/src/app.ts) and get the same prefix, pipe, filter, interceptor
+ * and body-parser configuration — replaying this list by hand is how an e2e
+ * harness silently drifts from production (routes 404 without the "api"
+ * prefix, responses lose the transform envelope).
+ */
+export async function createFreightApp(): Promise {
const app = await NestFactory.create(AppModule);
// Nest's own body-parser API, NOT `app.use(json(...))` from express: express
@@ -86,14 +98,14 @@ async function bootstrap() {
// pnpm's hoisted dev store and died as MODULE_NOT_FOUND in the production
// image, where `pnpm deploy --prod` installs declared dependencies only.
// This also RECONFIGURES the default parsers rather than racing them.
- app.useBodyParser('json', { limit: JSON_BODY_LIMIT });
- app.useBodyParser('urlencoded', { limit: JSON_BODY_LIMIT, extended: true });
+ app.useBodyParser("json", { limit: JSON_BODY_LIMIT });
+ app.useBodyParser("urlencoded", { limit: JSON_BODY_LIMIT, extended: true });
// Dev CORS: reflect any localhost origin and allow credentials so the
// freight portal (5173), passenger portal (5174), backoffices (5183/5184)
// and any other dev port can call the API with cookies + Authorization.
// For production, restrict `origin` to known FQDNs.
-
+
app.enableCors({
origin: true, // reflect request origin
credentials: true,
@@ -130,8 +142,11 @@ async function bootstrap() {
maxAge: 86400, // cache preflight for 24h to cut chatter in dev
});
- // /callback stays un-prefixed: it's the Fayda OAuth redirect_uri ack endpoint.
- app.setGlobalPrefix("api", { exclude: ["callback"] });
+ // /fayda/callback stays un-prefixed: it's the Fayda OAuth redirect_uri ack
+ // endpoint. Exact path, not "fayda" — exclusion is an exact route match, so
+ // "fayda" would leave /fayda/callback prefixed (404 at the registered
+ // redirect_uri) while still reading as if it covered the whole subtree.
+ app.setGlobalPrefix("api", { exclude: ["fayda/callback"] });
// enableImplicitConversion is OFF: class-transformer's implicit boolean
// coercion turns any non-empty multipart/form-data string (including the
// literal "false") into `true`, silently corrupting flags like isHazardous
@@ -154,13 +169,21 @@ async function bootstrap() {
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup("api/docs", app, document);
+ return app;
+}
+
+async function bootstrap() {
+ const app = await createFreightApp();
const port = parseInt(process.env.PORT ?? "3001", 10);
// await app.listen(port, "0.0.0.0");
- await app.listen(
-
- port)
+ await app.listen(port);
// eslint-disable-next-line no-console
console.log(`[freight-api] listening on port ${port}`);
}
-bootstrap();
+// Only self-start when this file IS the entrypoint. The Dockerfile's
+// `CMD ["node", "dist/main.js"]` still boots; importers get `createFreightApp`
+// without the process binding a port behind their back.
+if (require.main === module) {
+ bootstrap();
+}
diff --git a/apps/edr-freight-api/src/migrations/3240000000000-CreateExchangeSettings.ts b/apps/edr-freight-api/src/migrations/3240000000000-CreateExchangeSettings.ts
new file mode 100644
index 000000000..68ad4f49b
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/3240000000000-CreateExchangeSettings.ts
@@ -0,0 +1,45 @@
+import { MigrationInterface, QueryRunner, Table } from 'typeorm';
+
+/**
+ * Single-row store for the USD→ETB fallback used when the CBE exchange-rate
+ * endpoint is unreachable. The live CBE rate always wins; every successful
+ * fetch overwrites this row, so it holds the last known good rate rather than
+ * a constant that drifts. Operators can also set it by hand during an outage.
+ *
+ * Seeded with the CBE USD transactional selling rate on 2026-08-04, so the
+ * fallback is usable before the first successful fetch.
+ */
+export class CreateExchangeSettings3240000000000 implements MigrationInterface {
+ name = 'CreateExchangeSettings3240000000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.createTable(
+ new Table({
+ schema: 'freight',
+ name: 'exchange_settings',
+ columns: [
+ { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
+ { name: 'fallback_rate', type: 'numeric', precision: 18, scale: 6 },
+ // AUTO when written by the CBE sync, MANUAL when set in the backoffice.
+ { name: 'fallback_source', type: 'varchar', length: '16', default: "'AUTO'" },
+ { name: 'last_synced_at', type: 'timestamptz', isNullable: true },
+ // IAM user id (iam.users) — no FK, iam schema is externally owned.
+ { name: 'updated_by_id', type: 'uuid', isNullable: true },
+ { name: 'created_at', type: 'timestamptz', default: 'now()' },
+ { name: 'updated_at', type: 'timestamptz', default: 'now()' },
+ { name: 'deleted_at', type: 'timestamptz', isNullable: true },
+ ],
+ }),
+ true,
+ );
+
+ await queryRunner.query(`
+ INSERT INTO freight.exchange_settings (fallback_rate, fallback_source)
+ VALUES (162.416500, 'AUTO')
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.dropTable('freight.exchange_settings', true);
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts
index 2063bb01d..84354d860 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts
@@ -1,8 +1,8 @@
import { Module, forwardRef } from "@nestjs/common";
import { UserTradeAccessModule } from "../user-trade-access/user-trade-access.module";
-import { ConfigService } from "@nestjs/config";
import { TypeOrmModule } from "@nestjs/typeorm";
-import { ExchangeModule, ExchangeOptions } from "@edr/api-common";
+
+import { registerExchangeModule } from "../exchange-settings/exchange-module-options";
// import { CustomersModule } from '../customers/customers.module';
import { CompaniesModule } from '../companies/companies.module';
@@ -86,11 +86,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
RuleEngineModule,
FileUploadSettingsModule,
SignaturesModule,
- ExchangeModule.forRootAsync({
- inject: [ConfigService],
- useFactory: (config: ConfigService): ExchangeOptions =>
- config.get("app.cbeExchange") ?? {},
- }),
+ registerExchangeModule(),
],
controllers: [BookingsController],
providers: [
diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts
index 7665d82eb..1c9d75487 100644
--- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts
+++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts
@@ -408,6 +408,30 @@ export class CompaniesController {
return this.companiesService.completeIdentityVerification(user.id, dto);
}
+ @Post("identity/gm/same-as-owner")
+ @ApiOperation({
+ summary:
+ "Declare the General Manager is the company's owner, copying the owner's verified identity across. " +
+ "Refused until the owner is Fayda-verified — there would be nothing proven to copy.",
+ })
+ async setGmSameAsOwner(
+ @CurrentUser() user: CurrentIamUser,
+ ): Promise {
+ return this.companiesService.setGmSameAsOwner(user.id);
+ }
+
+ @Delete("identity/gm")
+ @ApiOperation({
+ summary:
+ "Clear the General Manager's identity — the \"same as owner\" declaration or a verification, and the details either wrote. " +
+ "Leaves the GM open to be verified in their own right, or typed where Fayda is optional.",
+ })
+ async clearGmIdentity(
+ @CurrentUser() user: CurrentIamUser,
+ ): Promise {
+ return this.companiesService.clearGmIdentity(user.id);
+ }
+
@Delete("identity/fayda/poa")
@ApiOperation({
summary:
diff --git a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts
index 511c3aa25..32513df4f 100644
--- a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts
+++ b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts
@@ -21,9 +21,11 @@ import { POA_DELEGATION_FILE_KEY } from "../file-upload-settings/poa-delegation.
* is named, both nationalities must verify them, and their details come from
* the verified payload rather than the form.
*
- * The owner is NOT the general manager — GM is a separate, plain typed role
- * the portal offers a "same as owner" copy for, but it is never itself
- * Fayda-verified or gated on.
+ * The owner is NOT the general manager. The GM is proved the same way, by one
+ * of two routes — verifying in their own right, or being declared the owner,
+ * which reuses that verification rather than making one human prove themselves
+ * twice. It stays out of the trading gate either way: the GM names who to talk
+ * to, not what the company may do.
*/
interface Ctx {
@@ -418,10 +420,12 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
).resolves.toBeDefined();
});
- it("still requires a Fayda-verified PoA from a foreign company", async () => {
- // The owner's credential is nationality-specific; the representative's is
- // not. A PoA acts for the company inside Ethiopia whoever owns it, so a
- // typed foreign name is not a representative the platform can accept.
+ it("accepts a typed PoA from a foreign company, whose representative may hold no Fayda ID", async () => {
+ // Fayda is an Ethiopian national ID, so only an Ethiopian company's
+ // representative can be held to it. A foreign company is offered the
+ // verification and uses it where its representative holds one, but a typed
+ // name stays sufficient — holding it to Fayda would leave a foreign
+ // company whose representative has no Fayda ID unable to trade at all.
const { service } = makeService({
profileTypes: [ProfileType.importer],
nationality: CompanyNationality.Foreign,
@@ -434,6 +438,48 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
files: [paper()],
});
+ await expect(
+ service.createCompanyProfileForUser(
+ "user-1",
+ ProfileType.freightForwarder,
+ ),
+ ).resolves.toBeDefined();
+ });
+
+ it("still refuses a foreign company that named no PoA at all", async () => {
+ // The typed fallback is a different credential, not a waiver: a freight
+ // forwarder acts on other companies' behalf and needs a representative
+ // whatever its nationality.
+ const { service } = makeService({
+ profileTypes: [ProfileType.importer],
+ nationality: CompanyNationality.Foreign,
+ attributes: { ownerPassportNumber: "P1234567" },
+ files: [paper()],
+ });
+
+ await expect(
+ service.createCompanyProfileForUser(
+ "user-1",
+ ProfileType.freightForwarder,
+ ),
+ ).rejects.toBeInstanceOf(BadRequestException);
+ });
+
+ it("holds an Ethiopian company to a Fayda-verified PoA, typed details notwithstanding", async () => {
+ // The relaxation above is scoped to foreign companies only — an Ethiopian
+ // representative holds a Fayda ID, so typing a name must not substitute.
+ const { service } = makeService({
+ profileTypes: [ProfileType.importer],
+ nationality: CompanyNationality.Ethiopian,
+ attributes: {
+ ...OWNER_VERIFIED,
+ poaName: "Abebe Bekele",
+ poaEmail: "abebe@example.com",
+ poaPhone: "+251911000000",
+ },
+ files: [paper()],
+ });
+
await expect(
service.createCompanyProfileForUser(
"user-1",
@@ -463,4 +509,119 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
),
).rejects.toBeInstanceOf(BadRequestException);
});
+
+ // -------------------------------------------------------------------------
+ // General manager
+ // -------------------------------------------------------------------------
+
+ it("reuses the owner's verified identity when the GM is declared the same person", async () => {
+ // The GM is very often the owner. Copying the proven identity is the whole
+ // point — asking one human to complete two verifications proves nothing
+ // extra, and typing the details instead would forge a verified badge.
+ const { service, ctx } = makeService({
+ attributes: {
+ ...OWNER_VERIFIED,
+ ownerEmail: "abebe@example.com",
+ ownerPhone: "+251911222333",
+ },
+ });
+
+ const state = await service.setGmSameAsOwner("user-1");
+
+ expect(state.gm.verified).toBe(true);
+ expect(state.gmSameAsOwner).toBe(true);
+ expect(state.gm.name).toBe("Abebe Bikila");
+ expect(ctx.attributes.gmFaydaSub).toBe("owner-sub");
+ // The notifiers mail the flat column, so a linked GM has to land there too.
+ expect(ctx.attributes.generalManagerEmail).toBe("abebe@example.com");
+ });
+
+ it("refuses to declare the GM is the owner while the owner is unverified", async () => {
+ // Without a verification there is no proven identity to copy — only typed
+ // text, which would arrive wearing a badge it had not earned.
+ const { service } = makeService({ attributes: {} });
+
+ await expect(service.setGmSameAsOwner("user-1")).rejects.toBeInstanceOf(
+ BadRequestException,
+ );
+ });
+
+ 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.
+ const { service, ctx } = makeService({
+ attributes: { ...OWNER_VERIFIED },
+ verification: {
+ purpose: "VERIFY",
+ verified: true,
+ sub: "owner-sub",
+ fullName: "Abebe Bikila",
+ email: "abebe@example.com",
+ phoneNumber: "+251911222333",
+ },
+ });
+
+ const state = await service.completeIdentityVerification("user-1", {
+ subject: "gm",
+ code: "c",
+ state: "s",
+ });
+
+ expect(state.gm.verified).toBe(true);
+ expect(ctx.attributes.gmFaydaSub).toBe("owner-sub");
+ 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",
+ },
+ });
+
+ await expect(
+ service.completeIdentityVerification("user-1", {
+ subject: "poa",
+ code: "c",
+ state: "s",
+ }),
+ ).rejects.toBeInstanceOf(BadRequestException);
+ });
+
+ it("reports a pre-existing typed GM as unverified rather than blank", async () => {
+ // Companies onboarded before the GM was verifiable have typed details and
+ // no gm* attributes. Those details are still what the notifiers mail, so
+ // they must survive — flagged unverified so the portal offers the upgrade.
+ const { service, company } = makeService({
+ attributes: {
+ ...OWNER_VERIFIED,
+ generalManagerName: "Legacy Manager",
+ generalManagerEmail: "legacy@example.com",
+ },
+ });
+
+ const state = service.getCompanyIdentityState(company() as never);
+
+ expect(state.gm.verified).toBe(false);
+ expect(state.gm.name).toBe("Legacy Manager");
+ expect(state.gm.email).toBe("legacy@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.
+ const { service } = makeService({
+ attributes: { ...OWNER_VERIFIED },
+ });
+
+ await expect(
+ service.createCompanyProfileForUser("user-1", ProfileType.importer),
+ ).resolves.toBeDefined();
+ });
});
diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts
index e0af07e0b..f21263c7f 100644
--- a/apps/edr-freight-api/src/modules/companies/companies.service.ts
+++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts
@@ -33,6 +33,7 @@ import {
buildCompanyIdentityState,
CompanyIdentityStateDto,
CompleteIdentityVerificationDto,
+ IDENTITY_SUBJECTS,
IdentitySubject,
} from "./dto/complete-identity-verification.dto";
import { ETradeService } from "./services/etrade.service";
@@ -122,31 +123,47 @@ const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [
/**
* `attributes` key prefix per verifiable person. The owner is NOT the general
- * manager — GM is a plain typed role (the portal offers a "same as owner" copy
- * once the owner is verified), while the owner is who this verification
- * actually proves. They're very often the same human; that's what the copy is
- * for.
+ * manager: the owner is who the verification proves the company through, the
+ * GM is personnel it names. They're very often the same human, which is what
+ * the portal's "same as owner" copy is for.
*/
-const IDENTITY_PREFIX: Record = {
+const IDENTITY_PREFIX: Record = {
owner: "owner",
poa: "poa",
+ gm: "gm",
};
const IDENTITY_LABEL: Record = {
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
+ * verified GM whose address the system never actually uses.
+ */
+const GM_TYPED_FIELDS = [
+ "generalManagerName",
+ "generalManagerEmail",
+ "generalManagerPhone",
+] as const;
+
/**
* Identity fields a Fayda verification owns outright, per person. Once verified
* these can no longer be typed — the government IdP is the source, so an edit
* that disagrees with it is either a mistake or an attempt to launder the
- * guarantee away. The GM fields are deliberately absent: GM is never itself
- * Fayda-verified, so it stays freely editable regardless of the owner's state.
+ * guarantee away.
+ *
+ * The GM's entries are its typed columns: a verified GM is locked the same way
+ * the others are, while an unverified one (a foreign company's, or a record
+ * that predates this) stays freely editable.
*/
const IDENTITY_OWNED_FIELDS: Record = {
owner: ["ownerName", "ownerEmail", "ownerPhone", "ownerAddress"],
poa: ["poaName", "poaEmail", "poaPhone", "poaAddress"],
+ gm: [...GM_TYPED_FIELDS],
};
/**
@@ -834,7 +851,7 @@ export class CompaniesService {
// Renaming a Fayda-verified person by hand would launder the guarantee
// away, so the fields the verification owns are refused once it exists.
- for (const subject of ["owner", "poa"] as IdentitySubject[]) {
+ for (const subject of IDENTITY_SUBJECTS) {
if (!attrUpdates[`${IDENTITY_PREFIX[subject]}FaydaSub`]) continue;
for (const field of IDENTITY_OWNED_FIELDS[subject]) {
const incoming = (dto as Record)[field];
@@ -2693,12 +2710,17 @@ 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.
- 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.`,
- );
+ // 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.`,
+ );
+ }
}
const now = new Date().toISOString();
@@ -2714,13 +2736,26 @@ export class CompaniesService {
...(result.address ? { [`${prefix}Address`]: result.address } : {}),
};
+ // A GM verification also lands on the typed columns the rest of the system
+ // already reads (the booking, train-scheduling and contract notifiers all
+ // mail `generalManagerEmail`), and clears any earlier "same as owner"
+ // declaration — verifying in their own right is the GM answering for
+ // themselves.
+ if (dto.subject === "gm") {
+ identity.gmSameAsOwner = false;
+ if (result.fullName) identity.generalManagerName = result.fullName;
+ if (result.email) identity.generalManagerEmail = result.email;
+ if (result.phoneNumber)
+ identity.generalManagerPhone = normalizeE164(result.phoneNumber);
+ }
+
// An approved company's *owner* is its identity proof, so re-verifying one
// is staged for backoffice review rather than quietly rewriting a live
- // record. The PoA is personnel — the company names its own representative,
- // and the delegation letter backing them is what the reviewer sees — so a
- // PoA verification lands live, matching the typed PoA fields in
- // `SELF_SERVICE_ATTRIBUTES`.
- if (company.status === CompanyStatus.Active && dto.subject !== "poa") {
+ // record. The PoA and GM are personnel — the company names its own
+ // representative and manager, and the delegation letter backing the PoA is
+ // what the reviewer sees — so those land live, matching their typed
+ // counterparts in `SELF_SERVICE_ATTRIBUTES`.
+ if (company.status === CompanyStatus.Active && dto.subject === "owner") {
await this.stageIdentityChange(company, userId, identity);
return this.getCompanyIdentityState(company);
}
@@ -2734,6 +2769,85 @@ export class CompaniesService {
return this.getCompanyIdentityState(updated);
}
+ /**
+ * Declare that the General Manager is the company's owner.
+ *
+ * The GM is very often the owner, and making that human verify twice buys
+ * nothing — the owner's verification already proves them. So this copies the
+ * owner's verified identity across rather than starting a second flow, and
+ * records `gmSameAsOwner` so the portal can show it as a declaration rather
+ * than as a verification the GM passed in their own right.
+ *
+ * Refused until the owner is actually verified: without that there is no
+ * proven identity to copy, only typed text that would arrive wearing a
+ * verified badge.
+ */
+ async setGmSameAsOwner(userId: string): Promise {
+ const { company } = await this.getCompanyInfoByUserId(userId);
+ const attrs = company.attributes ?? {};
+ const ownerSub = attrs.ownerFaydaSub as string | undefined;
+ if (!ownerSub) {
+ throw new BadRequestException(
+ "Verify the company owner with Fayda first — there is no proven identity to reuse yet.",
+ );
+ }
+
+ const copied: Record = {
+ gmSameAsOwner: true,
+ gmFaydaSub: ownerSub,
+ gmFaydaVerifiedAt: attrs.ownerFaydaVerifiedAt ?? new Date().toISOString(),
+ gmName: attrs.ownerName ?? null,
+ gmEmail: attrs.ownerEmail ?? null,
+ gmPhone: attrs.ownerPhone ?? null,
+ 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,
+ };
+
+ 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 "same as owner" declaration, clearing the copied identity so the
+ * GM can be verified in their own right (or typed, where Fayda is optional).
+ */
+ async clearGmIdentity(userId: string): Promise {
+ const { company } = await this.getCompanyInfoByUserId(userId);
+ const attrs = { ...(company.attributes ?? {}) };
+ for (const key of [
+ "gmSameAsOwner",
+ "gmFaydaSub",
+ "gmFaydaVerifiedAt",
+ "gmName",
+ "gmEmail",
+ "gmPhone",
+ "gmAddress",
+ "gmBirthdate",
+ "gmGender",
+ ...GM_TYPED_FIELDS,
+ ]) {
+ 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.
@@ -2864,14 +2978,28 @@ export class CompaniesService {
);
}
- // The representative is not. A PoA acts for the company inside Ethiopia
- // whoever owns it, so they are always an Ethiopian holding a Fayda ID —
- // a foreign company nominates one rather than typing a name.
const poaNamed = POA_ATTRIBUTES.some((k) =>
(company.attributes?.[k] as string | undefined)?.trim(),
);
if (!opts.requirePoa && !poaNamed) return;
+ // Fayda is an Ethiopian national ID, so only an Ethiopian company's
+ // representative can be held to it. A foreign company is offered the
+ // verification and nominates a Fayda-holding representative where it can,
+ // but a typed name has to remain sufficient — otherwise a foreign company
+ // whose representative holds no Fayda ID could never trade at all. Mirrors
+ // `poaProven` in buildCompanyIdentityState; the two must agree.
+ if (state.passportRequired) {
+ if (!state.poa.verified && !state.poa.name?.trim()) {
+ throw new BadRequestException(
+ opts.requirePoa
+ ? "Name your Power of Attorney — a freight forwarder cannot operate without one."
+ : "Complete the Power of Attorney you named, or remove the representative.",
+ );
+ }
+ return;
+ }
+
if (!state.poa.verified) {
throw new BadRequestException(
opts.requirePoa
diff --git a/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts
index a9988cd28..d41e61e8e 100644
--- a/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts
+++ b/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts
@@ -5,13 +5,15 @@ import { Company, CompanyNationality } from "../entities/company.entity";
import { ProfileType } from "../entities/company-profile.entity";
/**
- * The two people a company is verified through — its owner and its Power of
- * Attorney. "Owner" is not the same as the General Manager: a company's GM is
- * a plain typed role (with a "same as owner" copy the portal offers), while
- * the owner is the person this verification proves. They're very often the
- * same human, which is exactly what the copy is for.
+ * The three people a company is verified through — its owner, its Power of
+ * Attorney and its General Manager. The owner is the person the company's
+ * existence is proven by; the other two are personnel it names.
+ *
+ * The GM is very often the owner, which is what the portal's "same as owner"
+ * copy is for: that path reuses the owner's verified identity outright rather
+ * than asking the same human to verify twice.
*/
-export const IDENTITY_SUBJECTS = ["owner", "poa"] as const;
+export const IDENTITY_SUBJECTS = ["owner", "poa", "gm"] as const;
export type IdentitySubject = (typeof IDENTITY_SUBJECTS)[number];
export class CompleteIdentityVerificationDto {
@@ -73,6 +75,19 @@ export class CompanyIdentityStateDto {
@ApiProperty({ type: IdentityVerificationStateDto })
poa!: IdentityVerificationStateDto;
+ @ApiProperty({
+ type: IdentityVerificationStateDto,
+ description:
+ "General manager. `verified` is true both when the GM verified with Fayda in their own right and when the company declared the GM is the owner — in the latter case the owner's Fayda sub backs it.",
+ })
+ gm!: IdentityVerificationStateDto;
+
+ @ApiProperty({
+ description:
+ "True when the GM's identity is the owner's, declared through the portal's \"same as owner\" copy rather than a separate verification.",
+ })
+ gmSameAsOwner!: boolean;
+
@ApiProperty({
description:
"False while a mandatory requirement (Fayda for Ethiopian, passport for foreign) is still outstanding.",
@@ -81,11 +96,28 @@ export class CompanyIdentityStateDto {
}
/** `attributes` key prefix per person. */
-const PREFIX: Record = {
+const PREFIX: Record = {
owner: "owner",
poa: "poa",
+ gm: "gm",
};
+/**
+ * Typed GM fields, kept in step with the Fayda-written ones.
+ *
+ * The GM predates this verification: its details are plain company columns
+ * that three notifier services mail (booking-lifecycle, train-scheduling and
+ * contract notifiers all read `company.generalManagerEmail`). A verification
+ * therefore writes BOTH — the `gm*` attributes carry the proof, these carry
+ * the value everything else already reads — and an unverified company keeps
+ * showing whatever was typed before this existed.
+ */
+const GM_TYPED_KEYS = {
+ name: "generalManagerName",
+ email: "generalManagerEmail",
+ phone: "generalManagerPhone",
+} as const;
+
/** company.attributes keys that together mean "a PoA was entered". */
const POA_KEYS = [
"poaName",
@@ -101,7 +133,7 @@ function stateFor(
): IdentityVerificationStateDto {
const p = PREFIX[subject];
const read = (key: string) => (attrs[key] as string | undefined) ?? null;
- return {
+ const state: IdentityVerificationStateDto = {
verified: Boolean(read(`${p}FaydaSub`)),
name: read(`${p}Name`),
phone: read(`${p}Phone`),
@@ -111,6 +143,18 @@ function stateFor(
birthdate: read(`${p}Birthdate`),
gender: read(`${p}Gender`),
};
+ if (subject !== "gm") 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.
+ return {
+ ...state,
+ name: state.name ?? read(GM_TYPED_KEYS.name),
+ email: state.email ?? read(GM_TYPED_KEYS.email),
+ phone: state.phone ?? read(GM_TYPED_KEYS.phone),
+ };
}
/**
@@ -144,14 +188,34 @@ export function buildCompanyIdentityState(
(p) => p.type === ProfileType.freightForwarder,
) || POA_KEYS.some((k) => (attrs[k] as string | undefined)?.trim());
- // Only the *owner's* credential is nationality-specific. A Power of Attorney
- // acts for the company inside Ethiopia whoever owns it, so the PoA is always
- // proven with Fayda — a foreign company nominates a representative who holds
- // one rather than typing a name nothing backs.
+ const gm = stateFor(attrs, "gm");
+ const gmSameAsOwner = Boolean(attrs.gmSameAsOwner);
+
const ownerProven = faydaRequired
? owner.verified
: !passportRequired || Boolean(owner.passportNumber);
- const complete = ownerProven && (!poaDue || poa.verified);
- return { faydaRequired, passportRequired, owner, poa, complete };
+ // Fayda is an Ethiopian national ID, so only an Ethiopian company's
+ // personnel can be held to it. A foreign company may nominate a
+ // representative who holds one — and is offered the verification — but a
+ // typed name has to remain sufficient, or a foreign company whose PoA has no
+ // Fayda ID could never finish onboarding.
+ const poaProven = faydaRequired
+ ? poa.verified
+ : poa.verified || Boolean(poa.name?.trim());
+
+ // The GM is deliberately absent from this verdict: it names who to talk to,
+ // not what the company may do, and it has never gated trading. Capturing it
+ // through Fayda changes how it is collected, not whether it is required.
+ const complete = ownerProven && (!poaDue || poaProven);
+
+ return {
+ faydaRequired,
+ passportRequired,
+ owner,
+ poa,
+ gm,
+ gmSameAsOwner,
+ complete,
+ };
}
diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts
index cb956878d..ea41b57a2 100644
--- a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts
+++ b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts
@@ -208,6 +208,9 @@ export class ContractTemplatesService {
year: "numeric",
}),
contractYear: now.getFullYear(),
+ // Representative validity window for the admin preview only.
+ contractStartDate: `1 January ${now.getFullYear()}`,
+ contractEndDate: `31 December ${now.getFullYear()}`,
client: {
companyName: "Abyssinia Trading PLC",
companyAddress: "Bole Sub-city, Woreda 03, H.No 1234, Addis Ababa",
@@ -239,6 +242,11 @@ export class ContractTemplatesService {
scheduledDate: "—",
contractType: "GENERAL",
cargoDescription: isBulk ? "Steel billets — 2,800 MT" : "40ft containers — FMCG cargo",
+ cargoTypeName: isBulk ? "Steel billets" : "Coffee",
+ containerType: isBulk ? "—" : "40ft",
+ cargoSummary: isBulk
+ ? "Steel billets × 2,800"
+ : "Coffee (40ft) × 12; Sesame (20ft) × 6",
totalWeightVgm: "—",
equipmentReturn: isBulk ? "—" : "With empty return",
hazardousLabel: "No",
diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts
index 0e60462a4..2a07b02e5 100644
--- a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts
@@ -1,9 +1,8 @@
import { Module, forwardRef } from '@nestjs/common';
import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module';
-import { ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
-import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
+import { registerExchangeModule } from '../exchange-settings/exchange-module-options';
import { BillingModule } from '../billing/billing.module';
import { CompaniesModule } from '../companies/companies.module';
import { FilesModule } from '../files/files.module';
@@ -102,11 +101,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
// by ContractBookingService.createUnderContract. forwardRef because
// TrainSchedulingModule already imports ContractsModule.
forwardRef(() => TrainSchedulingModule),
- ExchangeModule.forRootAsync({
- inject: [ConfigService],
- useFactory: (config: ConfigService): ExchangeOptions =>
- config.get('app.cbeExchange') ?? {},
- }),
+ registerExchangeModule(),
],
controllers: [ContractsController, GlExchangeController],
providers: [
diff --git a/apps/edr-freight-api/src/modules/exchange-settings/dto/update-exchange-setting.dto.ts b/apps/edr-freight-api/src/modules/exchange-settings/dto/update-exchange-setting.dto.ts
new file mode 100644
index 000000000..98e87007c
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/exchange-settings/dto/update-exchange-setting.dto.ts
@@ -0,0 +1,13 @@
+import { IsNumber, Max, Min } from "class-validator";
+
+/**
+ * Operator-set USD→ETB fallback. Bounded well outside any plausible published
+ * rate but far short of a fat-fingered magnitude error — this value multiplies
+ * real invoice amounts whenever CBE is unreachable.
+ */
+export class UpdateExchangeSettingDto {
+ @IsNumber({ maxDecimalPlaces: 6 })
+ @Min(1)
+ @Max(10_000)
+ fallbackRate!: number;
+}
diff --git a/apps/edr-freight-api/src/modules/exchange-settings/entities/exchange-setting.entity.ts b/apps/edr-freight-api/src/modules/exchange-settings/entities/exchange-setting.entity.ts
new file mode 100644
index 000000000..1e1f4ad66
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/exchange-settings/entities/exchange-setting.entity.ts
@@ -0,0 +1,47 @@
+import { BaseEntity } from "@edr/api-common";
+import { Column, Entity } from "typeorm";
+
+/**
+ * Whether the stored fallback rate was written by the automatic sync (after a
+ * successful CBE fetch) or typed in by an operator in the backoffice.
+ */
+export type ExchangeFallbackSource = "AUTO" | "MANUAL";
+
+/**
+ * Single-row table holding the USD→ETB fallback used when the CBE endpoint is
+ * unreachable. The live CBE rate always wins; this is only consulted on
+ * failure, and is overwritten by every successful fetch so it tracks the last
+ * known good rate.
+ */
+@Entity({ schema: "freight", name: "exchange_settings" })
+export class ExchangeSetting extends BaseEntity {
+ /** USD→ETB rate served while the CBE endpoint is failing. */
+ @Column({
+ name: "fallback_rate",
+ type: "numeric",
+ precision: 18,
+ scale: 6,
+ transformer: {
+ to: (value: number) => value,
+ from: (value: string | null) => (value === null ? null : Number(value)),
+ },
+ })
+ fallbackRate!: number;
+
+ /** `AUTO` when written by the sync, `MANUAL` when set in the backoffice. */
+ @Column({
+ name: "fallback_source",
+ type: "varchar",
+ length: 16,
+ default: "AUTO",
+ })
+ fallbackSource!: ExchangeFallbackSource;
+
+ /** When the fallback last changed — i.e. the last successful CBE fetch. */
+ @Column({ name: "last_synced_at", type: "timestamptz", nullable: true })
+ lastSyncedAt?: Date | null;
+
+ /** IAM user id of the last operator to set the rate manually. */
+ @Column({ name: "updated_by_id", type: "uuid", nullable: true })
+ updatedById?: string | null;
+}
diff --git a/apps/edr-freight-api/src/modules/exchange-settings/exchange-module-options.ts b/apps/edr-freight-api/src/modules/exchange-settings/exchange-module-options.ts
new file mode 100644
index 000000000..fb126f969
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/exchange-settings/exchange-module-options.ts
@@ -0,0 +1,27 @@
+import { ExchangeModule, ExchangeOptions } from "@edr/api-common";
+import { ConfigService } from "@nestjs/config";
+import { DynamicModule } from "@nestjs/common";
+
+import { ExchangeSettingsService } from "./exchange-settings.service";
+
+/**
+ * The app's single `ExchangeModule` registration shape: CBE endpoint config
+ * from `app.cbeExchange`, with the DB-backed fallback wired in.
+ *
+ * `ExchangeModule` is registered per-feature-module (bookings, contracts,
+ * warehouses), so this keeps the three call sites identical rather than
+ * letting their options drift apart.
+ */
+export function registerExchangeModule(): DynamicModule {
+ return ExchangeModule.forRootAsync({
+ inject: [ConfigService, ExchangeSettingsService],
+ useFactory: (
+ config: ConfigService,
+ settings: ExchangeSettingsService,
+ ): ExchangeOptions => ({
+ ...(config.get("app.cbeExchange") ?? {}),
+ loadFallbackRate: () => settings.loadFallbackRate(),
+ saveFallbackRate: (rate: number) => settings.saveFallbackRate(rate),
+ }),
+ });
+}
diff --git a/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts
new file mode 100644
index 000000000..a6c08a317
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts
@@ -0,0 +1,56 @@
+import { Body, Controller, Get, Patch } from "@nestjs/common";
+import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
+import { CurrentUser } from "@edr/api-common";
+import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
+
+import { FreightAdmin } from "../../common/booking-guards";
+import { UpdateExchangeSettingDto } from "./dto/update-exchange-setting.dto";
+import { ExchangeSettingsService } from "./exchange-settings.service";
+
+@ApiTags("exchange-settings")
+@ApiBearerAuth()
+@Controller("exchange-settings")
+export class ExchangeSettingsController {
+ constructor(private readonly service: ExchangeSettingsService) {}
+
+ @Get()
+ @FreightAdmin()
+ @ApiOperation({
+ summary: "Current USD→ETB fallback rate and CBE feed health",
+ })
+ async get() {
+ const setting = await this.service.get();
+ const status = this.service.getFeedStatus();
+
+ return {
+ fallbackRate: setting.fallbackRate,
+ fallbackSource: setting.fallbackSource,
+ lastSyncedAt: setting.lastSyncedAt,
+ updatedById: setting.updatedById,
+ feed: status,
+ };
+ }
+
+ @Patch()
+ @FreightAdmin()
+ @ApiOperation({
+ summary:
+ "Set the USD→ETB fallback by hand (used only while CBE is unreachable)",
+ })
+ async update(
+ @Body() dto: UpdateExchangeSettingDto,
+ @CurrentUser() user: TCurrentUser,
+ ) {
+ const updated = await this.service.setManualRate(
+ dto.fallbackRate,
+ user?.id ?? null,
+ );
+
+ return {
+ fallbackRate: updated.fallbackRate,
+ fallbackSource: updated.fallbackSource,
+ lastSyncedAt: updated.lastSyncedAt,
+ updatedById: updated.updatedById,
+ };
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.module.ts b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.module.ts
new file mode 100644
index 000000000..df0f08c62
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.module.ts
@@ -0,0 +1,25 @@
+import { Global, Module } from "@nestjs/common";
+import { TypeOrmModule } from "@nestjs/typeorm";
+
+import { ExchangeSetting } from "./entities/exchange-setting.entity";
+import { ExchangeSettingsController } from "./exchange-settings.controller";
+import { registerExchangeModule } from "./exchange-module-options";
+import { ExchangeSettingsService } from "./exchange-settings.service";
+
+/**
+ * Global so the several `ExchangeModule.forRootAsync` registrations (bookings,
+ * contracts, warehouses) can inject {@link ExchangeSettingsService} into their
+ * options factory without each importing this module.
+ *
+ * Also registers its own `ExchangeModule` so `ExchangeSettingsController` can
+ * report the live CBE feed status (`ExchangeService.getProviderStatus()`)
+ * alongside the DB-backed fallback rate.
+ */
+@Global()
+@Module({
+ imports: [TypeOrmModule.forFeature([ExchangeSetting]), registerExchangeModule()],
+ controllers: [ExchangeSettingsController],
+ providers: [ExchangeSettingsService],
+ exports: [ExchangeSettingsService],
+})
+export class ExchangeSettingsModule {}
diff --git a/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.service.ts b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.service.ts
new file mode 100644
index 000000000..e0b670292
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.service.ts
@@ -0,0 +1,141 @@
+import { Injectable, Logger } from "@nestjs/common";
+import { InjectRepository } from "@nestjs/typeorm";
+import { Repository } from "typeorm";
+
+import { ExchangeSetting } from "./entities/exchange-setting.entity";
+
+/**
+ * Rate used before the row exists and before the first successful CBE fetch —
+ * the CBE USD transactional selling rate on 2026-08-04.
+ */
+const SEED_FALLBACK_RATE = 162.4165;
+
+/** Health of the CBE feed, as surfaced to the backoffice. */
+export interface ExchangeFeedStatus {
+ /** Rate most recently observed, whatever its source. */
+ rate: number | null;
+ /** `live` means CBE answered; `stored`/`default` mean it is failing. */
+ source: "live" | "stored" | null;
+ /** ISO timestamp of the last successful fetch. */
+ lastSuccessAt: string | null;
+ /** Message from the most recent failure, cleared on success. */
+ lastError: string | null;
+}
+
+/**
+ * Owns the single `exchange_settings` row: the USD→ETB fallback used when the
+ * CBE endpoint is unreachable.
+ *
+ * The live CBE rate is always preferred. This value is only read on failure,
+ * and every successful fetch overwrites it, so it tracks the last known good
+ * rate rather than drifting into a stale constant.
+ */
+@Injectable()
+export class ExchangeSettingsService {
+ private readonly logger = new Logger(ExchangeSettingsService.name);
+
+ /**
+ * Feed health, recorded from the exchange provider's callbacks rather than
+ * read off an injected `ExchangeService`. The provider is registered several
+ * times (bookings, contracts, warehouses), so no single instance sees every
+ * fetch — and injecting one here would be circular, since those
+ * registrations inject *this* service.
+ */
+ private feed: ExchangeFeedStatus = {
+ rate: null,
+ source: null,
+ lastSuccessAt: null,
+ lastError: null,
+ };
+
+ constructor(
+ @InjectRepository(ExchangeSetting)
+ private readonly repository: Repository,
+ ) {}
+
+ /** Health of the CBE feed as last observed by any provider instance. */
+ getFeedStatus(): ExchangeFeedStatus {
+ return { ...this.feed };
+ }
+
+ /** The settings row, created at the seed rate on first access. */
+ async get(): Promise {
+ const existing = await this.repository.findOne({ where: {} });
+ if (existing) return existing;
+
+ return this.repository.save(
+ this.repository.create({
+ fallbackRate: SEED_FALLBACK_RATE,
+ fallbackSource: "AUTO",
+ lastSyncedAt: null,
+ }),
+ );
+ }
+
+ /**
+ * Reads the stored fallback for the exchange provider. Returns `null` on any
+ * failure so the provider falls through to its own static default rather
+ * than propagating a database error into a pricing call.
+ */
+ async loadFallbackRate(): Promise {
+ // Only reached when the live fetch failed, so this call is itself the
+ // signal that the feed is down.
+ try {
+ const { fallbackRate } = await this.get();
+ const usable = Number.isFinite(fallbackRate) && fallbackRate > 0;
+ this.feed = {
+ ...this.feed,
+ rate: usable ? fallbackRate : this.feed.rate,
+ source: "stored",
+ lastError: this.feed.lastError ?? "CBE endpoint unreachable",
+ };
+ return usable ? fallbackRate : null;
+ } catch (err) {
+ const message = (err as Error).message;
+ this.feed = { ...this.feed, source: "stored", lastError: message };
+ this.logger.warn(`Could not read stored exchange fallback: ${message}`);
+ return null;
+ }
+ }
+
+ /**
+ * Records a freshly fetched live rate as the new fallback. Marked `AUTO`,
+ * overwriting a manual entry — a manual rate is a stopgap for while CBE is
+ * down, so a working CBE feed takes precedence again.
+ */
+ async saveFallbackRate(rate: number): Promise {
+ // Only called after a successful fetch, so the feed is confirmed healthy.
+ this.feed = {
+ rate,
+ source: "live",
+ lastSuccessAt: new Date().toISOString(),
+ lastError: null,
+ };
+
+ const current = await this.get();
+ await this.repository.update(current.id, {
+ fallbackRate: rate,
+ fallbackSource: "AUTO",
+ lastSyncedAt: new Date(),
+ updatedById: null,
+ });
+ this.logger.log(`Exchange fallback synced from CBE: ${rate} ETB/USD`);
+ }
+
+ /** Operator sets the fallback by hand, e.g. during a prolonged CBE outage. */
+ async setManualRate(
+ rate: number,
+ updatedById?: string | null,
+ ): Promise {
+ const current = await this.get();
+ await this.repository.update(current.id, {
+ fallbackRate: rate,
+ fallbackSource: "MANUAL",
+ updatedById: updatedById ?? null,
+ });
+ this.logger.warn(
+ `Exchange fallback set manually to ${rate} ETB/USD by ${updatedById ?? "unknown user"}`,
+ );
+ return this.get();
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts
index 3883f8d99..3b0dc6ec9 100644
--- a/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts
+++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts
@@ -1,7 +1,23 @@
-import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
+import {
+ Body,
+ Controller,
+ Delete,
+ Get,
+ HttpCode,
+ HttpStatus,
+ Param,
+ ParseUUIDPipe,
+ Patch,
+ Post,
+ Query,
+} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
-import { FleetManage, StaffReference } from '../../common/booking-guards';
+import {
+ BookingStaff,
+ FleetManage,
+ StaffReference,
+} from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { CreateLocomotiveDto } from './dto/create-locomotive.dto';
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
@@ -59,4 +75,18 @@ export class LocomotivesController {
decommission(@Param('id', ParseUUIDPipe) id: string) {
return this.locomotivesService.decommission(id);
}
+
+ // BookingStaff, not FleetManage: the latter also accepts the coarse
+ // fleet:manage key, which would hand an irreversible purge to everyone who
+ // can edit the fleet. This action requires its own grant, nothing else.
+ @Delete(':id/permanent')
+ @BookingStaff(FREIGHT_PERMS.locomotives.hardDelete)
+ @HttpCode(HttpStatus.NO_CONTENT)
+ @ApiOperation({
+ summary:
+ 'Permanently delete a locomotive (irreversible; refused if any train references it)',
+ })
+ purge(@Param('id', ParseUUIDPipe) id: string) {
+ return this.locomotivesService.purge(id);
+ }
}
diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts
index 1da03072e..1d45cbeff 100644
--- a/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts
+++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts
@@ -222,4 +222,61 @@ export class LocomotivesService {
return updated;
}
+
+ /**
+ * Permanently purge a locomotive — irreversible, and only for rows nothing
+ * references: a mistyped or duplicated entry.
+ *
+ * Every FK onto `locomotives` is NO ACTION, so Postgres would reject the
+ * delete with a raw constraint error. The references are resolved up front
+ * instead, naming the trains involved so the message says what to detach.
+ * Decommissioning (`decommission`) stays the answer for a real locomotive
+ * leaving service.
+ */
+ async purge(id: string): Promise {
+ const locomotive = await this.locomotivesRepository.findById(id);
+ if (!locomotive) {
+ throw new NotFoundException(`Locomotive ${id} not found`);
+ }
+
+ const [builtTrains, setsViaJoin, setsDirect] = await Promise.all([
+ this.dataSource.query>(
+ `SELECT t.code
+ FROM freight.train_locomotives tl
+ JOIN freight.trains t ON t.id = tl.train_id
+ WHERE tl.locomotive_id = $1`,
+ [id],
+ ),
+ this.dataSource.query>(
+ `SELECT t.code
+ FROM freight.train_set_locomotives tsl
+ JOIN freight.train_sets ts ON ts.id = tsl.train_set_id
+ LEFT JOIN freight.trains t ON t.id = ts.train_id
+ WHERE tsl.locomotive_id = $1`,
+ [id],
+ ),
+ this.dataSource.query>(
+ `SELECT t.code
+ FROM freight.train_sets ts
+ LEFT JOIN freight.trains t ON t.id = ts.train_id
+ WHERE ts.locomotive_id = $1`,
+ [id],
+ ),
+ ]);
+
+ const referencing = [...builtTrains, ...setsViaJoin, ...setsDirect];
+ if (referencing.length > 0) {
+ const codes = [
+ ...new Set(referencing.map((r) => r.code).filter(Boolean)),
+ ];
+ const named = codes.length > 0 ? ` (${codes.join(', ')})` : '';
+ throw new ConflictException(
+ `Locomotive ${locomotive.code} is used by ${referencing.length} train record(s)${named}; detach it before deleting it permanently. Decommission it instead to take it out of service.`,
+ );
+ }
+
+ await this.dataSource
+ .getRepository(Locomotive)
+ .delete({ id });
+ }
}
diff --git a/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts b/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts
index cddc67b2d..ac021c322 100644
--- a/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts
+++ b/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts
@@ -1,62 +1,22 @@
import { Injectable, Logger } from "@nestjs/common";
-import { ConfigService } from "@nestjs/config";
-import axios, { isAxiosError } from "axios";
import { NotificationStrategy } from "./notification.strategy";
+import { SmsClientService } from "../sms-client.service";
@Injectable()
export class SmsNotificationStrategy implements NotificationStrategy {
private readonly logger = new Logger(SmsNotificationStrategy.name);
- constructor(private readonly configService: ConfigService) {}
+ constructor(private readonly smsClient: SmsClientService) {}
async send(recipient: string, message: string): Promise {
- const url =
- this.configService.get("OZIKING_SMS_URL") ??
- "https://notification-dev.license.aafda.gov.et/api/sms-services/ozeking/sms";
-
- const appKey = this.configService.get("OZIKING_APP_KEY") ?? "";
- if (!appKey) {
- this.logger.warn("OZIKING_APP_KEY is not set — SMS may be rejected by the API");
- }
-
- this.logger.debug(`Sending SMS to ${recipient} via ${url}`);
-
- // axios defaults to no timeout — a hanging gateway would block the caller
- // (and any transaction it sits in) indefinitely. Always bound the wait.
- const timeout = Number(this.configService.get("SMS_TIMEOUT_MS") ?? 8000);
-
- try {
- const response = await axios.post(
- url,
- {
- to: recipient,
- sourceId: this.configService.get("OZIKING_SOURCE_ID") ?? "EDR",
- sourceName: this.configService.get("OZIKING_SOURCE_NAME") ?? "EDR Freight",
- appKey,
- text: message,
- callbackUrl: "",
- },
- {
- timeout,
- headers: {
- accept: "*/*",
- "Content-Type": "application/json",
- },
- },
- );
-
- this.logger.debug(`SMS API response: ${response.status} ${JSON.stringify(response.data)}`);
- return true;
- } catch (err) {
- if (isAxiosError(err)) {
- this.logger.error(
- `SMS API error: ${err.message} | status=${err.response?.status} | body=${JSON.stringify(err.response?.data)}`,
- );
- } else {
- this.logger.error(`SMS send failed: ${String(err)}`);
- }
- throw err;
+ const { queued } = await this.smsClient.sendSms({
+ to: recipient,
+ message,
+ });
+ if (!queued) {
+ this.logger.error(`SMS to ${recipient} was not queued to RabbitMQ`);
}
+ return queued;
}
}
diff --git a/apps/edr-freight-api/src/modules/otp/otp.service.ts b/apps/edr-freight-api/src/modules/otp/otp.service.ts
index d5688e1c2..a7361fbdd 100644
--- a/apps/edr-freight-api/src/modules/otp/otp.service.ts
+++ b/apps/edr-freight-api/src/modules/otp/otp.service.ts
@@ -265,10 +265,10 @@ export class OtpService {
/**
* SMS half of {@link dispatchEmail}; same swallow-and-report contract. Sent
- * via NotificationsService's direct-HTTP Ozeking strategy — the same
- * transport the notification system uses — rather than the RabbitMQ
- * `SMS_SERVICE` queue, so `queued: true` here means the gateway accepted the
- * request, not just that a broker took ownership of the message.
+ * via NotificationsService's `directSend`, which now routes through the
+ * same RabbitMQ `SMS_SERVICE` queue as every other SMS in freight-api, so
+ * `queued: true` here means the broker confirmed ownership of the message,
+ * not that the carrier delivered it.
*/
private async dispatchSms(
phone: string,
diff --git a/apps/edr-freight-api/src/modules/routes/purge-guard.spec.ts b/apps/edr-freight-api/src/modules/routes/purge-guard.spec.ts
new file mode 100644
index 000000000..c737f18be
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/routes/purge-guard.spec.ts
@@ -0,0 +1,61 @@
+import { ConflictException, NotFoundException } from '@nestjs/common';
+import { RoutesService } from './routes.service';
+
+const ROUTE = {
+ id: 'r1',
+ originYard: { code: 'ADD', label: 'Addis' },
+ destinationYard: { code: 'DIR', label: 'Dire Dawa' },
+ milestones: [],
+};
+
+const makeService = (scheduleCount: number, route: unknown = ROUTE) => {
+ const deletes: string[] = [];
+ const manager = {
+ getRepository: (entity: { name?: string }) => ({
+ delete: async () => {
+ deletes.push(entity?.name ?? 'unknown');
+ },
+ }),
+ };
+ const dataSource = {
+ query: jest.fn(async () => [{ count: scheduleCount }]),
+ transaction: jest.fn(async (cb: (m: unknown) => Promise) => cb(manager)),
+ };
+ const routesRepository = {};
+ const svc = new RoutesService(dataSource as never, routesRepository as never);
+ // findById is the service's own loader; stub it to isolate the purge guard.
+ (svc as unknown as { findById: (id: string) => Promise }).findById =
+ async () => {
+ if (!route) throw new NotFoundException('Route not found');
+ return route;
+ };
+ return { svc, dataSource, deletes };
+};
+
+describe('RoutesService.purge', () => {
+ it('purges a route no schedule references', async () => {
+ const { svc, dataSource, deletes } = makeService(0);
+ await svc.purge('r1');
+ expect(dataSource.transaction).toHaveBeenCalled();
+ // Milestones then the route itself, inside one transaction.
+ expect(deletes).toHaveLength(2);
+ });
+
+ it('refuses while train schedules reference it', async () => {
+ const { svc, dataSource } = makeService(4);
+ await expect(svc.purge('r1')).rejects.toThrow(ConflictException);
+ await expect(svc.purge('r1')).rejects.toThrow(/4 train schedule\(s\)/);
+ expect(dataSource.transaction).not.toHaveBeenCalled();
+ });
+
+ it('names the route in the refusal so the message is actionable', async () => {
+ const { svc } = makeService(1);
+ await expect(svc.purge('r1')).rejects.toThrow(/ADD|Addis/);
+ });
+
+ it('propagates a not-found route', async () => {
+ const { svc, dataSource } = makeService(0, null);
+ await expect(svc.purge('nope')).rejects.toThrow(NotFoundException);
+ expect(dataSource.transaction).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/edr-freight-api/src/modules/routes/routes.controller.ts b/apps/edr-freight-api/src/modules/routes/routes.controller.ts
index 259dd4c9f..ed61f08b0 100644
--- a/apps/edr-freight-api/src/modules/routes/routes.controller.ts
+++ b/apps/edr-freight-api/src/modules/routes/routes.controller.ts
@@ -1,7 +1,23 @@
-import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
+import {
+ Body,
+ Controller,
+ Delete,
+ Get,
+ HttpCode,
+ HttpStatus,
+ Param,
+ ParseUUIDPipe,
+ Patch,
+ Post,
+ Query,
+} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
-import { FleetManage, FleetView } from '../../common/booking-guards';
+import {
+ BookingStaff,
+ FleetManage,
+ FleetView,
+} from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { CreateRouteDto } from './dto/create-route.dto';
import { FilterRoutesDto } from './dto/filter-routes.dto';
@@ -48,6 +64,21 @@ export class RoutesController {
return this.routesService.update(id, dto);
}
+ // Declared before @Delete(':id') so "permanent" is never captured as an id.
+ // BookingStaff, not FleetManage: the latter also accepts the coarse
+ // fleet:manage key, which would hand an irreversible purge to everyone who
+ // can edit the fleet. This action requires its own grant, nothing else.
+ @Delete(':id/permanent')
+ @BookingStaff(FREIGHT_PERMS.routes.hardDelete)
+ @HttpCode(HttpStatus.NO_CONTENT)
+ @ApiOperation({
+ summary:
+ 'Permanently delete a route (irreversible; refused while any train schedule references it)',
+ })
+ purge(@Param('id', ParseUUIDPipe) id: string) {
+ return this.routesService.purge(id);
+ }
+
@Delete(':id')
@FleetManage(FREIGHT_PERMS.routes.delete)
@ApiOperation({ summary: 'Deactivate route' })
diff --git a/apps/edr-freight-api/src/modules/routes/routes.service.ts b/apps/edr-freight-api/src/modules/routes/routes.service.ts
index 8c2989b87..017c23551 100644
--- a/apps/edr-freight-api/src/modules/routes/routes.service.ts
+++ b/apps/edr-freight-api/src/modules/routes/routes.service.ts
@@ -206,6 +206,41 @@ export class RoutesService {
return this.findById(id);
}
+ /**
+ * Permanently purge a route — irreversible, and only for corridors nothing
+ * has run on: a mistyped or duplicated definition.
+ *
+ * `train_schedules.route_id` is NO ACTION, so Postgres would reject the
+ * delete with a raw constraint error; the schedules are counted up front
+ * instead so the refusal says what is blocking. The route's own milestones
+ * cascade with it, which is correct — they are the route's definition, not
+ * history that outlives it. Deactivating (`deactivate`) stays the answer for
+ * a corridor that has actually been used.
+ */
+ async purge(id: string): Promise {
+ const route = await this.findById(id);
+
+ const [schedules] = await this.dataSource.query>(
+ `SELECT count(*)::int AS count
+ FROM freight.train_schedules
+ WHERE route_id = $1`,
+ [id],
+ );
+
+ if (schedules?.count > 0) {
+ throw new ConflictException(
+ `Route ${formatRouteLabel(route)} cannot be permanently deleted — ${schedules.count} train schedule(s) still reference it. Deactivate it instead, which keeps the history intact.`,
+ );
+ }
+
+ await this.dataSource.transaction(async (manager) => {
+ // Milestones are FK-cascaded, but delete them explicitly so the intent is
+ // visible here rather than depending on the constraint alone.
+ await manager.getRepository(RouteMilestone).delete({ routeId: id });
+ await manager.getRepository(Route).delete({ id });
+ });
+ }
+
/**
* A route IS its ordered stop list — "Addis → Adama → Dire Dawa" and
* "Addis → Dire Dawa" share endpoints but are different corridors. So the
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts
index 6cdef8c05..c60dd14a6 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts
@@ -91,7 +91,16 @@ export class BookingWindowService implements OnModuleInit {
// 10-second cadence: every transition is derived from persisted timestamps
// and applied idempotently, so a finer tick only shrinks the lag between a
// deadline passing and the phase actually moving (was a full minute).
- @Cron('*/10 * * * * *', { name: 'booking-window-tick', timeZone: BATCH_TIMEZONE })
+ //
+ // Overridable because that lag is the integration suite's pacing floor: every
+ // window phase, wagon allocation and expiry it waits on lands on this tick, so
+ // a 10s cadence costs ~5s of pure latency per wait across a few hundred waits.
+ // The suite runs it at `*/1 * * * * *`. Read at class-definition time, so the
+ // env var must be set before the module is imported.
+ @Cron(process.env.BOOKING_WINDOW_TICK_CRON ?? '*/10 * * * * *', {
+ name: 'booking-window-tick',
+ timeZone: BATCH_TIMEZONE,
+ })
async tick(): Promise {
if (this.ticking) return;
this.ticking = true;
diff --git a/apps/edr-freight-api/src/modules/verifayda/fayda-callback.controller.ts b/apps/edr-freight-api/src/modules/verifayda/fayda-callback.controller.ts
index 1c5569750..7f19eb313 100644
--- a/apps/edr-freight-api/src/modules/verifayda/fayda-callback.controller.ts
+++ b/apps/edr-freight-api/src/modules/verifayda/fayda-callback.controller.ts
@@ -6,12 +6,14 @@ import { VerifaydaCallbackDto } from './verifayda.dto';
/**
* Plain acknowledgement endpoint for the Fayda redirect_uri when it points at
* the API instead of the web app (e.g. MOBILE clients or connectivity checks).
- * Registered at /callback (excluded from the global /api prefix in main.ts).
+ * Registered at /fayda/callback (excluded by exact path from the global /api
+ * prefix in main.ts — the exclusion must NOT be widened to "fayda", or
+ * /api/fayda/verification/* loses its prefix too).
* It does NOT consume the verification session — the client must still call
* GET /api/fayda/verification/complete with the echoed code+state.
*/
@ApiTags('Fayda Verification')
-@Controller('callback')
+@Controller('fayda/callback')
export class FaydaCallbackController {
@Get()
@IsPublic()
diff --git a/apps/edr-freight-api/src/modules/wagons/purge-guard.spec.ts b/apps/edr-freight-api/src/modules/wagons/purge-guard.spec.ts
new file mode 100644
index 000000000..ab86cea51
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/wagons/purge-guard.spec.ts
@@ -0,0 +1,61 @@
+import { ConflictException, NotFoundException } from '@nestjs/common';
+import { WagonsService } from './wagons.service';
+
+// Minimal stubs: only what purge() touches.
+const makeService = (wagon: any, counts: [number, number, number], pinned = false) => {
+ const wagonRepo = { findOne: jest.fn().mockResolvedValue(wagon), remove: jest.fn().mockResolvedValue(undefined) };
+ // Keyed off the SQL so the stub survives repeated purge() calls in one test.
+ const dataSource = {
+ query: jest.fn(async (sql: string) => {
+ if (sql.includes('train_schedule')) return pinned ? [{ x: 1 }] : [];
+ if (sql.includes('wagon_movements')) return [{ count: counts[0] }];
+ if (sql.includes('containers')) return [{ count: counts[1] }];
+ if (sql.includes('train_set_wagons')) return [{ count: counts[2] }];
+ return [];
+ }),
+ };
+ const svc = new WagonsService(wagonRepo as any, {} as any, dataSource as any);
+ return { svc, wagonRepo };
+};
+
+describe('WagonsService.purge', () => {
+ const clean = { id: 'w1', wagonNumber: 'W-0001', trainId: null };
+
+ it('purges a wagon with no references', async () => {
+ const { svc, wagonRepo } = makeService(clean, [0, 0, 0]);
+ await svc.purge('w1');
+ expect(wagonRepo.remove).toHaveBeenCalledWith(clean);
+ });
+
+ it('refuses when the wagon has movement history', async () => {
+ const { svc, wagonRepo } = makeService(clean, [12, 0, 0]);
+ await expect(svc.purge('w1')).rejects.toThrow(ConflictException);
+ await expect(svc.purge('w1')).rejects.toThrow(/12 movement record/);
+ expect(wagonRepo.remove).not.toHaveBeenCalled();
+ });
+
+ it('refuses when containers or train-set slots reference it', async () => {
+ const { svc, wagonRepo } = makeService(clean, [0, 3, 2]);
+ await expect(svc.purge('w1')).rejects.toThrow(/3 container\(s\), 2 train-set slot/);
+ expect(wagonRepo.remove).not.toHaveBeenCalled();
+ });
+
+ it('refuses a coupled wagon before any count query runs', async () => {
+ const { svc, wagonRepo } = makeService({ ...clean, trainId: 't1' }, [0, 0, 0]);
+ await expect(svc.purge('w1')).rejects.toThrow(/coupled to a train/);
+ expect(wagonRepo.remove).not.toHaveBeenCalled();
+ });
+
+ it('refuses a wagon pinned to a live schedule', async () => {
+ const { svc, wagonRepo } = makeService(clean, [0, 0, 0], true);
+ await expect(svc.purge('w1')).rejects.toThrow(/pinned to an active schedule/);
+ expect(wagonRepo.remove).not.toHaveBeenCalled();
+ });
+
+ it('404s an unknown wagon', async () => {
+ const wagonRepo = { findOne: jest.fn().mockResolvedValue(null), remove: jest.fn() };
+ const svc = new WagonsService(wagonRepo as any, {} as any, { query: jest.fn() } as any);
+ await expect(svc.purge('nope')).rejects.toThrow(NotFoundException);
+ expect(wagonRepo.remove).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts
index c792057d8..f90e9ca32 100644
--- a/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts
+++ b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts
@@ -3,6 +3,8 @@ import {
Controller,
Delete,
Get,
+ HttpCode,
+ HttpStatus,
Param,
ParseUUIDPipe,
Patch,
@@ -12,7 +14,11 @@ import {
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
-import { FleetManage, StaffReference } from '../../common/booking-guards';
+import {
+ BookingStaff,
+ FleetManage,
+ StaffReference,
+} from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { CreateWagonDto } from './dto/create-wagon.dto';
import { ListWagonsQueryDto } from './dto/list-wagons-query.dto';
@@ -69,6 +75,21 @@ export class WagonsController {
return this.wagonsService.update(id, dto);
}
+ // Declared before @Delete(':id') so "permanent" is never captured as an id.
+ // BookingStaff, not FleetManage: the latter also accepts the coarse
+ // fleet:manage key, which would hand an irreversible purge to everyone who
+ // can edit the fleet. This action requires its own grant, nothing else.
+ @Delete(':id/permanent')
+ @BookingStaff(FREIGHT_PERMS.wagons.hardDelete)
+ @HttpCode(HttpStatus.NO_CONTENT)
+ @ApiOperation({
+ summary:
+ 'Permanently delete a wagon (irreversible; refused if it has movements, containers or train-set slots)',
+ })
+ purge(@Param('id', ParseUUIDPipe) id: string) {
+ return this.wagonsService.purge(id);
+ }
+
@Delete(':id')
@FleetManage(FREIGHT_PERMS.wagons.delete)
@ApiOperation({ summary: 'Delete a wagon' })
diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts
index f51846e2d..038ff83b3 100644
--- a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts
+++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts
@@ -212,6 +212,75 @@ export class WagonsService {
await this.wagonRepo.softRemove(wagon);
}
+ /**
+ * Permanently purge a wagon — irreversible, and only for rows that carry no
+ * history: a mistyped or duplicated entry someone wants gone for good.
+ *
+ * `wagon_movements` cascades on delete, so a wagon with movements would take
+ * its ledger history down with it. Rather than allow that, every reference is
+ * checked first and the purge is refused if any exist — soft delete (`remove`)
+ * stays the answer for a wagon that has actually been used.
+ *
+ * Soft-deleted wagons are purgeable, so `withDeleted` is used to find them.
+ */
+ async purge(id: string): Promise {
+ const wagon = await this.wagonRepo.findOne({
+ where: { id },
+ withDeleted: true,
+ });
+ if (!wagon) {
+ throw new NotFoundException(`Wagon ${id} not found`);
+ }
+
+ if (wagon.trainId != null) {
+ throw new ConflictException(
+ `Wagon ${wagon.wagonNumber} is coupled to a train; detach it via train-builder before deleting it permanently`,
+ );
+ }
+ if (await this.isWagonPinnedToLiveSchedule(id)) {
+ throw new ConflictException(
+ `Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be deleted permanently`,
+ );
+ }
+
+ // Each of these would either lose history (movements cascade) or silently
+ // blank a live reference (containers / train-set slots are SET NULL).
+ const blockers: string[] = [];
+ const [movements, containers, trainSetSlots] = await Promise.all([
+ this.dataSource.query(
+ `SELECT count(*)::int AS count FROM freight.wagon_movements WHERE wagon_id = $1`,
+ [id],
+ ),
+ this.dataSource.query(
+ `SELECT count(*)::int AS count FROM freight.containers WHERE wagon_id = $1`,
+ [id],
+ ),
+ this.dataSource.query(
+ `SELECT count(*)::int AS count FROM freight.train_set_wagons WHERE physical_wagon_id = $1`,
+ [id],
+ ),
+ ]);
+ if (movements[0]?.count > 0) {
+ blockers.push(`${movements[0].count} movement record(s)`);
+ }
+ if (containers[0]?.count > 0) {
+ blockers.push(`${containers[0].count} container(s)`);
+ }
+ if (trainSetSlots[0]?.count > 0) {
+ blockers.push(`${trainSetSlots[0].count} train-set slot(s)`);
+ }
+
+ if (blockers.length > 0) {
+ throw new ConflictException(
+ `Wagon ${wagon.wagonNumber} cannot be permanently deleted — it still has ${blockers.join(
+ ', ',
+ )}. Delete it normally instead, which keeps the history intact.`,
+ );
+ }
+
+ await this.wagonRepo.remove(wagon);
+ }
+
/**
* A wagon is busy when any live (DRAFT/SCHEDULED/DISPATCHED) schedule pins it
* to one of its slots — schedule occupancy lives on TrainSetWagon rows, not
diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts
index 4011bc14f..d91ac2baf 100644
--- a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts
@@ -1,8 +1,7 @@
import { Module, forwardRef } from '@nestjs/common';
-import { ConfigService } from '@nestjs/config';
-import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
import { TypeOrmModule } from '@nestjs/typeorm';
+import { registerExchangeModule } from '../exchange-settings/exchange-module-options';
import { BillingModule } from '../billing/billing.module';
import { DocumentsModule } from '../billing/documents/documents.module';
import { FilesModule } from '../files/files.module';
@@ -78,11 +77,7 @@ import { WarehousesService } from './warehouses.service';
NotificationsModule,
NotificationInboxModule,
SignaturesModule,
- ExchangeModule.forRootAsync({
- inject: [ConfigService],
- useFactory: (config: ConfigService): ExchangeOptions =>
- config.get('app.cbeExchange') ?? {},
- }),
+ registerExchangeModule(),
],
controllers: [
WarehousesController,
diff --git a/apps/edr-freight-api/src/scripts/seed-warehouse-demo.ts b/apps/edr-freight-api/src/scripts/seed-warehouse-demo.ts
index 24118d882..593abb305 100644
--- a/apps/edr-freight-api/src/scripts/seed-warehouse-demo.ts
+++ b/apps/edr-freight-api/src/scripts/seed-warehouse-demo.ts
@@ -5,6 +5,7 @@ import { resolve } from 'path';
config({ path: resolve(__dirname, '../../.env') });
import { NestFactory } from '@nestjs/core';
+import { DataSource } from 'typeorm';
import { AppModule } from '../app.module';
import { Batch14TestDataSeeder } from '../seed/batch1-4-test-data.seeder';
import { Batch5TestDataSeeder } from '../seed/batch5-test-data.seeder';
@@ -14,19 +15,33 @@ import { IndodeFacilitySeeder } from '../seed/indode-facility.seeder';
import { PricingDataSeeder } from '../seed/pricing-data.seeder';
import { WarehouseDemoSeeder } from '../seed/warehouse-demo.seeder';
+/** Demo data only — refuse to run against anything but a local dev database. */
+function assertLocalhost() {
+ const host = process.env.DB_HOST ?? 'localhost';
+ if (host !== 'localhost' && host !== '127.0.0.1') {
+ console.error(`Refusing to seed demo data: DB_HOST is "${host}", not localhost.`);
+ process.exit(1);
+ }
+}
+
async function main() {
+ assertLocalhost();
+
const app = await NestFactory.createApplicationContext(AppModule, {
logger: ['error', 'warn', 'log'],
});
try {
- await app.get(PricingDataSeeder).run();
- await app.get(IndodeFacilitySeeder).run();
- await app.get(Batch14TestDataSeeder).run();
- await app.get(Batch5TestDataSeeder).run();
- await app.get(Batch7TestDataSeeder).run();
- await app.get(Batch8TestDataSeeder).run();
- await app.get(WarehouseDemoSeeder).run();
+ // Demo seeders are intentionally not AppModule providers (they'd run on every
+ // boot), so construct them against the app's DataSource instead of via DI.
+ const dataSource = app.get(DataSource);
+ await new PricingDataSeeder(dataSource).run();
+ await new IndodeFacilitySeeder(dataSource).run();
+ await new Batch14TestDataSeeder(dataSource).run();
+ await new Batch5TestDataSeeder(dataSource).run();
+ await new Batch7TestDataSeeder(dataSource).run();
+ await new Batch8TestDataSeeder(dataSource).run();
+ await new WarehouseDemoSeeder(dataSource).run();
console.log('Warehouse demo data seeded.');
} finally {
diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts
index 9190316cd..c30a2ebda 100644
--- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts
+++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts
@@ -235,6 +235,7 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [
perm('e1a00001-0001-4000-8000-000000000002', 'edr_freight_app:locomotives:create', 'Create locomotive'),
perm('e1a00001-0001-4000-8000-000000000003', 'edr_freight_app:locomotives:update', 'Update locomotive'),
perm('e1a00001-0001-4000-8000-000000000004', 'edr_freight_app:locomotives:delete', 'Delete locomotive'),
+ perm('e1a00001-0001-4000-8000-000000000005', 'edr_freight_app:locomotives:hard_delete', 'Permanently delete locomotive'),
perm('e1b00001-0001-4000-8000-000000000001', 'edr_freight_app:wagons:view', 'View wagons'),
perm('e1b00001-0001-4000-8000-000000000002', 'edr_freight_app:wagons:create', 'Create wagon'),
perm('e1b00001-0001-4000-8000-000000000003', 'edr_freight_app:wagons:update', 'Update wagon'),
@@ -248,6 +249,7 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [
perm('e1b00001-0001-4000-8000-000000000008', 'edr_freight_app:wagons:transfer_view', 'View wagon transfer requests'),
perm('e1b00001-0001-4000-8000-000000000009', 'edr_freight_app:wagons:transfer_cancel', 'Withdraw a wagon transfer request'),
perm('e1b00001-0001-4000-8000-00000000000a', 'edr_freight_app:wagons:transfer_close_short', 'Close a transfer request short of the requested count'),
+ perm('e1b00001-0001-4000-8000-00000000000b', 'edr_freight_app:wagons:hard_delete', 'Permanently delete wagon'),
perm('e1c00001-0001-4000-8000-000000000001', 'edr_freight_app:trains:view', 'View trains'),
perm('e1c00001-0001-4000-8000-000000000002', 'edr_freight_app:trains:create', 'Create train'),
perm('e1c00001-0001-4000-8000-000000000003', 'edr_freight_app:trains:update', 'Update train'),
@@ -257,6 +259,7 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [
perm('e1d00001-0001-4000-8000-000000000002', 'edr_freight_app:routes:create', 'Create route'),
perm('e1d00001-0001-4000-8000-000000000003', 'edr_freight_app:routes:update', 'Update route'),
perm('e1d00001-0001-4000-8000-000000000004', 'edr_freight_app:routes:delete', 'Delete route'),
+ perm('e1d00001-0001-4000-8000-000000000005', 'edr_freight_app:routes:hard_delete', 'Permanently delete route'),
perm('e1e00001-0001-4000-8000-000000000001', 'edr_freight_app:containers:view', 'View containers'),
perm('e1e00001-0001-4000-8000-000000000002', 'edr_freight_app:containers:create', 'Create container'),
perm('e1e00001-0001-4000-8000-000000000003', 'edr_freight_app:containers:update', 'Update container'),
@@ -530,12 +533,19 @@ export const FREIGHT_PERMS = {
create: 'edr_freight_app:locomotives:create',
update: 'edr_freight_app:locomotives:update',
delete: 'edr_freight_app:locomotives:delete',
+ /**
+ * Permanently purge the row — irreversible, and separate from `delete`
+ * (which only decommissions) so it can be granted to far fewer people.
+ */
+ hardDelete: 'edr_freight_app:locomotives:hard_delete',
},
wagons: {
view: 'edr_freight_app:wagons:view',
create: 'edr_freight_app:wagons:create',
update: 'edr_freight_app:wagons:update',
delete: 'edr_freight_app:wagons:delete',
+ /** Permanently purge the row — irreversible; see locomotives.hardDelete. */
+ hardDelete: 'edr_freight_app:wagons:hard_delete',
// Requester creates a transfer request; OCC fulfils it (picks the wagons and
// executes the move). Distinct keys so OCC can hold fulfil without request.
transferRequest: 'edr_freight_app:wagons:transfer_request',
@@ -562,6 +572,8 @@ export const FREIGHT_PERMS = {
create: 'edr_freight_app:routes:create',
update: 'edr_freight_app:routes:update',
delete: 'edr_freight_app:routes:delete',
+ /** Permanently purge the row — irreversible; see locomotives.hardDelete. */
+ hardDelete: 'edr_freight_app:routes:hard_delete',
},
containers: {
view: 'edr_freight_app:containers:view',
diff --git a/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts b/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts
index d8e585a35..d669e0335 100644
--- a/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts
+++ b/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts
@@ -2,8 +2,10 @@ import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { Booking } from '../modules/bookings/entities/booking.entity';
+import { CustomerTruckAssignment } from '../modules/bookings/entities/customer-truck-assignment.entity';
import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity';
import { CompanyProfile } from '../modules/companies/entities/company-profile.entity';
+import { EmptyContainerReturn } from '../modules/import-operations/entities/empty-container-return.entity';
import { Locomotive } from '../modules/locomotives/entities/locomotive.entity';
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
import { Yard } from '../modules/rule-engine/entities/yard.entity';
@@ -23,6 +25,8 @@ import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.ent
* Import → Arrive Queue : an ARRIVED import train with IN_TRANSIT bookings (no inventory)
* Import → Unloaded Queue : UNLOADED import inventory
* Import → Dispatch Queue : READY_FOR_PICKUP import inventory (PASSED)
+ * Import → Import Trucks : a customer self-haul truck assigned to an unloaded booking
+ * Import → Container Returns : empty container returns at two different statuses
*
* Idempotent: guarded on a sentinel booking reference. Uses dedicated WH-DEMO-* references so it
* never collides with other seeders. To repopulate after items are walked through their lifecycle,
@@ -166,16 +170,19 @@ export class WarehouseDemoSeeder {
}
// 4) Import Unloaded Queue — UNLOADED import inventory (not inspected, not stored).
+ let firstUnloadedBooking: Booking | null = null;
for (let i = 1; i <= 3; i++) {
const b = await makeBooking(`WH-DEMO-UNL-${i}`, 'IMPORT', 'IN_TRANSIT', 5000 + i * 500, i);
await makeInventory(b, 'UNLOADED', 5000 + i * 500, {
arrivedAt: ago(90),
unloadedAt: ago(45),
});
+ firstUnloadedBooking ??= b;
created++;
}
// 5) Import Dispatch Queue — READY_FOR_PICKUP import inventory (inspection PASSED).
+ let firstPickupBooking: Booking | null = null;
for (let i = 1; i <= 3; i++) {
const b = await makeBooking(`WH-DEMO-PKR-${i}`, 'IMPORT', 'IN_TRANSIT', 5500 + i * 500, i);
await makeInventory(b, 'READY_FOR_PICKUP', 5500 + i * 500, {
@@ -185,6 +192,50 @@ export class WarehouseDemoSeeder {
inspectedAt: ago(120),
readyForPickupAt: ago(60),
});
+ firstPickupBooking ??= b;
+ created++;
+ }
+
+ // 6) Import Trucks / booking Trucks tab — a customer self-haul truck on the unloaded booking.
+ if (firstUnloadedBooking) {
+ await this.dataSource.getRepository(CustomerTruckAssignment).save(
+ this.dataSource.getRepository(CustomerTruckAssignment).create({
+ bookingId: firstUnloadedBooking.id,
+ plateNumber: 'WH-DEMO-3210',
+ driverName: 'Demo Driver',
+ truckType: 'FLATBED',
+ assignedAt: ago(80),
+ arrivedAt: ago(50),
+ }),
+ );
+ created++;
+ }
+
+ // 7) Container Returns — two empty returns at different stages of the return workflow.
+ if (firstPickupBooking) {
+ const returnRepo = this.dataSource.getRepository(EmptyContainerReturn);
+ await returnRepo.save(
+ returnRepo.create({
+ containerNumber: 'WHDU1234561',
+ bookingId: firstPickupBooking.id,
+ returnDate: ago(20),
+ facility: 'Indode',
+ status: 'RETURNED',
+ returnedBy: 'CUSTOMER',
+ statusHistory: [],
+ }),
+ );
+ await returnRepo.save(
+ returnRepo.create({
+ containerNumber: 'WHDU1234562',
+ bookingId: firstPickupBooking.id,
+ returnDate: ago(90),
+ facility: 'Indode',
+ status: 'DOCUMENTATION_CLEARED',
+ returnedBy: 'CUSTOMER',
+ statusHistory: [],
+ }),
+ );
created++;
}
diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx
index 4dc1fb9e9..51fcd7e42 100644
--- a/apps/edr-freight-web/backoffice/src/App.tsx
+++ b/apps/edr-freight-web/backoffice/src/App.tsx
@@ -292,7 +292,10 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
label: "Locomotives",
href: "/dashboard/locomotives",
icon: ,
- permission: [FREIGHT_PERMS.locomotives.view, FREIGHT_PERMS.fleet.view],
+ permission: [
+ FREIGHT_PERMS.locomotives.view,
+ FREIGHT_PERMS.fleet.view,
+ ],
},
{
label: "Train Builder",
@@ -818,7 +821,7 @@ const App = () => {
{UserManagementRoutes()}
{/* } /> */}
} />
- } />
+ } />
} />
{
} />
} />
} />
- } />
+ }
+ />
} />
} />
} />
@@ -1173,7 +1179,9 @@ const App = () => {
+
}
@@ -1181,7 +1189,12 @@ const App = () => {
+
}
@@ -1189,7 +1202,9 @@ const App = () => {
+
}
@@ -1197,7 +1212,9 @@ const App = () => {
+
}
@@ -1205,7 +1222,9 @@ const App = () => {
+
}
@@ -1213,7 +1232,9 @@ const App = () => {
+
}
@@ -1221,7 +1242,9 @@ const App = () => {
+
}
@@ -1242,7 +1265,12 @@ const App = () => {
+
}
@@ -1250,7 +1278,12 @@ const App = () => {
+
}
@@ -1336,7 +1369,9 @@ const App = () => {
+
}
@@ -1424,7 +1459,12 @@ const App = () => {
+
}
@@ -1432,7 +1472,9 @@ const App = () => {
+
}
@@ -1440,7 +1482,9 @@ const App = () => {
+
}
@@ -1448,7 +1492,9 @@ const App = () => {
+
}
@@ -1456,7 +1502,9 @@ const App = () => {
+
}
@@ -1464,7 +1512,9 @@ const App = () => {
+
}
@@ -1485,7 +1535,12 @@ const App = () => {
+
}
@@ -1493,7 +1548,12 @@ const App = () => {
+
}
diff --git a/apps/edr-freight-web/backoffice/src/complaints/utils/complaintVerificationStorage.ts b/apps/edr-freight-web/backoffice/src/complaints/utils/complaintVerificationStorage.ts
index f7a500cfc..44441a26f 100644
--- a/apps/edr-freight-web/backoffice/src/complaints/utils/complaintVerificationStorage.ts
+++ b/apps/edr-freight-web/backoffice/src/complaints/utils/complaintVerificationStorage.ts
@@ -47,7 +47,7 @@ export function isComplaintAuthContext(pathname = ""): boolean {
pathname.startsWith("/complaints") ||
pathname === "/complaint-form" ||
pathname === "/follow-complaint" ||
- pathname === "/callback"
+ pathname === "/fayda/callback"
);
}
diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx
index 4a1cb402f..52f7ee40d 100644
--- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx
@@ -20,7 +20,6 @@ import {
Loader,
Modal,
Paper,
- SegmentedControl,
Select,
Stack,
Switch,
@@ -49,7 +48,11 @@ import {
X,
} from "lucide-react";
import type { Freight } from "@edr/types";
-import { ExportTrainPicker, OperationDatePicker } from "@edr/ui-common";
+import {
+ CurrencySelector,
+ ExportTrainPicker,
+ OperationDatePicker,
+} from "@edr/ui-common";
import { api } from "@/services/api";
import { PageContainer } from "@/components/page";
@@ -1713,47 +1716,42 @@ export default function GlCreateBookingForm() {
title="Schedule"
description="Pick the binding shipment day. Only days with an open train that has enough matching wagons for the cargo can be selected."
/>
- {cargoQuery === null ? (
- }
- >
- Enter your cargo details first — available shipment days depend
- on the wagons your cargo needs.
-
- ) : (
-
- Shipment day *
-
- {
- setScheduledDate(d);
- // A new day invalidates the old train pick.
- setTrainScheduleId("");
- }}
- />
-
- {showErrors && dateError && (
-
- {dateError}
-
- )}
- {isExportPick && scheduledDate ? (
-
- ) : null}
+
+ Shipment day *
+
+ {/* Calendar stays visible before cargo is entered — all days
+ disabled with a hint, since availability depends on cargo. */}
+ {
+ setScheduledDate(d);
+ // A new day invalidates the old train pick.
+ setTrainScheduleId("");
+ }}
+ />
- )}
+ {showErrors && dateError && (
+
+ {dateError}
+
+ )}
+ {isExportPick && scheduledDate ? (
+
+ ) : null}
+
)}
@@ -1769,16 +1767,10 @@ export default function GlCreateBookingForm() {
? "Requested by the customer on their shipment request."
: "The contract is quoted in USD — pick the currency this shipment is invoiced in."}
- setPaymentCurrency(v as "USD" | "ETB")}
+ onChange={setPaymentCurrency}
disabled={isIntercity}
- data={[
- { label: "USD", value: "USD" },
- { label: "ETB", value: "ETB" },
- ]}
- color="edr-green"
- radius={10}
/>
diff --git a/apps/edr-freight-web/backoffice/src/components/errors/ApiErrorModal.tsx b/apps/edr-freight-web/backoffice/src/components/errors/ApiErrorModal.tsx
index 07c9a39d1..4f8b0186a 100644
--- a/apps/edr-freight-web/backoffice/src/components/errors/ApiErrorModal.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/errors/ApiErrorModal.tsx
@@ -28,7 +28,7 @@ let listener: Listener | null = null;
/** Current-page path patterns where the global modal must stay silent. */
const EXCLUDED_PATH_PATTERNS = [
/^\/auth/,
- /^\/callback/,
+ /^\/fayda/,
/warehouse/i,
/first-mile/i,
/last-mile/i,
diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/FleetCardGrid.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/FleetCardGrid.tsx
index 1f8ab3f9f..09544062f 100644
--- a/apps/edr-freight-web/backoffice/src/components/fleet/FleetCardGrid.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetCardGrid.tsx
@@ -22,6 +22,8 @@ export interface FleetCardGridProps {
/** Omit to hide the action (caller lacks the update/delete permission). */
onEdit?: (record: FleetRecord) => void;
onRemove?: (record: FleetRecord) => void;
+ /** Irreversible purge — omitted unless the caller holds the hard-delete grant. */
+ onPurge?: (record: FleetRecord) => void;
}
const FleetCardGrid = ({
@@ -35,6 +37,7 @@ const FleetCardGrid = ({
onPaginationChange,
onEdit,
onRemove,
+ onPurge,
}: FleetCardGridProps) => {
const presentation = resolveFleetCardPresentation(config);
@@ -183,6 +186,7 @@ const FleetCardGrid = ({
layout="compact"
onEdit={onEdit}
onRemove={onRemove}
+ onPurge={onPurge}
/>
diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx
index f0ccf0be0..b43c03f18 100644
--- a/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx
@@ -148,7 +148,7 @@ const FleetFormDialog = ({
});
}, [open, fields]);
- // Receive the ?code&state relayed by the /callback popup, exchange it for
+ // Receive the ?code&state relayed by the /fayda/callback popup, exchange it for
// the verified identity, and prefill the matching form fields.
useEffect(() => {
if (!open || !verifyWithFayda) return;
diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx
index 6d9978f2c..4ab2c42f9 100644
--- a/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx
@@ -1,4 +1,12 @@
-import { Edit2, Trash2, Eye, Users, MoreVertical, History } from "lucide-react";
+import {
+ Edit2,
+ Trash2,
+ Eye,
+ Users,
+ MoreVertical,
+ History,
+ ShieldAlert,
+} from "lucide-react";
import { ActionIcon, Menu, MenuItem, Tooltip } from "@mantine/core";
import { useNavigate } from "react-router-dom";
@@ -11,6 +19,8 @@ export interface FleetRecordActionsProps {
/** Omit to hide the action (caller lacks the update/delete permission). */
onEdit?: (record: FleetRecord) => void;
onRemove?: (record: FleetRecord) => void;
+ /** Irreversible purge — omitted unless the caller holds the hard-delete grant. */
+ onPurge?: (record: FleetRecord) => void;
onAssignDriver?: (record: FleetRecord) => void;
onHistory?: (record: FleetRecord) => void;
onViewDetail?: (record: FleetRecord) => void;
@@ -22,6 +32,7 @@ const FleetRecordActions = ({
config,
onEdit,
onRemove,
+ onPurge,
onAssignDriver,
onHistory,
onViewDetail,
@@ -46,6 +57,7 @@ const FleetRecordActions = ({
if (
!onEdit &&
!onRemove &&
+ !onPurge &&
!showDetail &&
!showViewDetail &&
!showHistory &&
@@ -170,6 +182,15 @@ const FleetRecordActions = ({
{removeLabel}
) : null}
+ {onPurge ? (
+
+ ) : null}
);
diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx
index 01bb5ceea..49aeb17cd 100644
--- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx
@@ -153,11 +153,13 @@ const RuleEngineFormDialog = ({
buildInitialValues(fields, initialRecord),
);
const [position, setPosition] = useState(RULE_ENGINE_POSITION_END);
+ const [fieldErrors, setFieldErrors] = useState>({});
useEffect(() => {
if (open) {
setValues(buildInitialValues(fields, initialRecord));
setPosition(RULE_ENGINE_POSITION_END);
+ setFieldErrors({});
}
}, [open, fields, initialRecord]);
@@ -185,6 +187,9 @@ const RuleEngineFormDialog = ({
const formRows = useMemo(() => buildFormRows(visibleFields), [visibleFields]);
const setField = (name: string, value: unknown) => {
+ setFieldErrors((current) =>
+ current[name] ? { ...current, [name]: "" } : current,
+ );
setValues((current) => {
const next = { ...current, [name]: value };
// Changing what a rate applies to (or its surcharge trigger) can invalidate
@@ -223,6 +228,10 @@ const RuleEngineFormDialog = ({
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault();
const payload: Record = {};
+ // Required selects that are empty block the submit and mark themselves,
+ // rather than posting an incomplete payload for the API to reject.
+ setFieldErrors({});
+ let blocked = false;
for (const field of visibleFields) {
// Derived fields always submit their computed value — never stale state.
@@ -241,7 +250,16 @@ const RuleEngineFormDialog = ({
field.type === "select" &&
(raw === "" || raw === RULE_ENGINE_SELECT_NONE)
) {
+ // A required select left empty must not silently submit nothing — the
+ // API rejects the payload with a message that reads as if the admin
+ // skipped a field they never saw cleared (e.g. yards reset by a trade
+ // direction change). Surface it on the field instead.
if (!field.required) continue;
+ setFieldErrors((current) => ({
+ ...current,
+ [field.name]: `${field.label} is required.`,
+ }));
+ blocked = true;
} else if (raw === "" || raw === undefined) {
if (!field.required) continue;
payload[field.name] = raw;
@@ -254,6 +272,8 @@ const RuleEngineFormDialog = ({
payload.code = String(payload.code).toUpperCase();
}
+ if (blocked) return;
+
if (!initialRecord && positionOptions && position !== RULE_ENGINE_POSITION_END) {
payload.insertAfterId = position;
}
@@ -340,10 +360,10 @@ const RuleEngineFormDialog = ({
value={resolveSelectValue(field, values)}
onChange={(v) => setField(field.name, v === RULE_ENGINE_SELECT_NONE ? "" : v)}
disabled={selectOptionsLoading}
- // Native required blocks submit while a mandatory select is empty —
- // without it the form posts and the API 400s (e.g. a container
- // customs/lashing rate with no container type picked).
+ // Mantine's Select is not a native input, so `required` only marks it
+ // visually — handleSubmit is what actually blocks an empty one.
required={field.required}
+ error={fieldErrors[field.name] || undefined}
data={options
.filter((opt) => opt.value !== "")
.map((opt) => ({
diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
index 0e3facead..d8993df04 100644
--- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts
+++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
@@ -53,6 +53,10 @@ export const URL_CONSTANTS = {
NOTIFICATIONS: "/settings/notifications",
},
+ EXCHANGE_SETTINGS: {
+ BASE: "/exchange-settings",
+ },
+
DROPDOWN_SETTINGS: {
BASE: "/dropdown-settings",
BY_ID: (id: string) => `/api/dropdown-settings/${id}`,
diff --git a/apps/edr-freight-web/backoffice/src/hooks/useExchangeSettings.ts b/apps/edr-freight-web/backoffice/src/hooks/useExchangeSettings.ts
new file mode 100644
index 000000000..d5fca3c6e
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/hooks/useExchangeSettings.ts
@@ -0,0 +1,34 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useTranslation } from "react-i18next";
+import { toast } from "sonner";
+
+import { exchangeSettingsService } from "@/services/exchangeSettings.service";
+import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
+
+const QUERY_KEY = ["exchangeSettings"];
+
+export const useExchangeSettingsQuery = () =>
+ useQuery({
+ queryKey: QUERY_KEY,
+ queryFn: () => exchangeSettingsService.get(),
+ // Feed health is only interesting while it is being looked at.
+ staleTime: 30_000,
+ refetchOnWindowFocus: true,
+ });
+
+export const useSetExchangeFallbackRate = () => {
+ const queryClient = useQueryClient();
+ const { t } = useTranslation();
+ const { handleError } = useErrorHandler(t);
+
+ return useMutation({
+ mutationFn: (rate: number) => exchangeSettingsService.setFallbackRate(rate),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: QUERY_KEY });
+ toast.success(
+ t("exchangeSettings.updated", "Fallback exchange rate updated"),
+ );
+ },
+ onError: handleError,
+ });
+};
diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts
index e50f93c2c..bc70c95a2 100644
--- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts
+++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts
@@ -122,12 +122,16 @@ export const FREIGHT_PERMS = {
create: "edr_freight_app:locomotives:create",
update: "edr_freight_app:locomotives:update",
delete: "edr_freight_app:locomotives:delete",
+ /** Permanent purge — irreversible, granted separately from `delete`. */
+ hardDelete: "edr_freight_app:locomotives:hard_delete",
},
wagons: {
view: "edr_freight_app:wagons:view",
create: "edr_freight_app:wagons:create",
update: "edr_freight_app:wagons:update",
delete: "edr_freight_app:wagons:delete",
+ /** Permanent purge — irreversible, granted separately from `delete`. */
+ hardDelete: "edr_freight_app:wagons:hard_delete",
transferRequest: "edr_freight_app:wagons:transfer_request",
transferFulfill: "edr_freight_app:wagons:transfer_fulfill",
transferHistoryAll: "edr_freight_app:wagons:transfer_history_all",
@@ -148,6 +152,8 @@ export const FREIGHT_PERMS = {
create: "edr_freight_app:routes:create",
update: "edr_freight_app:routes:update",
delete: "edr_freight_app:routes:delete",
+ /** Permanent purge — irreversible, granted separately from `delete`. */
+ hardDelete: "edr_freight_app:routes:hard_delete",
},
containers: {
view: "edr_freight_app:containers:view",
@@ -598,6 +604,19 @@ export function canFleetAction(
);
}
+/**
+ * Permanent-purge check for locomotives and wagons. Unlike
+ * {@link canFleetAction} this does NOT fall back to the coarse fleet:manage
+ * key — an irreversible delete needs its own grant, and the API guards these
+ * endpoints the same way.
+ */
+export function canFleetHardDelete(
+ user: AuthUser | null | undefined,
+ resource: "locomotives" | "wagons" | "routes",
+): boolean {
+ return hasPermission(user, FREIGHT_PERMS[resource].hardDelete);
+}
+
export function isFreightAdmin(user: AuthUser | null | undefined): boolean {
return hasPermission(user, FREIGHT_PERMS.admin);
}
diff --git a/apps/edr-freight-web/backoffice/src/pages/FaydaCallbackPage.tsx b/apps/edr-freight-web/backoffice/src/pages/FaydaCallbackPage.tsx
index 5febb664a..48b9d32ce 100644
--- a/apps/edr-freight-web/backoffice/src/pages/FaydaCallbackPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/FaydaCallbackPage.tsx
@@ -5,7 +5,7 @@ import type { FaydaCallbackMessage } from "@/services/verifayda.service";
/**
* Landing page for the eSignet redirect_uri (FAYDA_WEB_REDIRECT_URI →
- * http://localhost:5183/callback). Runs inside the verification popup:
+ * http://localhost:5183/fayda/callback). Runs inside the verification popup:
* relays ?code&state (or ?error) to the window that opened it via
* postMessage, then closes itself. The opener performs the /complete call
* so the single-use session is only consumed once, in one place.
diff --git a/apps/edr-freight-web/backoffice/src/pages/SettingsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/SettingsPage.tsx
index 8f4281c14..d005064b0 100644
--- a/apps/edr-freight-web/backoffice/src/pages/SettingsPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/SettingsPage.tsx
@@ -33,6 +33,7 @@ import {
AlertDialogTitle,
} from "@/shared/common/ui/alert-dialog";
import { toast } from "sonner";
+import ExchangeRateSettingsCard from "./settings/ExchangeRateSettingsCard";
export default function SettingsPage() {
const [createDialogOpen, setCreateDialogOpen] = useState(false);
@@ -77,6 +78,8 @@ export default function SettingsPage() {
return (
+
+
diff --git a/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplateEditorPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplateEditorPage.tsx
index 17977b743..42942b3e7 100644
--- a/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplateEditorPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplateEditorPage.tsx
@@ -21,20 +21,23 @@ import {
Title,
Tooltip,
} from "@mantine/core";
+import ReactQuill from "react-quill-new";
+import "react-quill-new/dist/quill.snow.css";
+
import {
AlertTriangle,
ArrowDown,
+ ArrowLeftRight,
ArrowUp,
+ Boxes,
Building2,
+ Container,
CalendarClock,
CalendarDays,
CalendarRange,
ChevronDown,
Coins,
Hash,
- ListOrdered,
- ListPlus,
- ListTree,
Mail,
MapPin,
Package,
@@ -58,9 +61,10 @@ import {
useUpdateContractTemplate,
} from "@/hooks/contract-templates/useContractTemplates";
import type { ContractTemplateArticle } from "@/services/contract-templates.service";
+import { bodyToHtml, htmlToBody } from "./article-html";
const BODY_HINT =
- 'One clause per line. Use "New clause" for the next number (1., 2., …), "Sub-clause" for a nested number (1.1, then 1.1.1), and "Bullet" for a • point — the number or bullet is typed for you, just add the text. Placeholders are filled from the contract when the document is generated.';
+ "Enter starts a new line. Use the numbered list for clauses and Tab (or Indent) to nest — levels number 1. → a. → i. like a word processor. Numbering is assigned when the document is generated, so it always comes out sequential. Placeholders are filled from the contract.";
interface ArticleDraft {
id?: string;
@@ -105,6 +109,18 @@ const QUICK_PLACEHOLDERS: PlaceholderDef[] = [
icon: CalendarRange,
hint: "Year the contract is signed",
},
+ {
+ token: "{{contractStartDate}}",
+ label: "Start date",
+ icon: CalendarClock,
+ hint: "Date the contract's validity begins",
+ },
+ {
+ token: "{{contractEndDate}}",
+ label: "End date",
+ icon: CalendarClock,
+ hint: "Date the contract's validity ends",
+ },
];
const MORE_PLACEHOLDER_GROUPS: { label: string; items: PlaceholderDef[] }[] = [
@@ -170,6 +186,42 @@ const MORE_PLACEHOLDER_GROUPS: { label: string; items: PlaceholderDef[] }[] = [
icon: Package,
hint: "Description of the cargo",
},
+ {
+ token: "{{schedule.cargoTypeName}}",
+ label: "Cargo type",
+ icon: Package,
+ hint: "Named commodity on its own, e.g. Coffee",
+ },
+ {
+ token: "{{schedule.containerType}}",
+ label: "Container type",
+ icon: Container,
+ hint: "Container size, e.g. 20ft / 40ft — dash for bulk",
+ },
+ {
+ token: "{{schedule.cargoSummary}}",
+ label: "Cargo summary",
+ icon: Boxes,
+ hint: "Every cargo line, e.g. Coffee (40ft) × 12",
+ },
+ {
+ token: "{{schedule.tradeDirection}}",
+ label: "Trade direction",
+ icon: ArrowLeftRight,
+ hint: "IMPORT / EXPORT / DOMESTIC",
+ },
+ {
+ token: "{{schedule.freightType}}",
+ label: "Freight type",
+ icon: Boxes,
+ hint: "CONTAINER or BULK",
+ },
+ {
+ token: "{{schedule.hazardousLabel}}",
+ label: "Hazardous",
+ icon: AlertTriangle,
+ hint: "Declared hazard class + UN number, or No",
+ },
{
token: "{{schedule.totalWeightVgm}}",
label: "Total weight",
@@ -237,6 +289,22 @@ const ALL_PLACEHOLDERS: PlaceholderDef[] = [
...MORE_PLACEHOLDER_GROUPS.flatMap((g) => g.items),
];
+/**
+ * Deliberately narrow toolbar: the stored body carries STRUCTURE only (clause
+ * depth + bullets), which is what the contract renderer numbers and lays out.
+ * Bold/colour/font would be dropped on save, so they are not offered —
+ * an author never loses formatting they were allowed to apply.
+ */
+const QUILL_MODULES = {
+ toolbar: [
+ [{ list: "ordered" }, { list: "bullet" }],
+ [{ indent: "-1" }, { indent: "+1" }],
+ ["clean"],
+ ],
+};
+
+const QUILL_FORMATS = ["list", "indent"];
+
const KNOWN_TOKENS = new Set([
...ALL_PLACEHOLDERS.map((p) => p.token),
// Still filled by the renderer, just no longer offered as an insert button.
@@ -282,6 +350,49 @@ function matchDepth(match: RegExpExecArray | null): number | null {
/** Deepest supported sub-clause level. */
const MAX_CLAUSE_DEPTH = 6;
+/** 1 → "a", 2 → "b", … 27 → "aa". Mirrors the API's `toAlpha`. */
+function toAlpha(n: number): string {
+ let out = "";
+ let value = n;
+ while (value > 0) {
+ const rem = (value - 1) % 26;
+ out = String.fromCharCode(97 + rem) + out;
+ value = Math.floor((value - 1) / 26);
+ }
+ return out || "a";
+}
+
+const ROMAN: Array<[number, string]> = [
+ [1000, "m"], [900, "cm"], [500, "d"], [400, "cd"],
+ [100, "c"], [90, "xc"], [50, "l"], [40, "xl"],
+ [10, "x"], [9, "ix"], [5, "v"], [4, "iv"], [1, "i"],
+];
+
+/** 1 → "i", 4 → "iv". Mirrors the API's `toRoman`. */
+function toRoman(n: number): string {
+ let value = n;
+ let out = "";
+ for (const [amount, numeral] of ROMAN) {
+ while (value >= amount) {
+ out += numeral;
+ value -= amount;
+ }
+ }
+ return out || "i";
+}
+
+/**
+ * Outline marker for a clause at its own level, cycling 1. → a. → i. by depth.
+ * Mirrors `clauseMarker` in the API's contract-article.util.ts — the preview
+ * must match the generated document exactly.
+ */
+function clauseMarker(counter: number, depth: number): string {
+ const style = (depth - 1) % 3;
+ if (style === 1) return toAlpha(counter);
+ if (style === 2) return toRoman(counter);
+ return String(counter);
+}
+
/**
* Mirror of the API renderer's rules (contract-article.util.ts): one clause per
* line; a leading outline number ("2. ", "2.1 ") nests the line as a sub-clause
@@ -311,7 +422,7 @@ function parseArticleBody(body: string): ParsedBody {
counters[depth - 1] += 1;
clauses.push({
text: match ? cleaned.slice(match[0].length).trim() : cleaned,
- number: counters.slice(0, depth).join("."),
+ number: clauseMarker(counters[depth - 1], depth),
depth,
bullets: [],
});
@@ -326,31 +437,6 @@ function parseArticleBody(body: string): ParsedBody {
return { clauses };
}
-/**
- * Rewrite the leading outline tokens in a body so every numbered clause line
- * carries its computed sequential number (stale numbers self-heal). Lines
- * without a number token and bullet lines pass through untouched.
- */
-function renumberBody(body: string): string {
- const counters: number[] = [];
- return body
- .split("\n")
- .map((raw) => {
- const line = raw.trim();
- if (!line || line.startsWith("- ")) return raw;
- const match = CLAUSE_NUMBER_RE.exec(line);
- let depth = matchDepth(match) ?? 1;
- depth = Math.min(depth, counters.length + 1);
- counters.splice(depth);
- while (counters.length < depth) counters.push(0);
- counters[depth - 1] += 1;
- if (!match) return raw;
- const number = counters.slice(0, depth).join(".");
- return `${number}. ${line.slice(match[0].length).trim()}`;
- })
- .join("\n");
-}
-
/** Render clause text with {{placeholders}} highlighted as green chips. */
function HighlightedText({ text }: { text: string }) {
const parts = text.split(/(\{\{[^{}]+\}\})/g);
@@ -674,88 +760,46 @@ function ArticleEditorModal({
}: ArticleEditorModalProps) {
const [title, setTitle] = useState(initial.title);
const [body, setBody] = useState(initial.body);
+ // Quill is uncontrolled-ish: it owns its own DOM, so seed it once from the
+ // stored body and let onChange convert edits back rather than re-deriving
+ // HTML from `body` on every keystroke (which would fight the caret).
+ const [html, setHtml] = useState(() => bodyToHtml(initial.body));
const titleRef = useRef(null);
- const bodyRef = useRef(null);
+ const quillRef = useRef(null);
// Placeholders drop into whichever field held the cursor last (body default).
const lastFocused = useRef<"title" | "body">("body");
- const insertAtCursor = (snippet: string) => {
- const isTitle = lastFocused.current === "title";
- const el = isTitle ? titleRef.current : bodyRef.current;
- const value = isTitle ? title : body;
- const start = el?.selectionStart ?? value.length;
- const end = el?.selectionEnd ?? start;
- const next = value.slice(0, start) + snippet + value.slice(end);
- if (isTitle) setTitle(next);
- else setBody(next);
- // Refocus and place the caret right after the inserted snippet once the
- // controlled re-render has flushed.
- requestAnimationFrame(() => {
- if (!el) return;
- el.focus();
- const caret = start + snippet.length;
- el.setSelectionRange(caret, caret);
- });
+ /** Body is the source of truth for saving/preview; HTML is the editor view. */
+ const applyHtml = (nextHtml: string) => {
+ setHtml(nextHtml);
+ setBody(htmlToBody(nextHtml));
};
- /**
- * Insert a structured line (clause / sub-clause / bullet) on a fresh line
- * below the one the caret is on. Clause lines get their outline number typed
- * in automatically ("3. ", "3.1. ", …) and every numbered line in the body is
- * renumbered so the text always matches the preview.
- */
- const insertStructuredLine = (kind: "clause" | "sub" | "bullet") => {
- const el = bodyRef.current;
- lastFocused.current = "body";
- const caret = el?.selectionStart ?? body.length;
- // Structured lines never split a sentence — insert after the caret's line.
- const lineEnd = body.indexOf("\n", caret);
- const insertAt = lineEnd === -1 ? body.length : lineEnd;
- const before = body.slice(0, insertAt);
- const after = body.slice(insertAt); // "" or starts with "\n"
-
- let prefix: string;
- if (kind === "bullet") {
- prefix = "- ";
- } else {
- // New clause always starts a fresh top-level number. Sub-clause nests
- // one level under a clause (1 → 1.1) but adds a SIBLING when the caret
- // is already on a sub-clause (1.1 → 1.2 → 1.3, not ever-deeper) — a
- // third level is reached by typing its number (e.g. "1.1.1 ") directly.
- const above = parseArticleBody(before);
- const lastDepth = above.paragraph
- ? 1
- : (above.clauses[above.clauses.length - 1]?.depth ?? 0);
- const depth =
- kind === "sub"
- ? lastDepth <= 1
- ? Math.min(lastDepth + 1, MAX_CLAUSE_DEPTH)
- : lastDepth
- : 1;
- // Digits are placeholders — renumberBody assigns the real value.
- prefix = `${Array.from({ length: depth }, () => "1").join(".")}. `;
+ const insertAtCursor = (snippet: string) => {
+ if (lastFocused.current === "title") {
+ const el = titleRef.current;
+ const start = el?.selectionStart ?? title.length;
+ const end = el?.selectionEnd ?? start;
+ setTitle(title.slice(0, start) + snippet + title.slice(end));
+ requestAnimationFrame(() => {
+ if (!el) return;
+ el.focus();
+ const caret = start + snippet.length;
+ el.setSelectionRange(caret, caret);
+ });
+ return;
}
-
- const beforeLines = before.length > 0 ? before.split("\n") : [];
- const afterLines =
- after.length > 0 ? after.slice(1).split("\n") : [];
- const insertedIdx = beforeLines.length;
- const joined = [...beforeLines, prefix, ...afterLines].join("\n");
- const next = kind === "bullet" ? joined : renumberBody(joined);
- setBody(next);
-
- // Caret lands at the end of the inserted line, ready for typing.
- const caretTarget = next
- .split("\n")
- .slice(0, insertedIdx + 1)
- .join("\n").length;
- requestAnimationFrame(() => {
- const field = bodyRef.current;
- if (!field) return;
- field.focus();
- field.setSelectionRange(caretTarget, caretTarget);
- });
+ // Quill tracks its own selection; insert there so the token lands where the
+ // author was typing instead of at the end of the document.
+ const editor = quillRef.current?.getEditor();
+ if (!editor) return;
+ const range = editor.getSelection(true);
+ const at = range?.index ?? editor.getLength();
+ editor.deleteText(at, range?.length ?? 0);
+ editor.insertText(at, snippet, "user");
+ editor.setSelection(at + snippet.length, 0);
+ applyHtml(editor.root.innerHTML);
};
const parsed = useMemo(() => parseArticleBody(body), [body]);
@@ -846,78 +890,24 @@ function ArticleEditorModal({
- Add structure
+ Article body
-
-
- }
- onMouseDown={(e) => e.preventDefault()}
- onClick={() => insertStructuredLine("clause")}
- >
- New clause
-
-
-
- }
- disabled={body.trim().length === 0}
- onMouseDown={(e) => e.preventDefault()}
- onClick={() => insertStructuredLine("sub")}
- >
- Sub-clause
-
-
-
- }
- disabled={body.trim().length === 0}
- onMouseDown={(e) => e.preventDefault()}
- onClick={() => insertStructuredLine("bullet")}
- >
- Bullet
-
-
-
+
+ {BODY_HINT}
+
+ (lastFocused.current = "body")}>
+
+
-