Merge branch 'dev' of github.com:Tria-plc/edr-platform into origin/freight_feature/transit

This commit is contained in:
marshal
2026-09-02 22:38:15 +00:00
425 changed files with 30919 additions and 3238 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 673 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

View File

@@ -0,0 +1,312 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>EDR Freight Portal — Priority Flows</title>
<style>
@page { size: A4; margin: 0; }
* { box-sizing: border-box; }
body {
font-family: -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
color: #1a1a1a;
margin: 0;
font-size: 13px;
line-height: 1.45;
}
.cover {
height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
padding: 60px 70px;
background: linear-gradient(135deg, #0b6b4f 0%, #0e8a63 60%, #14a074 100%);
color: white;
page-break-after: always;
}
.cover .tag { font-size: 14px; letter-spacing: 3px; text-transform: uppercase; opacity: .85; margin-bottom: 18px; }
.cover h1 { font-size: 42px; margin: 0 0 14px 0; line-height: 1.15; }
.cover p.sub { font-size: 17px; opacity: .92; max-width: 520px; }
.cover .meta { margin-top: 60px; font-size: 12px; opacity: .8; border-top: 1px solid rgba(255,255,255,.35); padding-top: 16px; max-width: 520px; }
.cover .meta div { margin-bottom: 4px; }
.toc { padding: 60px 70px; page-break-after: always; }
.toc h2 { font-size: 22px; margin-bottom: 24px; color: #0b6b4f; }
.toc ol { padding-left: 22px; }
.toc li { margin-bottom: 10px; font-size: 14px; }
.toc .section-label { font-weight: 700; color: #0b6b4f; }
section.flow { padding: 40px 60px 10px 60px; }
section.flow > h2 {
font-size: 24px;
color: #0b6b4f;
border-bottom: 3px solid #0b6b4f;
padding-bottom: 8px;
margin-bottom: 6px;
page-break-before: always;
}
section.flow > p.lede { color: #444; font-size: 13.5px; margin-bottom: 22px; }
.step { margin-bottom: 28px; page-break-inside: avoid; }
.step .step-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.step .num {
background: #0b6b4f; color: white; font-weight: 700; font-size: 13px;
width: 26px; height: 26px; border-radius: 50%;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.step .title { font-weight: 700; font-size: 14.5px; }
.step .desc { margin: 0 0 10px 36px; color: #333; font-size: 13px; }
.step img {
display: block;
max-width: 100%;
border: 1px solid #d8dde3;
border-radius: 6px;
box-shadow: 0 1px 3px rgba(0,0,0,.08);
}
.note {
margin: 10px 36px 18px 36px;
padding: 10px 14px;
border-left: 4px solid #2f7fd6;
background: #eef5fd;
border-radius: 4px;
font-size: 12.5px;
}
.note.limit { border-left-color: #c98a1c; background: #fdf6e8; }
.note.bug { border-left-color: #c0392b; background: #fbeceb; }
.note.howto { border-left-color: #0b6b4f; background: #eaf6f0; }
.note b { display: block; margin-bottom: 3px; }
.note ol { margin: 6px 0 0 0; padding-left: 18px; }
.note ol li { margin-bottom: 4px; }
</style>
</head>
<body>
<div class="cover">
<div class="tag">EDR Freight — Priority Flows</div>
<h1>Portal Side<br>Contract Signing · Booking · Payment</h1>
<p class="sub">A step-by-step walkthrough of the same three flows, driven live as the real
customer — every screen, button, and validation message as the account actually saw it.</p>
<div class="meta">
<div>Driven live against the shared dev environment (edr_dev)</div>
<div>Personas: hager@gmail.com (Hagernesh Tadesse) for contract &amp; booking; nati@gmail.com (NAti Wondish) for payment</div>
<div>Companion document to EDR-Freight-Priority-Flows.pdf (backoffice/staff side)</div>
</div>
</div>
<div class="toc">
<h2>Contents</h2>
<ol>
<li><span class="section-label">1. Contract creation &amp; signing</span> — Home, Contracts list, the New Contract wizard (Setup → Cargo/Route → Review), the unit-rate quotation approval step, the duplicate-contract validation, and how/where a customer signs once EDR approves.</li>
<li><span class="section-label">2. Booking</span> — the Bookings list and the document-review flow a customer works through on an existing booking.</li>
<li><span class="section-label">3. Payment</span> — the customer's Invoices page, filtering to what's actually payable, the invoice detail screen with its "Pay" action, and the bank-transfer steps behind it.</li>
</ol>
</div>
<section class="flow">
<h2>1 · Contract creation &amp; signing</h2>
<p class="lede">Portal customer hager@gmail.com, logged into the freight portal (localhost:5273).</p>
<div class="step">
<div class="step-head"><div class="num">1</div><div class="title">Sign in</div></div>
<div class="desc">The portal login form. A customer signs in with their email and password — no OTP step for an already-onboarded account (OTP only applies during self-signup).</div>
<img src="{{02-login-full.png}}">
</div>
<div class="step">
<div class="step-head"><div class="num">2</div><div class="title">Home</div></div>
<div class="desc">Landing page after login: quick stats and shortcuts into Contracts, Bookings, and Invoices.</div>
<img src="{{05-home-full.png}}">
</div>
<div class="step">
<div class="step-head"><div class="num">3</div><div class="title">Contracts list</div></div>
<div class="desc">The customer's contracts: one Active (CTR-2026-00043, Fully Executed) and two In Progress (CTR-2026-00086, CTR-2026-00087 — both Submitted, awaiting EDR review). "New Contract" starts a fresh one.</div>
<img src="{{04-contracts-list-full.png}}">
</div>
<div class="step">
<div class="step-head"><div class="num">4</div><div class="title">New Contract — Step 1: Setup</div></div>
<div class="desc">Operation Type, Contract Kind (One-Time vs. Framework), New-vs-Renewal, and the Service Type cards — the entry point of the wizard.</div>
<img src="{{07-contract-step1-full.png}}">
</div>
<div class="step">
<div class="step-head"><div class="num">5</div><div class="title">Step 1 filled — Operation Type + Service</div></div>
<div class="desc">Operation Type set to Import and "Rail Transport with Customs" selected — this reveals the trucking/customs options below (first mile, last mile, and the included customs clearing service).</div>
<img src="{{08-contract-step1-filled-full.png}}">
</div>
<div class="step">
<div class="step-head"><div class="num">6</div><div class="title">Step 2: Cargo &amp; Route</div></div>
<div class="desc">Cargo scope (containerised, both 20ft/40ft), and the origin/destination yard pair that defines the route.</div>
<img src="{{09-contract-step2-full.png}}">
</div>
<div class="step">
<div class="step-head"><div class="num">7</div><div class="title">Step 3: Review &amp; Submit</div></div>
<div class="desc">Final review of the assembled contract terms — operation, service, route, cargo scope, and customs handling — before submitting.</div>
<img src="{{10-contract-step3-review-full.png}}">
</div>
<div class="step">
<div class="step-head"><div class="num">8</div><div class="title">Approve your quotation</div></div>
<div class="desc">Submitting immediately opens a per-unit rate quotation for the customer to approve — freight rates for 20ft/40ft containers plus the customs clearance service fee per container size. The dialog is explicit that these are per-unit rates, not a total: the payable amount is computed per booking from the quantities actually shipped. Approving submits the contract for EDR staff review.</div>
<img src="{{11b-quotation-approve-full.png}}">
</div>
<div class="step">
<div class="step-head"><div class="num">9</div><div class="title">Real validation: duplicate contract blocked</div></div>
<div class="desc">Re-submitting a route/service/cargo-scope combination that already has an active or in-review contract is rejected outright, naming the conflicting contract by number. This is a genuine business rule hit live, not staged.</div>
<img src="{{11-contract-duplicate-blocked-full.png}}">
</div>
<div class="note howto">
<b>Once EDR approves the contract, the customer signs it</b>
A submitted contract moves through EDR review as: <i>Submitted → Pending Approval → Approved →
Approved Pending Signature → Contract Ready.</i> The "View &amp; sign contract" button only
appears once the contract reaches <b>Contract Ready</b> — it shows on the Contracts list row and
on the contract's own detail page, and opens a dedicated preview-and-sign page
(<code>/contracts/:id/view</code>), not a plain button on the detail page.
<ol>
<li>EDR staff approve the contract and price it.</li>
<li>The customer is notified the moment it's ready to sign — <b>SMS, email, and an in-app
notification</b> all fire together (per the customer's description of the real notification
flow — not re-verified against the notification-sending code for this document).</li>
<li>The customer opens the notification or the Contracts list, clicks "View &amp; sign
contract", scrolls the full contract text, and ticks the consent checkbox.</li>
<li>They draw or reuse a saved signature, upload a company stamp, then verify by OTP
(sent to their registered phone/email) to finalize the signature.</li>
<li>Status moves to Signed — Awaiting Staff, then Fully Executed once EDR counter-signs.</li>
</ol>
CTR-2026-00086 and CTR-2026-00087 above are still at Submitted — EDR hasn't approved them yet,
so no sign button is showing for either.
</div>
<div class="step">
<div class="step-head"><div class="num">11</div><div class="title">Where "View &amp; sign" actually shows (a different customer's contract list)</div></div>
<div class="desc">A second real account (nati@gmail.com, 17 contracts) confirms the button live: its Action column carries "View", "Request shipment", "Book shipment", and "Initiate booking" depending on each row's status — the same column shows "View &amp; sign" the moment a row reaches Contract Ready.</div>
<img src="{{16-nati-contracts-list-actions.png}}">
</div>
<div class="note limit">
<b>The signable window is real and it closes</b>
This account's own CTR-2026-00089 was captured with a live "View &amp; sign" button two days ago
(per the customer's own screenshot) — by the time this document was rebuilt, that same contract
had already moved to <b>Expired</b> with no button left to click. Contract Ready is a real,
time-boxed state, not a permanent one. No currently-open contract exists on either test account
at time of writing, so the sign page itself (scroll-to-consent → draw signature → stamp → OTP)
could not be re-captured live in this pass; it was documented from the customer's own screenshots
and code inspection instead.
</div>
<div class="step">
<div class="step-head"><div class="num">10</div><div class="title">Fully executed contract — signatures</div></div>
<div class="desc">CTR-2026-00043's detail page: both STAFF and CUSTOMER signatures are complete and the contract is Fully Executed — the end state of the flow described above.</div>
<img src="{{06-contract-detail-signed-full.png}}">
</div>
</section>
<section class="flow">
<h2>2 · Booking</h2>
<p class="lede">A portal customer does not have a bare "New booking" button on the Bookings page
itself — booking creation is triggered from a signed contract's row, once EDR has cleared it for
shipment (contract status Active). What that entry point actually is depends on the contract's
state: "Initiate booking" opens a brand-new booking; "Book shipment" / "Book" continues one EDR
has already pre-cleared.</p>
<div class="step">
<div class="step-head"><div class="num">1</div><div class="title">Initiate booking — confirmation</div></div>
<div class="desc">Clicking "Initiate booking" on an Active contract (CTR-2026-00071) doesn't create the booking immediately — it confirms first: "This creates a new shipment booking under contract CTR-2026-00071. You'll upload the import documents next, and the shipment quantity is drawn down from your contract's reserved capacity." Cancelled here rather than completed, since creating a live booking is a real mutation.</div>
<img src="{{17-initiate-booking-confirm.png}}">
</div>
<div class="step">
<div class="step-head"><div class="num">2</div><div class="title">Complete Your Booking — cargo details</div></div>
<div class="desc">Following an already-cleared booking's "Book" action (BK-2026-000209, Bulk cargo under CTR-2026-00093) lands on this form: the contract's fixed route, a cargo-details block (Total weight for Bulk cargo — a Container-scoped contract shows an Excel-import table instead), and a Schedule block picking the binding shipment day. The billing currency here is fixed to ETB, paid through the payment gateway. The calendar is real and reactive: it read <i>"Enter your cargo details first — available shipment days depend on the wagons your cargo needs,"</i> and no day was actually open on this route at the time of capture (0 available days) — the same live batch-window behaviour documented in Section 1.</div>
<img src="{{19-complete-booking-cargo-full.png}}">
</div>
<div class="step">
<div class="step-head"><div class="num">3</div><div class="title">Bookings list — one contract's view</div></div>
<div class="desc">This account's one existing booking, BK-2026-000068 on CTR-2026-00043 — status "In review," amount still ETB 0.00 because pricing is finalised after document review, not at booking creation. Every row carries a "Review documents" action.</div>
<img src="{{13-bookings-list-full.png}}">
</div>
<div class="step">
<div class="step-head"><div class="num">4</div><div class="title">Import documents dialog</div></div>
<div class="desc">Clicking "Review documents" opens this in place, no page navigation. All four required import documents — Bill of Lading/Waybill, Packing List, Import declaration, and Ethiopia T1 &amp; Djibouti T1 — show status "Under review," each with View/Download and a "Replace file" drop zone. The banner is explicit: <i>"Only re-upload the documents flagged with a query below — approved documents stay as they are."</i> "Submit documents" stays disabled while nothing is flagged.</div>
<img src="{{14-booking-documents-modal-full.png}}">
</div>
<div class="step">
<div class="step-head"><div class="num">5</div><div class="title">Bookings list — the fuller picture (nati@gmail.com, 38 bookings)</div></div>
<div class="desc">Across a larger real history the Action column carries every state at once: "Book" (cleared, awaiting cargo details), "View" (nothing to do), "Rebook wagons" (a cancelled reservation), "Pay [amount]" (booked, payment outstanding), "Review documents" / "Documents", and "Track" once a train is assigned. BK-2026-000188 shows "Pay 16,323.25 ETB" while its own Status column reads Cancelled — a live inconsistency (the booking lapsed after its payment window closed, but the stale Pay action hadn't been cleared from the row).</div>
<img src="{{18-nati-bookings-list-actions.png}}">
</div>
</section>
<section class="flow">
<h2>3 · Payment</h2>
<p class="lede">The customer-facing payment surface is the Invoices page.</p>
<div class="step">
<div class="step-head"><div class="num">1</div><div class="title">Invoices — hager@gmail.com (nothing to pay)</div></div>
<div class="desc">Outstanding, Overdue, and Total Invoices all read 0. This lines up with the Booking flow above: BK-2026-000068 is still in document review and hasn't been priced, so nothing has been invoiced to this customer yet. The customer supplied a second real login (nati@gmail.com) to reach the actual payment screen.</div>
<img src="{{03-hager-invoices-empty-full.png}}">
</div>
<div class="step">
<div class="step-head"><div class="num">2</div><div class="title">Invoices — nati@gmail.com, filtered to Due</div></div>
<div class="desc">This account has a long invoice history (35 invoices) and 5 currently Outstanding. The status filter's real options are Due / Overdue / Paid / Draft / Cancelled / Refunded; filtering to "Due" narrows the list to what's actually payable right now, each row with a "Pay" action.</div>
<img src="{{01-invoices-due-full.png}}">
</div>
<div class="step">
<div class="step-head"><div class="num">3</div><div class="title">Invoice detail — the Pay screen</div></div>
<div class="desc">Opening INV-20260820-00017 (Br 200.00, a port-charges line item off booking BK-2026-000112) shows the full detail a customer sees before paying: billed-to company, source, issue/due dates, the line-item breakdown, a "Download invoice" action, and a "Pay Br 200.00" button sized to the exact amount due.</div>
<img src="{{00-invoice-detail-pay.png}}">
</div>
<div class="note howto">
<b>What "Pay" opens (from the customer's own screenshot)</b>
Clicking "Pay" opens a "Complete your payment" dialog: the amount due, a red warning —
<i>"Pay only from a bank account registered under your company name — DE BE KE. A payment sent
from an account under any other name will not be recognized as paid"</i> — a payment-method
picker (the option shown was <b>CBE Bill Payment — "Pay at any CBE branch, app or USSD · ETB"</b>),
a "Secured — you'll be redirected to your provider to pay" note, then "Continue to payment".
This was captured by the customer directly, not by this document — every attempt here to click a
live "Pay" button (on this invoice and on two different bookings) was blocked by the harness's own
financial-mutation guard before the dialog could be reached.
</div>
<div class="note howto">
<b>Paying by bank transfer — step by step (customer-provided process)</b>
<ol>
<li>Open your internet banking app or account.</li>
<li>Copy the PNR code EDR sent you by SMS and email.</li>
<li>In your bank's payment menu, choose <b>Travel</b>, then <b>Land Transport</b>, then
<b>EDR Freight</b> as the biller.</li>
<li>Paste the PNR code into the reference/biller-code field.</li>
<li>Confirm the amount shown matches the invoice total, then pay.</li>
</ol>
This is the settlement path behind the "Pay" button above — the bank confirms the PNR against
EDR's own records, so the code must match exactly what was sent. These steps were supplied
directly by the customer describing the real banking flow; they are not something this
document observed inside the portal UI itself, which only exposes the "Pay" button shown above.
</div>
<div class="note limit">
<b>Stopped short of submitting the payment — three separate attempts, all blocked</b>
Clicking "Pay Br 200.00" on the invoice above, and separately clicking "Pay" on two different
bookings (BK-2026-000195 and BK-2026-000188) while rebuilding this document, were all blocked by
the harness's own auto-mode permission classifier as a real financial mutation against the shared
dev database — the same guard that blocked "Confirm paid" on the backoffice side. No workaround
was attempted, per the classifier's own instruction. The invoice screen above is the actual entry
point a customer uses; the backoffice-side companion document (EDR-Freight-Priority-Flows.pdf)
covers staff-side settlement (Transactions → Manual Payments).
</div>
</section>
</body>
</html>

View File

@@ -0,0 +1,25 @@
import { chromium } from '/home/tria/projects/hagernesh/edr-platform/node_modules/.pnpm/playwright-core@1.61.1/node_modules/playwright-core/index.mjs';
import { readFileSync } from 'node:fs';
import path from 'node:path';
const dir = path.dirname(new URL(import.meta.url).pathname);
let html = readFileSync(path.join(dir, 'portal-flows.html'), 'utf8');
html = html.replace(/\{\{([\w.-]+\.png)\}\}/g, (_, file) => {
const buf = readFileSync(path.join(dir, file));
return `data:image/png;base64,${buf.toString('base64')}`;
});
const browser = await chromium.launch({
executablePath: '/home/tria/.cache/ms-playwright/chromium-1237/chrome-linux64/chrome',
});
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'load' });
await page.pdf({
path: '/home/tria/projects/hagernesh/edr-platform/EDR-Freight-Priority-Flows-Portal.pdf',
format: 'A4',
printBackground: true,
margin: { top: '14mm', bottom: '16mm', left: '14mm', right: '14mm' },
});
await browser.close();
console.log('done');

BIN
INV-20260812-00005-mor.pdf Normal file

Binary file not shown.

Binary file not shown.

View File

@@ -18,6 +18,7 @@
"type-check": "tsc --noEmit",
"seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts",
"seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts",
"seed:warehouse-layout": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-layout.ts",
"seed:warehouse-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-demo.ts",
"seed:warehouse-export-receive-ready": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-export-receive-ready.ts",
"seed:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.ts",
@@ -31,6 +32,7 @@
"seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts",
"seed:dropdown-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-dropdown-settings.ts",
"seed:gov-companies": "ts-node -r tsconfig-paths/register src/scripts/seed-gov-companies.ts",
"seed:mor-test-buyers": "ts-node -r tsconfig-paths/register src/scripts/seed-mor-test-buyers.ts",
"seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh",
"iam:typeorm:cli": "cross-env MIGRATIONS_DIR=node_modules/@tria-plc/iamapi-common/dist/db/migrations/*.{ts,js} ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d ./node_modules/@tria-plc/api-common/dist/modules/typeorm/typeorm.config.js",
"iam:migration:run": "pnpm run iam:typeorm:cli migration:run",

View File

@@ -96,6 +96,7 @@ import { TrainsModule } from "./modules/trains/trains.module";
import { VerifaydaModule } from "./modules/verifayda/verifayda.module";
import { EimsModule } from "./modules/eims/eims.module";
import { FleetHistoryModule } from "./modules/fleet-history/fleet-history.module";
import { WagonHistoryModule } from "./modules/wagon-history/wagon-history.module";
import { WagonsModule } from "./modules/wagons/wagons.module";
import { ContainersModule } from "./modules/container-management/containers.module";
import { CargoesModule } from "./modules/cargoes/cargoes.module";
@@ -116,6 +117,7 @@ import { FacilitiesModule } from "./modules/facilities/facilities.module";
import { GpsTrackingModule } from "./modules/gps-tracking/gps-tracking.module";
import { FirstMileModule } from "./modules/first-mile/first-mile.module";
import { LastMileModule } from "./modules/last-mile/last-mile.module";
import { EmptyReturnRequestsModule } from "./modules/empty-return-requests/empty-return-requests.module";
import { LastMileRequestsModule } from "./modules/last-mile-requests/last-mile-requests.module";
import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module";
import { ImportOperationsModule } from "./modules/import-operations/import-operations.module";
@@ -258,11 +260,13 @@ if (!process.env.APPLICATION_NAME) {
FirstMileModule,
LastMileModule,
LastMileRequestsModule,
EmptyReturnRequestsModule,
InterchangeDocumentsModule,
ImportOperationsModule,
VerifaydaModule,
EimsModule,
FleetHistoryModule,
WagonHistoryModule,
AiModule,
AuditModule,
ChatModule,

View File

@@ -0,0 +1,148 @@
import {
collectAllPositions,
resolveActiveEmployee,
type SnapshotEmployee,
} from './freight-jwt.guard';
// Shapes and ids taken from the real dev session for `test_dj_gl_director`
// (iam.sessions 800ad793-…), an employee holding two posts on one row.
const CHIEF = {
id: '990189f1-e872-4b8c-9f6a-36259a0df480',
employeePositionId: 'd0d527f6-f344-49aa-ab8b-25a448a770b6',
name: { en: 'Djibouti GL Chief' },
isDelegate: false,
};
const DIRECTOR = {
id: '258a8d82-28c4-401f-bf88-78f58bb6bd0e',
employeePositionId: 'b97aa265-5de8-4ffe-95bf-01f94d38a2df',
name: { en: 'Djibouti GL Director' },
isDelegate: false,
};
const EMPLOYEE_ID = '70545ee5-c7d7-4196-af7e-a7eb7e76b21b';
const oneRow: SnapshotEmployee[] = [
{ id: EMPLOYEE_ID, positions: [CHIEF, DIRECTOR] },
];
describe('resolveActiveEmployee', () => {
it('leaves the parent guard alone when no position header is sent', () => {
const { owner, active } = resolveActiveEmployee(
oneRow,
undefined,
EMPLOYEE_ID,
);
expect(owner).toBe(oneRow[0]);
expect(active).toBeUndefined();
});
it("resolves freight's header value (employeePositionId)", () => {
const { active } = resolveActiveEmployee(
oneRow,
DIRECTOR.employeePositionId,
EMPLOYEE_ID,
);
expect(active).toBe(DIRECTOR);
});
// The regression this guard exists for: the stock IAM guard matches the
// header against employeePositionId only, so Smart Office's position.id
// matched nothing and every request silently ran as positions[0].
it("resolves Smart Office's header value (position.id)", () => {
const { active } = resolveActiveEmployee(oneRow, DIRECTOR.id, EMPLOYEE_ID);
expect(active).toBe(DIRECTOR);
expect(active).not.toBe(CHIEF);
});
it('falls back to the parent row when the header names nothing', () => {
const { owner, active } = resolveActiveEmployee(
oneRow,
'not-a-position-id',
EMPLOYEE_ID,
);
expect(owner).toBe(oneRow[0]);
expect(active).toBeUndefined();
});
describe('when the two posts sit on different employee rows', () => {
const smartOfficeRow: SnapshotEmployee = {
id: 'emp-smart-office',
positions: [CHIEF],
};
const freightRow: SnapshotEmployee = {
id: 'emp-freight',
positions: [DIRECTOR],
};
const twoRows = [smartOfficeRow, freightRow];
it('selects the row that owns the requested position', () => {
const { owner, active } = resolveActiveEmployee(
twoRows,
DIRECTOR.employeePositionId,
// The parent guard matches the header against position.id only, so it
// matched neither row and fell through to the first.
smartOfficeRow.id,
);
expect(owner).toBe(freightRow);
expect(active).toBe(DIRECTOR);
});
it('keeps the parent row when no header is sent', () => {
const { owner } = resolveActiveEmployee(twoRows, undefined, freightRow.id);
expect(owner).toBe(freightRow);
});
it('falls back to the first row when the parent row is unknown', () => {
const { owner } = resolveActiveEmployee(twoRows, undefined, undefined);
expect(owner).toBe(smartOfficeRow);
});
});
});
describe('collectAllPositions', () => {
it('unions posts held across separate employee rows', () => {
// The real shape: IAM keeps one employee row per organization, and "EDR"
// and "EDR Freight" are separate orgs, so a user holding a Smart Office
// post and a freight post owns one row each.
const smartOfficeRow: SnapshotEmployee = {
id: 'emp-edr',
organizationId: 'org-edr',
positions: [CHIEF],
};
const freightRow: SnapshotEmployee = {
id: 'emp-edr-freight',
organizationId: 'org-edr-freight',
positions: [DIRECTOR],
};
expect(collectAllPositions([smartOfficeRow, freightRow])).toEqual([
CHIEF,
DIRECTOR,
]);
});
it('keeps every post when they share one row', () => {
expect(collectAllPositions(oneRow)).toEqual([CHIEF, DIRECTOR]);
});
it('de-duplicates a post repeated across rows', () => {
const rows: SnapshotEmployee[] = [
{ id: 'a', positions: [CHIEF] },
{ id: 'b', positions: [CHIEF, DIRECTOR] },
];
expect(collectAllPositions(rows)).toEqual([CHIEF, DIRECTOR]);
});
it('tolerates rows carrying no positions', () => {
const rows: SnapshotEmployee[] = [{ id: 'a' }, { id: 'b', positions: [] }];
expect(collectAllPositions(rows)).toEqual([]);
});
});

View File

@@ -3,29 +3,124 @@ import { Reflector } from '@nestjs/core';
import { InjectDataSource } from '@nestjs/typeorm';
import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { CURRENT_POSITION_ID } from '@tria-plc/api-common/utils/constants/tenant.constant';
import { DataSource } from 'typeorm';
/** One position as the login snapshot stores it (`iam.sessions.userInfo`). */
type SnapshotPosition = { id?: string; [key: string]: unknown };
export type SnapshotPosition = {
id?: string;
employeePositionId?: string;
isDelegate?: boolean;
[key: string]: unknown;
};
type SessionUserInfo = {
employee?: { id?: string; positions?: SnapshotPosition[] }[];
/** One employee row as the snapshot stores it. A user may hold several. */
export type SnapshotEmployee = {
id?: string;
positions?: SnapshotPosition[];
[key: string]: unknown;
};
type SessionUserInfo = { employee?: SnapshotEmployee[] };
/**
* `x-current-position-id` is sent with two different meanings by two different
* frontends, and the IAM guard reads it both ways in the same function: it
* picks the EMPLOYEE row by `position.id` but the POSITION by
* `employeePositionId`. Freight sends `employeePositionId`, Smart Office sends
* `position.id` — so whichever value arrives, one of the two lookups silently
* matches nothing and falls back to the first entry.
*
* Matching both fields is what makes the header mean one thing again.
*/
const identifies = (position: SnapshotPosition, id: string): boolean =>
position?.id === id || position?.employeePositionId === id;
/**
* Every post the user holds, across every employee row, first occurrence kept.
*
* IAM keeps one employee row per ORGANIZATION, and "EDR" and "EDR Freight" are
* separate organizations — so a user given a freight post and a Smart Office
* post owns two rows, one post on each. Only one row can be the active one, and
* a permission check that reads only that row cannot see the other post at all.
*/
export const collectAllPositions = (
employees: SnapshotEmployee[],
): SnapshotPosition[] => {
const seen = new Set<string>();
const all: SnapshotPosition[] = [];
for (const employee of employees) {
for (const position of employee.positions ?? []) {
const key = position.employeePositionId ?? position.id;
if (key) {
if (seen.has(key)) continue;
seen.add(key);
}
all.push(position);
}
}
return all;
};
/**
* Like the IAM JwtGuard, but keeps the caller's SECONDARY positions.
* Which employee row the caller is acting as, and which of its positions the
* request selected. Pure so it can be tested without a session or a token.
*
* IAM models an employee as holding many positions, and the login snapshot in
* `iam.sessions.userInfo` carries all of them. `JwtGuard.parseToken` then
* collapses that to a single `employee.position` — whichever the request
* headers select, else `positions[0]` — and drops the rest. Non-delegate
* secondary positions vanish entirely, so staff holding two posts resolve to
* only one post's permissions and every check on the other one rejects them.
* `owner` is the row holding the requested position; failing that the row the
* parent guard already picked; failing that the first. `active` is undefined
* when no header was sent or it names nothing — the caller then leaves the
* parent's choice of `employee.position` alone.
*/
export const resolveActiveEmployee = (
employees: SnapshotEmployee[],
requestedId: string | undefined,
parentEmployeeId: string | undefined,
): { owner: SnapshotEmployee | undefined; active: SnapshotPosition | undefined } => {
const owner =
(requestedId &&
employees.find((candidate) =>
(candidate.positions ?? []).some((position) =>
identifies(position, requestedId),
),
)) ||
employees.find(
(candidate) => candidate.id && candidate.id === parentEmployeeId,
) ||
employees[0];
const active = requestedId
? (owner?.positions ?? []).find((position) =>
identifies(position, requestedId),
)
: undefined;
return { owner, active };
};
/**
* Like the IAM JwtGuard, but resolves the caller's position honestly.
*
* This re-attaches the full list as `employee.positions`. `employee.position`
* is left exactly as the parent set it, so everything reading the single
* position today (audit log, delegation deadline) is unaffected; only the
* permission utils, which prefer the array, see the difference.
* IAM models an employee as holding many positions — and a user as possibly
* holding several employee rows — and the login snapshot in
* `iam.sessions.userInfo` carries all of them. `JwtGuard.parseToken` collapses
* that to a single `employee.position` and drops the rest, so staff holding two
* posts resolve to one post's permissions and every check on the other one
* rejects them.
*
* This guard re-reads the snapshot and fixes three things the parent gets wrong:
*
* 1. re-attaches the full position list as `employee.positions`, which is what
* the permission utils union over;
* 2. selects the employee row that actually owns the requested position, so a
* post held on a second employee row is reachable at all;
* 3. sets `employee.position` to the requested position when the parent's
* one-sided id match missed it, keeping `auditUser` in step.
*
* Every correction is skipped unless the snapshot positively resolves it, so an
* unreadable session degrades to the parent's single-position behaviour rather
* than to no position at all.
*/
@Injectable()
export class FreightJwtGuard extends IamJwtGuard implements CanActivate {
@@ -35,7 +130,7 @@ export class FreightJwtGuard extends IamJwtGuard implements CanActivate {
private static readonly CACHE_MAX_ENTRIES = 5_000;
private readonly cache = new Map<
string,
{ positions: SnapshotPosition[]; expiresAt: number }
{ employees: SnapshotEmployee[]; expiresAt: number }
>();
constructor(
@@ -48,44 +143,78 @@ export class FreightJwtGuard extends IamJwtGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
if (!(await super.canActivate(context))) return false;
const user = context.switchToHttp().getRequest().user as
| TCurrentUser
| undefined;
const employee = user?.employee;
const request = context.switchToHttp().getRequest();
const user = request.user as TCurrentUser | undefined;
const employee = user?.employee as SnapshotEmployee | undefined;
if (!employee || !user?.sessionId) return true;
const positions = await this.positionsForSession(
user.sessionId,
const employees = await this.employeesForSession(user.sessionId);
if (!employees.length) return true;
const requestedId = request.headers?.[CURRENT_POSITION_ID] as
| string
| undefined;
const { owner, active } = resolveActiveEmployee(
employees,
requestedId,
employee.id,
);
// Never blank out what the parent resolved: an unreadable session or a
// snapshot without positions must degrade to the single-position
// behaviour, not to no positions at all.
if (positions.length) {
(employee as { positions?: SnapshotPosition[] }).positions = positions;
const ownerPositions = owner?.positions ?? [];
// Never blank out what the parent resolved: a snapshot without positions
// must degrade to the single-position behaviour, not to no positions.
if (!ownerPositions.length) return true;
// Carries the owning row's id / unitId / organizationId too, which unit
// scoping downstream reads — a swapped row must be swapped whole.
Object.assign(employee, owner);
// `collectPermissionKeys` / `collectPositionTypeKeys` union over this, and
// a user's posts can span several employee rows (one per organization), so
// it carries every row's — otherwise a freight post is invisible whenever
// another organization's row wins the active slot.
employee.positions = collectAllPositions(employees);
// Delegation stays scoped to the active desk: yard scope widens on
// `delegatedPositions`, and someone standing in on another organization's
// row is not this desk's stand-in.
employee.delegatedPositions = ownerPositions.filter(
(position) => position.isDelegate,
);
// The full set, for `/auth/me` — the position picker has to be able to
// offer a desk on a row that is not the active one.
(user as { employeeRows?: SnapshotEmployee[] }).employeeRows = employees;
if (active) {
employee.position = active;
// The parent already built `auditUser` from the position it guessed.
if (request.auditUser) {
request.auditUser.employeeId = employee.id;
request.auditUser.positionId = active.id;
request.auditUser.employeePositionId = active.employeePositionId;
}
}
return true;
}
/** Every position the login snapshot holds for this employee. */
private async positionsForSession(
/** Every employee row the login snapshot holds for this session. */
private async employeesForSession(
sessionId: string,
employeeId: string | undefined,
): Promise<SnapshotPosition[]> {
): Promise<SnapshotEmployee[]> {
const now = Date.now();
const hit = this.cache.get(sessionId);
if (hit && hit.expiresAt > now) return hit.positions;
if (hit && hit.expiresAt > now) return hit.employees;
let positions: SnapshotPosition[] = [];
let employees: SnapshotEmployee[] = [];
try {
const rows: { userInfo: SessionUserInfo | null }[] = await this.ds.query(
`SELECT "userInfo" FROM iam.sessions WHERE id = $1`,
[sessionId],
);
const employees = rows[0]?.userInfo?.employee ?? [];
const match =
employees.find((e) => e?.id && e.id === employeeId) ?? employees[0];
positions = match?.positions ?? [];
employees = rows[0]?.userInfo?.employee ?? [];
} catch {
return []; // iam unreachable — caller keeps the parent's single position
}
@@ -93,9 +222,9 @@ export class FreightJwtGuard extends IamJwtGuard implements CanActivate {
if (this.cache.size >= FreightJwtGuard.CACHE_MAX_ENTRIES)
this.cache.clear();
this.cache.set(sessionId, {
positions,
employees,
expiresAt: now + FreightJwtGuard.CACHE_TTL_MS,
});
return positions;
return employees;
}
}

View File

@@ -1,4 +1,4 @@
import { usesEdrMileService } from './mile-haulage.util';
import { edrHaulsThisBooking, usesEdrMileService } from './mile-haulage.util';
/**
* The road legs are chosen on the contract and copied onto the booking. EDR
@@ -26,27 +26,76 @@ describe('usesEdrMileService', () => {
});
it('an export that chose collection uses EDR haulage', () => {
expect(
usesEdrMileService(booking({ tradeDirection: 'EXPORT', firstMile: 'Modjo' })),
).toBe(true);
expect(usesEdrMileService(booking({ tradeDirection: 'EXPORT', firstMile: 'Modjo' }))).toBe(
true,
);
});
it('ignores the delivery address on an export — delivery is the import leg', () => {
expect(
usesEdrMileService(booking({ tradeDirection: 'EXPORT', lastMile: 'Djibouti' })),
).toBe(false);
expect(usesEdrMileService(booking({ tradeDirection: 'EXPORT', lastMile: 'Djibouti' }))).toBe(
false,
);
});
it('a domestic booking counts either leg', () => {
expect(
usesEdrMileService(booking({ tradeDirection: 'DOMESTIC', firstMile: 'Adama' })),
).toBe(true);
expect(
usesEdrMileService(booking({ tradeDirection: 'DOMESTIC', lastMile: 'Dire Dawa' })),
).toBe(true);
expect(usesEdrMileService(booking({ tradeDirection: 'DOMESTIC', firstMile: 'Adama' }))).toBe(
true,
);
expect(usesEdrMileService(booking({ tradeDirection: 'DOMESTIC', lastMile: 'Dire Dawa' }))).toBe(
true,
);
});
it('treats a whitespace-only address as no choice', () => {
expect(usesEdrMileService(booking({ lastMile: ' ' }))).toBe(false);
});
});
/**
* Self-haul is closed only once EDR has committed to the leg. Delivery chosen
* on the contract is a request the chief still has to approve; collection has
* no approval step.
*/
describe('edrHaulsThisBooking', () => {
const booking = (over: Partial<Parameters<typeof edrHaulsThisBooking>[0]> = {}) => ({
tradeDirection: 'IMPORT',
firstMile: null,
lastMile: null,
lastMileCommitted: false,
...over,
});
it('an import whose last-mile request is not yet approved may still self-haul', () => {
expect(edrHaulsThisBooking(booking({ lastMile: 'Bole, Addis Ababa' }))).toBe(false);
});
it('an import whose last-mile request was approved is hauled by EDR', () => {
expect(
edrHaulsThisBooking(booking({ lastMile: 'Bole, Addis Ababa', lastMileCommitted: true })),
).toBe(true);
});
it('an import that chose no delivery self-hauls, whatever the leg tables say', () => {
expect(edrHaulsThisBooking(booking({ lastMileCommitted: true }))).toBe(false);
});
it('an export that chose collection is hauled by EDR — no approval step on that leg', () => {
expect(edrHaulsThisBooking(booking({ tradeDirection: 'EXPORT', firstMile: 'Modjo' }))).toBe(
true,
);
});
it('a domestic booking is blocked by collection, or by an approved delivery', () => {
expect(edrHaulsThisBooking(booking({ tradeDirection: 'DOMESTIC', firstMile: 'Adama' }))).toBe(
true,
);
expect(
edrHaulsThisBooking(booking({ tradeDirection: 'DOMESTIC', lastMile: 'Dire Dawa' })),
).toBe(false);
expect(
edrHaulsThisBooking(
booking({ tradeDirection: 'DOMESTIC', lastMile: 'Dire Dawa', lastMileCommitted: true }),
),
).toBe(true);
});
});

View File

@@ -39,7 +39,60 @@ export const SELF_HAUL_CONFLICT_MESSAGE =
'This booking is delivered by the customers own truck — an EDR mile leg cannot also be assigned.';
export const EDR_HAULAGE_CONFLICT_MESSAGE =
'Customer truck assignment is only allowed when first/last mile delivery is not selected';
'Customer truck assignment is only allowed when first/last mile delivery is not selected, or when the EDR last-mile request has not been approved';
/** The booking fields that decide whether the customer may still bring their own truck. */
export interface MileCommitmentRow extends MileHaulageRow {
/**
* EDR has actually committed to the delivery leg: the booking's last-mile
* request was approved, or a `freight.last_mile` leg row exists for it.
* Selecting delivery on the contract is only a request — see
* `edrHaulsThisBooking`.
*/
lastMileCommitted: boolean;
}
/**
* SQL for `MileCommitmentRow.lastMileCommitted`, to be selected alongside the
* booking row aliased `b`. Both services that gate self-haul read the same
* fragment so the rule cannot drift between them.
*/
export const LAST_MILE_COMMITTED_SQL = `(
EXISTS (SELECT 1
FROM freight.last_mile lm
WHERE lm.booking_id = b.id AND lm.deleted_at IS NULL)
OR EXISTS (SELECT 1
FROM freight.last_mile_requests lmr
WHERE lmr.booking_id = b.id
AND lmr.deleted_at IS NULL
AND lmr.status = 'APPROVED')
)`;
/**
* Whether EDR is hauling this booking's road leg, such that the customer may
* NOT assign their own truck. Stricter than `usesEdrMileService` on the
* delivery side: choosing last-mile delivery on the contract opens a request
* that the Truck & Machinery chief still has to approve, and until that
* approval the customer is free to self-haul instead. Collection (the export
* leg) has no approval step, so the contract choice alone decides it.
*
* `usesEdrMileService` keeps answering the other question — whether the booking
* belongs in the EDR mile queues at all — and the queue side still refuses a
* booking that already carries a customer truck, so the two paths remain
* mutually exclusive whichever acts first.
*/
export function edrHaulsThisBooking(booking: MileCommitmentRow): boolean {
const hasFirstMile = Boolean(booking.firstMile?.trim());
const lastMileApproved = Boolean(booking.lastMile?.trim()) && booking.lastMileCommitted;
switch (booking.tradeDirection) {
case 'IMPORT':
return lastMileApproved;
case 'EXPORT':
return hasFirstMile;
default:
return hasFirstMile || lastMileApproved;
}
}
/**
* The road legs are chosen on the contract. A booking whose contract bought

View File

@@ -45,6 +45,16 @@ describe('assertTruckLoad', () => {
).toThrow(BadRequestException);
});
it('allows two containers only when both are explicitly 20ft', () => {
expect(() =>
assertTruckLoad({
containers: ['ABCD1234567', 'ABCD7654321'],
bookingContainers: booking,
sizes: ['20ft', '45ft'],
}),
).toThrow(BadRequestException);
});
it('rejects more than two containers', () => {
expect(() =>
assertTruckLoad({

View File

@@ -54,10 +54,11 @@ export function assertTruckLoad({
}
}
// A 40ft fills the bed, so it travels alone.
if (containers.length > 1 && sizes.some((size) => size.includes('40'))) {
// A truck may pair containers only when BOTH are explicitly 20ft. A 40ft
// (and any legacy/unknown larger size) fills the bed and travels alone.
if (containers.length > 1 && sizes.some((size) => !size.includes('20'))) {
throw new BadRequestException(
'A 40ft container fills the truck — assign only 1 container to this truck',
'Truck capacity is either 1 x 40ft container or up to 2 x 20ft containers',
);
}
}

View File

@@ -26,6 +26,20 @@ const withEnv = (vars: Record<string, string | undefined>, fn: () => void) => {
};
describe("eims.config — private key / certificate resolution", () => {
it("requires EIMS_API_KEY when EIMS is enabled without exposing a value", () => {
withEnv(
{
...REQUIRED,
EIMS_API_KEY: undefined,
EIMS_PRIVATE_KEY: "private-key-present",
EIMS_CERTIFICATE: "certificate-present",
},
() => {
expect(() => eimsConfigFactory()).toThrow(/env vars are missing: EIMS_API_KEY/);
},
);
});
it("unescapes a literal \\n when the PEM was pasted without real newlines", () => {
withEnv(
{ ...REQUIRED, EIMS_PRIVATE_KEY: "line1\\nline2", EIMS_CERTIFICATE_PATH: "/dev/null" },

View File

@@ -29,6 +29,8 @@ const FIXTURE: MorLocationTuple[] = [
[70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 976, "Wal-Mera"],
[70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 909, "Akaki woreda"],
[70, "Ethiopia", 13, "ADDIS ABABA", 78, "BOLE", 1100, "WOREDA 1"],
[70, "Ethiopia", 13, "ADDIS ABABA", 78, "BOLE", 1102, "WOREDA 3"],
[70, "Ethiopia", 13, "ADDIS ABABA", 81, "KOLFIE KERANIYO", 1139, "WOREDA 7"],
[253, "Djibouti", 1, "DJIBOUTI", 1, "DJIBOUTI VILLE", 1, "BALBALA"],
];
@@ -181,6 +183,93 @@ describe("resolveMorGeo", () => {
});
});
describe("Addis Ababa, where MoR has no zone tier", () => {
// e-Trade's real shape for a chartered city: `zone` repeats the region, the sub-city sits in
// `woreda`, and the numbered woreda sits in `kebele`. This is how every company imported from
// e-Trade stores an Addis Ababa address, and it is the shape that blocked INV-20260829-00011.
const ETRADE_SHAPE = {
country: "Ethiopia",
region: "Addis Ababa",
zone: "Addis Ababa",
woreda: "Kolfe-Keraniyo",
kebele: "07",
};
it("reads the sub-city and woreda one level down when the zone repeats the region", () => {
expect(resolveMorGeo(ETRADE_SHAPE, FIXTURE)).toEqual({
Country: "70",
Region: "13",
City: "81",
Wereda: "1139",
});
});
it("matches MoR's own 'KOLFIE KERANIYO' spelling of the sub-city", () => {
expect(resolveMorGeo({ ...ETRADE_SHAPE, woreda: "Kolfe Keranio" }, FIXTURE).City).toBe("81");
});
it("reads a zero-padded number as MoR's 'WOREDA n' locality, in either slot", () => {
const bole = { country: "Ethiopia", region: "Addis Ababa", zone: "Bole" };
expect(resolveMorGeo({ ...bole, woreda: "03" }, FIXTURE).Wereda).toBe("1102");
expect(resolveMorGeo({ ...bole, woreda: "Woreda 03" }, FIXTURE).Wereda).toBe("1102");
expect(resolveMorGeo({ ...bole, woreda: "WOREDA 3" }, FIXTURE).Wereda).toBe("1102");
});
it("still resolves the already-correct shape without shifting", () => {
expect(
resolveMorGeo(
{ country: "Ethiopia", region: "ADDIS ABABA", zone: "BOLE", woreda: "WOREDA 1" },
FIXTURE,
),
).toEqual({ Country: "70", Region: "13", City: "78", Wereda: "1100" });
});
it("reports the zone failure, not the shifted one, when the shift does not resolve", () => {
// LEMI KURA is a 2020 sub-city the Ministry sheet does not list. The shift must not turn
// that into a confusing locality error, and must never land on a neighbouring sub-city.
expect(() =>
resolveMorGeo({ ...ETRADE_SHAPE, woreda: "Lemi Kura", kebele: "02" }, FIXTURE),
).toThrow(/no MoR CITY_NAME match for country="Ethiopia", region="Addis Ababa"/);
});
it("does not shift when the zone is simply an unknown zone", () => {
expect(() =>
resolveMorGeo(
{
country: "Ethiopia",
region: "OROMIA",
zone: "East Zone",
woreda: "KERSA",
kebele: "01",
},
FIXTURE,
),
).toThrow(/no MoR CITY_NAME match/);
});
});
it("resolves the regions and zones MoR spells differently from e-Trade", () => {
// Guards the reviewed alias table: MoR's PARISH_NAME is "AMAHARA", and it keeps the Amharic
// compass words for the Oromia zones ("MISRAK SHOA" for East Shewa).
const rows: MorLocationTuple[] = [
...FIXTURE,
[70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 21, "ADAMA"],
[70, "Ethiopia", 11, "AMAHARA", 53, "WEST GOJAM", 149, "MECHA"],
];
expect(
resolveMorGeo(
{ country: "Ethiopia", region: "Oromia", zone: "East Shewa", woreda: "Adama" },
rows,
),
).toEqual({ Country: "70", Region: "2", City: "16", Wereda: "21" });
expect(
resolveMorGeo(
{ country: "Ethiopia", region: "Amhara", zone: "West Gojjam", woreda: "Mecha" },
rows,
),
).toEqual({ Country: "70", Region: "11", City: "53", Wereda: "149" });
});
describe("failures happen locally, before anything is filed", () => {
const cases: Array<[string, Record<string, string>, RegExp]> = [
["unknown country", { ...JIJIGA, country: "Wakanda" }, /no MoR COUNTRY_NAME match/],

View File

@@ -43,6 +43,11 @@ export interface MorAddressInput {
region?: string | null;
zone?: string | null;
woreda?: string | null;
/**
* Only read for the city-region shift below — in Addis Ababa e-Trade stores the numbered woreda
* here. Never consulted for an ordinary region/zone/woreda address.
*/
kebele?: string | null;
}
type Level = "country" | "region" | "zone" | "woreda";
@@ -115,6 +120,24 @@ const ALIASES: MorAlias[] = [
from: "Jigjiga",
to: "JIJIGA",
},
// MoR misspells the region itself — PARISH_NO 11 is "AMAHARA". No other parish is close to it.
{ level: "region", from: "Amhara", to: "AMAHARA" },
// Addis Ababa sub-cities, where MoR's sheet and e-Trade disagree on spelling. Each confirmed by
// CITY_NO under PARISH_NO 13; the seven that already agree (ARADA, ADDIS KETEMA, LIDETA, KIRKOS,
// YEKA, BOLE, GULLELE) need no entry. LEMI KURA is deliberately absent — the Ministry sheet does
// not list the 2020 split at all, so it must keep failing rather than be mapped onto a neighbour.
{ level: "zone", region: "ADDIS ABABA", from: "Kolfe Keraniyo", to: "KOLFIE KERANIYO" }, // 81
{ level: "zone", region: "ADDIS ABABA", from: "Kolfe Keranio", to: "KOLFIE KERANIYO" }, // 81
{ level: "zone", region: "ADDIS ABABA", from: "Nifas Silk Lafto", to: "NEFAS SILK LAFTO" }, // 80
{ level: "zone", region: "ADDIS ABABA", from: "Akaki Kality", to: "AKAKI KALITI" }, // 79
// MoR keeps the Amharic compass words for the Oromia zones; e-Trade stores the English ones.
// Each pair confirmed by the zone's own localities in the sheet: MISRAK SHOA holds ADAMA and
// BISHOFTU, MIRAB SHOA holds AMBO and WELMERA, MIRAB HARARGE holds CHIRO and GEMMECHIS.
{ level: "zone", region: "OROMIA", from: "East Shewa", to: "MISRAK SHOA" }, // 16
{ level: "zone", region: "OROMIA", from: "West Shewa", to: "MIRAB SHOA" }, // 62
{ level: "zone", region: "OROMIA", from: "West Hararge", to: "MIRAB HARARGE" }, // 7
// MoR drops a J. Confirmed by BAHIRDAR ZURIA / MECHA / BURIE sitting under CITY_NO 53.
{ level: "zone", region: "AMAHARA", from: "West Gojjam", to: "WEST GOJAM" }, // 53
];
/**
@@ -127,6 +150,20 @@ const ALIASES: MorAlias[] = [
const zoneSuffixCandidates = (normalized: string): string[] =>
normalized.endsWith(" ZONE") ? [] : [`${normalized} ZONE`];
/**
* In the chartered cities MoR names each locality "WOREDA 7", while e-Trade stores the bare,
* zero-padded number ("07") and EDR's own forms sometimes store "Woreda 05". All three mean the
* same locality, so the MoR spelling is tried as a second exact-match candidate — MoR writes no
* leading zero, hence the strip. Applied to the locality level only.
*
* This runs ahead of the numeric LOCALITY_NO fallback below, and can never mask it: no city in the
* Ministry sheet contains both a "WOREDA n" locality and a locality whose LOCALITY_NO is n.
*/
const woredaNumberCandidates = (normalized: string): string[] => {
const match = /^(?:WOREDA )?0*([0-9]{1,2})$/.exec(normalized);
return match ? [`WOREDA ${match[1]}`] : [];
};
export class MorGeoMappingError extends BadRequestException {
constructor(code: "EIMS_GEO_MAPPING_FAILED" | "EIMS_GEO_AMBIGUOUS", message: string) {
super({ code, message });
@@ -158,6 +195,7 @@ function matchLevel(
if (normalizeName(alias.from) === wanted) candidates.push(normalizeName(alias.to));
}
if (level === "zone") candidates.push(...zoneSuffixCandidates(wanted));
if (level === "woreda") candidates.push(...woredaNumberCandidates(wanted));
}
let matched: MorLocationTuple[] = [];
@@ -221,25 +259,46 @@ export function resolveMorGeo(
const inCountry = matchLevel(rows, "country", country, {}, input);
const inRegion = matchLevel(inCountry.rows, "region", input.region, {}, input);
const regionScope = normalizeName(inRegion.rows[0][SLOTS.region.name] as string);
const inZone = matchLevel(inRegion.rows, "zone", input.zone, { region: regionScope }, input);
const zoneScope = normalizeName(inZone.rows[0][SLOTS.zone.name] as string);
const inWoreda = matchLevel(
inZone.rows,
"woreda",
input.woreda,
{
region: regionScope,
zone: zoneScope,
},
input,
);
return {
Country: String(inCountry.no),
Region: String(inRegion.no),
City: String(inZone.no),
Wereda: String(inWoreda.no),
type Name = string | null | undefined;
const below = (zone: Name, woreda: Name): MorGeoCodes => {
const inZone = matchLevel(inRegion.rows, "zone", zone, { region: regionScope }, input);
const zoneScope = normalizeName(inZone.rows[0][SLOTS.zone.name] as string);
const inWoreda = matchLevel(
inZone.rows,
"woreda",
woreda,
{
region: regionScope,
zone: zoneScope,
},
input,
);
return {
Country: String(inCountry.no),
Region: String(inRegion.no),
City: String(inZone.no),
Wereda: String(inWoreda.no),
};
};
try {
return below(input.zone, input.woreda);
} catch (err) {
// Addis Ababa (and every other chartered city) has no zone tier: MoR's CITY level *is* the
// sub-city and its LOCALITY level is the numbered woreda. e-Trade fills the missing tier by
// repeating the region in `zone`, which pushes the sub-city into `woreda` and the woreda
// number into `kebele` — one level down the whole way. Retry with that reading, but only when
// `zone` genuinely repeats the region, and only accept it when *both* shifted levels resolve
// exactly. A zone MoR simply does not list still fails with its own message, unreinterpreted.
const zone = normalizeName(input.zone);
if (!zone || (zone !== regionScope && zone !== normalizeName(input.region))) throw err;
try {
return below(input.woreda, input.kebele);
} catch {
throw err;
}
}
}
/** Non-throwing variant for callers that already have a working fallback (the seller identity). */

View File

@@ -0,0 +1,36 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Attaches an eTrade business licence to each operational profile.
*
* A TIN routinely holds a dozen or more licences, split by activity ("Export
* trade in coffee", "Freight Forwarders"), and until now the company picked one
* for the whole record — every role shared it. Each profile now names the
* business it actually operates as.
*
* Stored as a snapshot ({@link ETradeBusinessOption}: licenceNumber, tradeName,
* activity, renewedTo) rather than a bare licence number, so the portal and the
* backoffice can show which business is attached without an eTrade round-trip —
* eTrade is slow, serves a broken TLS chain, and is regularly down.
*
* Nullable: existing profiles have none until the customer attaches one, and a
* co-operative or investor-licence company has no eTrade record at all.
* Deliberately NOT unique — one business can back several profiles.
*/
export class CompanyProfileEtradeBusiness3760000000000 implements MigrationInterface {
name = 'CompanyProfileEtradeBusiness3760000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.company_profiles
ADD COLUMN IF NOT EXISTS etrade_business jsonb
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.company_profiles
DROP COLUMN IF EXISTS etrade_business
`);
}
}

View File

@@ -0,0 +1,24 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Empties backfilled into the yard belong to a company that may not be a
* registered customer yet, so `customer_id` cannot hold it. `company_name` is
* the typed fallback, and the display label when the customer IS registered.
*/
export class EmptyContainerReturnCompanyName3790000000000 implements MigrationInterface {
name = 'EmptyContainerReturnCompanyName3790000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.empty_container_returns
ADD COLUMN IF NOT EXISTS company_name varchar(200)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.empty_container_returns
DROP COLUMN IF EXISTS company_name
`);
}
}

View File

@@ -0,0 +1,34 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Backlog registration of full containers that were already sitting in a yard
* before the system knew about them. Such a row carries a true, backdated
* `arrived_at` for the record but accrues NO storage or demurrage — the
* operator decided these are not billable retroactively — so the flag exists
* to keep the fee engine off them.
*
* `company_id` / `company_name` carry the owner, since a backlog row has no
* booking to inherit one from. The name is free text for a company that is not
* a registered customer yet.
*/
export class WarehouseInventoryBacklogRegistration3800000000000 implements MigrationInterface {
name = 'WarehouseInventoryBacklogRegistration3800000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.warehouse_inventory
ADD COLUMN IF NOT EXISTS backlog_registration boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS company_id uuid,
ADD COLUMN IF NOT EXISTS company_name varchar(200)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.warehouse_inventory
DROP COLUMN IF EXISTS backlog_registration,
DROP COLUMN IF EXISTS company_id,
DROP COLUMN IF EXISTS company_name
`);
}
}

View File

@@ -0,0 +1,55 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Seed `edr_freight_app:warehouse_zones:delete` — the zone counterpart of the
* warehouse and yard delete permissions, which already exist.
*
* `ROLE_PERMISSION_PRESETS` spreads `Object.values(FREIGHT_PERMS.warehouseZones)`
* into the warehouse positions, so the moment the key is added to the registry
* `FreightPositionsSeeder.loadPermissionIds` resolves it against `iam.permissions`
* at boot — and throws `missing_permissions:<key>` if the row is absent. The
* catalog is otherwise written by `EdrOrgSeeder`, which skips itself unless
* `SEED_EDR_ORG` is set, so a migration is the only path that runs everywhere.
*
* Idempotent on `key`; keeps the registry's fixed uuid so every environment
* lands on the same id. Skips silently when the freight application row is
* absent, since there is nothing to attach to.
*/
export class WarehouseZoneDeletePermission3810000000000 implements MigrationInterface {
private static readonly KEY = 'edr_freight_app:warehouse_zones:delete';
private static readonly ID = 'f1c00001-0001-4000-8000-000000000004';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`INSERT INTO iam.permissions (id, key, name, application_id)
SELECT $2::uuid,
$1::varchar,
'{"am": "Delete warehouse zone", "en": "Delete warehouse zone"}'::jsonb,
a.id
FROM iam.application a
WHERE a.key = 'edr_freight_app'
AND NOT EXISTS (SELECT 1 FROM iam.permissions p WHERE p.key = $1::varchar)`,
[WarehouseZoneDeletePermission3810000000000.KEY, WarehouseZoneDeletePermission3810000000000.ID],
);
}
/**
* Grants go first, or the delete trips the position/role permission foreign
* keys — a half-removed permission is worse than one left in place.
*/
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DELETE FROM iam.position_permissions
WHERE permission_id IN (SELECT id FROM iam.permissions WHERE key = $1)`,
[WarehouseZoneDeletePermission3810000000000.KEY],
);
await queryRunner.query(
`DELETE FROM iam.role_permissions
WHERE permission_id IN (SELECT id FROM iam.permissions WHERE key = $1)`,
[WarehouseZoneDeletePermission3810000000000.KEY],
);
await queryRunner.query(`DELETE FROM iam.permissions WHERE key = $1`, [
WarehouseZoneDeletePermission3810000000000.KEY,
]);
}
}

View File

@@ -0,0 +1,60 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Unified per-wagon history ledger. One append-only row per transition
* (yard move, coupling, schedule pin/dispatch/release, status flip, cargo
* load/unload, container placement, lifecycle edits), written in the same
* transaction as the change. No foreign keys: history must survive the wagon,
* train, schedule or booking it points at. The two composite indexes back
* keyset pagination of a single wagon's timeline (optionally per category);
* the partial ones answer "what happened on this schedule / booking".
*/
export class WagonEvents3820000000000 implements MigrationInterface {
name = 'WagonEvents3820000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.wagon_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
wagon_id uuid NOT NULL,
wagon_number varchar,
event_type varchar(40) NOT NULL,
category varchar(20) NOT NULL,
occurred_at timestamptz NOT NULL DEFAULT now(),
actor_user_id uuid,
from_yard_id uuid,
to_yard_id uuid,
train_id uuid,
train_schedule_id uuid,
booking_id uuid,
from_value varchar(120),
to_value varchar(120),
reason text,
metadata jsonb,
created_at timestamptz NOT NULL DEFAULT now()
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_wagon_events_wagon_time
ON freight.wagon_events (wagon_id, occurred_at DESC, id DESC)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_wagon_events_wagon_cat_time
ON freight.wagon_events (wagon_id, category, occurred_at DESC, id DESC)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_wagon_events_schedule
ON freight.wagon_events (train_schedule_id)
WHERE train_schedule_id IS NOT NULL
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_wagon_events_booking
ON freight.wagon_events (booking_id)
WHERE booking_id IS NOT NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_events`);
}
}

View File

@@ -0,0 +1,21 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* `freight.warehouses.freight_type` — CONTAINER or BULK, or null for a site
* that takes both.
*
* Nullable with no backfill on purpose: every existing warehouse predates the
* field and is unrestricted today, so writing a value would narrow live
* allocation behind the operator's back.
*/
export class WarehouseFreightType3820000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.warehouses ADD COLUMN IF NOT EXISTS freight_type varchar(16)`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE freight.warehouses DROP COLUMN IF EXISTS freight_type`);
}
}

View File

@@ -0,0 +1,128 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Physical container positions below the zone: a stack is the ground footprint,
* a slot is one level in it. Adds `stack_id` / `slot_id` to warehouse inventory.
*
* Everything is additive and nullable. Existing inventory keeps warehouse /
* yard / zone as its only location and stays valid — nothing is backfilled,
* because no one can know where a box already in the yard is actually stacked.
*
* Occupancy is not stored on the slot. `uq_warehouse_inventory_active_slot`
* makes the inventory row the single source of truth: one live placement per
* slot, enforced by Postgres. Its status list must stay in step with
* `SLOT_OCCUPYING_STATUSES` in warehouse-inventory.entity.ts.
*/
export class WarehouseZoneStacksSlots3830000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.warehouse_zone_stacks (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
zone_id uuid NOT NULL REFERENCES freight.warehouse_zones(id) ON DELETE CASCADE,
code varchar(40) NOT NULL,
name varchar(160),
"row" varchar(20),
bay varchar(20),
"position" varchar(20),
max_stack_height int NOT NULL DEFAULT 3,
status varchar(16) NOT NULL DEFAULT 'ACTIVE',
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT chk_warehouse_zone_stacks_height CHECK (max_stack_height >= 1)
)
`);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS idx_warehouse_zone_stacks_zone ON freight.warehouse_zone_stacks (zone_id)`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS idx_warehouse_zone_stacks_status ON freight.warehouse_zone_stacks (status)`,
);
// Partial: a soft-deleted stack must not block reusing its code.
await queryRunner.query(
`CREATE UNIQUE INDEX IF NOT EXISTS uq_warehouse_zone_stacks_zone_code
ON freight.warehouse_zone_stacks (zone_id, code) WHERE deleted_at IS NULL`,
);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.warehouse_zone_slots (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
stack_id uuid NOT NULL REFERENCES freight.warehouse_zone_stacks(id) ON DELETE CASCADE,
level int NOT NULL,
status varchar(16) NOT NULL DEFAULT 'AVAILABLE',
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT chk_warehouse_zone_slots_level CHECK (level >= 1)
)
`);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS idx_warehouse_zone_slots_stack ON freight.warehouse_zone_slots (stack_id, level)`,
);
await queryRunner.query(
`CREATE UNIQUE INDEX IF NOT EXISTS uq_warehouse_zone_slots_stack_level
ON freight.warehouse_zone_slots (stack_id, level) WHERE deleted_at IS NULL`,
);
await queryRunner.query(
`ALTER TABLE freight.warehouse_inventory ADD COLUMN IF NOT EXISTS stack_id uuid`,
);
await queryRunner.query(
`ALTER TABLE freight.warehouse_inventory ADD COLUMN IF NOT EXISTS slot_id uuid`,
);
// Named FKs added defensively — ADD CONSTRAINT has no IF NOT EXISTS.
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.warehouse_inventory
ADD CONSTRAINT fk_warehouse_inventory_stack
FOREIGN KEY (stack_id) REFERENCES freight.warehouse_zone_stacks(id);
EXCEPTION WHEN duplicate_object THEN NULL; END $$
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.warehouse_inventory
ADD CONSTRAINT fk_warehouse_inventory_slot
FOREIGN KEY (slot_id) REFERENCES freight.warehouse_zone_slots(id);
EXCEPTION WHEN duplicate_object THEN NULL; END $$
`);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_stack ON freight.warehouse_inventory (stack_id)`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_slot ON freight.warehouse_inventory (slot_id)`,
);
// One live container per slot. Statuses past the yard gate (LOADED,
// DISPATCHED, DELIVERED, UNLOADED_AT_DJIBOUTI_PORT) free the position
// without any exit path having to clear the column.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_warehouse_inventory_active_slot
ON freight.warehouse_inventory (slot_id)
WHERE deleted_at IS NULL
AND slot_id IS NOT NULL
AND status IN ('UNLOADED','RECEIVED','STORED','RESERVED','READY_FOR_LOADING','READY_FOR_PICKUP')
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight.uq_warehouse_inventory_active_slot`);
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_warehouse_inventory_slot`);
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_warehouse_inventory_stack`);
await queryRunner.query(
`ALTER TABLE freight.warehouse_inventory DROP CONSTRAINT IF EXISTS fk_warehouse_inventory_slot`,
);
await queryRunner.query(
`ALTER TABLE freight.warehouse_inventory DROP CONSTRAINT IF EXISTS fk_warehouse_inventory_stack`,
);
await queryRunner.query(`ALTER TABLE freight.warehouse_inventory DROP COLUMN IF EXISTS slot_id`);
await queryRunner.query(`ALTER TABLE freight.warehouse_inventory DROP COLUMN IF EXISTS stack_id`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_zone_slots`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_zone_stacks`);
}
}

View File

@@ -0,0 +1,67 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Customer-initiated empty container return, for a booking that did NOT buy
* the return service up front. The customer names the containers coming back,
* operations approves and prices it off the contract's WITH_RETURN rate, the
* customer pays that invoice and then books the date and truck. The empty
* itself is still recorded through `empty_container_returns` when the truck
* actually arrives — this table only carries the request up to that point.
*/
export class EmptyReturnRequests3840000000000 implements MigrationInterface {
name = 'EmptyReturnRequests3840000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.empty_return_requests (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
booking_id uuid NOT NULL,
company_id uuid,
status varchar(30) NOT NULL DEFAULT 'SUBMITTED',
container_numbers text[] NOT NULL DEFAULT '{}',
container_count smallint NOT NULL DEFAULT 0,
quoted_unit_amount numeric(14,2),
quoted_total_amount numeric(14,2),
currency varchar(8),
invoice_id uuid,
paid_at timestamptz,
requested_return_date date,
truck_plate_number varchar(32),
truck_driver_name varchar(120),
truck_type varchar(60),
scheduled_at timestamptz,
submitted_by_user_id uuid,
submitted_at timestamptz NOT NULL DEFAULT now(),
reviewed_by_staff_id uuid,
reviewed_at timestamptz,
rejection_reason text,
completed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_empty_return_requests_booking
ON freight.empty_return_requests (booking_id)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_empty_return_requests_status
ON freight.empty_return_requests (status)
`);
// A container number may only be owed back once at a time. That guard is
// per array element, so it lives in the service (see assertContainersFree)
// rather than in a unique index — this GIN index is what makes the check
// cheap.
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_empty_return_requests_containers
ON freight.empty_return_requests USING gin (container_numbers)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.empty_return_requests`);
}
}

View File

@@ -4,24 +4,17 @@ import {
HttpException,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable, tap } from 'rxjs';
import type { Request, Response } from 'express';
} from "@nestjs/common";
import { Observable, tap } from "rxjs";
import type { Request, Response } from "express";
import { AuditService } from './audit.service';
import {
auditEndpointMatcher,
type MatchedAuditEndpoint,
} from './audit-endpoint-matcher';
import {
isAuditableActor,
resolveAuditActor,
type AuditActorSource,
} from './audit-actor';
import { redactUrlQuery, sanitizeRequestPayload } from './audit.sanitizer';
import { AuditService } from "./audit.service";
import { auditEndpointMatcher, type MatchedAuditEndpoint } from "./audit-endpoint-matcher";
import { isAuditableActor, resolveAuditActor, type AuditActorSource } from "./audit-actor";
import { redactUrlQuery, sanitizeRequestPayload } from "./audit.sanitizer";
/** Methods that can change state. Everything else is never audited. */
const AUDITED_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
const AUDITED_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
/** `error_message` ceiling — stack traces do not belong in this column. */
const MAX_ERROR_LENGTH = 2_000;
@@ -52,7 +45,7 @@ export class AuditInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
// Non-HTTP contexts (the RabbitMQ microservice transport) have no request.
if (context.getType() !== 'http') return next.handle();
if (context.getType() !== "http") return next.handle();
const httpContext = context.switchToHttp();
const request = httpContext.getRequest<RequestWithUser>();
@@ -72,10 +65,7 @@ export class AuditInterceptor implements NestInterceptor {
const startedAt = Date.now();
// The body is captured up front: handlers are free to mutate the DTO they
// are given, so reading it after the fact can record post-mutation values.
const requestPayload = sanitizeRequestPayload(
request.body,
request.files ?? request.file,
);
const requestPayload = sanitizeRequestPayload(request.body, request.files ?? request.file);
return next.handle().pipe(
tap({
@@ -137,7 +127,6 @@ export class AuditInterceptor implements NestInterceptor {
resourceId: matched.resourceId,
request: requestPayload,
ipAddress: resolveIp(request),
userAgent: request.headers['user-agent'] ?? null,
requestId: resolveRequestId(request),
durationMs: Date.now() - startedAt,
});
@@ -154,10 +143,10 @@ function resolveErrorMessage(error: unknown): string | null {
if (error instanceof HttpException) {
const response = error.getResponse();
const message =
typeof response === 'string'
typeof response === "string"
? response
: ((response as { message?: unknown })?.message ?? error.message);
const text = Array.isArray(message) ? message.join('; ') : String(message);
const text = Array.isArray(message) ? message.join("; ") : String(message);
return text.slice(0, MAX_ERROR_LENGTH);
}
@@ -171,19 +160,19 @@ function resolveErrorMessage(error: unknown): string | null {
* entry (the original client) taken.
*/
function resolveIp(request: Request): string | null {
const forwarded = request.headers['x-forwarded-for'];
const forwarded = request.headers["x-forwarded-for"];
const raw = Array.isArray(forwarded) ? forwarded[0] : forwarded;
const candidate = raw?.split(',')[0]?.trim() || request.ip;
const candidate = raw?.split(",")[0]?.trim() || request.ip;
if (!candidate) return null;
// Normalize IPv4-mapped IPv6 (`::ffff:10.0.0.1`), which the `inet` column
// accepts but which reads badly and breaks grouping by address.
return candidate.startsWith('::ffff:') ? candidate.slice(7) : candidate;
return candidate.startsWith("::ffff:") ? candidate.slice(7) : candidate;
}
/** Correlation id from the proxy/tracing layer, when present. */
function resolveRequestId(request: RequestWithUser): string | null {
const header = request.headers['x-request-id'] ?? request.headers['x-correlation-id'];
const header = request.headers["x-request-id"] ?? request.headers["x-correlation-id"];
const value = Array.isArray(header) ? header[0] : header;
return (value ?? request.id ?? null)?.toString().slice(0, 64) ?? null;
}

View File

@@ -0,0 +1,112 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { In, Repository } from "typeorm";
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
import { ExternalProfile } from "../companies/entities/external-profile.entity";
/**
* One portal login belonging to a customer company: the company-side profile
* joined to the IAM account that actually signs in.
*
* The two halves drift apart routinely — `company.email` is business contact
* detail, while `email` here is the credential a reset link goes to — which is
* exactly why staff need to see the IAM side rather than the company row.
*/
export interface CustomerAccount {
/** external_profiles.id */
profileId: string;
userId: string;
firstName: string;
lastName: string;
jobTitle: string | null;
isPrimaryContact: boolean;
onboardingStep: string | null;
onboardingCompleted: boolean;
/** Null when the profile points at a user row that no longer exists. */
username: string | null;
email: string | null;
phoneNumber: string | null;
phoneVerified: boolean | null;
/** IAM account status (`EUserStatus`), surfaced as-is. */
status: string | null;
isActive: boolean | null;
/** False means the account was created but never activated by its owner. */
hasSetPassword: boolean | null;
createdAt: Date;
}
@Injectable()
export class CustomerAccountsService {
constructor(
@InjectRepository(ExternalProfile)
private readonly profiles: Repository<ExternalProfile>,
@InjectRepository(User)
private readonly users: Repository<User>,
) {}
/**
* Every portal account for a company, primary contact first.
*
* Deliberately NOT filtered to active accounts: a suspended or never-activated
* login is the case staff are usually looking into, and hiding it would leave
* "the customer says they can't log in" unanswerable from this screen.
*/
async listForCompany(companyId: string): Promise<CustomerAccount[]> {
const profiles = await this.profiles.find({ where: { companyId } });
if (profiles.length === 0) return [];
const userIds = profiles.map((p) => p.userId).filter(Boolean);
// Explicit select: the User entity's relations include credentials and
// sessions, and this response goes to a browser.
const users = userIds.length
? await this.users
.createQueryBuilder("user")
.select([
"user.id",
"user.username",
"user.email",
"user.phoneNumber",
"user.isPhoneNumberVerified",
"user.status",
"user.isActive",
"user.hasSetPassword",
])
.where({ id: In(userIds) })
.getMany()
: [];
const byId = new Map(users.map((u) => [u.id, u]));
return profiles
.map((p) => {
const user = byId.get(p.userId);
return {
profileId: p.id,
userId: p.userId,
firstName: p.firstName,
lastName: p.lastName,
jobTitle: p.jobTitle ?? null,
isPrimaryContact: p.isPrimaryContact,
onboardingStep: p.onboardingStep ?? null,
onboardingCompleted: p.onboardingCompleted ?? false,
username: user?.username ?? null,
email: user?.email ?? null,
phoneNumber: user?.phoneNumber ?? null,
phoneVerified: user?.isPhoneNumberVerified ?? null,
status: user?.status ?? null,
isActive: user?.isActive ?? null,
hasSetPassword: user?.hasSetPassword ?? null,
createdAt: p.createdAt,
};
})
.sort((a, b) => {
// Primary contact first — it is the account every staff action
// (password reset, notifications) actually targets.
if (a.isPrimaryContact !== b.isPrimaryContact) {
return a.isPrimaryContact ? -1 : 1;
}
return a.createdAt.getTime() - b.createdAt.getTime();
});
}
}

View File

@@ -12,6 +12,10 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { BookingStaff } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { BackofficeResetPasswordDto } from "./dto/forgot-password.dto";
import {
CustomerAccount,
CustomerAccountsService,
} from "./customer-accounts.service";
import {
CustomerResetService,
CustomerResetTarget,
@@ -25,7 +29,22 @@ import {
@Controller("backoffice/customers")
@ApiBearerAuth()
export class CustomerResetController {
constructor(private readonly customerResetService: CustomerResetService) {}
constructor(
private readonly customerResetService: CustomerResetService,
private readonly customerAccountsService: CustomerAccountsService,
) {}
@Get(":companyId/accounts")
@BookingStaff(FREIGHT_PERMS.customers.view)
@ApiOperation({
summary:
"The portal login accounts belonging to a customer, primary contact first",
})
async accounts(
@Param("companyId", ParseUUIDPipe) companyId: string,
): Promise<CustomerAccount[]> {
return this.customerAccountsService.listForCompany(companyId);
}
@Get(":companyId/reset-target")
@BookingStaff(FREIGHT_PERMS.customers.resetPassword)

View File

@@ -13,6 +13,7 @@ import { AccountController } from './account.controller';
import { AccountService } from './account.service';
import { CheckAvailabilityController } from './check-availability.controller';
import { CheckAvailabilityService } from './check-availability.service';
import { CustomerAccountsService } from './customer-accounts.service';
import { CustomerResetController } from './customer-reset.controller';
import { CustomerResetService } from './customer-reset.service';
import { ForgotPasswordController } from './forgot-password.controller';
@@ -50,6 +51,7 @@ import { ListUsersService } from './list-users.service';
CheckAvailabilityService,
ForgotPasswordService,
CustomerResetService,
CustomerAccountsService,
],
// Shipping-line registration mints activation links through the same
// staff-triggered reset path customers use.

View File

@@ -3,6 +3,7 @@ import { InjectDataSource } from '@nestjs/typeorm';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { DataSource } from 'typeorm';
import type { SnapshotEmployee } from '../../common/freight-jwt.guard';
import {
collectPermissionKeys,
isSuperAdmin,
@@ -82,8 +83,26 @@ export class FreightMeService {
? [employeeRecord.position]
: [];
const enrichedPositions = await Promise.all(
rawPositions.map(async (position) => {
// IAM keeps one employee row per organization, so a user holding a freight
// post and a Smart Office post owns two rows. The backoffice reads
// `employee` as an array and the position picker lists what it finds there
// — returning only the active row hides the other desk and makes it
// unselectable. `FreightJwtGuard` leaves the full set here.
const employeeRows = (user as { employeeRows?: SnapshotEmployee[] })
.employeeRows;
// Active row first: the backoffice reads `employee[0]` for
// unitId/organizationId, so the desk the caller is acting as must lead.
const rows: SnapshotEmployee[] = employeeRows?.length
? [
...employeeRows.filter((row) => row.id === employeeRecord?.id),
...employeeRows.filter((row) => row.id !== employeeRecord?.id),
]
: employeeRecord
? [{ ...employeeRecord, positions: rawPositions } as SnapshotEmployee]
: [];
const enrichPosition = async (position: TokenPosition) => {
const [positionType, positionTypePermissionKeys] = await Promise.all([
this.lookupPositionType(position.id),
this.lookupPositionTypePermissions(position.id),
@@ -114,20 +133,24 @@ export class FreightMeService {
positionType,
},
};
}),
};
const enrichedRows = await Promise.all(
rows.map(async (row) => ({
row,
positions: await Promise.all(
((row.positions ?? []) as TokenPosition[]).map(enrichPosition),
),
})),
);
const employee = employeeRecord
? [
{
id: employeeRecord.id,
organizationId: employeeRecord.organizationId,
unitId: employeeRecord.unitId,
name: employeeRecord.name,
positions: enrichedPositions.map((p) => p.position),
},
]
: [];
const employee = enrichedRows.map(({ row, positions }) => ({
id: row.id as string,
organizationId: row.organizationId as string,
unitId: row.unitId as string,
name: row.name,
positions: positions.map((p) => p.position),
}));
// `collectPermissionKeys` reads the raw token (position-level only), so
// union the type-level grants in — the backoffice prefers this flat list
@@ -135,7 +158,9 @@ export class FreightMeService {
const permissionKeys = [
...new Set([
...collectPermissionKeys(user),
...enrichedPositions.flatMap((p) => p.positionTypePermissionKeys),
...enrichedRows.flatMap(({ positions }) =>
positions.flatMap((p) => p.positionTypePermissionKeys),
),
]),
];

View File

@@ -14,6 +14,8 @@ import { InvoiceLineRepository } from "./invoice-line.repository";
import { PaymentModule } from "../payment/payment.module";
import { CompaniesModule } from "../companies/companies.module";
import { FilesModule } from "../files/files.module";
import { NotificationsModule } from "../notifications/notifications.module";
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
@Module({
imports: [
@@ -24,6 +26,10 @@ import { FilesModule } from "../files/files.module";
DocumentsModule,
UserTradeAccessModule,
FilesModule,
// Customer notice when Finance confirms a manual payment. The inbox module
// reaches this one back through CompaniesModule, hence forwardRef.
NotificationsModule,
forwardRef(() => NotificationInboxModule),
],
controllers: [BillingController, PortalBillingController, PaymentController],
providers: [BillingService, InvoiceRepository, InvoiceLineRepository],

View File

@@ -83,6 +83,8 @@ describe("BillingService.generateInvoice", () => {
{} as never, // files
{ get: () => undefined } as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
{ directSend: jest.fn() } as never, // notifications
{ notify: jest.fn() } as never, // inbox
);
});
@@ -166,6 +168,8 @@ describe("BillingService.issueMemo", () => {
{} as never,
{ get: () => undefined } as never,
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
{ directSend: jest.fn() } as never, // notifications
{ notify: jest.fn() } as never, // inbox
);
return { service, manager, savedLines };
}
@@ -301,6 +305,8 @@ describe("BillingService.markInvoiceAsPaid", () => {
{} as never, // files
{ get: () => undefined } as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
{ directSend: jest.fn() } as never, // notifications
{ notify: jest.fn() } as never, // inbox
);
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
@@ -357,6 +363,8 @@ describe("BillingService.markInvoiceAsPaid", () => {
{} as never, // files
{ get: () => undefined } as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
{ directSend: jest.fn() } as never, // notifications
{ notify: jest.fn() } as never, // inbox
);
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
@@ -403,6 +411,8 @@ describe("BillingService.settleByPaymentId", () => {
{} as never, // files
{ get: () => undefined } as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
{ directSend: jest.fn() } as never, // notifications
{ notify: jest.fn() } as never, // inbox
);
return { service, mg, events };
}
@@ -517,6 +527,8 @@ describe("BillingService.recordPayment", () => {
{} as never, // files
{ get: () => undefined } as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
{ directSend: jest.fn() } as never, // notifications
{ notify: jest.fn() } as never, // inbox
);
return { service, mg, events };
}
@@ -635,6 +647,8 @@ describe("BillingService.expirePayable — locked write runs in a transaction",
{} as never,
{} as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
{ directSend: jest.fn() } as never, // notifications
{ notify: jest.fn() } as never, // inbox
);
return { service, defaultManager, txManager, transaction };
};
@@ -709,6 +723,8 @@ describe("BillingService.issuePayable", () => {
{} as never,
{} as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
{ directSend: jest.fn() } as never, // notifications
{ notify: jest.fn() } as never, // inbox
);
return { service, manager };
};
@@ -801,6 +817,8 @@ describe("BillingService — CAC Bank (OTP debit)", () => {
{} as never,
{} as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
{ directSend: jest.fn() } as never, // notifications
{ notify: jest.fn() } as never, // inbox
);
return { service, repo };
};
@@ -885,6 +903,8 @@ describe("BillingService — CBE bill amounts carry cents, never rounded", () =>
{} as never,
{} as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
{ directSend: jest.fn() } as never, // notifications
{ notify: jest.fn() } as never, // inbox
);
return { service, repo };
};
@@ -939,8 +959,12 @@ describe("BillingService.document", () => {
const build = (invoice: Record<string, unknown>) => {
const render = jest.fn().mockResolvedValue({ filename: "x.pdf", buffer: Buffer.from("") });
const renderThermal = jest.fn().mockResolvedValue({ filename: "x-thermal.pdf", buffer: Buffer.from("") });
// `toDocumentModel` reads the booking (route/wagons, PNR) straight off the
// data source for a booking-sourced invoice — a stub that answers "no such
// booking" keeps these summary assertions about the invoice itself.
const dataSource = { getRepository: () => ({ findOne: jest.fn().mockResolvedValue(null) }) };
const service = new BillingService(
{} as never,
dataSource as never,
{ findById: jest.fn().mockResolvedValue(invoice) } as never,
{ findAll: jest.fn().mockResolvedValue([]) } as never,
{} as never,
@@ -955,6 +979,8 @@ describe("BillingService.document", () => {
: undefined,
} as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
{ directSend: jest.fn() } as never, // notifications
{ notify: jest.fn() } as never, // inbox
);
return { service, render, renderThermal };
};
@@ -1014,6 +1040,34 @@ describe("BillingService.document", () => {
expect(model.qrImageUrl).toBe("data:image/png;base64,signed-payload");
});
it("prints the provider transaction reference of a settled invoice", async () => {
const { service, render } = build(
invoiceRow({
status: Freight.InvoiceStatus.Paid,
paidAmount: 100,
balanceAmount: 0,
payments: [{ amount: 100, method: "GATEWAY", reference: "FT26082700123", paidAt: "2026-08-27T09:00:00.000Z" }],
payment: { transactionId: "FT26082700123" },
}),
);
await service.document("inv-1");
const model = render.mock.calls[0][0];
expect(model.summary).toContainEqual({ label: "Transaction ref", value: "FT26082700123" });
});
it("adds no transaction reference row to an unpaid invoice", async () => {
const { service, render } = build(invoiceRow());
await service.document("inv-1");
const model = render.mock.calls[0][0];
expect(
model.summary.find((r: { label: string }) => r.label === "Transaction ref"),
).toBeUndefined();
});
it("calls render (not renderThermal) for the default format", async () => {
const { service, render, renderThermal } = build(invoiceRow());
jest.spyOn(service as never, "toDocumentModel").mockResolvedValue({} as never);
@@ -1047,6 +1101,8 @@ describe("BillingService.confirmOfflinePayment pay-window guard", () => {
function makeService(invoiceType: string) {
const invoice = {
id: "inv-1",
invoiceNumber: "INV-001",
companyId: "company-1",
source: Freight.InvoiceSource.Booking,
sourceId: "booking-1",
type: invoiceType,
@@ -1057,9 +1113,16 @@ describe("BillingService.confirmOfflinePayment pay-window guard", () => {
const recordPayment = jest.fn().mockResolvedValue(invoice);
const dataSource = {
getRepository: () => ({
findOne: async () => ({ id: "booking-1", paymentDeadline: PAST }),
findOne: async () => ({
id: "booking-1",
reference: "BK-001",
paymentDeadline: PAST,
}),
}),
query: async () => [{ phone: "+251900000000", email: "c@x.com" }],
};
const directSend = jest.fn().mockResolvedValue(undefined);
const notify = jest.fn().mockResolvedValue(undefined);
const service = new BillingService(
dataSource as never,
{ findById: async () => invoice } as never,
@@ -1071,10 +1134,12 @@ describe("BillingService.confirmOfflinePayment pay-window guard", () => {
{ upload: async () => ({ id: "file-1", name: "slip.pdf" }) } as never,
{ get: () => undefined } as never,
{ isEnabled: async () => true } as never,
{ directSend } as never,
{ notify } as never,
);
(service as unknown as { recordPayment: unknown }).recordPayment =
recordPayment;
return { service, recordPayment };
return { service, recordPayment, directSend, notify };
}
const slip = { originalname: "slip.pdf" } as never;
@@ -1097,6 +1162,42 @@ describe("BillingService.confirmOfflinePayment pay-window guard", () => {
);
});
it("notifies the customer (inbox + SMS + email) once the payment is confirmed", async () => {
const { service, notify, directSend } = makeService(
WAGON_CANCEL_FEE_INVOICE_TYPE,
);
await service.confirmOfflinePayment("inv-1", slip, {});
expect(notify).toHaveBeenCalledWith(
expect.objectContaining({
recipients: { companyId: "company-1" },
type: "PAYMENT_RECEIVED",
link: "/billing/inv-1",
body: expect.stringMatching(/500 ETB .*INV-001 \(booking BK-001\)/),
}),
);
expect(directSend).toHaveBeenCalledWith(
"sms",
"+251900000000",
expect.stringContaining("INV-001"),
);
expect(directSend).toHaveBeenCalledWith(
"email",
"c@x.com",
expect.stringContaining("INV-001"),
);
});
it("still settles when the customer notice fails", async () => {
const { service, notify, recordPayment } = makeService(
WAGON_CANCEL_FEE_INVOICE_TYPE,
);
notify.mockRejectedValueOnce(new Error("inbox down"));
await expect(
service.confirmOfflinePayment("inv-1", slip, {}),
).resolves.toBeDefined();
expect(recordPayment).toHaveBeenCalled();
});
it("still requires the bank slip for a cancellation fee", async () => {
const { service } = makeService(WAGON_CANCEL_FEE_INVOICE_TYPE);
await expect(

View File

@@ -1,4 +1,9 @@
import { Freight, PaymentReferenceType } from "@edr/types";
import {
Freight,
NotificationAudience,
NotificationType,
PaymentReferenceType,
} from "@edr/types";
import { ConfigService } from "@nestjs/config";
import {
BadRequestException,
@@ -20,6 +25,10 @@ import { WAGON_CANCEL_FEE_INVOICE_TYPE } from "../bookings/entities/booking-wago
import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity";
import { ShippingLineCredit } from "../shipping-lines/entities/shipping-line-credit.entity";
import { ManualPaymentSettingsService } from "../payment-settings/manual-payment-settings.service";
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
import { NotificationsService } from "../notifications/notifications.service";
import { sendCompanyChannels } from "../notifications/notify-company.util";
import { resolveShippingLineNotifyTarget } from "../notifications/resolve-shipping-line-contact.util";
import { EimsConfig } from "../../config/eims.config";
import { CompaniesService } from "../companies/companies.service";
import { EimsInvoiceStatus } from "../eims/eims-registration.types";
@@ -29,9 +38,12 @@ import { PaymentService } from "../payment/payment.service";
import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto";
import {
InvoiceDocumentModel,
sameCompanyName,
InvoiceDocumentService,
pngDataUrl,
} from "./documents/invoice-document.service";
import { amountInWords } from "./documents/mor-document.util";
import { buildEimsSeller, resolveLineTax } from "../eims/eims-invoice-context";
import { INVOICE_SORT_COLUMNS } from "./dto/filter-invoice.dto";
import { InvoiceLine } from "./entities/invoice-line.entity";
import { Invoice, InvoicePayment } from "./entities/invoice.entity";
@@ -41,6 +53,7 @@ import {
applySettlement,
invoicePaymentMethodExpr,
round2,
settlementReferences,
} from "./invoice-settlement.util";
import { InvoiceRepository } from "./invoice.repository";
@@ -114,6 +127,8 @@ export interface InvoiceListFilters {
status?: Freight.InvoiceStatus;
statuses?: Freight.InvoiceStatus[];
sources?: string[];
/** What the invoice bills for (`PREPAID`, `DEMURRAGE`, …) — free-form per source. */
types?: string[];
eimsStatuses?: string[];
/** Settled payment method, normalised UPPER_SNAKE — see `invoicePaymentMethodExpr`. */
paymentMethods?: string[];
@@ -267,6 +282,8 @@ export class BillingService {
private readonly files: FilesService,
private readonly config: ConfigService,
private readonly manualPaymentSettings: ManualPaymentSettingsService,
private readonly notifications: NotificationsService,
private readonly inbox: NotificationInboxService,
) { }
// ── Reads ──────────────────────────────────────────────────────────────────
@@ -300,7 +317,12 @@ export class BillingService {
});
}
if (filter.sources?.length) {
qb.andWhere("invoice.source IN (:...sources)", { sources: filter.sources });
qb.andWhere("invoice.source IN (:...sources)", {
sources: filter.sources,
});
}
if (filter.types?.length) {
qb.andWhere("invoice.type IN (:...types)", { types: filter.types });
}
if (filter.eimsStatuses?.length) {
qb.andWhere("invoice.eimsStatus IN (:...eimsStatuses)", {
@@ -326,7 +348,9 @@ export class BillingService {
});
}
if (filter.issuedTo) {
qb.andWhere("invoice.issuedAt <= :issuedTo", { issuedTo: filter.issuedTo });
qb.andWhere("invoice.issuedAt <= :issuedTo", {
issuedTo: filter.issuedTo,
});
}
if (filter.dueFrom) {
qb.andWhere("invoice.dueAt >= :dueFrom", { dueFrom: filter.dueFrom });
@@ -589,21 +613,28 @@ export class BillingService {
/**
* Finance's manual-settlement worklist: USD invoices (paid by bank transfer,
* never through the gateway) and ETB invoices Finance settles by hand (bank
* transfer / counter) instead of the customer paying online. Open ones by
* default or a single status when filtered; both currencies unless
* `currency` narrows it. Booking-sourced rows carry the booking's reference,
* trade direction and pay-window deadline so the UI can show the countdown
* and link to the booking.
* transfer / counter) instead of the customer paying online. Both currencies
* unless `currency` narrows it, and only ones whose manual-payment channel is
* switched on. Open ones by default — pin `status` or `statuses` to widen
* that. Every other dimension is the invoice list's own (`applyInvoiceFilters`
* + `INVOICE_SORT_COLUMNS`), so the two screens filter and sort alike.
* Booking-sourced rows carry the booking's reference, trade direction and
* pay-window deadline so the UI can show the countdown and link to the
* booking.
*/
async findOfflineUsdPaginated(
filter: {
status?: Freight.InvoiceStatus;
search?: string;
currency?: "USD" | "ETB";
filter: InvoiceListFilters & {
page?: number;
pageSize?: number;
sortBy?: string;
sortOrder?: "ASC" | "DESC";
} = {},
): Promise<{ items: OfflineUsdInvoiceRow[]; total: number }> {
): Promise<{
items: OfflineUsdInvoiceRow[];
total: number;
/** Sum of `balanceAmount` over the WHOLE filtered set, by currency. */
outstanding: Record<string, number>;
}> {
const page = filter.page && filter.page > 0 ? filter.page : 1;
const pageSize =
filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20;
@@ -612,33 +643,75 @@ export class BillingService {
// a row Finance cannot act on is noise, and the confirm endpoint would
// refuse it anyway. All off → nothing to work.
const enabled = await this.manualPaymentSettings.enabledCurrencies();
if (!enabled.length) return { items: [], total: 0 };
const currencies = filter.currency
? enabled.filter((c) => c === filter.currency)
: enabled;
if (!currencies.length) return { items: [], total: 0 };
const empty = { items: [], total: 0, outstanding: {} };
if (!enabled.length) return empty;
const wanted = filter.currency?.toUpperCase();
const currencies = wanted ? enabled.filter((c) => c === wanted) : enabled;
if (!currencies.length) return empty;
const qb = this.dataSource
.getRepository(Invoice)
.createQueryBuilder("invoice")
.leftJoinAndSelect("invoice.company", "company")
.where("UPPER(invoice.currency) IN (:...currencies)", { currencies })
.orderBy("invoice.issuedAt", "DESC")
/**
* The worklist narrows by the same vocabulary as the main invoice list, so
* both share `applyInvoiceFilters` — which references the `company` and
* `payment` aliases, hence the unconditional joins. `select` is false for
* the aggregate pass, where joined columns would break the GROUP BY.
*/
const buildQb = (select: boolean) => {
const qb = this.dataSource
.getRepository(Invoice)
.createQueryBuilder("invoice");
if (select) {
qb.leftJoinAndSelect("invoice.company", "company").leftJoinAndSelect(
"invoice.payment",
"payment",
);
} else {
qb.leftJoin("invoice.company", "company").leftJoin(
"invoice.payment",
"payment",
);
}
qb.where("UPPER(invoice.currency) IN (:...currencies)", { currencies });
// "What still needs settling" is the default cut, but only until the
// caller pins a status — either the single-status param or the filter
// bar's multi-select.
if (!filter.status && !filter.statuses?.length) {
qb.andWhere("invoice.status IN (:...open)", { open: OPEN_STATUSES });
}
// `currency` is already enforced by the enabled-currency IN above, and
// re-applying it would only repeat the same predicate.
this.applyInvoiceFilters(qb, { ...filter, currency: undefined });
return qb;
};
const qb = buildQb(true)
// sortBy is whitelisted through INVOICE_SORT_COLUMNS, never interpolated
// raw; the id tiebreaker keeps paging stable when the column ties.
.orderBy(
INVOICE_SORT_COLUMNS[filter.sortBy ?? ""] ?? "invoice.issuedAt",
filter.sortOrder ?? "DESC",
)
.addOrderBy("invoice.id", "ASC")
.skip((page - 1) * pageSize)
.take(pageSize);
if (filter.status) {
qb.andWhere("invoice.status = :status", { status: filter.status });
} else {
qb.andWhere("invoice.status IN (:...open)", { open: OPEN_STATUSES });
}
if (filter.search) {
qb.andWhere(
"(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)",
{ search: `%${filter.search}%` },
);
}
const [rawItems, total] = await qb.getManyAndCount();
// Outstanding across the whole filtered set, not the visible page — the
// KPI must not change as Finance pages through the worklist.
const outstandingRows: { currency: string; outstanding: string }[] =
await buildQb(false)
.select("invoice.currency", "currency")
.addSelect("SUM(invoice.balanceAmount)", "outstanding")
.groupBy("invoice.currency")
.getRawMany();
// Folded case-insensitively on the way out: stored casing has drifted
// ("usd" rows exist), so two groups can address the same currency.
const outstanding: Record<string, number> = {};
for (const row of outstandingRows) {
const key = (row.currency ?? "").toUpperCase();
outstanding[key] =
(outstanding[key] ?? 0) + (Number(row.outstanding) || 0);
}
const items = await this.attachShippingLineCompanies(rawItems);
const bookingIds = items
@@ -702,6 +775,7 @@ export class BillingService {
} as OfflineUsdInvoiceRow;
}),
total,
outstanding,
};
}
@@ -769,8 +843,9 @@ export class BillingService {
uploadedByName: input.userName ?? null,
});
return this.recordPayment(invoiceId, {
amount: Number(invoice.balanceAmount),
const amount = Number(invoice.balanceAmount);
const paid = await this.recordPayment(invoiceId, {
amount,
method: "BANK_TRANSFER",
reference: input.reference || slip.name,
metadata: {
@@ -780,6 +855,104 @@ export class BillingService {
confirmedByName: input.userName ?? null,
},
});
// The customer did not pay through the portal, so nothing else tells them
// Finance has settled their invoice — this is their only confirmation.
await this.notifyCustomerManualPaymentConfirmed(paid, amount);
return paid;
}
/**
* Tell the customer Finance confirmed their manual (bank transfer / counter)
* payment: portal inbox entry plus SMS and email to the company's contact
* (or the shipping line's own contact for a credit invoice). Best-effort —
* a notification failure never undoes the settlement, it is only logged.
*/
private async notifyCustomerManualPaymentConfirmed(
invoice: Invoice,
amount: number,
): Promise<void> {
try {
const bookingRef =
invoice.source === Freight.InvoiceSource.Booking
? await this.bookingReferenceFor(invoice.sourceId)
: null;
const body =
`Your payment of ${round2(amount)} ${invoice.currency} for invoice ${invoice.invoiceNumber}` +
(bookingRef ? ` (booking ${bookingRef})` : "") +
` has been received and confirmed. Thank you.`;
const title = "Payment confirmed";
const data = {
invoiceId: invoice.id,
invoiceNumber: invoice.invoiceNumber,
bookingId: bookingRef ? invoice.sourceId : null,
};
if (invoice.companyId || invoice.companyProfileId) {
await this.inbox.notify({
recipients: invoice.companyId
? { companyId: invoice.companyId }
: { companyProfileId: invoice.companyProfileId! },
audience: NotificationAudience.PORTAL,
type: NotificationType.PAYMENT_RECEIVED,
title,
body,
link: `/billing/${invoice.id}`,
data,
});
if (invoice.companyId) {
await sendCompanyChannels(
this.dataSource,
this.notifications,
invoice.companyId,
body,
);
}
return;
}
if (invoice.shippingLineCompanyId) {
const target = await resolveShippingLineNotifyTarget(
this.dataSource,
invoice.shippingLineCompanyId,
);
if (target.userId) {
await this.inbox.notify({
recipients: { userIds: [target.userId] },
audience: NotificationAudience.PORTAL,
type: NotificationType.PAYMENT_RECEIVED,
title,
body,
link: `/shipping-line/invoices/${invoice.id}`,
data,
});
}
for (const [method, to] of [
["sms", target.phone],
["email", target.email],
] as const) {
if (!to) continue;
try {
await this.notifications.directSend(method, to, body);
} catch {
/* best-effort: provider unavailable */
}
}
}
} catch (err) {
this.logger.warn(
`Manual payment confirmed notify failed for invoice ${invoice.id}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
/** Booking reference for a booking id, or null when the booking is gone. */
private async bookingReferenceFor(bookingId: string): Promise<string | null> {
const booking = await this.dataSource.getRepository(Booking).findOne({
where: { id: bookingId },
select: ["id", "reference"],
});
return booking?.reference ?? null;
}
/** Invoice header plus its line items. */
@@ -848,7 +1021,9 @@ export class BillingService {
{
label: "Wagons",
value:
booking.wagonsRequired != null ? String(booking.wagonsRequired) : null,
booking.wagonsRequired != null
? String(booking.wagonsRequired)
: null,
},
];
}
@@ -875,10 +1050,21 @@ export class BillingService {
totals.push({ label: "Paid", amount: Number(invoice.paidAmount) });
totals.push({ label: "Balance", amount: Number(invoice.balanceAmount) });
const tradeName = invoice.companyProfile?.etradeBusiness?.tradeName?.trim();
const summary: InvoiceDocumentModel["summary"] = [
// Buyer identity — was missing entirely; a MoR-registered invoice must show who it was
// filed against, not just the seller. VatNumber shown only when the company has one.
{ label: "Buyer", value: invoice.company?.name ?? null },
// The trade name of the eTrade licence THIS profile operates as. A TIN
// holds many licences and the invoiced role (importer/exporter/forwarder)
// is usually a different business from the one the company registered
// under, so the buyer's name alone doesn't say which one was billed.
// Suppressed when it just repeats the buyer name — most companies trade
// under their registered name and a duplicate row helps nobody.
...(tradeName && !sameCompanyName(tradeName, invoice.company?.name)
? [{ label: "Buyer trade name", value: tradeName }]
: []),
{ label: "Buyer TIN", value: invoice.company?.tin ?? null },
...(invoice.company?.vatNumber
? [{ label: "Buyer VAT No.", value: invoice.company.vatNumber }]
@@ -907,11 +1093,24 @@ export class BillingService {
const eimsCfg = this.config.get<EimsConfig>("eims");
if (eimsCfg?.tin) summary.push({ label: "Seller TIN", value: eimsCfg.tin });
if (eimsCfg?.invoice?.sellerVatNumber) {
summary.push({ label: "Seller VAT No.", value: eimsCfg.invoice.sellerVatNumber });
summary.push({
label: "Seller VAT No.",
value: eimsCfg.invoice.sellerVatNumber,
});
}
// MoR EIMS reference — only once actually registered, never a placeholder row.
if (invoice.eimsIrn) summary.push({ label: "EIMS IRN", value: invoice.eimsIrn });
if (invoice.eimsIrn)
summary.push({ label: "EIMS IRN", value: invoice.eimsIrn });
// The provider's transaction number for the money actually received — CBE's `FT…`,
// telebirr's receipt number, or the bank-slip reference a teller recorded manually.
// It is what a payer holding a receipt can match this invoice against, and what
// finance reconciles a bank statement with; without it a PAID invoice proves only
// that EDR says it was paid. `findById` already loads the `payment` relation, so both
// sources are in hand here — see settlementReferences for why both are read.
const txnRefs = settlementReferences(invoice);
if (txnRefs) summary.push({ label: "Transaction ref", value: txnRefs });
// PNR — the CBE_BILL reference the customer pays against, written onto the booking at
// payment-initiation time (see initiatePayment()). Not a column on Invoice/Payment, so
@@ -921,7 +1120,8 @@ export class BillingService {
where: { id: invoice.sourceId },
select: ["id", "pnrCode"],
});
if (booking?.pnrCode) summary.push({ label: "PNR", value: booking.pnrCode });
if (booking?.pnrCode)
summary.push({ label: "PNR", value: booking.pnrCode });
}
return {
@@ -933,16 +1133,133 @@ export class BillingService {
currency: invoice.currency,
summary,
categoryHeader: "Charge type",
lines: invoice.lines.map((l) => ({
description: l.description ?? l.chargeType,
category: l.chargeType,
quantity: l.quantity,
unitRate: l.unitRate,
amount: l.amount,
currency: l.currency,
})),
lines: invoice.lines.map((l) => {
// Same resolver the filing used, so the printed Tax Code / Excise / Discount columns
// state what MoR actually holds for this line.
const tax = eimsCfg?.invoice ? resolveLineTax(eimsCfg, l.chargeType) : null;
return {
description: l.description ?? l.chargeType,
category: l.chargeType,
quantity: l.quantity,
unitRate: l.unitRate,
amount: l.amount,
currency: l.currency,
nature: eimsCfg?.invoice?.natureOfSupplies ?? null,
uom: eimsCfg?.invoice?.unitDefault ?? null,
taxCode: tax?.code ?? null,
excise: tax?.exciseTaxValue ?? null,
discount: tax?.discount ?? null,
};
}),
totals,
qrImageUrl: invoice.eimsSignedQr ? pngDataUrl(invoice.eimsSignedQr) : null,
qrImageUrl: invoice.eimsSignedQr
? pngDataUrl(invoice.eimsSignedQr)
: null,
mor: eimsCfg?.invoice ? this.buildMorDetails(invoice, eimsCfg) : null,
};
}
/**
* The MoR tax-document view of an invoice (ADD-P001) — the bilingual layout a customer also sees
* when they scan the QR on the Ministry's portal.
*
* Built from the invoice plus EIMS configuration alone, never from a live EIMS call: a document
* has to print whether or not it is registered yet, and printing must not depend on the gateway
* being up. Per-line tax comes from `resolveLineTax`, the same resolver that decided what was
* actually filed, so the paper and the filing cannot disagree.
*/
private buildMorDetails(
invoice: Invoice & { lines: InvoiceLine[] },
cfg: EimsConfig,
): InvoiceDocumentModel["mor"] {
const seller = buildEimsSeller(cfg);
const company = invoice.company;
const documentType = (invoice.eimsDocumentType as "INV" | "DEB" | "CRE" | undefined) ?? "INV";
// CREDIT until the money is in: the title states the sale's payment nature, not its status.
const isCash = Number(invoice.paidAmount) >= Number(invoice.totalAmount);
const TITLES: Record<string, { am: string; en: string }> = {
INV: isCash
? { am: "የእጅ በእጅ ሽያጭ ደረሰኝ / ተ.እ.ታ / ኤክሳይዝ ታክስ", en: "Cash sales invoice / VAT / Excise Tax" }
: { am: "የዱቤ ሽያጭ ደረሰኝ / ተ.እ.ታ / ኤክሳይዝ ታክስ", en: "Credit sales invoice / VAT / Excise Tax" },
CRE: { am: "የታክስ ክሬዲት ሰነድ", en: "Tax Credit Note" },
DEB: { am: "የታክስ ዴቢት ሰነድ", en: "Tax Debit Note" },
};
let total = 0;
let excise = 0;
let discount = 0;
let vatAmount = 0;
let vatTaxable = 0;
for (const line of invoice.lines) {
const tax = resolveLineTax(cfg, line.chargeType);
const lineTotal = Number(line.amount);
total += lineTotal;
excise += tax.exciseTaxValue;
discount += tax.discount;
if (tax.ratePercent > 0) {
vatTaxable += lineTotal;
vatAmount += (lineTotal * tax.ratePercent) / 100;
}
}
const totalIncludingTax = Number(invoice.totalAmount);
const rate = cfg.invoice.taxRatePercent ?? 0;
const title = TITLES[documentType] ?? TITLES.INV;
return {
titleAm: title.am,
titleEn: title.en,
saleType: cfg.invoice.transactionType,
irn: invoice.eimsIrn,
systemNumber: cfg.systemNumber || null,
referenceNumber: invoice.eimsDocumentNumber ?? null,
relatedDocumentIrn: invoice.relatedInvoice?.eimsIrn ?? null,
seller: {
name: cfg.invoice.sellerLegalName || seller.LegalName,
city: seller.City,
subCity: seller.SubCity,
woreda: seller.Wereda,
kebele: seller.Locality,
houseNo: seller.HouseNumber,
tin: seller.Tin,
vatNumber: seller.VatNumber,
},
buyer: {
name: company?.name ?? "N/A",
city: company?.zone ?? null,
subCity: company?.zone ?? null,
woreda: company?.woreda ?? null,
kebele: company?.kebele ?? null,
houseNo: company?.houseNo ?? null,
tin: company?.tin ?? null,
vatNumber: company?.vatNumber ?? null,
},
tax: {
total: round2(total),
discount: round2(discount),
taxableTotal: round2(vatTaxable),
excise: round2(excise),
vatTaxableAmount: round2(vatTaxable),
// An exempt seller still prints the row, labelled the way the Ministry's portal labels it.
vatLabel: rate > 0 ? `ተ.እ.ታ / VAT ${rate}%` : `${cfg.invoice.taxCode} ታክስ / ${cfg.invoice.taxCode} Tax rate (N/A%)`,
vatAmount: round2(vatAmount),
incomeWithholding: cfg.invoice.incomeWithholdValue ?? 0,
vatWithholding: cfg.invoice.transactionWithholdValue ?? 0,
totalIncludingTax: round2(totalIncludingTax),
amountInWords: amountInWords(totalIncludingTax),
},
payment: {
mode: isCash ? "CASH" : "CREDIT",
typeMethod: cfg.invoice.paymentTerm,
receiverName: company?.name ?? null,
},
// A memo is an amendment to a filed document; MoR's layout carries the sign-off that
// authorised it. Names come from the recorded reason until an approval chain exists.
approval:
documentType === "INV"
? null
: { requestedBy: invoice.eimsReason ?? null, checkedBy: null, approvedBy: null },
};
}
@@ -1201,7 +1518,9 @@ export class BillingService {
metadata: l.metadata ?? null,
}));
const total = round2(lines.reduce((sum, l) => sum + Number(l.amount ?? 0), 0));
const total = round2(
lines.reduce((sum, l) => sum + Number(l.amount ?? 0), 0),
);
if (!(total > 0)) {
throw new BadRequestException("A memo must have a positive total.");
}
@@ -1231,7 +1550,9 @@ export class BillingService {
subtotalAmount: total,
taxAmount: 0,
totalAmount: total,
...(settled ? { status: Freight.InvoiceStatus.Paid, dueAt: new Date() } : {}),
...(settled
? { status: Freight.InvoiceStatus.Paid, dueAt: new Date() }
: {}),
},
mg,
code,
@@ -1242,7 +1563,11 @@ export class BillingService {
eimsReason: reason,
relatedInvoiceId: original.id,
...(settled
? { paidAmount: memo.totalAmount, balanceAmount: 0, paidAt: new Date() }
? {
paidAmount: memo.totalAmount,
balanceAmount: 0,
paidAt: new Date(),
}
: {}),
};
await mg.update(Invoice, memo.id, patch);
@@ -1302,7 +1627,7 @@ export class BillingService {
input.dueAt ??
new Date(
Date.now() +
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
);
const invoiceNumber = await this.nextInvoiceNumber(mg, code);
@@ -1823,9 +2148,9 @@ export class BillingService {
dueAt,
...(issuing
? {
status: Freight.InvoiceStatus.Pending,
issuedAt: invoice.issuedAt ?? new Date(),
}
status: Freight.InvoiceStatus.Pending,
issuedAt: invoice.issuedAt ?? new Date(),
}
: {}),
};
await mg.update(Invoice, { id: invoice.id }, patch);
@@ -1889,10 +2214,7 @@ export class BillingService {
const repo = this.dataSource.getRepository(Invoice);
const invoices = await repo.findBy({
paymentId,
status: In([
Freight.InvoiceStatus.Issued,
Freight.InvoiceStatus.Pending,
]),
status: In([Freight.InvoiceStatus.Issued, Freight.InvoiceStatus.Pending]),
});
for (const invoice of invoices) {
await repo.update(
@@ -2069,7 +2391,10 @@ export class BillingService {
// Same reference, for an ad-hoc additional charge — its own column, since
// an AdditionalCharge doesn't own a Booking-scoped `pnrCode` and a booking
// can carry many of these at once.
if (billReference && invoice.source === Freight.InvoiceSource.AdditionalCharge) {
if (
billReference &&
invoice.source === Freight.InvoiceSource.AdditionalCharge
) {
await this.dataSource
.getRepository(AdditionalCharge)
.update({ id: invoice.sourceId }, { paymentReference: billReference });

View File

@@ -1,4 +1,4 @@
import { InvoiceDocumentModel, InvoiceDocumentService } from "./invoice-document.service";
import { InvoiceDocumentModel, InvoiceDocumentService, sameCompanyName } from "./invoice-document.service";
const model = (over: Partial<InvoiceDocumentModel> = {}): InvoiceDocumentModel => ({
kind: "INVOICE",
@@ -87,3 +87,203 @@ describe("InvoiceDocumentService.buildThermalHtml", () => {
expect(html).not.toContain("right: 160px");
});
});
describe("sameCompanyName", () => {
it("treats eTrade's legal-suffix spellings as the same name", () => {
expect(sameCompanyName("ABIJOEL PLC", "ABIJOEL P L C")).toBe(true);
expect(
sameCompanyName(
"WISH TRADING PLC",
"WISH TRADING PRIVATE LIMITED COMPANY",
),
).toBe(true);
expect(
sameCompanyName("TUTA TRADING PLC", "TUTA TRADING ONE MEMBER PLC"),
).toBe(true);
});
it("keeps a genuinely different trade name distinct", () => {
// Real pairs from eTrade: the licence trades under a different name than
// the company registered under, which is exactly the row worth printing.
expect(
sameCompanyName("Cozy Coffee Grower and Exporter", "ABIJOEL P L C"),
).toBe(false);
expect(sameCompanyName("MENNA PRODUCTION", "ICOFFEE TRADING PLC")).toBe(
false,
);
expect(
sameCompanyName("YUNABEK TRADING PLC", "YUNABEK INVESTMENT PLC"),
).toBe(false);
});
it("is false when either side is missing, so no row is printed", () => {
expect(sameCompanyName("", "ABIJOEL P L C")).toBe(false);
expect(sameCompanyName(null, null)).toBe(false);
expect(sameCompanyName("ABIJOEL P L C", undefined)).toBe(false);
});
});
describe("InvoiceDocumentService.buildHtml — MoR tax-document layout (ADD-P001)", () => {
const service = new InvoiceDocumentService({} as never, {} as never, {} as never);
const mor = (over: Partial<NonNullable<InvoiceDocumentModel["mor"]>> = {}) =>
({
titleAm: "የዱቤ ሽያጭ ደረሰኝ / ተ.እ.ታ / ኤክሳይዝ ታክስ",
titleEn: "Credit sales invoice / VAT / Excise Tax",
saleType: "B2B",
irn: "IRN-123",
systemNumber: "2B6E48BB75",
seller: { name: "Ethio-Djibouti Railway SC", tin: "0053481357" },
buyer: { name: "Afri Software Solutions", tin: "0089238373" },
tax: {
total: 904008.15,
discount: 0,
taxableTotal: 0,
excise: 0,
vatTaxableAmount: 0,
vatLabel: "VATEX ታክስ / VATEX Tax rate (N/A%)",
vatAmount: 0,
incomeWithholding: 0,
vatWithholding: 0,
totalIncludingTax: 904008.15,
amountInWords: "Nine hundred and four thousand and eight Birr and fifteen Cents",
},
payment: { mode: "CREDIT", typeMethod: "IMMIDIATE", receiverName: "Afri Software Solutions" },
...over,
}) as NonNullable<InvoiceDocumentModel["mor"]>;
it("switches layout only when the mor block is present", () => {
expect(service.buildHtml(model())).not.toContain("Total including Tax");
expect(service.buildHtml(model({ mor: mor() }))).toContain("Total including Tax");
});
it("prints the bilingual title, sale type, IRN and system number", () => {
const html = service.buildHtml(model({ mor: mor() }));
expect(html).toContain("Credit sales invoice / VAT / Excise Tax");
expect(html).toContain("የዱቤ ሽያጭ ደረሰኝ");
expect(html).toContain("(B2B)");
expect(html).toContain("IRN-123");
expect(html).toContain("2B6E48BB75");
});
it("prints every totals row even when the figure is zero", () => {
const html = service.buildHtml(model({ mor: mor() }));
for (const label of [
"Discount Amount",
"Taxable Total",
"Excise Tax",
"Total VAT Taxable Amount",
"Total Withheld Amount",
"Total VAT Withheld Amount",
"Total including Tax (in words)",
]) {
expect(html).toContain(label);
}
});
it("renders amounts bare, with the currency named once in the total label", () => {
const html = service.buildHtml(model({ mor: mor() }));
expect(html).toContain("904,008.15");
expect(html).toContain("Total (ETB)");
// The generic "1 Birr (ETB)" per-cell format must not leak into the tax layout.
expect(html).not.toContain("904,008.15 Birr (ETB)");
});
it("carries the MoR item columns", () => {
const html = service.buildHtml(
model({
mor: mor(),
lines: [
{
description: "Container Import",
quantity: 3,
unitRate: 5223,
amount: 15670,
nature: "service",
uom: "PCS",
taxCode: "VATEX",
excise: 0,
discount: 0,
},
],
}),
);
expect(html).toContain("Tax Code");
expect(html).toContain("VATEX");
expect(html).toContain("service");
expect(html).toContain("PCS");
});
it("shows the related document and approval block on a credit/debit note", () => {
const html = service.buildHtml(
model({
mor: mor({
titleEn: "Tax Credit Note",
relatedDocumentIrn: "ORIGINAL-IRN",
approval: { requestedBy: "biruk", checkedBy: "ermias", approvedBy: "kassahun" },
}),
}),
);
expect(html).toContain("Related Document");
expect(html).toContain("ORIGINAL-IRN");
expect(html).toContain("INVOICE AMENDMENT AUTHORIZATION");
expect(html).toContain("kassahun");
});
it("renders the sales receipt's linked-invoice table", () => {
const html = service.buildHtml(
model({
mor: mor({
titleEn: "Cash Receipt Voucher",
receipt: {
rrn: "RRN-9",
reason: "Payment for goods purchased",
collectedAmount: 950,
invoices: [
{
irn: "INV-IRN-1",
paymentCoverage: "PARTIAL",
totalAmount: 1200,
remainingAmount: 250,
paidAmount: 950,
},
],
},
}),
}),
);
expect(html).toContain("RRN-9");
expect(html).toContain("Payment Coverage");
expect(html).toContain("PARTIAL");
expect(html).toContain("Remaining Amount");
});
it("renders the withholding receipt without an item table", () => {
const html = service.buildHtml(
model({
mor: mor({
titleEn: "Withholding tax on payment",
tax: null,
withholding: {
receiptNumber: "WH-26-574705075",
counter: "574705075",
reason: "Tax Withholding",
type: "TWTH",
invoiceCurrency: "ETB",
preTaxAmount: 8640000,
withheldAmount: 259200,
systemType: "MAN",
systemNumber: "2B6E48BB75",
},
}),
lines: [{ description: "ignored", amount: 1 }],
}),
);
expect(html).toContain("WH-26-574705075");
expect(html).toContain("TWTH");
expect(html).toContain("Pre Tax Amount");
expect(html).toContain("259,200.00");
// A withholding receipt has no billed items — the item table must be suppressed entirely.
expect(html).not.toContain("Unit Price");
});
});

View File

@@ -5,6 +5,11 @@ import { LogoSettingsService } from "../../logo-settings/logo-settings.service";
import { PdfRenderService } from "./pdf-render.service";
import { sealClass, sealImageCss, sealMarkup } from "./seal-markup.util";
import { logoImageCss, logoMarkup } from "./logo-markup.util";
import {
formatDocumentTime,
formatEthiopianDate,
formatGregorianDate,
} from "./mor-document.util";
import {
PdfColor,
assembleSinglePagePdf,
@@ -44,10 +49,53 @@ function money(amount: unknown, currency: string): string {
return `${Number(amount ?? 0).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`;
}
/**
* Bare fixed-2 amount for the MoR tax layout — `1,304,228.00`, no currency suffix.
*
* The Ministry's own documents name the currency once, in the `ድምር (ETB) / Total (ETB)` label, and
* keep every figure a plain right-aligned number. Repeating "Birr (ETB)" in each cell (what the
* generic `money` helper does) both breaks that column alignment and reads as a different
* document from the one the customer sees when they scan the QR.
*/
function amount2(value: unknown): string {
return Number(value ?? 0).toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
function formatDate(value: unknown): string {
return value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-";
}
/**
* Is this trade name just the company name again?
*
* Compared loosely on purpose: eTrade spells the same legal suffix as "PLC",
* "P L C" and "PRIVATE LIMITED COMPANY", and pads names with double spaces, so
* an exact comparison would call two spellings of one name different and print
* a redundant row. Used only to decide whether a trade-name row is worth
* showing — never to decide that two businesses ARE the same.
*/
export function sameCompanyName(
a: string | null | undefined,
b: string | null | undefined,
): boolean {
const norm = (v: string | null | undefined) =>
(v ?? "")
.toUpperCase()
.replace(/[.,]/g, "")
.replace(/\s+/g, " ")
.trim()
.replace(/\bPRIVATE LIMITED COMPANY\b/g, "PLC")
.replace(/\bP L C\b/g, "PLC")
.replace(/\bONE (MEMBER|PERSON) PLC\b/g, "PLC")
.replace(/\s+/g, " ")
.trim();
const left = norm(a);
return left !== "" && left === norm(b);
}
/** One billed line on the document (charge type / fee type agnostic). */
export interface InvoiceDocumentLine {
description: string | null;
@@ -57,6 +105,115 @@ export interface InvoiceDocumentLine {
unitRate?: number | null;
amount?: number | null;
currency?: string | null;
/**
* MoR tax-document columns (ADD-P001). Populated only for documents that carry a
* {@link MorDocumentDetails}; the generic EDR layout ignores them.
*/
nature?: string | null;
uom?: string | null;
taxCode?: string | null;
excise?: number | null;
discount?: number | null;
}
/** One party block (`ከ / From`, `ለ / To`) of a MoR tax document. */
export interface MorPartyDetails {
name: string;
city?: string | null;
/** `ዞን / ክ/ከተማ` — Zone/Sub city. */
subCity?: string | null;
woreda?: string | null;
kebele?: string | null;
houseNo?: string | null;
tin?: string | null;
subTin?: string | null;
vatNumber?: string | null;
}
/**
* The Ministry's totals block, in its printed order. Every row prints even at zero — a tax
* document states each figure explicitly rather than omitting the ones that happen to be nil.
*/
export interface MorTaxSummary {
total: number;
discount: number;
taxableTotal: number;
excise: number;
vatTaxableAmount: number;
/** e.g. `ተ.እ.ታ / VAT 15%`, or `VATEX ታክስ / VATEX Tax rate (N/A%)` for an exempt seller. */
vatLabel: string;
vatAmount: number;
incomeWithholding: number;
vatWithholding: number;
totalIncludingTax: number;
amountInWords: string;
}
export interface MorPaymentDetails {
/** `CASH` / `CREDIT` — also selects the document title. */
mode: string;
/** `IMMEDIATE` and friends. */
typeMethod: string;
receiverName?: string | null;
}
/** Credit/debit memo authorisation block. */
export interface MorApprovalDetails {
requestedBy?: string | null;
checkedBy?: string | null;
approvedBy?: string | null;
}
/** Sales receipt (CRV) specifics. */
export interface MorReceiptDetails {
rrn: string;
reason: string;
collectedAmount: number;
invoices: Array<{
irn: string;
paymentCoverage: string;
totalAmount: number;
remainingAmount: number;
paidAmount: number;
}>;
}
/** Withholding receipt specifics — a different document shape, with no item table. */
export interface MorWithholdingDetails {
receiptNumber: string;
counter: string;
reason: string;
/** MoR withholding type, e.g. `TWTH`. */
type: string;
invoiceCurrency: string;
preTaxAmount: number;
withheldAmount: number;
systemType: string;
systemNumber: string;
}
/**
* Everything the MoR (ADD-P001) print layout needs beyond the generic model. Present ⇒ the
* document renders in the Ministry's bilingual tax-document format instead of the plain EDR one.
*/
export interface MorDocumentDetails {
/** Bilingual heading, e.g. `የእጅ በእጅ ሽያጭ ደረሰኝ / ተ.እ.ታ / ኤክሳይዝ ታክስ` + `Cash sales invoice / VAT / Excise Tax`. */
titleAm: string;
titleEn: string;
/** `B2B` / `B2C` / `B2G`. */
saleType?: string | null;
irn?: string | null;
systemNumber?: string | null;
referenceNumber?: string | null;
/** Original document's IRN — credit and debit notes only. */
relatedDocumentIrn?: string | null;
seller: MorPartyDetails;
buyer: MorPartyDetails;
tax?: MorTaxSummary | null;
payment?: MorPaymentDetails | null;
approval?: MorApprovalDetails | null;
receipt?: MorReceiptDetails | null;
withholding?: MorWithholdingDetails | null;
}
/** A labelled total row in the totals box; mark `grand` for the headline total. */
@@ -101,6 +258,12 @@ export interface InvoiceDocumentModel {
* itself goes through the ordinary `summary` rows, not a dedicated field.
*/
qrImageUrl?: string | null;
/**
* Present ⇒ render the Ministry's bilingual tax-document layout (ADD-P001) rather than the
* generic EDR one. Set for every document EIMS knows about: invoice, credit/debit note, sales
* receipt and withholding receipt.
*/
mor?: MorDocumentDetails | null;
}
/**
@@ -204,12 +367,38 @@ export class InvoiceDocumentService {
})
.join("");
const totalRows = model.totals
.map(
(total) =>
`<div class="total-row${total.grand ? " grand" : ""}"><span>${esc(total.label)}</span><strong>${esc(money(total.amount, model.currency))}</strong></div>`,
)
.join("");
// A thermal receipt is a compact derivative of the A4 tax document, not a different document:
// the tax breakdown, the amount in words and the payment mode are the legally load-bearing
// parts and must survive the narrower page. Only the item-table columns are dropped.
const tax = model.mor?.tax;
const totalRows = tax
? [
["Total", money(tax.total, model.currency)],
["Discount", money(tax.discount, model.currency)],
["Taxable Total", money(tax.taxableTotal, model.currency)],
["Excise Tax", money(tax.excise, model.currency)],
[tax.vatLabel, money(tax.vatAmount, model.currency)],
["Withheld", money(tax.incomeWithholding, model.currency)],
["VAT Withheld", money(tax.vatWithholding, model.currency)],
]
.map(
([label, value]) =>
`<div class="total-row"><span>${esc(label)}</span><strong>${esc(value)}</strong></div>`,
)
.join("") +
`<div class="total-row grand"><span>Total incl. Tax</span><strong>${esc(money(tax.totalIncludingTax, model.currency))}</strong></div>` +
`<div class="words">${esc(tax.amountInWords)}</div>`
: model.totals
.map(
(total) =>
`<div class="total-row${total.grand ? " grand" : ""}"><span>${esc(total.label)}</span><strong>${esc(money(total.amount, model.currency))}</strong></div>`,
)
.join("");
const payMarkup = model.mor?.payment
? `<div class="rule"></div><div class="row"><span class="label">Mode of Payment</span><span class="value">${esc(model.mor.payment.mode)}</span></div>
<div class="row"><span class="label">Type/Method</span><span class="value">${esc(model.mor.payment.typeMethod)}</span></div>`
: "";
const qrMarkup = model.qrImageUrl
? `<div class="qr"><img src="${esc(model.qrImageUrl)}" alt="EIMS verification QR" /><div class="qr-caption">Scan to verify (MoR EIMS)</div></div>`
@@ -236,6 +425,7 @@ export class InvoiceDocumentService {
.item-calc { text-align: right; font-family: monospace; font-size: 8.5px; }
.total-row { display: flex; justify-content: space-between; font-size: 9px; padding: 2px 0; }
.total-row.grand { font-size: 11px; font-weight: 800; border-top: 1px solid #0f172a; margin-top: 3px; padding-top: 4px; }
.words { font-size: 8px; text-align: center; margin-top: 4px; font-style: italic; }
.qr { text-align: center; margin: 8px 0; }
.qr img { width: 150px; height: 150px; }
.qr-caption { font-size: 7px; color: #64748b; margin-top: 2px; }
@@ -254,6 +444,7 @@ export class InvoiceDocumentService {
${itemBlocks}
<div class="rule"></div>
${totalRows}
${payMarkup}
${qrMarkup}
<div class="footer">Thank you</div>
</div>
@@ -309,7 +500,11 @@ export class InvoiceDocumentService {
let y = 700;
const colX = [36, 300];
const colW = 250;
model.summary.slice(0, 16).forEach((row, i) => {
// 20, not 16: a booking invoice already fills 16 rows with every optional one present
// (buyer trade name, buyer VAT, seller TIN/VAT, IRN, PNR) and the transaction ref is the
// 17th — the old cap silently dropped whichever row landed last. Still fits: 20 rows end
// at y=423, leaving the line-item table its full run down to the y<190 cut-off.
model.summary.slice(0, 20).forEach((row, i) => {
const x = colX[i % 2];
if (i % 2 === 0 && i > 0) y -= 27;
ops.push(textOp((row.label ?? "").toUpperCase(), x, y, 7, "F1", PdfColor.gray));
@@ -382,6 +577,10 @@ export class InvoiceDocumentService {
}
buildHtml(model: InvoiceDocumentModel): string {
// A MoR-registered document prints in the Ministry's own bilingual format (ADD-P001). Anything
// else — internal fee notes, statements — keeps the plain EDR layout below.
if (model.mor) return this.buildMorHtml(model, model.mor);
const date = formatDate;
const showCategory = Boolean(model.categoryHeader);
const sealText =
@@ -499,7 +698,317 @@ export class InvoiceDocumentService {
</html>`;
}
/**
* MoR EIMS tax-document layout (ADD-P001) — invoice, credit/debit note, sales receipt and
* withholding receipt share this one template, differing only in which optional blocks appear.
*
* Field labels and their order come from the Ministry's own portal rendering of a registered EDR
* invoice, so a printout and the page a customer reaches by scanning the QR read the same way.
* Every totals row prints even at zero: a tax document states each figure rather than hiding the
* nil ones.
*/
buildMorHtml(model: InvoiceDocumentModel, mor: MorDocumentDetails): string {
const currency = model.currency;
const party = (p: MorPartyDetails, sideAm: string, sideEn: string, tinAm: string, tinEn: string): string => `
<table class="party">
<tr><th class="side"><span class="am">${esc(sideAm)}</span><span class="en">${esc(sideEn)}</span></th>
<td class="pname">${esc(p.name)}</td></tr>
${morRow("ከተማ", "City/Town", p.city)}
${morRow("ዞን / ክ/ከተማ", "Zone/Sub city", p.subCity)}
${morRow("ወረዳ", "Woreda", p.woreda)}
${morRow("ቀበሌ", "Kebele", p.kebele)}
${morRow("የቤ/ቁ", "H/No", p.houseNo)}
${morRow("የግብር ከፋይ መለያ ቁጥር", `${tinEn}'s TIN`, p.tin, tinAm)}
${morRow("ንዑስ/ቁ", "Sub-TIN", p.subTin)}
${morRow("ተ.እ.ታ ቁጥር", `${tinEn}'s VAT`, p.vatNumber)}
</table>`;
const itemRows = model.lines
.map(
(item, i) => `<tr>
<td class="num">${i + 1}</td>
<td>${esc(item.description)}</td>
<td>${esc(item.nature ?? "-")}</td>
<td>${esc(item.uom ?? "-")}</td>
<td class="num">${esc(item.quantity ?? 0)}</td>
<td class="num">${esc(amount2(item.unitRate))}</td>
<td>${esc(item.taxCode ?? "-")}</td>
<td class="num">${esc(amount2(item.excise ?? 0))}</td>
<td class="num">${esc(amount2(item.discount ?? 0))}</td>
<td class="num strong">${esc(amount2(item.amount))}</td>
</tr>`,
)
.join("");
const tax = mor.tax;
const taxRows = tax
? [
totalRow("ድምር", `Total (${currency})`, amount2(tax.total)),
totalRow("የቅናሽ መጠን", "Discount Amount", amount2(tax.discount)),
totalRow("ታክስ የሚከፈልበት ድምር", "Taxable Total", amount2(tax.taxableTotal)),
totalRow("ኤክሳይዝ ታክስ", "Excise Tax", amount2(tax.excise)),
totalRow("ተ.እ.ታ የሚከፈልበት ድምር", "Total VAT Taxable Amount", amount2(tax.vatTaxableAmount)),
totalRow("", tax.vatLabel, amount2(tax.vatAmount)),
totalRow("ጠቅላላ የተያዘ መጠን", "Total Withheld Amount", amount2(tax.incomeWithholding)),
totalRow("ጠቅላላ የተያዘ መጠን ተ.እ", "Total VAT Withheld Amount", amount2(tax.vatWithholding)),
totalRow("ጠቅላላ ዋጋ ከታክስ ጋር", "Total including Tax", amount2(tax.totalIncludingTax), true),
].join("")
: "";
const wordsRow = tax
? `<tr class="words"><td class="wl"><span class="am">ጠቅላላ ዋጋ ከታክስ ጋር (በፊደል)</span><span class="en">Total including Tax (in words)</span></td>
<td class="wv">${esc(tax.amountInWords)}</td></tr>`
: "";
const receipt = mor.receipt;
const receiptBlock = receipt
? `<table class="kv">
${morRow("የክፍያ ምክንያት", "Payment Reason", receipt.reason)}
${morRow("የተሰበሰበ መጠን", "Collected Amount", amount2(receipt.collectedAmount))}
</table>
<div class="sec">የደረሰኞች ዝርዝር / Invoices</div>
<table class="items">
<thead><tr>
<th>IRN</th>
<th>${esc("የክፍያ ሽፋን / Payment Coverage")}</th>
<th class="num">${esc("ጠቅላላ ዋጋ / Total Amount")}</th>
<th class="num">${esc("ቀሪ / Remaining Amount")}</th>
<th class="num">${esc("የተከፈለ / Paid Amount")}</th>
</tr></thead>
<tbody>${receipt.invoices
.map(
(inv) => `<tr>
<td class="irn">${esc(inv.irn)}</td>
<td>${esc(inv.paymentCoverage)}</td>
<td class="num">${esc(amount2(inv.totalAmount))}</td>
<td class="num">${esc(amount2(inv.remainingAmount))}</td>
<td class="num strong">${esc(amount2(inv.paidAmount))}</td>
</tr>`,
)
.join("")}</tbody>
</table>
<div class="paid-total">ጠቅላላ የተከፈለ መጠን / Total Paid: <strong>${esc(amount2(receipt.collectedAmount))}</strong></div>`
: "";
const wh = mor.withholding;
const withholdingBlock = wh
? `<table class="kv">
${morRow("የደረሰኝ ቁጥር", "Receipt #", wh.receiptNumber)}
${morRow("ቆጣሪ", "Counter", wh.counter)}
${morRow("ምክንያት", "Reason", wh.reason)}
${morRow("አይነት", "Type", wh.type)}
</table>
<table class="items">
<thead><tr>
<th>${esc("የደረሰኝ ቁጥር / Invoice Doc. Number")}</th>
<th>${esc("የገንዘብ ዓይነት / Invoice Currency")}</th>
<th class="num">${esc("ከታክስ በፊት ያለው ዋጋ / Pre Tax Amount")}</th>
<th class="num">${esc("ተይዞ የቀረ መጠን / Withheld Amount")}</th>
</tr></thead>
<tbody><tr>
<td>${esc(wh.receiptNumber)}</td>
<td>${esc(wh.invoiceCurrency)}</td>
<td class="num">${esc(amount2(wh.preTaxAmount))}</td>
<td class="num strong">${esc(amount2(wh.withheldAmount))}</td>
</tr></tbody>
</table>
<div class="paid-total">በገዥ ተይዞ የቀረ መጠን / Withheld Amount: <strong>${esc(amount2(wh.withheldAmount))}</strong></div>
<table class="kv sys">
${morRow("የስርዓት አይነት", "System Type", wh.systemType)}
${morRow("የስርዓት ቁጥር", "System Number", wh.systemNumber)}
</table>`
: "";
const payment = mor.payment;
const paymentBlock = payment
? `<table class="pay">
<tr>
<td><span class="am">የክፍያ ሁኔታ</span><span class="en">Mode of Payment</span><strong>${esc(payment.mode)}</strong></td>
<td><span class="am">አይነት</span><span class="en">Type/Method</span><strong>${esc(payment.typeMethod)}</strong></td>
<td><span class="am">የተቀባይ ስምና ፊርማ</span><span class="en">Receiver Name &amp; Signature</span><strong>${esc(payment.receiverName ?? "")}</strong></td>
</tr>
</table>`
: "";
const approval = mor.approval;
const approvalBlock = approval
? `<div class="amend">INVOICE AMENDMENT AUTHORIZATION</div>
<div class="amend-note">This amendment has been reviewed and approved in accordance with the company's approval matrix.</div>
<table class="pay">
<tr>
<td><span class="am">የጠየቀው</span><span class="en">Requested By</span><strong>${esc(approval.requestedBy ?? "")}</strong></td>
<td><span class="am">ያረጋገጠው</span><span class="en">Checked By</span><strong>${esc(approval.checkedBy ?? "")}</strong></td>
<td><span class="am">ያፀደቀው</span><span class="en">Approved By</span><strong>${esc(approval.approvedBy ?? "")}</strong></td>
</tr>
</table>`
: "";
const qrBlock = model.qrImageUrl
? `<img class="qr" src="${esc(model.qrImageUrl)}" alt="EIMS verification QR" />`
: "";
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>${esc(mor.titleEn)} ${esc(model.documentNumber)}</title>
<style>
@page { size: A4; margin: 10mm 9mm 12mm; }
body { font-family: "Noto Sans Ethiopic", "Abyssinica SIL", Arial, sans-serif; color: #111827; margin: 0; font-size: 9.5px; }
.doc { position: relative; }
.am { display: block; font-size: 8px; color: #374151; }
.en { display: block; font-size: 8.5px; color: #6b7280; }
/* Header ------------------------------------------------------------- */
.hdr { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 2px solid #0f766e; padding-bottom: 6px; }
.hdr-logo { max-height: 42px; max-width: 150px; object-fit: contain; display: block; margin-bottom: 4px; }
.org { font-size: 12px; font-weight: 700; color: #0f172a; }
.org-sub { font-size: 8.5px; color: #4b5563; line-height: 1.45; }
.hdr-meta { text-align: right; font-size: 8.5px; }
.hdr-meta div { margin-bottom: 2px; }
.hdr-meta b { display: inline-block; min-width: 92px; text-align: right; color: #111827; }
/* Title -------------------------------------------------------------- */
.title { text-align: center; margin: 8px 0 4px; }
.title .t-am { font-size: 12px; font-weight: 700; }
.title .t-en { font-size: 11.5px; font-weight: 700; text-decoration: underline; }
.title .t-type { font-size: 9.5px; color: #4b5563; margin-top: 2px; }
/* Identity strip ----------------------------------------------------- */
.ident { display: flex; justify-content: space-between; gap: 10px; margin: 6px 0 8px; }
.ident table { border-collapse: collapse; }
.ident td { padding: 1.5px 0; vertical-align: top; font-size: 8.5px; }
.ident td.k { color: #6b7280; padding-right: 8px; white-space: nowrap; }
.ident td.v { font-weight: 600; word-break: break-all; max-width: 330px; }
.qr { width: 96px; height: 96px; flex: none; }
/* Parties ------------------------------------------------------------ */
.parties { display: flex; gap: 8px; }
.parties > div { flex: 1; min-width: 0; }
table.party { width: 100%; border-collapse: collapse; border: 1px solid #9ca3af; }
table.party th, table.party td { border: 1px solid #d1d5db; padding: 2.5px 5px; text-align: left; vertical-align: top; font-weight: normal; }
table.party th.side { width: 42%; background: #f9fafb; }
table.party td.pname { font-weight: 700; font-size: 10px; }
table.party td.pv { font-weight: 600; word-break: break-all; }
/* Items -------------------------------------------------------------- */
.sec { margin: 8px 0 3px; font-size: 9px; font-weight: 700; color: #374151; }
table.items { width: 100%; border-collapse: collapse; margin-top: 6px; table-layout: fixed; }
table.items th { background: #f3f4f6; font-size: 7.5px; color: #374151; }
table.items th, table.items td { border: 1px solid #9ca3af; padding: 3px 4px; text-align: left; word-wrap: break-word; }
table.items td { font-size: 8.5px; }
table.items .num { text-align: right; }
table.items .strong { font-weight: 700; }
table.items td.irn { font-size: 7px; word-break: break-all; }
/* Totals ------------------------------------------------------------- */
table.totals { width: 100%; border-collapse: collapse; margin-top: -1px; }
table.totals td { border: 1px solid #9ca3af; padding: 3px 6px; font-size: 8.5px; }
table.totals td.tl { text-align: right; }
table.totals td.tv { text-align: right; width: 130px; font-weight: 600; }
table.totals tr.grand td { font-weight: 800; font-size: 10px; background: #f9fafb; }
table.totals tr.words td { padding: 4px 6px; }
table.totals td.wl { width: 240px; }
table.totals td.wv { font-weight: 700; text-align: center; }
/* Key/value + payment ------------------------------------------------ */
table.kv { width: 100%; border-collapse: collapse; margin-top: 6px; }
table.kv td { border: 1px solid #9ca3af; padding: 3px 6px; font-size: 8.5px; }
table.kv td.k { width: 220px; background: #f9fafb; }
table.kv td.v { font-weight: 600; }
table.kv.sys { margin-top: 10px; }
.paid-total { text-align: right; font-size: 9px; margin-top: 4px; }
table.pay { width: 100%; border-collapse: collapse; margin-top: 10px; }
table.pay td { border: 1px solid #9ca3af; padding: 4px 6px; width: 33.33%; }
table.pay strong { display: block; font-size: 10px; margin-top: 2px; }
.amend { margin-top: 12px; text-align: center; font-weight: 800; font-size: 10px; color: #b91c1c; letter-spacing: .04em; }
.amend-note { text-align: center; font-size: 8px; color: #6b7280; }
/* Footer ------------------------------------------------------------- */
.foot { margin-top: 14px; border-top: 1px solid #d1d5db; padding-top: 4px; display: flex; justify-content: space-between; font-size: 7.5px; color: #6b7280; }
</style>
</head>
<body>
<div class="doc">
<div class="hdr">
<div>
${model.logoImageUrl ? `<img class="hdr-logo" src="${esc(model.logoImageUrl)}" alt="EDR" />` : ""}
<div class="org">${esc(mor.seller.name)}</div>
<div class="org-sub">Ethio-Djibouti Railway S.C.</div>
</div>
<div class="hdr-meta">
<div><span class="am">የደረሰኝ ቁጥር</span><span class="en">Document No</span><b>${esc(model.documentNumber)}</b></div>
<div><span class="am">ቀን</span><span class="en">Date</span><b>${esc(formatEthiopianDate(model.issuedAt))}</b></div>
<div><b>${esc(formatGregorianDate(model.issuedAt))}</b></div>
<div><span class="am">ሰአት</span><span class="en">Time</span><b>${esc(formatDocumentTime(model.issuedAt))}</b></div>
</div>
</div>
<div class="title">
<div class="t-am">${esc(mor.titleAm)}</div>
<div class="t-en">${esc(mor.titleEn)}</div>
${mor.saleType ? `<div class="t-type">የሽያጭ አይነት (${esc(mor.saleType)})</div>` : ""}
</div>
<div class="ident">
<table>
${mor.irn ? `<tr><td class="k">IRN</td><td class="v">${esc(mor.irn)}</td></tr>` : ""}
${mor.receipt ? `<tr><td class="k">RRN</td><td class="v">${esc(mor.receipt.rrn)}</td></tr>` : ""}
${mor.systemNumber ? `<tr><td class="k">System Number</td><td class="v">${esc(mor.systemNumber)}</td></tr>` : ""}
${mor.referenceNumber ? `<tr><td class="k">Reference Number</td><td class="v">${esc(mor.referenceNumber)}</td></tr>` : ""}
${mor.relatedDocumentIrn ? `<tr><td class="k">Related Document</td><td class="v">${esc(mor.relatedDocumentIrn)}</td></tr>` : ""}
</table>
${qrBlock}
</div>
<div class="parties">
<div>${party(mor.seller, "ከ", "From", "የሻጭ", "Seller")}</div>
<div>${party(mor.buyer, "ለ", "To", "የገዢ", "Customer")}</div>
</div>
${withholdingBlock}
${receiptBlock}
${
model.lines.length > 0 && !mor.withholding
? `<table class="items">
<thead>
<tr>
<th style="width:4%">${esc("ተ/ቁ")}<br/>No.</th>
<th style="width:24%">${esc("የዕቃው / አገልግሎት አይነት")}<br/>Description</th>
<th style="width:9%">${esc("ምድብ")}<br/>Nature</th>
<th style="width:7%">${esc("መለኪያ")}<br/>UoM</th>
<th style="width:7%" class="num">${esc("ብዛት")}<br/>Qty</th>
<th style="width:12%" class="num">${esc("የአንዱ ዋጋ")}<br/>Unit Price</th>
<th style="width:9%">${esc("ታክስ ኮድ")}<br/>Tax Code</th>
<th style="width:9%" class="num">${esc("ኤክሳይዝ")}<br/>Excise</th>
<th style="width:9%" class="num">${esc("ቅናሽ")}<br/>Discount</th>
<th style="width:14%" class="num">${esc("ጠቅላላ ዋጋ")}<br/>Total Amount</th>
</tr>
</thead>
<tbody>${itemRows}</tbody>
</table>`
: ""
}
${tax ? `<table class="totals">${taxRows}${wordsRow}</table>` : ""}
${paymentBlock}
${approvalBlock}
<div class="foot">
<div>Ethio-Djibouti Railway S.C. — ${esc(mor.titleEn)}</div>
<div>Page 1 of 1 &nbsp;·&nbsp; Printed ${esc(formatGregorianDate(new Date()))} ${esc(formatDocumentTime(new Date()))}</div>
</div>
</div>
</body>
</html>`;
}
safeFilename(value: string): string {
return value.replace(/[^a-zA-Z0-9_-]+/g, "-");
}
}
/** One bilingual label/value row inside a party or key-value table. */
function morRow(am: string, en: string, value: unknown, amOverride?: string): string {
return `<tr><td class="k"><span class="am">${esc(amOverride ? `${amOverride} ${am}` : am)}</span><span class="en">${esc(en)}</span></td><td class="v pv">${esc(
value === null || value === undefined || value === "" ? "N/A" : value,
)}</td></tr>`;
}
/** One row of the Ministry's totals block. */
function totalRow(am: string, en: string, value: string, grand = false): string {
return `<tr class="${grand ? "grand" : ""}"><td class="tl">${esc(am ? `${am} / ${en}` : en)}</td><td class="tv">${esc(value)}</td></tr>`;
}

View File

@@ -0,0 +1,65 @@
import {
amountInWords,
formatEthiopianDate,
formatGregorianDate,
gregorianToEthiopian,
numberToWords,
} from "./mor-document.util";
describe("gregorianToEthiopian", () => {
it("matches the MoR portal's own rendering of a registered EDR invoice", () => {
// portal.mor.gov.et printed `25-12-2018 ዓ/ም` beside `31-08-2026 G.C` for INV document no. 3.
expect(gregorianToEthiopian(new Date(2026, 7, 31))).toEqual({ year: 2018, month: 12, day: 25 });
expect(formatEthiopianDate(new Date(2026, 7, 31))).toBe("25-12-2018 ዓ/ም");
expect(formatGregorianDate(new Date(2026, 7, 31))).toBe("31-08-2026 G.C");
});
it("rolls the year on Ethiopian new year, not on the Gregorian one", () => {
// 11 Sep 2026 is 1 መስከረም 2019; the day before is still 2018.
expect(gregorianToEthiopian(new Date(2026, 8, 10))).toMatchObject({ year: 2018, month: 13 });
expect(gregorianToEthiopian(new Date(2026, 8, 11))).toEqual({ year: 2019, month: 1, day: 1 });
});
it("returns a placeholder rather than throwing on a missing date", () => {
expect(formatEthiopianDate(null)).toBe("-");
expect(formatGregorianDate(undefined)).toBe("-");
});
});
describe("amountInWords", () => {
it("spells an amount with cents the way the reference tax invoice does", () => {
// WISCOM's certified printout: 3,759.93 -> "three thousand seven hundred and fifty-nine Birr
// and ninety-three Cents".
expect(amountInWords(3759.93)).toBe(
"Three thousand seven hundred and fifty-nine Birr and ninety-three Cents",
);
});
it("keeps the 'and' inside a scale group, as the reference printouts do", () => {
// 407,422.98 on the reference credit-sales invoice reads "Four Hundred And Seven Thousand Four
// Hundred And Twenty-Two Birr and Ninety-Eight Cents". Note the MoR portal itself uses the
// other convention ("nine hundred four thousand"); the printed document follows the reference.
expect(amountInWords(407422.98)).toBe(
"Four hundred and seven thousand four hundred and twenty-two Birr and ninety-eight Cents",
);
});
it("omits the cents clause on a whole amount", () => {
expect(amountInWords(880)).toBe("Eight hundred and eighty Birr");
});
it("carries rounded cents into the Birr instead of printing 100 Cents", () => {
expect(amountInWords(9.999)).toBe("Ten Birr");
});
it("handles zero and sub-Birr amounts", () => {
expect(amountInWords(0)).toBe("Zero Birr");
expect(amountInWords(0.5)).toBe("Zero Birr and fifty Cents");
});
it("spells the scale words", () => {
expect(numberToWords(1_000_000)).toBe("one million");
expect(numberToWords(21)).toBe("twenty-one");
expect(numberToWords(115)).toBe("one hundred and fifteen");
});
});

View File

@@ -0,0 +1,178 @@
/**
* Presentation helpers for MoR EIMS tax documents (ADD-P001 print layout).
*
* The layout these serve is modelled on the Ministry's own portal rendering of a registered EDR
* invoice (portal.mor.gov.et), which is the authoritative source for the bilingual field labels —
* not on any one vendor's template.
*/
/** Ethiopian month names, index 0 = መስከረም. */
const ETHIOPIAN_MONTHS = [
"መስከረም",
"ጥቅምት",
"ኅዳር",
"ታኅሣሥ",
"ጥር",
"የካቲት",
"መጋቢት",
"ሚያዝያ",
"ግንቦት",
"ሰኔ",
"ሐምሌ",
"ነሐሴ",
"ጳጉሜ",
] as const;
export interface EthiopianDate {
year: number;
month: number;
day: number;
}
/**
* Gregorian → Ethiopian, via Julian Day Number.
*
* JDN rather than day-of-year arithmetic because the Ethiopian new year drifts against September
* 11/12 on the Gregorian leap cycle; JDN is the same conversion the passenger portal already uses.
*/
export function gregorianToEthiopian(date: Date): EthiopianDate {
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
const a = Math.floor((14 - month) / 12);
const y = year + 4800 - a;
const m = month + 12 * a - 3;
const jdn =
day +
Math.floor((153 * m + 2) / 5) +
365 * y +
Math.floor(y / 4) -
Math.floor(y / 100) +
Math.floor(y / 400) -
32045;
// 1723856 is the JDN of 1 መስከረም 1 E.C.
const r = (jdn - 1723856) % 1461;
const n = (r % 365) + 365 * Math.floor(r / 1460);
const ethYear = 4 * Math.floor((jdn - 1723856) / 1461) + Math.floor(r / 365) - Math.floor(r / 1460);
const ethMonth = Math.floor(n / 30) + 1;
const ethDay = (n % 30) + 1;
return { year: ethYear, month: ethMonth, day: ethDay };
}
/** `25-12-2018 ዓ/ም` — the numeric form the MoR portal prints beside the Gregorian date. */
export function formatEthiopianDate(value: Date | string | null | undefined): string {
const date = value ? new Date(value) : null;
if (!date || Number.isNaN(date.getTime())) return "-";
const { year, month, day } = gregorianToEthiopian(date);
const pad = (n: number) => String(n).padStart(2, "0");
return `${pad(day)}-${pad(month)}-${year} ዓ/ም`;
}
/** `ሐምሌ 25, 2018` — the long form, when a document has room for it. */
export function formatEthiopianDateLong(value: Date | string | null | undefined): string {
const date = value ? new Date(value) : null;
if (!date || Number.isNaN(date.getTime())) return "-";
const { year, month, day } = gregorianToEthiopian(date);
return `${ETHIOPIAN_MONTHS[month - 1] ?? ""} ${day}, ${year}`;
}
/** `31-08-2026 G.C` — Gregorian, labelled the way the MoR portal labels it. */
export function formatGregorianDate(value: Date | string | null | undefined): string {
const date = value ? new Date(value) : null;
if (!date || Number.isNaN(date.getTime())) return "-";
const pad = (n: number) => String(n).padStart(2, "0");
return `${pad(date.getDate())}-${pad(date.getMonth() + 1)}-${date.getFullYear()} G.C`;
}
/** `10:58:30`, 24-hour, to match the portal's `ሰአት/Time` row. */
export function formatDocumentTime(value: Date | string | null | undefined): string {
const date = value ? new Date(value) : null;
if (!date || Number.isNaN(date.getTime())) return "-";
const pad = (n: number) => String(n).padStart(2, "0");
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
const ONES = [
"",
"one",
"two",
"three",
"four",
"five",
"six",
"seven",
"eight",
"nine",
"ten",
"eleven",
"twelve",
"thirteen",
"fourteen",
"fifteen",
"sixteen",
"seventeen",
"eighteen",
"nineteen",
];
const TENS = ["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety"];
const SCALES: [number, string][] = [
[1_000_000_000, "billion"],
[1_000_000, "million"],
[1_000, "thousand"],
];
/** 0-999 in words. */
function underThousand(value: number): string {
if (value < 20) return ONES[value];
if (value < 100) {
const rest = value % 10;
return TENS[Math.floor(value / 10)] + (rest ? `-${ONES[rest]}` : "");
}
const rest = value % 100;
return `${ONES[Math.floor(value / 100)]} hundred${rest ? ` and ${underThousand(rest)}` : ""}`;
}
/** Whole number in words. Returns "zero" for 0. */
export function numberToWords(value: number): string {
const n = Math.floor(Math.abs(value));
if (n === 0) return "zero";
const parts: string[] = [];
let remaining = n;
for (const [scale, name] of SCALES) {
const count = Math.floor(remaining / scale);
if (count > 0) {
parts.push(`${numberToWords(count)} ${name}`);
remaining %= scale;
}
}
if (remaining > 0) {
// "and" only before a trailing sub-hundred group, matching how the amount reads aloud
// ("three thousand seven hundred and fifty-nine", not "three thousand and seven hundred").
parts.push(parts.length > 0 && remaining < 100 ? `and ${underThousand(remaining)}` : underThousand(remaining));
}
return parts.join(" ");
}
/**
* `Total including Tax (in words)` — the legally required spelling-out of the payable amount.
*
* Computed here rather than read back from MoR: the Ministry renders its own copy on the portal,
* but returns nothing carrying it on `/v1/register`, and the line has to print on a document that
* may not be registered yet.
*/
export function amountInWords(value: number, currencyLabel = "Birr", fractionLabel = "Cents"): string {
const amount = Number.isFinite(value) ? Math.abs(value) : 0;
const birr = Math.floor(amount);
// Round the remainder rather than truncate: 0.155 must read as sixteen cents, not fifteen.
const cents = Math.round((amount - birr) * 100);
// Rounding cents can carry into the next Birr (x.999 -> 100 cents).
const [wholeBirr, wholeCents] = cents === 100 ? [birr + 1, 0] : [birr, cents];
const head = `${numberToWords(wholeBirr)} ${currencyLabel}`;
const text = wholeCents > 0 ? `${head} and ${numberToWords(wholeCents)} ${fractionLabel}` : head;
return text.charAt(0).toUpperCase() + text.slice(1);
}

View File

@@ -22,6 +22,7 @@ describe("FilterInvoiceDto", () => {
search: "INV-2026",
statuses: "PENDING,OVERDUE",
sources: "booking,warehouse",
types: "PREPAID,WAGON_CANCEL_FEE",
eimsStatuses: "NOT_SUBMITTED",
currency: "etb",
issuedFrom: "2026-08-01T00:00:00.000Z",
@@ -39,6 +40,7 @@ describe("FilterInvoiceDto", () => {
expect(errors).toEqual([]);
expect(dto.statuses).toEqual(["PENDING", "OVERDUE"]);
expect(dto.sources).toEqual(["booking", "warehouse"]);
expect(dto.types).toEqual(["PREPAID", "WAGON_CANCEL_FEE"]);
expect(dto.currency).toBe("ETB");
expect(dto.minAmount).toBe(100);
expect(dto.hasBalance).toBe(true);

View File

@@ -89,6 +89,18 @@ export class FilterInvoiceDto {
@IsIn(Object.values(Freight.InvoiceSource), { each: true })
sources?: Freight.InvoiceSource[];
/**
* What the invoice bills for (`?types=PREPAID,WAGON_CANCEL_FEE`). Free-form
* like `paymentMethods`: every billing source mints its own `type` string, so
* an `IsIn` here would silently drop a real value.
*/
@ApiPropertyOptional({ isArray: true, example: ["PREPAID"] })
@IsOptional()
@Transform(csv)
@IsArray()
@IsString({ each: true })
types?: string[];
/** MoR filing state — Finance's "what still needs registering" cut. */
@ApiPropertyOptional({ isArray: true, enum: EimsInvoiceStatus })
@IsOptional()

View File

@@ -0,0 +1,50 @@
import { settlementReferences } from "./invoice-settlement.util";
describe("settlementReferences", () => {
it("returns the provider reference recorded on the invoice ledger", () => {
expect(
settlementReferences({
payments: [{ reference: "FT26082700123" }],
}),
).toBe("FT26082700123");
});
it("reads the linked gateway payment row when the ledger has no reference", () => {
expect(
settlementReferences({
payments: [{ reference: null }],
payment: { transactionId: "TB998877" },
}),
).toBe("TB998877");
});
it("does not repeat a reference that both sources carry", () => {
expect(
settlementReferences({
payments: [{ reference: "FT26082700123" }],
payment: { transactionId: "FT26082700123" },
}),
).toBe("FT26082700123");
});
it("lists every leg of a partially-then-fully paid invoice, oldest first", () => {
expect(
settlementReferences({
payments: [{ reference: "SLIP-001" }, { reference: "FT26082700123" }],
}),
).toBe("SLIP-001, FT26082700123");
});
it("drops the internal intent id the gateway path falls back to", () => {
expect(
settlementReferences({
payments: [{ reference: "3f8a1c2e-9b4d-4a71-8c6e-2d5f7a9b1c30" }],
}),
).toBeNull();
});
it("is null for an unpaid invoice", () => {
expect(settlementReferences({ payments: [] })).toBeNull();
expect(settlementReferences({})).toBeNull();
});
});

View File

@@ -74,3 +74,44 @@ export const INVOICE_PAYMENT_METHODS = [
/** Settled at a gateway whose provider row is no longer linked. */
"GATEWAY",
] as const;
/** Anything shaped enough to read settlement references off. */
interface SettlementReferenceSource {
payments?: Array<{ reference?: string | null }> | null;
payment?: { transactionId?: string | null } | null;
}
/**
* A settlement reference is the PROVIDER's own transaction number, never ours.
* The gateway path falls back to the intent id when a provider returns no txn
* ref (`markInvoiceAsPaid`: `providerTxnId ?? paymentId`), and that id is a
* uuid — an internal correlation key that means nothing to a payer holding a
* bank slip, so it is dropped rather than printed. No provider's reference is
* uuid-shaped: CBE sends `FT…`, telebirr/ebirr/waafi send digit strings.
*/
const INTERNAL_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/**
* Every provider transaction reference recorded against an invoice, oldest
* first, joined for display — CBE's `FT…`, telebirr's receipt number, or the
* bank-slip number a teller typed into a manual settlement. Null when nothing
* identifiable was recorded.
*
* Reads BOTH sources because neither alone is complete: the invoice's own
* ledger is the only record of manual settlements and of each leg of a
* partially-paid invoice, while the linked `freight.payments` row is the only
* place a provider txn id lands when it arrives after settlement (a webhook
* that stamps `transactionId` on an already-settled intent). Deduped, since
* the ordinary gateway path writes the same value to both.
*/
export function settlementReferences(
invoice: SettlementReferenceSource,
): string | null {
const refs = [
...(invoice.payments ?? []).map((p) => p.reference),
invoice.payment?.transactionId,
].filter(
(ref): ref is string => Boolean(ref) && !INTERNAL_ID.test(ref as string),
);
return [...new Set(refs)].join(", ") || null;
}

View File

@@ -0,0 +1,146 @@
import {
CARGO_TYPE_SUBTREE_SQL,
bookingContainerCountSql,
bookingContainerVgmSql,
bookingContentMatchSql,
bookingContentSql,
bookingHasContainerTypeSql,
bookingRequestedCargoSql,
bookingRequestedContainerCountSql,
} from './booking-content.sql';
describe('bookingContentSql', () => {
const sql = bookingContentSql('b');
it('prefers the container lines, since container bookings carry no description', () => {
expect(sql.indexOf('freight.booking_container')).toBeLessThan(
sql.indexOf('freight.cargo_types'),
);
expect(sql).toContain('freight.container_types');
expect(sql).toContain('bc.deleted_at IS NULL');
});
it('falls back to commodity, then to the free-text description', () => {
expect(sql.indexOf('cgt.cargo_type_name')).toBeLessThan(
sql.indexOf('b.cargo_free_text'),
);
});
// An empty string is not a missing value to COALESCE — without NULLIF a blank
// description would win over the commodity behind it.
it('treats an empty string as absent at every level', () => {
expect(sql.match(/NULLIF/g)).toHaveLength(3);
});
it('rewrites every reference when embedded under another alias', () => {
expect(bookingContentSql('bk')).not.toMatch(/\bb\.(cargo|id)/);
});
});
describe('CARGO_TYPE_SUBTREE_SQL', () => {
// The filter offers groups, not just leaves, so picking "Bulk" has to reach
// commodities at any depth beneath it — two levels today, more tomorrow.
it('walks the tree recursively rather than one level of children', () => {
expect(CARGO_TYPE_SUBTREE_SQL).toContain('WITH RECURSIVE');
expect(CARGO_TYPE_SUBTREE_SQL).toContain('c.parent_group_id = sub.id');
});
it('includes the picked node itself, so a leaf still matches exactly', () => {
expect(CARGO_TYPE_SUBTREE_SQL).toContain('WHERE id = :cargoTypeId');
});
});
describe('bookingContentMatchSql', () => {
const sql = bookingContentMatchSql('b');
it('searches all three places content can live', () => {
expect(sql).toContain('b.cargo_free_text ILIKE :cargoText');
expect(sql).toContain('cgt.cargo_type_name ILIKE :cargoText');
expect(sql).toContain('cnt.code ILIKE :cargoText');
});
// Anything but OR would make the text box match nothing for whole freight
// types — a container booking has no commodity, a bulk one has no container.
it('ORs them, and stays one parenthesised term for andWhere', () => {
expect(sql).not.toContain(' AND :cargoText');
expect(sql.startsWith('(')).toBe(true);
expect(sql.trimEnd().endsWith(')')).toBe(true);
});
});
describe('bookingContainerCountSql', () => {
// booking_container is one row per LINE carrying a quantity, so counting rows
// would report a 54-container booking as 1.
it('sums the line quantities rather than counting lines', () => {
expect(bookingContainerCountSql('b')).toContain('SUM(bc.quantity)');
expect(bookingContainerCountSql('b')).not.toContain('COUNT(');
});
it('counts every type by default and one type when scoped', () => {
expect(bookingContainerCountSql('b')).not.toContain('container_type_id');
expect(bookingContainerCountSql('b', true)).toContain(
'bc.container_type_id = :containerTypeId',
);
});
it('is 0, never NULL, so a bound comparison still decides', () => {
expect(bookingContainerCountSql('b')).toContain('COALESCE(SUM(bc.quantity), 0)');
});
it('ignores soft-deleted lines', () => {
expect(bookingContainerCountSql('b')).toContain('bc.deleted_at IS NULL');
expect(bookingHasContainerTypeSql('b')).toContain('bc.deleted_at IS NULL');
});
it('rewrites the booking reference under another alias', () => {
expect(bookingContainerCountSql('bk')).toContain('bc.booking_id = bk.id');
expect(bookingHasContainerTypeSql('bk')).toContain('bc.booking_id = bk.id');
});
});
describe('bookingContainerVgmSql', () => {
// The whole point: b.cargo_total_weight_vgm is 0 for portal container
// bookings, so the weight has to come off the lines.
it('reads the lines, never the booking-level column', () => {
const sql = bookingContainerVgmSql('b');
expect(sql).toContain('SUM(bc.total_vgm_tons)');
expect(sql).not.toContain('cargo_total_weight_vgm');
expect(sql).toContain('bc.deleted_at IS NULL');
});
});
describe('requested (shipment-request) cargo', () => {
const cargo = bookingRequestedCargoSql('b');
const count = bookingRequestedContainerCountSql('b');
it('reads the request, never the booking or its container lines', () => {
for (const sql of [cargo, count]) {
expect(sql).toContain('freight.booking_requests br');
expect(sql).toContain('br.created_booking_id = b.id');
expect(sql).not.toContain('freight.booking_container');
}
});
// requested_lines is a free-form jsonb column; jsonb_array_elements throws on
// a non-array, which would 500 the whole list for one malformed row.
it('survives a requested_lines with no container array', () => {
for (const sql of [cargo, count]) {
expect(sql).toContain("jsonb_typeof(br.requested_lines->'containers') = 'array'");
expect(sql).toContain("ELSE '[]'::jsonb");
}
});
it('renders the bulk shape too, not only containers', () => {
expect(cargo).toContain("'bulk'->>'cargoWeightTons'");
expect(cargo).toContain("'bulk'->>'itemCount'");
});
it('counts 0 rather than NULL when no request exists', () => {
expect(count).toContain("COALESCE(SUM((l->>'quantity')::int), 0)");
});
it('ignores soft-deleted requests', () => {
expect(cargo).toContain('br.deleted_at IS NULL');
expect(count).toContain('br.deleted_at IS NULL');
});
});

View File

@@ -0,0 +1,148 @@
/**
* What the customer said is IN the booking, per freight type — the list
* filter, the summary and the export all read this one expression so the
* column, the pill and the sheet can never disagree.
*
* BULK the commodity picked from the cargo tree (`cargo_types`), falling
* back to the free-text description for a bare group or a legacy row
* that has no commodity.
* CONTAINER the wizard asks for no description at all — VGM and contents are
* captured later in operations — so the closest thing to the
* customer's own words is the container lines they entered:
* "2 × 40FT, 1 × 20FT".
*
* Containers are checked FIRST: a container booking has no `cargo_type_id`
* (the API rejects one), so the order only matters for a mixed legacy row,
* where the physical lines are the better answer.
*/
export function bookingContentSql(alias = 'b'): string {
return `COALESCE(
NULLIF((SELECT string_agg(bc.quantity || ' × ' || COALESCE(cnt.label, cnt.code), ', '
ORDER BY cnt.size_ft DESC NULLS LAST, cnt.code)
FROM freight.booking_container bc
JOIN freight.container_types cnt ON cnt.id = bc.container_type_id
WHERE bc.booking_id = ${alias}.id AND bc.deleted_at IS NULL), ''),
NULLIF((SELECT cgt.cargo_type_name FROM freight.cargo_types cgt
WHERE cgt.id = ${alias}.cargo_type_id), ''),
NULLIF(${alias}.cargo_free_text, ''))`;
}
/**
* Cargo types at or under `:cargoTypeId`, so picking a GROUP in the filter
* matches every commodity beneath it — the same group→commodity drill-down the
* booking wizard offers, read back. Recursive because `cargo_types` is an
* arbitrary-depth tree (Bulk → Steel Billet → S1 → …), not two levels.
*/
export const CARGO_TYPE_SUBTREE_SQL = `(
WITH RECURSIVE sub AS (
SELECT id FROM freight.cargo_types WHERE id = :cargoTypeId
UNION ALL
SELECT c.id FROM freight.cargo_types c JOIN sub ON c.parent_group_id = sub.id
)
SELECT id FROM sub)`;
/**
* Contains-match over every part of the content a customer can type or pick:
* their own description, the commodity's name, and the container types on the
* booking. Bind `:cargoText` already wrapped in `%`.
*/
export function bookingContentMatchSql(alias = 'b'): string {
return `(${alias}.cargo_free_text ILIKE :cargoText
OR EXISTS (SELECT 1 FROM freight.cargo_types cgt
WHERE cgt.id = ${alias}.cargo_type_id
AND cgt.cargo_type_name ILIKE :cargoText)
OR EXISTS (SELECT 1 FROM freight.booking_container bc
JOIN freight.container_types cnt ON cnt.id = bc.container_type_id
WHERE bc.booking_id = ${alias}.id AND bc.deleted_at IS NULL
AND (cnt.label ILIKE :cargoText OR cnt.code ILIKE :cargoText)))`;
}
/**
* Containers on a booking, as a count of physical boxes — `booking_container`
* is one row PER LINE with a `quantity`, not one row per box, so this sums the
* quantity rather than counting rows.
*
* `scopedToType` narrows the sum to `:containerTypeId`, which is what makes one
* number filter answer both "10 containers in total" and "10 forty-footers":
* the count filter reads the container-type filter when one is set, and counts
* every type when it is not.
*/
export function bookingContainerCountSql(alias = 'b', scopedToType = false): string {
return `(SELECT COALESCE(SUM(bc.quantity), 0)
FROM freight.booking_container bc
WHERE bc.booking_id = ${alias}.id
AND bc.deleted_at IS NULL${
scopedToType ? '\n AND bc.container_type_id = :containerTypeId' : ''
})`;
}
/** Bookings carrying at least one line of `:containerTypeId`. */
export function bookingHasContainerTypeSql(alias = 'b'): string {
return `EXISTS (SELECT 1 FROM freight.booking_container bc
WHERE bc.booking_id = ${alias}.id
AND bc.deleted_at IS NULL
AND bc.container_type_id = :containerTypeId)`;
}
/**
* Container VGM on a booking, in tons — the sum of the per-line totals.
*
* NOT `bookings.cargo_total_weight_vgm`: the portal wizard leaves that at 0 for
* container freight (VGM is captured per container, later, in operations), so
* reading the booking-level column showed every portal container booking as
* weighing nothing. Same reason `bookingTonsSql` falls through to these lines.
*/
export function bookingContainerVgmSql(alias = 'b'): string {
return `(SELECT COALESCE(SUM(bc.total_vgm_tons), 0)
FROM freight.booking_container bc
WHERE bc.booking_id = ${alias}.id
AND bc.deleted_at IS NULL)`;
}
/**
* Cargo the customer declared on the SHIPMENT REQUEST behind a booking, which
* is not the same fact as cargo on the booking itself.
*
* On a GENERAL + customs contract the customer cannot book directly: they
* submit a request (day + quantities), and `initiateForShipmentRequest` opens a
* BARE instance from it — "the request itself carries the quantities; the
* instance carries none". So between initiation and `completeUnderContract` the
* booking legitimately holds no cargo while the customer's declared quantities
* sit on `booking_requests.requested_lines`.
*
* Kept in its own column rather than folded into the real container count: a
* declared 2 × 20FT is a request, not two boxes on a booking, and merging the
* two would overstate operational totals.
*/
const REQUESTED_CONTAINER_LINES = `jsonb_array_elements(
CASE WHEN jsonb_typeof(br.requested_lines->'containers') = 'array'
THEN br.requested_lines->'containers'
ELSE '[]'::jsonb END)`;
/** Human-readable declared cargo: "2 × 20FT", "12 t", "40 items". */
export function bookingRequestedCargoSql(alias = 'b'): string {
return `(SELECT COALESCE(
(SELECT string_agg((l->>'quantity') || ' × ' || upper(l->>'containerSize'), ', '
ORDER BY l->>'containerSize')
FROM ${REQUESTED_CONTAINER_LINES} AS l),
NULLIF(br.requested_lines->'bulk'->>'cargoWeightTons', '') || ' t',
NULLIF(br.requested_lines->'bulk'->>'itemCount', '') || ' items')
FROM freight.booking_requests br
WHERE br.created_booking_id = ${alias}.id
AND br.deleted_at IS NULL
ORDER BY br.created_at DESC
LIMIT 1)`;
}
/**
* Boxes declared on the shipment request. Pairs with the real container count:
* `Containers = 0` AND `Requested containers >= 1` is exactly the set awaiting
* completion.
*/
export function bookingRequestedContainerCountSql(alias = 'b'): string {
return `(SELECT COALESCE(SUM((l->>'quantity')::int), 0)
FROM freight.booking_requests br
CROSS JOIN LATERAL ${REQUESTED_CONTAINER_LINES} AS l
WHERE br.created_booking_id = ${alias}.id
AND br.deleted_at IS NULL)`;
}

View File

@@ -0,0 +1,30 @@
import { bookingTonsSql } from './booking-tons.sql';
describe('bookingTonsSql', () => {
const sql = bookingTonsSql('b');
// The regression this exists for: a plain COALESCE stops at the portal's
// literal 0 for container bookings and reports them as weighing nothing.
it('treats a stored 0 as "no figure" on both booking-level columns', () => {
expect(sql).toContain('NULLIF(b.bulk_total_weight_tons, 0)');
expect(sql).toContain('NULLIF(b.cargo_total_weight_vgm, 0)');
});
it('falls back to the per-line container VGM, excluding soft-deleted lines', () => {
expect(sql).toContain('SUM(bc.total_vgm_tons)');
expect(sql).toContain('freight.booking_container bc');
expect(sql).toContain('bc.booking_id = b.id');
expect(sql).toContain('bc.deleted_at IS NULL');
});
it('never returns NULL, so callers may SUM it directly', () => {
expect(sql.trimEnd().endsWith('0)')).toBe(true);
});
it('rewrites every reference when embedded under another alias', () => {
const aliased = bookingTonsSql('bk');
expect(aliased).not.toMatch(/\bb\./);
expect(aliased).toContain('bk.cargo_total_weight_vgm');
expect(aliased).toContain('bc.booking_id = bk.id');
});
});

View File

@@ -0,0 +1,26 @@
/**
* SQL mirror of `bookingCargoTons()` (train-scheduling/train-capacity.util.ts).
*
* Three storage conventions share `bookings.cargo_total_weight_vgm`:
* - BULK PER_TON — the column holds tons.
* - BULK PER_ITEM — the column holds an ITEM COUNT; the tons are in
* `bulk_total_weight_tons`.
* - CONTAINER — the portal wizard captures VGM per line, not per booking,
* and sends 0 (portal NewBookingPage: "containers carry NO weight at the
* wizard"). The tons live in `booking_container.total_vgm_tons`. The
* backoffice wizard does store a booking-level total, so both shapes exist
* in the same table.
*
* Hence NULLIF on both columns: a plain
* `COALESCE(bulk_total_weight_tons, cargo_total_weight_vgm)` stops at the
* portal's 0 — COALESCE falls through on NULL, never on 0 — and every
* portal-created container booking reads as 0 tons in exports and reports.
*/
export function bookingTonsSql(alias = 'b'): string {
return `COALESCE(
NULLIF(${alias}.bulk_total_weight_tons, 0),
NULLIF(${alias}.cargo_total_weight_vgm, 0),
(SELECT SUM(bc.total_vgm_tons) FROM freight.booking_container bc
WHERE bc.booking_id = ${alias}.id AND bc.deleted_at IS NULL),
0)`;
}

View File

@@ -370,7 +370,21 @@ export class BookingTransitionService {
async startTransit(bookingId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ["PAID"]);
// Paid is read from the PAYMENT status only; the booking status merely
// guards against re-entering transit from a later stage.
if (booking.paymentStatus !== "PAID") {
throw new ConflictException(
`Booking must be paid before it can start transit (payment status "${booking.paymentStatus ?? "PENDING"}")`,
);
}
assertBookingStatus(booking, [
"PAID",
"FULLY_EXECUTED",
"PNR_GENERATED",
"WAGON_ASSIGNED",
"READY_FOR_ASSIGNMENT",
"APPROVED",
]);
const updated = await this.bookingsRepository.update(bookingId, {
status: "IN_TRANSIT",
@@ -1739,6 +1753,7 @@ export class BookingTransitionService {
// (portal and backoffice). Degrades to null like every fragile field here.
let trainSchedule: {
trainNumber: string | null;
voyageNumber: string | null;
reference: string | null;
scheduledDepartureDate: Date | null;
} | null = null;
@@ -1750,6 +1765,8 @@ export class BookingTransitionService {
if (s) {
trainSchedule = {
trainNumber: s.trainNumber ?? null,
// The schedule's own voyage (sailing) number shown to the customer.
voyageNumber: s.voyageNumber ?? null,
reference: s.reference ?? null,
scheduledDepartureDate: s.scheduledDepartureDate ?? null,
};

View File

@@ -232,3 +232,91 @@ describe('BookingWagonCancellationService.buildRebookDto (bulk wagon count)', ()
expect(dto.requestedWagons).toBeUndefined();
});
});
/**
* The cancellation fee is paid BEFORE the credit is redeemed.
*
* An at-loading cut applies immediately and opens the credit while its fee
* invoice stays open, so CREDIT_AVAILABLE on its own never means the fee was
* settled. Without the gate the customer rebooks the same wagons and the
* cancellation fee is simply never collected. EDR-fault cuts carry no fee and
* must stay freely rebookable — partial or whole, container or bulk.
*/
describe('BookingWagonCancellationService.rebook (cancellation fee gate)', () => {
const source = {
id: 'b1',
contractId: 'c1',
paymentCurrency: 'ETB',
originYardId: 'y1',
destinationYardId: 'y2',
tradeDirection: 'IMPORT',
};
const makeSvc = (row: Record<string, unknown>) => {
const svc = Object.create(BookingWagonCancellationService.prototype) as Record<
string,
unknown
> & { rebook(id: string, dto: unknown): Promise<unknown> };
svc.repo = { findById: async () => row };
svc.bookingsRepository = {
findById: async () => source,
findByIdWithFiles: async () => null,
};
return svc;
};
/** Bulk credit — no bySize, so nothing depends on container snapshots. */
const bulkRow = (over: Record<string, unknown>) => ({
id: 'wc1',
bookingId: 'b1',
status: 'CREDIT_AVAILABLE',
creditAmount: 5000,
wagonsCancelled: 2,
cancelledQuantities: { bulkTons: 100 },
feeCurrency: 'ETB',
...over,
});
it('blocks a rebook while a customer-fault fee is unpaid', async () => {
const svc = makeSvc(
bulkRow({ fault: 'CUSTOMER', feeAmount: 1500, feePaidAt: null }),
);
await expect(svc.rebook('wc1', { scheduledDate: '2026-09-01' })).rejects.toThrow(
/pay the ETB 1500\.00 cancellation fee for 2 wagon\(s\)/i,
);
});
it('blocks a WHOLE-booking customer-fault cancel just the same', async () => {
const svc = makeSvc(
bulkRow({ fault: 'CUSTOMER', feeAmount: 4000, feePaidAt: null, wagonsCancelled: 4 }),
);
await expect(svc.rebook('wc1', { scheduledDate: '2026-09-01' })).rejects.toThrow(
/4 wagon\(s\) before rebooking/i,
);
});
it('lets the rebook through once the fee is paid', async () => {
const svc = makeSvc(
bulkRow({ fault: 'CUSTOMER', feeAmount: 1500, feePaidAt: new Date() }),
);
// Past the gate it fails later (no contract/create wiring in this harness) —
// what matters is that it is no longer the fee that stops it.
await expect(
svc.rebook('wc1', { scheduledDate: '2026-09-01' }),
).rejects.not.toThrow(/cancellation fee/i);
});
it('never charges an EDR-fault cut', async () => {
const svc = makeSvc(bulkRow({ fault: 'EDR', feeAmount: 0, feePaidAt: null }));
await expect(
svc.rebook('wc1', { scheduledDate: '2026-09-01' }),
).rejects.not.toThrow(/cancellation fee/i);
});
it('leaves legacy rows without a fee untouched', async () => {
const svc = makeSvc(bulkRow({ fault: null, feeAmount: 0, feePaidAt: null }));
await expect(
svc.rebook('wc1', { scheduledDate: '2026-09-01' }),
).rejects.not.toThrow(/cancellation fee/i);
});
});

View File

@@ -53,6 +53,8 @@ import {
CancelledUnitSnapshot,
WAGON_CANCEL_FEE_INVOICE_TYPE,
} from './entities/booking-wagon-cancellation.entity';
import { WagonEventType } from '@edr/types';
import { WagonHistoryService } from '../wagon-history/wagon-history.service';
export { WAGON_CANCEL_FEE_INVOICE_TYPE };
@@ -134,6 +136,7 @@ export class BookingWagonCancellationService {
private readonly firstMile: FirstMileService,
private readonly inbox: NotificationInboxService,
private readonly events: EventEmitter2,
private readonly wagonHistory: WagonHistoryService,
) {}
// ── T1: request ────────────────────────────────────────────────────────────
@@ -1003,6 +1006,18 @@ export class BookingWagonCancellationService {
'This cancellation has no rebooking credit — the booking was never paid. Create a new booking instead.',
);
}
// Customer-fault fee settles BEFORE the credit is redeemed. An at-loading
// cut applies immediately and opens the credit while its invoice stays
// open, so CREDIT_AVAILABLE alone does not mean the fee was paid — without
// this the customer rebooks the wagons and never pays the cancellation
// fee the notice already promised. EDR fault carries no fee and is
// unaffected; onFeePaid stamps feePaidAt and the gate opens by itself.
if (row.fault === 'CUSTOMER' && Number(row.feeAmount) > 0 && !row.feePaidAt) {
throw new BadRequestException(
`Pay the ${row.feeCurrency} ${Number(row.feeAmount).toFixed(2)} cancellation fee for ` +
`${Math.ceil(Number(row.wagonsCancelled))} wagon(s) before rebooking this credit.`,
);
}
const source = await this.bookingsRepository.findById(row.bookingId);
if (!source) throw new NotFoundException(`Booking ${row.bookingId} not found.`);
if (!source.contractId) {
@@ -1835,6 +1850,7 @@ export class BookingWagonCancellationService {
.getRepository(WagonAllocationContainerItem)
.delete(cut.map((i) => i.id));
if (cut.length === items.length) {
await this.recordAllocationRelease(manager, [alloc.id], bookingId, 'Containers cancelled from booking');
await manager.getRepository(WagonBookingAllocation).delete(alloc.id);
} else {
const cutWeight = cut.reduce((s, i) => s + Number(i.grossWeightTons ?? 0), 0);
@@ -1881,9 +1897,66 @@ export class BookingWagonCancellationService {
await manager
.getRepository(WagonAllocationBulkLoad)
.delete({ wagonBookingAllocationId: In(ids) });
await this.recordAllocationRelease(manager, ids, bookingId, 'Wagons cancelled from booking');
await manager.getRepository(WagonBookingAllocation).delete(ids);
}
/**
* BOOKING_CANCELLED history row for every physical wagon behind the released
* allocations — resolved through the slot BEFORE the allocation rows go, one
* query for the whole batch. Slots with no wagon pinned yet leave no row.
*/
private async recordAllocationRelease(
manager: EntityManager,
allocationIds: string[],
bookingId: string,
reason: string,
): Promise<void> {
if (!allocationIds.length) return;
const rows: Array<{
allocationId: string;
wagonId: string;
wagonNumber: string;
yardId: string | null;
trainId: string | null;
scheduleId: string | null;
weightTons: string | null;
loadType: string | null;
}> = await manager.query(
`SELECT a.id AS "allocationId",
w.id AS "wagonId",
w.wagon_number AS "wagonNumber",
w.current_yard_id AS "yardId",
w.train_id AS "trainId",
w.current_train_schedule_id AS "scheduleId",
a.allocated_weight_tons AS "weightTons",
a.load_type AS "loadType"
FROM freight.wagon_booking_allocations a
JOIN freight.train_set_wagons tsw ON tsw.id = a.train_set_wagon_id
JOIN freight.wagons w ON w.id = tsw.physical_wagon_id
WHERE a.id = ANY($1::uuid[])`,
[allocationIds],
);
await this.wagonHistory.record(
manager,
rows.map((r) => ({
wagonId: r.wagonId,
wagonNumber: r.wagonNumber,
type: WagonEventType.BookingCancelled,
fromYardId: r.yardId,
trainId: r.trainId,
trainScheduleId: r.scheduleId,
bookingId,
reason,
metadata: {
allocationId: r.allocationId,
loadType: r.loadType,
weightTons: r.weightTons == null ? null : Number(r.weightTons),
},
})),
);
}
/** Pre-reduction quantities snapshot (only when the booking was never split before). */
private async currentQuantities(
manager: EntityManager,
@@ -1951,9 +2024,12 @@ export class BookingWagonCancellationService {
// the same cargo); number/seal/VGM come from the override when given.
units: sized.map((u, i) => ({
containerNumber: replacement?.[i]?.containerNumber ?? u.containerNumber,
// A credit snapshot taken before seals were mandatory can carry
// none; the booking service normalizes the blank back to null
// rather than blocking the rebook of already-paid cargo.
sealNumber: replacement
? (replacement[i]?.sealNumber ?? undefined)
: (u.sealNumber ?? undefined),
? (replacement[i]?.sealNumber ?? '')
: (u.sealNumber ?? ''),
vgmTons: replacement?.[i]?.vgmTons ?? u.vgmTons,
isHazardous: u.isHazardous,
isReefer: u.isReefer,

View File

@@ -87,6 +87,7 @@ import {
import { ContractViewDto } from "./dto/contract-view.dto";
import { CustomerTruckAssignmentDto } from "./dto/customer-truck-assignment.dto";
import { AddCustomerTruckDto } from "./dto/add-customer-truck.dto";
import { BulkCustomerTrucksDto } from "./dto/bulk-customer-truck.dto";
import { DepartCustomerTruckDto } from "./dto/depart-customer-truck.dto";
import { LoadCustomerTruckDto } from "./dto/load-customer-truck.dto";
import { CustomerTruckService } from "./customer-truck.service";
@@ -665,9 +666,11 @@ export class BookingsController {
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
// GL (createBooking) rebooks credits and must see the ledger for that.
const staff =
hasFreightPermission(user, FREIGHT_PERMS.bookings.view) ||
hasFreightPermission(user, FREIGHT_PERMS.bookings.wagonCancellationView);
hasFreightPermission(user, FREIGHT_PERMS.bookings.wagonCancellationView) ||
hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking);
if (!staff) {
await this.bookingsService.assertCustomerCanAccessBooking(
user?.id,
@@ -848,6 +851,14 @@ export class BookingsController {
staffPermission: string,
): Promise<void> {
if (hasFreightPermission(user, staffPermission)) return;
// Rebooking a credit creates a booking under the contract — GL's booking
// creation key covers it even where the dedicated rebook key was never granted.
if (
staffPermission === FREIGHT_PERMS.bookings.wagonCancellationRebook &&
hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking)
) {
return;
}
const row = await this.wagonCancellationService.findById(cancellationId);
const booking = await this.bookingsService.findById(row.bookingId);
await this.bookingsService.assertCustomerCanAccessBooking(
@@ -907,7 +918,7 @@ export class BookingsController {
})
async bulkAddCustomerTrucks(
@Param("id", ParseUUIDPipe) id: string,
@Body() payload: { trucks: AddCustomerTruckDto[] },
@Body() payload: BulkCustomerTrucksDto,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);

View File

@@ -21,6 +21,13 @@ import { ShippingLineCompany } from '../shipping-lines/entities/shipping-line-co
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
import { ContractRoute } from '../contracts/entities/contract-route.entity';
import { applyDirectionScope } from '../user-trade-access/trade-scope.util';
import {
CARGO_TYPE_SUBTREE_SQL,
bookingContainerCountSql,
bookingContentMatchSql,
bookingHasContainerTypeSql,
bookingRequestedContainerCountSql,
} from './booking-content.sql';
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
import {
BookingDocumentReview,
@@ -65,7 +72,17 @@ export interface BookingListFilterOptions {
contractId?: string;
contractType?: string;
serviceTypeId?: string;
/** Cargo type OR cargo group — a group matches every commodity beneath it. */
cargoTypeId?: string;
/** Contains-search over content: description, commodity name, container types. */
cargoText?: string;
/** Bookings carrying this container type; also scopes the container count. */
containerTypeId?: string;
containersMin?: number;
containersMax?: number;
/** Bounds on containers declared on the shipment request behind the booking. */
requestedContainersMin?: number;
requestedContainersMax?: number;
freightType?: string;
bookingType?: string;
tradeDirection?: string;
@@ -1176,11 +1193,55 @@ export class BookingsRepository extends BaseRepository<Booking> {
serviceTypeId: options.serviceTypeId,
});
}
// A group is selectable in the filter, not just a leaf commodity, so this
// matches the whole subtree — picking "Bulk" must return every commodity
// under it, the same drill-down the booking wizard offers, read back.
if (options.cargoTypeId) {
qb.andWhere('booking.cargo_type_id = :cargoTypeId', {
qb.andWhere(`booking.cargo_type_id IN ${CARGO_TYPE_SUBTREE_SQL}`, {
cargoTypeId: options.cargoTypeId,
});
}
if (options.cargoText) {
qb.andWhere(bookingContentMatchSql('booking'), {
cargoText: `%${options.cargoText}%`,
});
}
if (options.containerTypeId) {
qb.andWhere(bookingHasContainerTypeSql('booking'), {
containerTypeId: options.containerTypeId,
});
}
// One count filter, two questions: with a container type picked it counts
// that type, without one it counts every box on the booking.
if (options.containersMin != null || options.containersMax != null) {
const count = bookingContainerCountSql(
'booking',
Boolean(options.containerTypeId),
);
if (options.containersMin != null) {
qb.andWhere(`${count} >= :containersMin`, {
containersMin: options.containersMin,
});
}
if (options.containersMax != null) {
qb.andWhere(`${count} <= :containersMax`, {
containersMax: options.containersMax,
});
}
}
// Declared on the shipment request, not on the booking. Pairs with the
// count above: containers 0..0 AND requested >= 1 is the set awaiting
// completion after clearance.
if (options.requestedContainersMin != null) {
qb.andWhere(`${bookingRequestedContainerCountSql('booking')} >= :requestedContainersMin`, {
requestedContainersMin: options.requestedContainersMin,
});
}
if (options.requestedContainersMax != null) {
qb.andWhere(`${bookingRequestedContainerCountSql('booking')} <= :requestedContainersMax`, {
requestedContainersMax: options.requestedContainersMax,
});
}
if (omit !== 'freightType' && options.freightType) {
qb.andWhere('booking.freight_type = :freightType', {
freightType: options.freightType,

View File

@@ -29,6 +29,11 @@ import { DataSource, In } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { assertExportReceivedWithGrn, DIRECT_TO_TRAIN } from '../../common/export-received-gate';
import {
EDR_HAULAGE_CONFLICT_MESSAGE,
LAST_MILE_COMMITTED_SQL,
edrHaulsThisBooking,
} from '../../common/mile-haulage.util';
import { Yard } from '../rule-engine/entities/yard.entity';
import { ServiceType } from '../rule-engine/entities/service-type.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
@@ -112,8 +117,13 @@ interface CarriageAcceptanceWagonRow {
departureAt: Date | null;
marshalledAt: string | null;
arrivalAt: string | null;
/** Per-row stations: the slot's own board/alight yard, else the schedule's endpoints. */
departureStation: string | null;
arrivalStation: string | null;
containerNumbers: string | null;
sealNumbers: string | null;
/** Allocation status — LOADED/DEPARTED means EDR has the cargo. */
status: string | null;
}
/** A received-but-not-yet-marshalled export line, standing in for a wagon row. */
@@ -169,18 +179,23 @@ export class BookingsService {
dto: CustomerTruckAssignmentDto,
): Promise<Booking> {
const booking = await this.findById(bookingId);
const hasFirstMile = Boolean(booking.firstMilePickupAddress?.trim());
const hasLastMile = Boolean(booking.lastMileDeliveryAddress?.trim());
const usesMileService =
booking.tradeDirection === 'IMPORT'
? hasLastMile
: booking.tradeDirection === 'EXPORT'
? hasFirstMile
: hasFirstMile || hasLastMile;
if (usesMileService) {
throw new BadRequestException(
'Customer truck assignment is only allowed when first/last mile delivery is not selected',
);
// Same rule as CustomerTruckService.assertSelfHaulPaid: an EDR delivery leg
// closes self-haul only once it has been approved.
const [commitment]: Array<{ lastMileCommitted: boolean }> = await this.dataSource.query(
`SELECT ${LAST_MILE_COMMITTED_SQL} AS "lastMileCommitted"
FROM freight.bookings b
WHERE b.id = $1`,
[bookingId],
);
if (
edrHaulsThisBooking({
tradeDirection: booking.tradeDirection ?? null,
firstMile: booking.firstMilePickupAddress ?? null,
lastMile: booking.lastMileDeliveryAddress ?? null,
lastMileCommitted: Boolean(commitment?.lastMileCommitted),
})
) {
throw new BadRequestException(EDR_HAULAGE_CONFLICT_MESSAGE);
}
if (booking.customerTruckAssignedAt) {
throw new ConflictException('Customer truck assignment is already submitted and locked');
@@ -263,9 +278,13 @@ export class BookingsService {
/**
* Carriage acceptance sheet — one per booking, listing every wagon the booking
* occupies. Handed to the customer when EDR accepts the cargo (export) and when
* the wagons are allocated before marshalling (import), so it is only available
* once the booking has wagon allocations.
* occupies. A booking is routinely loaded in parts (some containers go, the
* rest wait for the next train), so each row carries a Status of Loaded or
* Not loaded and the totals count only the loaded ones: the customer sees the
* whole plan on one page without the sheet overstating what EDR has taken.
*
* Handed to the customer when EDR accepts the cargo (export) and when the
* wagons are allocated before marshalling (import).
*/
async carriageAcceptanceSheet(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
const booking = await this.findById(bookingId);
@@ -285,6 +304,9 @@ export class BookingsService {
s.scheduled_departure_date AS "departureAt",
so.label AS "marshalledAt",
sd.label AS "arrivalAt",
COALESCE(by_.label, so.label) AS "departureStation",
COALESCE(ay.label, sd.label) AS "arrivalStation",
a.status AS "status",
string_agg(DISTINCT ci.container_number, ', ') AS "containerNumbers",
string_agg(DISTINCT ci.seal_number, ', ') AS "sealNumbers"
FROM freight.wagon_booking_allocations a
@@ -296,13 +318,31 @@ export class BookingsService {
ON s.train_set_id = tsw.train_set_id AND s.deleted_at IS NULL
LEFT JOIN freight.yards so ON so.id = s.origin_station_id
LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id
LEFT JOIN freight.yards by_ ON by_.id = tsw.board_yard_id
LEFT JOIN freight.yards ay ON ay.id = tsw.alight_yard_id
LEFT JOIN freight.wagon_allocation_container_items ci
ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL
AND (
$2 <> 'EXPORT' OR $3 <> 'CONTAINER' OR EXISTS (
SELECT 1
FROM freight.booking_container_units received_unit
JOIN freight.booking_container received_line
ON received_line.id = received_unit.booking_container_id
AND received_line.deleted_at IS NULL
WHERE received_line.booking_id = a.booking_id
AND received_unit.container_number = ci.container_number
AND received_unit.received_to_port = true
AND NULLIF(TRIM(received_unit.grn_number), '') IS NOT NULL
AND received_unit.deleted_at IS NULL
)
)
WHERE a.booking_id = $1 AND a.deleted_at IS NULL
GROUP BY tsw.id, a.id, wt.code, wt.name, w.wagon_number, wt.tare_weight_tons,
s.train_number, s.scheduled_departure_date, so.label, sd.label
GROUP BY tsw.id, a.id, a.status, wt.code, wt.name, w.wagon_number, wt.tare_weight_tons,
s.train_number, s.scheduled_departure_date, so.label, sd.label,
by_.label, ay.label
HAVING $2 <> 'EXPORT' OR $3 <> 'CONTAINER' OR COUNT(ci.id) > 0
ORDER BY tsw.sequence_no`,
[bookingId],
[bookingId, booking.tradeDirection, booking.freightType],
);
// Export acceptance happens at the warehouse gate, not at marshalling: EDR
// takes custody of the cargo when it receives it, and the customer is handed
@@ -337,17 +377,17 @@ export class BookingsService {
)
: booking.tradeDirection === 'EXPORT'
? await this.dataSource.query(
`SELECT inv.weight AS "allocatedWeightTons",
c.container_number AS "containerNumbers"
FROM freight.warehouse_inventory inv
LEFT JOIN freight.containers c
ON c.id = inv.container_id AND c.deleted_at IS NULL
WHERE inv.booking_id = $1 AND inv.deleted_at IS NULL
AND COALESCE(
NULLIF(TRIM(inv.grn_number), ''),
substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')
) IS NOT NULL
ORDER BY inv.created_at`,
`SELECT unit.vgm_tons AS "allocatedWeightTons",
unit.container_number AS "containerNumbers",
unit.seal_number AS "sealNumbers"
FROM freight.booking_container_units unit
JOIN freight.booking_container line
ON line.id = unit.booking_container_id AND line.deleted_at IS NULL
WHERE line.booking_id = $1
AND unit.deleted_at IS NULL
AND unit.received_to_port = true
AND NULLIF(TRIM(unit.grn_number), '') IS NOT NULL
ORDER BY unit.received_at, unit.container_number`,
[bookingId],
)
: [];
@@ -381,8 +421,12 @@ export class BookingsService {
departureAt: null,
marshalledAt: null,
arrivalAt: null,
departureStation: null,
arrivalStation: null,
containerNumbers: row.containerNumbers,
sealNumbers: row.sealNumbers ?? null,
// A received line has no allocation; it is cargo EDR already holds.
status: null,
}));
}
@@ -497,7 +541,17 @@ export class BookingsService {
const header = wagons[0];
const sheetDate = header.departureAt ? new Date(header.departureAt) : new Date();
const totals = wagons.reduce(
// Loaded = EDR has the cargo. A booking is routinely loaded in parts, so the
// totals count only those: the sheet shows the whole plan, but must never
// total up cargo still sitting in the yard. A received-line sheet
// (pendingWagons) has no allocation status, and every line on it is cargo
// already accepted, so it counts in full.
const isLoaded = (w: CarriageAcceptanceWagonRow) =>
pendingWagons || w.status === 'LOADED' || w.status === 'DEPARTED';
const loadedWagons = wagons.filter(isLoaded);
const notLoadedCount = wagons.length - loadedWagons.length;
const totals = loadedWagons.reduce(
(acc, w) => ({
tare: acc.tare + (Number(w.tareWeightTons) || 0),
capacity: acc.capacity + (Number(w.loadCapacityTons) || 0),
@@ -507,7 +561,7 @@ export class BookingsService {
{ tare: 0, capacity: 0, load: 0, length: 0 },
);
// A wagon carrying no weight and no container is running empty under this booking.
const fullWagons = wagons.filter(
const fullWagons = loadedWagons.filter(
(w) => (Number(w.allocatedWeightTons) || 0) > 0 || Boolean(w.containerNumbers),
).length;
@@ -520,11 +574,14 @@ export class BookingsService {
<td class="num">${num(w.tareWeightTons, 2)}</td>
<td class="num">${num(w.equatedLength)}</td>
<td class="num">${num(w.loadCapacityTons)}</td>
<td>${esc(arrivalStation)}</td>
<td>${esc(w.arrivalStation ?? arrivalStation)}</td>
<td>${esc(cargoName)}</td>
<td>${esc(departureStation)}</td>
<td>${esc(w.departureStation ?? departureStation)}</td>
<td>${esc(w.containerNumbers)}</td>
<td>${esc(w.sealNumbers)}</td>
<td class="${isLoaded(w) ? 'loaded' : 'pending'}">${
pendingWagons ? 'Accepted' : isLoaded(w) ? 'Loaded' : 'Not loaded'
}</td>
<td class="num">${money(prices[i])}</td>
</tr>`,
)
@@ -535,23 +592,37 @@ export class BookingsService {
// figure from the printed sheet.
const totalsRow = `<tr class="totals">
<td>TOT</td>
<td>${wagons.length} ${pendingWagons ? 'received lines' : 'wagons'}</td>
<td>${
pendingWagons
? 'pending marshalling'
: `full ${fullWagons} / empty ${wagons.length - fullWagons}`
}</td>
<td>${loadedWagons.length} ${pendingWagons ? 'received lines' : 'wagons loaded'}</td>
<td></td>
<td class="num">${num(totals.tare, 2)}</td>
<td class="num">${num(totals.length)}</td>
<td class="num">${num(totals.capacity)}</td>
<td></td>
<td>Gross ${num(totals.tare + totals.load)} T</td>
<td></td>
<td></td>
<td></td>
<td></td>
<td>${notLoadedCount > 0 ? `loaded only (${notLoadedCount} not loaded)` : ''}</td>
<td class="num">${money(totalAmount)}</td>
</tr>`;
// The signed footer of the paper sheet. Rendered as .tile so the
// Chromium-less fallback (buildTabularFallbackPdf parses .tile, not
// arbitrary divs) still prints every figure.
const footer = `
<div class="summary footer-summary">
<div class="tile"><span>In Total Wagon No.</span><strong>${loadedWagons.length}</strong></div>
<div class="tile"><span>Tare Weight (T)</span><strong>${num(totals.tare, 2)}</strong></div>
<div class="tile"><span>Load Capacity (T)</span><strong>${num(totals.capacity)}</strong></div>
<div class="tile"><span>Gross Weight (T)</span><strong>${num(totals.tare + totals.load)}</strong></div>
<div class="tile"><span>Equated Length</span><strong>${num(totals.length)}</strong></div>
<div class="tile"><span>Full Wagon</span><strong>${pendingWagons ? '-' : fullWagons}</strong></div>
<div class="tile"><span>Empty Wagon</span><strong>${
pendingWagons ? '-' : loadedWagons.length - fullWagons
}</strong></div>
<div class="tile"><span>Total Amount (${esc(currency)})</span><strong>${money(totalAmount)}</strong></div>
</div>`;
return `<!doctype html>
<html>
<head>
@@ -568,6 +639,8 @@ export class BookingsService {
.meta { text-align: right; font-size: 11px; color: #475569; min-width: 210px; }
.meta strong { display: block; margin-top: 4px; color: #0f172a; font-size: 15px; }
.summary { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin: 14px 0; }
.footer-summary { grid-template-columns: repeat(8, 1fr); margin: 10px 0 0; }
.footer-summary .tile { background: #f8fafc; }
.tile { border: 1px solid #cbd5e1; padding: 8px; min-height: 50px; }
.tile span { display: block; color: #64748b; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 4px; }
.tile strong { font-size: 11px; }
@@ -575,6 +648,8 @@ export class BookingsService {
th { background: #f8fafc; color: #475569; text-align: left; }
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
.num { text-align: right; }
.loaded { color: #0f766e; font-weight: 700; }
.pending { color: #b45309; font-weight: 700; }
tr.totals td { background: #f8fafc; font-weight: 700; }
.notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; }
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; }
@@ -618,6 +693,7 @@ export class BookingsService {
<th>Departure Station</th>
<th>Container No.</th>
<th>Seal No.</th>
<th>Status</th>
<th class="num">Price (${esc(currency)})</th>
</tr>
</thead>
@@ -626,6 +702,7 @@ export class BookingsService {
${totalsRow}
</tbody>
</table>
${footer}
<div class="notice">
${
@@ -1845,6 +1922,12 @@ export class BookingsService {
contractType: filter.contractType,
serviceTypeId: filter.serviceTypeId,
cargoTypeId: filter.cargoTypeId,
cargoText: filter.cargoText,
containerTypeId: filter.containerTypeId,
containersMin: filter.containersMin,
containersMax: filter.containersMax,
requestedContainersMin: filter.requestedContainersMin,
requestedContainersMax: filter.requestedContainersMax,
freightType: filter.freightType,
bookingType: filter.bookingType,
tradeDirection: filter.tradeDirection,
@@ -2131,6 +2214,12 @@ export class BookingsService {
contractType: filter.contractType,
serviceTypeId: filter.serviceTypeId,
cargoTypeId: filter.cargoTypeId,
cargoText: filter.cargoText,
containerTypeId: filter.containerTypeId,
containersMin: filter.containersMin,
containersMax: filter.containersMax,
requestedContainersMin: filter.requestedContainersMin,
requestedContainersMax: filter.requestedContainersMax,
freightType: filter.freightType,
bookingType: filter.bookingType,
tradeDirection: filter.tradeDirection,

View File

@@ -24,3 +24,84 @@ describe('carriage acceptance sheet — price split', () => {
expect(shares).toEqual([33.33, 33.33, 33.34]);
});
});
// The HTML builder only reaches `this` for two prototype helpers (escapeHtml,
// splitAmountAcrossWagons), so the prototype itself serves as `this`.
const buildSheet = (wagons: unknown[], booking: Record<string, unknown> = {}): string =>
(
BookingsService.prototype as unknown as {
buildCarriageAcceptanceSheetHtml(
b: unknown,
w: unknown[],
o: { pendingWagons: boolean },
): string;
}
).buildCarriageAcceptanceSheetHtml.call(
BookingsService.prototype,
{
reference: 'BK-1',
tradeDirection: 'EXPORT',
totalAmount: 100,
paymentCurrency: 'ETB',
originYard: { label: 'Booking Origin' },
destinationYard: { label: 'Booking Destination' },
...booking,
},
wagons,
{ pendingWagons: false },
);
const wagon = (over: Record<string, unknown> = {}) => ({
sequenceNo: 1,
wagonType: 'FLAT',
wagonNumber: 'W-001',
tareWeightTons: '20',
equatedLength: '14',
loadCapacityTons: '60',
allocatedWeightTons: '40',
trainNumber: '8302',
departureAt: null,
marshalledAt: 'DCT/SGTD',
arrivalAt: 'GMP',
departureStation: null,
arrivalStation: null,
containerNumbers: 'CN-1',
sealNumbers: 'SL-1',
status: 'LOADED',
...over,
});
describe('carriage acceptance sheet — rows and footer', () => {
it('prints each row its own Departure/Arrival Station, falling back to the booking yards', () => {
const html = buildSheet([
wagon({ departureStation: 'Dire Dawa Port', arrivalStation: 'Adama' }),
wagon({ sequenceNo: 2, wagonNumber: 'W-002' }),
]);
expect(html).toContain('<td>Dire Dawa Port</td>');
expect(html).toContain('<td>Adama</td>');
expect(html).toContain('<td>Booking Origin</td>');
expect(html).toContain('<td>Booking Destination</td>');
});
it('totals the footer over loaded wagons only', () => {
const html = buildSheet([
wagon(),
wagon({ sequenceNo: 2, wagonNumber: 'W-002', status: 'ALLOCATED' }),
wagon({
sequenceNo: 3,
wagonNumber: 'W-003',
allocatedWeightTons: '0',
containerNumbers: null,
}),
]);
// 2 loaded of 3: tare 40, capacity 120, equated length 28, gross 40 + 40 load.
expect(html).toContain('<span>In Total Wagon No.</span><strong>2</strong>');
expect(html).toContain('<span>Tare Weight (T)</span><strong>40.00</strong>');
expect(html).toContain('<span>Load Capacity (T)</span><strong>120.000</strong>');
expect(html).toContain('<span>Gross Weight (T)</span><strong>80.000</strong>');
expect(html).toContain('<span>Equated Length</span><strong>28.000</strong>');
expect(html).toContain('<span>Full Wagon</span><strong>1</strong>');
expect(html).toContain('<span>Empty Wagon</span><strong>1</strong>');
expect(html).toContain('<span>Total Amount (ETB)</span><strong>100.00</strong>');
});
});

View File

@@ -9,12 +9,17 @@ import { DataSource, EntityManager, IsNull } from 'typeorm';
import { NotificationAudience, NotificationType } from '@edr/types';
import { AddCustomerTruckDto } from './dto/add-customer-truck.dto';
import type {
BulkTruckUploadError,
BulkTruckUploadResult,
} from './dto/bulk-customer-truck.dto';
import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto';
import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity';
import { CustomerTruckContainer } from './entities/customer-truck-container.entity';
import {
EDR_HAULAGE_CONFLICT_MESSAGE,
usesEdrMileService,
LAST_MILE_COMMITTED_SQL,
edrHaulsThisBooking,
} from '../../common/mile-haulage.util';
import {
assertBulkTonnageRemains,
@@ -35,6 +40,9 @@ interface BookingGuardRow {
lastMile: string | null;
paymentStatus: string | null;
status: string | null;
trainScheduleStatus: string | null;
/** See `MileCommitmentRow` — an approved EDR last-mile leg closes self-haul. */
lastMileCommitted: boolean;
}
/**
@@ -294,19 +302,16 @@ export class CustomerTruckService {
}
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
if (booking.freightType === 'CONTAINER' && !requested.length) {
throw new BadRequestException('Select the containers loaded on this truck');
}
if (requested.length) {
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
for (const n of requested) {
if (!bookingNumbers.includes(n)) {
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
}
}
const elsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId);
for (const n of requested) {
if (elsewhere.includes(n)) {
throw new ConflictException(`Container ${n} is already loaded onto another truck`);
}
}
assertTruckLoad({
containers: requested,
bookingContainers: await this.bookingContainerNumbers(bookingId),
sizes: await bookingContainerSizes(this.dataSource, bookingId, requested),
assignedElsewhere: await this.assignedContainerNumbersExcept(bookingId, assignmentId),
});
}
await this.dataSource.transaction(async (manager) => {
@@ -542,9 +547,17 @@ export class CustomerTruckService {
first_mile_pickup_address AS "firstMile",
last_mile_delivery_address AS "lastMile",
payment_status AS "paymentStatus",
status
FROM freight.bookings
WHERE id = $1 AND deleted_at IS NULL`,
b.status,
(SELECT ts.status
FROM freight.train_schedule_bookings tsb
JOIN freight.train_schedules ts
ON ts.id = tsb.train_schedule_id AND ts.deleted_at IS NULL
WHERE tsb.booking_id = b.id AND tsb.deleted_at IS NULL
ORDER BY ts.updated_at DESC
LIMIT 1) AS "trainScheduleStatus",
${LAST_MILE_COMMITTED_SQL} AS "lastMileCommitted"
FROM freight.bookings b
WHERE b.id = $1 AND b.deleted_at IS NULL`,
[bookingId],
);
if (!row) throw new NotFoundException(`Booking ${bookingId} not found`);
@@ -552,10 +565,12 @@ export class CustomerTruckService {
}
private assertSelfHaulPaid(booking: BookingGuardRow): void {
// Shared with the EDR side (LastMileService.assertNoCustomerTruck) so the two
// halves of this rule cannot drift apart — they did, and a booking ended up
// with a customer truck and an EDR leg at once.
if (usesEdrMileService(booking)) {
// Mirrors the EDR side (LastMileService.assertEdrHaulsThisBooking) so the
// two halves of this rule cannot drift apart — they did, and a booking ended
// up with a customer truck and an EDR leg at once. A last-mile leg only
// blocks self-haul once it is approved; until then the customer may still
// bring their own truck, and doing so makes the pending request unapprovable.
if (edrHaulsThisBooking(booking)) {
throw new BadRequestException(EDR_HAULAGE_CONFLICT_MESSAGE);
}
if (booking.paymentStatus !== 'PAID') {
@@ -575,7 +590,7 @@ export class CustomerTruckService {
private assertAssignmentWindow(booking: BookingGuardRow): void {
const status = booking.status ?? '';
if (booking.tradeDirection === 'IMPORT') {
if (status !== 'ARRIVED') {
if (status !== 'ARRIVED' && booking.trainScheduleStatus !== 'ARRIVED') {
throw new BadRequestException(
'Import pickup trucks can only be assigned after the train has arrived',
);
@@ -626,26 +641,29 @@ export class CustomerTruckService {
/** Contract container sizes (e.g. "20ft" / "40ft") for the given container numbers. */
/**
* Add trucks one at a time, keeping the good ones. Partial success is the
* right shape here: one mistyped plate in a twenty-row spreadsheet should not
* discard the other nineteen trucks. Every row still goes through `addTruck`,
* so no guard is skipped.
*/
async addBulkTrucks(
bookingId: string,
dtos: AddCustomerTruckDto[],
): Promise<{
success: number;
failed: number;
errors: Array<{ row: number; truck: string; reason: string }>;
}> {
const errors: Array<{ row: number; truck: string; reason: string }> = [];
): Promise<BulkTruckUploadResult> {
const errors: BulkTruckUploadError[] = [];
let successCount = 0;
for (let i = 0; i < dtos.length; i++) {
try {
await this.addTruck(bookingId, dtos[i]);
successCount++;
} catch (err: any) {
} catch (err) {
errors.push({
row: i + 2, // Row 1 is header
index: i,
row: i + 2, // Row 1 is the header
truck: dtos[i].truckPlateNumber,
reason: err.message || 'Unknown error',
reason: err instanceof Error ? err.message : 'Unknown error',
});
}
}

View File

@@ -12,7 +12,7 @@ import {
Min,
} from 'class-validator';
import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto';
import { CUSTOMER_TRUCK_TYPES, ISO_CONTAINER_NUMBER } from '@edr/types';
/**
* Add one external customer truck to a booking.
@@ -41,7 +41,7 @@ export class AddCustomerTruckDto {
@IsArray()
@ArrayMaxSize(2)
@ArrayUnique()
@Matches(/^[A-Z]{4}\d{7}$/, {
@Matches(ISO_CONTAINER_NUMBER, {
each: true,
message: 'each container number must match ISO container format, e.g. ABCD1234567',
})

View File

@@ -1,48 +1,41 @@
import { IsString, IsNotEmpty, IsIn, IsArray, ArrayMaxSize, ArrayUnique, Matches, IsOptional } from 'class-validator';
import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto';
import { ArrayMaxSize, ArrayMinSize, IsArray, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
export class BulkCustomerTruckRow {
@IsString()
@IsNotEmpty()
truckPlateNumber!: string;
@IsString()
@IsNotEmpty()
driverName!: string;
@IsString()
@IsNotEmpty()
@IsIn(CUSTOMER_TRUCK_TYPES)
truckType!: string;
@IsOptional()
@IsArray()
@ArrayMaxSize(2)
@ArrayUnique()
@Matches(/^[A-Z]{4}\d{7}$/, {
each: true,
message: 'each container must be ISO format (e.g. ABCD1234567)',
})
containerNumbers?: (string | null)[];
}
import { AddCustomerTruckDto } from './add-customer-truck.dto';
/**
* Bulk self-haul truck assignment, parsed from the customer's Excel upload in
* the browser and posted as JSON (the house pattern — the API never receives an
* .xlsx for import).
*
* Rows reuse `AddCustomerTruckDto` verbatim rather than redeclaring the fields:
* the earlier copy drifted, missing `plannedTons` / `plannedQuantity`, so bulk
* cargo could not be uploaded at all.
*/
export class BulkCustomerTrucksDto {
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(100)
trucks!: BulkCustomerTruckRow[];
@ValidateNested({ each: true })
@Type(() => AddCustomerTruckDto)
trucks!: AddCustomerTruckDto[];
}
export interface BulkTruckUploadError {
/**
* Position in the submitted array. The client knows which spreadsheet line it
* read each entry from, so it maps this back to the row number the customer
* actually sees.
*/
index: number;
/** 1-based row assuming a single header line — a fallback for non-Excel callers. */
row: number;
truck: string;
reason: string;
}
export interface BulkTruckUploadResult {
success: number;
failed: number;
errors: Array<{
row: number;
truck: string;
reason: string;
}>;
created: Array<{
truckPlateNumber: string;
driverName: string;
containers: number;
}>;
errors: BulkTruckUploadError[];
}

View File

@@ -1,12 +1,12 @@
import { IsIn, IsNotEmpty, IsString, Matches, MaxLength } from 'class-validator';
import { CUSTOMER_TRUCK_TYPES, ISO_CONTAINER_NUMBER } from '@edr/types';
export const CUSTOMER_TRUCK_TYPES = [
'Flatbed',
'Container Chassis',
'Lowboy',
'Box Truck',
'Tipper',
] as const;
/**
* Re-exported for the DTOs that already import it from here. The list itself
* lives in `@edr/types` so the portal's dropdown and its Excel template read the
* same values this validator enforces.
*/
export { CUSTOMER_TRUCK_TYPES };
export class CustomerTruckAssignmentDto {
@IsString()
@@ -27,7 +27,7 @@ export class CustomerTruckAssignmentDto {
@IsString()
@IsNotEmpty()
@MaxLength(16)
@Matches(/^[A-Z]{4}\d{7}$/, {
@Matches(ISO_CONTAINER_NUMBER, {
message: 'containerNumberToLoad must match ISO container format, e.g. ABCD1234567',
})
containerNumberToLoad!: string;

View File

@@ -8,6 +8,7 @@ import {
Matches,
Min,
} from 'class-validator';
import { ISO_CONTAINER_NUMBER } from '@edr/types';
/**
* Register an import self-haul truck leaving the port: the containers it actually
@@ -20,7 +21,7 @@ export class DepartCustomerTruckDto {
@IsArray()
@ArrayMaxSize(2)
@ArrayUnique()
@Matches(/^[A-Z]{4}\d{7}$/, {
@Matches(ISO_CONTAINER_NUMBER, {
each: true,
message: 'each container number must match ISO container format, e.g. ABCD1234567',
})

View File

@@ -62,11 +62,60 @@ export class FilterBookingDto {
@IsUUID()
serviceTypeId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@ApiPropertyOptional({
format: 'uuid',
description:
'Cargo type OR cargo group — a group matches every commodity beneath it',
})
@IsOptional()
@IsUUID()
cargoTypeId?: string;
@ApiPropertyOptional({
description:
'Contains-search over booking content: cargo description, commodity name, container types',
})
@IsOptional()
@Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.trim() : undefined,
)
cargoText?: string;
@ApiPropertyOptional({
format: 'uuid',
description:
'Bookings carrying this container type. Also scopes containersMin/Max to it.',
})
@IsOptional()
@IsUUID()
containerTypeId?: string;
@ApiPropertyOptional({
description:
'Minimum container count — of containerTypeId when set, else of all types',
})
@IsOptional()
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
containersMin?: number;
@ApiPropertyOptional({ description: 'Maximum container count — see containersMin' })
@IsOptional()
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
containersMax?: number;
@ApiPropertyOptional({
description:
'Minimum containers declared on the shipment request behind the booking',
})
@IsOptional()
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
requestedContainersMin?: number;
@ApiPropertyOptional({ description: 'Maximum requested containers — see requestedContainersMin' })
@IsOptional()
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
requestedContainersMax?: number;
@ApiPropertyOptional({ enum: FREIGHT_TYPES })
@IsOptional()
@IsIn([...FREIGHT_TYPES])

View File

@@ -1,4 +1,5 @@
import { ArrayMaxSize, ArrayMinSize, ArrayUnique, IsArray, Matches } from 'class-validator';
import { ISO_CONTAINER_NUMBER } from '@edr/types';
/** Containers loaded onto a truck at Truck_dispatch (after arrival, before it leaves). */
export class LoadCustomerTruckDto {
@@ -7,7 +8,7 @@ export class LoadCustomerTruckDto {
// A truck carries at most 2 containers (two 20ft, or one 40ft).
@ArrayMaxSize(2)
@ArrayUnique()
@Matches(/^[A-Z]{4}\d{7}$/, {
@Matches(ISO_CONTAINER_NUMBER, {
each: true,
message: 'each container number must match ISO container format, e.g. ABCD1234567',
})

View File

@@ -0,0 +1,78 @@
import 'reflect-metadata';
import { NotificationType, type NotifyInput } from '@edr/types';
import type { ChatConfig } from '../../config/chat.config';
import { ChatBridgeService } from './chat-bridge.service';
import type { MatrixClient } from './matrix.client';
const config: ChatConfig = {
enabled: true,
baseUrl: 'https://matrix.test',
publicBaseUrl: 'https://matrix.test',
webUrl: 'https://chat.test',
serverName: 'matrix.test',
jwtSecret: 'secret',
adminToken: 'syt_whatever',
};
function harness(overrides: Partial<ChatConfig> = {}) {
const matrix = {
ensureRoom: jest.fn(async (alias: string) => `!${alias}:matrix.test`),
sendMessage: jest.fn(
async (_roomId: string, _body: string, _html?: string) => undefined,
),
};
const service = new ChatBridgeService(
{ ...config, ...overrides },
matrix as unknown as MatrixClient,
);
return { service, matrix };
}
const notification = (type: NotificationType): NotifyInput =>
({ type, title: 'Booking BK-1', body: 'needs review' }) as unknown as NotifyInput;
describe('ChatBridgeService', () => {
it('posts every notification type into #freight-alerts', async () => {
// This used to route REQUEST_SUBMITTED and CLEARANCE_REVIEW to a hardcoded
// `dept-operation` alias, but the reconcile derives dept aliases from the
// IAM position key (`edr_freight_app/opn` shaped), so nothing it created
// ever matched. The bridge made its own empty room and posted there, where
// no employee was a member.
const { service, matrix } = harness();
for (const type of [
NotificationType.REQUEST_SUBMITTED,
NotificationType.CLEARANCE_REVIEW,
NotificationType.GENERIC,
]) {
await service.bridge(notification(type));
}
expect(new Set(matrix.ensureRoom.mock.calls.map(([alias]) => alias))).toEqual(
new Set(['freight-alerts']),
);
expect(matrix.sendMessage).toHaveBeenCalledTimes(3);
});
it('does nothing at all when chat is switched off', async () => {
const { service, matrix } = harness({ enabled: false });
await service.bridge(notification(NotificationType.GENERIC));
expect(matrix.ensureRoom).not.toHaveBeenCalled();
expect(matrix.sendMessage).not.toHaveBeenCalled();
});
it('never lets a chat failure escape into the notification that triggered it', async () => {
// Same contract as NotificationInboxService.notify(): bridging is
// best-effort and must not roll back the caller's transaction.
const { service, matrix } = harness();
matrix.ensureRoom.mockRejectedValueOnce(new Error('Matrix POST ... -> 429'));
await expect(
service.bridge(notification(NotificationType.GENERIC)),
).resolves.toBeUndefined();
});
});

View File

@@ -1,28 +1,11 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import type { ConfigType } from '@nestjs/config';
import { NotificationType, type NotifyInput } from '@edr/types';
import type { NotifyInput } from '@edr/types';
import chatConfig from '../../config/chat.config';
import { ALERTS_ROOM } from './chat-provisioning.service';
import { MatrixClient } from './matrix.client';
const FALLBACK_ROOM = { alias: 'freight-alerts', name: 'Freight Alerts' };
/**
* Best-effort per-type routing to an existing dept room. Anything not listed
* (including GENERIC) falls through to #freight-alerts — safer than a wrong
* guess at which department a type belongs to. Extend as real usage shows
* which types actually want a dept room instead of the shared feed.
*
* `name` matters only if this bridge is the very first thing to touch that
* alias (normally the nightly/on-demand reconcile creates dept rooms first,
* with the position's real name) — ensureRoom never renames an existing
* room, so this must match what ChatProvisioningService would have used.
*/
const ROOM_FOR_TYPE: Partial<Record<NotificationType, { alias: string; name: string }>> = {
[NotificationType.REQUEST_SUBMITTED]: { alias: 'dept-operation', name: 'Operation' },
[NotificationType.CLEARANCE_REVIEW]: { alias: 'dept-operation', name: 'Operation' },
};
/**
* Mirrors BACKOFFICE-audience notifications into chat so staff see them
* without having the inbox open. Hooked once into
@@ -31,6 +14,15 @@ const ROOM_FOR_TYPE: Partial<Record<NotificationType, { alias: string; name: str
*
* Gated on BACKOFFICE only: notify() also serves PORTAL (customer)
* notifications, which must never land in an internal staff room.
*
* Everything goes to one room. This used to route REQUEST_SUBMITTED and
* CLEARANCE_REVIEW to a hardcoded `dept-operation` alias — but the reconcile
* derives dept aliases from the IAM position key, which is `edr_freight_app/opn`
* shaped, so `#dept-operation` matched nothing it creates. The bridge quietly
* created its own empty room and posted every notification into it, where no
* employee was a member. A single room the reconcile actually populates beats
* per-type routing that silently misses; add routing back when real usage asks
* for it, keyed off the same derivation the reconcile uses.
*/
@Injectable()
export class ChatBridgeService {
@@ -46,8 +38,9 @@ export class ChatBridgeService {
if (!this.config.enabled) return;
try {
const room = ROOM_FOR_TYPE[input.type] ?? FALLBACK_ROOM;
const roomId = await this.matrix.ensureRoom(room.alias, room.name);
// get-or-create as a safety net only: the reconcile creates this room
// inside the space and joins every position holder to it.
const roomId = await this.matrix.ensureRoom(ALERTS_ROOM.alias, ALERTS_ROOM.name);
const body = input.link ? `${input.title}\n${input.body}\n${input.link}` : `${input.title}\n${input.body}`;
const html = `<strong>${escapeHtml(input.title)}</strong><br/>${escapeHtml(input.body)}${
input.link ? `<br/><a href="${escapeHtml(input.link)}">${escapeHtml(input.link)}</a>` : ''

View File

@@ -0,0 +1,194 @@
import 'reflect-metadata';
import type { DataSource } from 'typeorm';
import { ChatProvisioningService } from './chat-provisioning.service';
import type { MatrixClient } from './matrix.client';
const NAA = '03f5eb9e-23a0-4413-8d98-8de4b98b1be2';
const SUPER_ADMIN = 'f1534714-fa4a-4780-a081-05d4c1f6c25f';
const BOT = '@edrbot:m.test';
interface Holder {
positionKey: string;
positionName: string;
userId: string;
userName: string;
}
const holder = (
userId: string,
userName: string,
positionKey: string,
positionName = positionKey,
): Holder => ({ positionKey, positionName, userId, userName });
/**
* `members` maps a room id to who Matrix currently reports as joined, so a
* test can put a leaver in a room and watch what the reconcile does about it.
*/
function harness(holders: Holder[], members: Record<string, string[]> = {}) {
const matrix = {
mxidFor: jest.fn(
(userId: string, name: string) => `@${name}.${userId.slice(0, 6)}:m.test`,
),
whoami: jest.fn(async () => BOT),
ensureUser: jest.fn(async (_mxid: string, _name?: string) => undefined),
ensureRoom: jest.fn(
async (alias: string, _name?: string, _opts?: unknown) => `!${alias}:m.test`,
),
ensureJoined: jest.fn(async (_roomId: string, _mxid: string) => undefined),
joinedMembers: jest.fn(async (roomId: string) => members[roomId] ?? [BOT]),
kick: jest.fn(async (_roomId: string, _mxid: string, _reason: string) => undefined),
lockUser: jest.fn(async (_mxid: string) => undefined),
};
const dataSource = { query: jest.fn(async () => holders) };
const service = new ChatProvisioningService(
dataSource as unknown as DataSource,
matrix as unknown as MatrixClient,
);
return { service, matrix, dataSource };
}
/**
* `joinUserRooms` is the only thing standing between a first sign-in and an
* empty Element — the reconcile that would otherwise fill the room list runs
* nightly.
*/
describe('ChatProvisioningService.joinUserRooms', () => {
it('creates nothing for a user holding no current position', async () => {
// Super Admin on dev: three iam.employees rows, zero employee_positions.
// Synapse still auto-registers the account on JWT login, so the only
// symptom is a working sign-in into a client with no rooms in it.
const { service, matrix } = harness([]);
await expect(service.joinUserRooms(SUPER_ADMIN, 'Super Admin')).resolves.toBe(0);
expect(matrix.ensureUser).not.toHaveBeenCalled();
expect(matrix.ensureRoom).not.toHaveBeenCalled();
expect(matrix.ensureJoined).not.toHaveBeenCalled();
});
it('joins a holder to the space, #general, #freight-alerts and their dept room', async () => {
const { service, matrix } = harness([
holder(NAA, 'naa', 'edr_freight_app/marketer', 'Marketer'),
]);
await expect(service.joinUserRooms(NAA, 'naa')).resolves.toBe(4);
// The account has to exist before the admin join API will touch it — JWT
// auto-registration happens after this runs.
expect(matrix.ensureUser).toHaveBeenCalledWith('@naa.03f5eb:m.test', 'naa');
expect(matrix.ensureRoom.mock.calls.map(([alias]) => alias)).toEqual([
'edr-freight',
'general',
'freight-alerts',
'dept-edr_freight_app/marketer',
]);
// The space itself is joined, not only the rooms under it: Element shows a
// space in the left rail only to its members, so dropping this scatters
// every dept room loose into Home. #freight-alerts is joined here too, or
// a new hire sees no bridged notification until the nightly reconcile.
expect(matrix.ensureJoined.mock.calls.map(([roomId]) => roomId)).toEqual([
'!edr-freight:m.test',
'!general:m.test',
'!freight-alerts:m.test',
'!dept-edr_freight_app/marketer:m.test',
]);
});
it('scopes the position lookup to the one user', async () => {
const { service, dataSource } = harness([]);
await service.joinUserRooms(NAA, 'naa');
// Without the third parameter this would reconcile the whole unit on every
// click of "Open EDR Chat".
const [sql, params] = dataSource.query.mock.calls[0] as unknown as [
string,
unknown[],
];
expect(sql).toContain('AND e.user_id = $3');
expect(params).toEqual(['edr_freight', 'edr_freight_app', NAA]);
});
});
describe('ChatProvisioningService.reconcile', () => {
it('aborts instead of emptying every room when the holder query returns nothing', async () => {
// Zero holders never means "every employee left at once" — it means the
// query failed, the org/unit keys drifted, or a migration is mid-flight.
// Acting on it would kick every member of every room and lock every
// account, which is exactly the outage this guard exists to prevent.
const { service, matrix } = harness([]);
await expect(service.reconcile()).rejects.toThrow(/no current position holders/i);
expect(matrix.kick).not.toHaveBeenCalled();
expect(matrix.lockUser).not.toHaveBeenCalled();
});
it('locks a departed member rather than deactivating them', async () => {
const leaver = '@gone.999999:m.test';
const { service, matrix } = harness(
[holder(NAA, 'naa', 'marketer', 'Marketer')],
{
'!edr-freight:m.test': [BOT, '@naa.03f5eb:m.test', leaver],
'!general:m.test': [BOT, '@naa.03f5eb:m.test', leaver],
'!freight-alerts:m.test': [BOT, '@naa.03f5eb:m.test'],
'!dept-marketer:m.test': [BOT, '@naa.03f5eb:m.test'],
},
);
const result = await service.reconcile();
expect(matrix.kick.mock.calls.map(([, mxid]) => mxid)).toEqual([leaver, leaver]);
// Locking is reversible; deactivation is not, and on a homeserver with no
// password login it cannot be undone at all.
expect(matrix.lockUser).toHaveBeenCalledTimes(1);
expect(matrix.lockUser).toHaveBeenCalledWith(leaver);
expect(result.locked).toBe(1);
});
it('does not lock someone who only moved between positions', async () => {
const naaMxid = '@naa.03f5eb:m.test';
// naa holds `marketer` now; the room for their old position still lists them.
const { service, matrix } = harness(
[
holder(NAA, 'naa', 'marketer', 'Marketer'),
holder('aaa04914-b7ee-47b3-9c63-4324046a26bd', 'nati', 'opn', 'Operation'),
],
{ '!dept-opn:m.test': [BOT, naaMxid, '@nati.aaa049:m.test'] },
);
const result = await service.reconcile();
expect(matrix.kick).toHaveBeenCalledWith(
'!dept-opn:m.test',
naaMxid,
expect.any(String),
);
// Kicked from one room, still current elsewhere — their account stays open.
expect(matrix.lockUser).not.toHaveBeenCalled();
expect(result.locked).toBe(0);
});
it('refuses to empty a populated room when its desired set is empty', async () => {
// Per-room backstop for the paths the unit-level guard above cannot see.
const { service, matrix } = harness([holder(NAA, 'naa', 'marketer')], {
'!room:m.test': [BOT, '@naa.03f5eb:m.test', '@nati.aaa049:m.test'],
});
const diff = await (
service as unknown as {
syncMembership: (
roomId: string,
desired: Set<string>,
bot: string,
) => Promise<{ joined: number; kicked: string[] }>;
}
).syncMembership('!room:m.test', new Set<string>(), BOT);
expect(diff).toEqual({ joined: 0, kicked: [] });
expect(matrix.kick).not.toHaveBeenCalled();
});
});

View File

@@ -13,6 +13,11 @@ const UNIT_KEY = 'edr_freight_app';
const SPACE_ALIAS = 'edr-freight';
const GENERAL_ALIAS = 'general';
/** Where ChatBridgeService mirrors backoffice notifications. Provisioned here,
* with every position holder in it, so bridged messages land somewhere staff
* actually are — the bridge only ever get-or-creates it as a safety net. */
export const ALERTS_ROOM = { alias: 'freight-alerts', name: 'Freight Alerts' };
interface PositionHolder {
positionKey: string;
positionName: string;
@@ -24,7 +29,8 @@ export interface ReconcileResult {
rooms: number;
joined: number;
kicked: number;
deactivated: number;
/** Departed accounts locked — reversible. See {@link MatrixClient.lockUser}. */
locked: number;
}
/**
@@ -55,7 +61,7 @@ export class ChatProvisioningService {
const result = await this.reconcile();
this.logger.log(
`Chat reconcile: ${result.rooms} room(s), ${result.joined} joined, ` +
`${result.kicked} kicked, ${result.deactivated} deactivated`,
`${result.kicked} kicked, ${result.locked} locked`,
);
} catch (err) {
// Never throws into the scheduler — chat provisioning must not be able
@@ -113,10 +119,23 @@ export class ChatProvisioningService {
const spaceId = await this.matrix.ensureRoom(SPACE_ALIAS, 'EDR Freight', {
isSpace: true,
});
// The space itself, not only the rooms under it: Element lists a space in
// the left rail only for members of that space, so skipping this scatters
// every dept room loose into Home and the "EDR Freight" grouping never
// appears at all.
await this.matrix.ensureJoined(spaceId, mxid);
const generalRoomId = await this.matrix.ensureRoom(GENERAL_ALIAS, 'General', {
parentSpaceId: spaceId,
});
await this.matrix.ensureJoined(generalRoomId, mxid);
// Without this a new hire sees no bridged notification until the nightly
// reconcile puts them in the alerts room.
const alertsRoomId = await this.matrix.ensureRoom(
ALERTS_ROOM.alias,
ALERTS_ROOM.name,
{ parentSpaceId: spaceId },
);
await this.matrix.ensureJoined(alertsRoomId, mxid);
for (const position of positions) {
const roomId = await this.matrix.ensureRoom(
@@ -127,10 +146,10 @@ export class ChatProvisioningService {
await this.matrix.ensureJoined(roomId, mxid);
}
return positions.length + 1;
return positions.length + 3; // space + general + alerts
}
/** Force-joins additions, kicks+deactivates users no longer entitled anywhere. */
/** Force-joins additions, kicks users no longer entitled to this room. */
private async syncMembership(
roomId: string,
desiredUserIds: Set<string>,
@@ -139,6 +158,19 @@ export class ChatProvisioningService {
const current = await this.matrix.joinedMembers(roomId);
const currentSet = new Set(current.filter((id) => id !== botMxid));
// An empty desired set against a populated room is not "everyone left" —
// it is a query that failed, a key that drifted, or a migration caught
// mid-flight. Acting on it would clear the room and then lock every
// account that was in it. {@link reconcile} guards the same shape at the
// unit level; this is the per-room backstop for the paths it cannot see.
if (desiredUserIds.size === 0 && currentSet.size > 0) {
this.logger.warn(
`Refusing to empty room ${roomId}: desired membership is empty while ` +
`${currentSet.size} member(s) are joined. Left untouched.`,
);
return { joined: 0, kicked: [] };
}
let joined = 0;
for (const userId of desiredUserIds) {
if (!currentSet.has(userId)) {
@@ -160,6 +192,17 @@ export class ChatProvisioningService {
async reconcile(): Promise<ReconcileResult> {
const holders = await this.currentHolders();
// The desired state for the whole unit. Empty means the IAM query failed,
// the org/unit keys drifted, or a migration is mid-flight — it never means
// every employee left at once. Continuing would kick every member of every
// room and lock every account, so refuse the run and keep yesterday's
// state, which is wrong at worst by a day.
if (holders.length === 0) {
throw new Error(
`Chat reconcile aborted: no current position holders for ${ORG_KEY}/${UNIT_KEY}. ` +
'Refusing to read that as "remove everyone".',
);
}
const botMxid = await this.matrix.whoami();
const spaceId = await this.matrix.ensureRoom(SPACE_ALIAS, 'EDR Freight', {
@@ -168,6 +211,11 @@ export class ChatProvisioningService {
const generalRoomId = await this.matrix.ensureRoom(GENERAL_ALIAS, 'General', {
parentSpaceId: spaceId,
});
const alertsRoomId = await this.matrix.ensureRoom(
ALERTS_ROOM.alias,
ALERTS_ROOM.name,
{ parentSpaceId: spaceId },
);
const allUserIds = new Set(
holders.map((h) => this.matrix.mxidFor(h.userId, h.userName)),
@@ -184,19 +232,32 @@ export class ChatProvisioningService {
await this.matrix.ensureUser(mxid, h.userName);
}
let rooms = 2; // space + general
let rooms = 3; // space + general + alerts
let joined = 0;
let kicked = 0;
// A user kicked from anything while holding zero current positions
// anywhere in the unit (allUserIds spans every position) is a full
// leaver, not just moved between positions — deactivate their account.
// leaver, not just moved between positions — lock their account.
const kickedUserIds = new Set<string>();
// Space membership follows the org tree exactly like room membership —
// see the ensureJoined in joinUserRooms for why the space needs joining
// at all.
const spaceDiff = await this.syncMembership(spaceId, allUserIds, botMxid);
joined += spaceDiff.joined;
kicked += spaceDiff.kicked.length;
spaceDiff.kicked.forEach((uid) => kickedUserIds.add(uid));
const generalDiff = await this.syncMembership(generalRoomId, allUserIds, botMxid);
joined += generalDiff.joined;
kicked += generalDiff.kicked.length;
generalDiff.kicked.forEach((uid) => kickedUserIds.add(uid));
const alertsDiff = await this.syncMembership(alertsRoomId, allUserIds, botMxid);
joined += alertsDiff.joined;
kicked += alertsDiff.kicked.length;
alertsDiff.kicked.forEach((uid) => kickedUserIds.add(uid));
const byPosition = new Map<string, { name: string; userIds: Set<string> }>();
for (const h of holders) {
const entry = byPosition.get(h.positionKey) ?? {
@@ -219,19 +280,19 @@ export class ChatProvisioningService {
diff.kicked.forEach((uid) => kickedUserIds.add(uid));
}
let deactivated = 0;
let locked = 0;
for (const userId of kickedUserIds) {
if (allUserIds.has(userId)) continue; // moved position, still current elsewhere
try {
await this.matrix.deactivateUser(userId);
deactivated += 1;
await this.matrix.lockUser(userId);
locked += 1;
} catch (err) {
this.logger.warn(
`Failed to deactivate departed user ${userId}: ${(err as Error).message}`,
`Failed to lock departed user ${userId}: ${(err as Error).message}`,
);
}
}
return { rooms, joined, kicked, deactivated };
return { rooms, joined, kicked, locked };
}
}

View File

@@ -11,6 +11,8 @@ import { MatrixClient } from './matrix.client';
providers: [MatrixClient, ChatSsoService, ChatProvisioningService, ChatBridgeService],
// ChatBridgeService: consumed by NotificationInboxModule to mirror
// BACKOFFICE notifications into chat — see notification-inbox.module.ts.
exports: [ChatBridgeService],
// MatrixClient: HealthModule's readiness probe reports whether
// MATRIX_ADMIN_TOKEN really carries server-admin rights.
exports: [ChatBridgeService, MatrixClient],
})
export class ChatModule {}

View File

@@ -1,4 +1,5 @@
import { chatLocalpart } from './matrix.client';
import type { ChatConfig } from '../../config/chat.config';
import { MatrixClient, chatLocalpart } from './matrix.client';
describe('chatLocalpart', () => {
it('reads from the name, not the id', () => {
@@ -33,3 +34,180 @@ describe('chatLocalpart', () => {
}
});
});
const config: ChatConfig = {
enabled: true,
baseUrl: 'https://matrix.test',
publicBaseUrl: 'https://matrix.test',
webUrl: 'https://chat.test',
serverName: 'matrix.test',
jwtSecret: 'secret',
adminToken: 'syt_whatever',
};
type FetchFn = typeof globalThis.fetch;
/** Just enough of a Response for {@link MatrixClient}'s fetch wrappers. */
function response(status: number, body: unknown) {
return {
ok: status >= 200 && status < 300,
status,
json: async () => body,
text: async () => JSON.stringify(body),
};
}
const realFetch: FetchFn = globalThis.fetch;
const fetchMock = jest.fn();
beforeEach(() => {
fetchMock.mockReset();
globalThis.fetch = fetchMock as unknown as FetchFn;
});
afterAll(() => {
globalThis.fetch = realFetch;
});
describe('MatrixClient.verifyServerAdmin', () => {
it('accepts a token that can actually call the Synapse admin API', async () => {
fetchMock
.mockResolvedValueOnce(response(200, { user_id: '@edrbot:matrix.test' }))
.mockResolvedValueOnce(response(200, { users: [], total: 1 }));
const check = await new MatrixClient(config).verifyServerAdmin();
expect(check).toEqual({ ok: true, actingAs: '@edrbot:matrix.test' });
// The admin ping is the check. If this ever regresses to whoami alone,
// the assertion below is what catches it.
expect(String(fetchMock.mock.calls[1][0])).toContain('/_synapse/admin/');
});
it('rejects a valid token that is not a server admin', async () => {
// The dev outage, exactly: MATRIX_ADMIN_TOKEN held @super-admin's own
// token. whoami answered 200, every /_synapse/admin call answered 403,
// ensureUser threw, ChatSsoService swallowed it, and every employee got a
// working sign-in into an Element with no rooms in it.
fetchMock
.mockResolvedValueOnce(
response(200, { user_id: '@super-admin.f15347:matrix.test' }),
)
.mockResolvedValueOnce(
response(403, {
errcode: 'M_FORBIDDEN',
error: 'You are not a server admin',
}),
);
const check = await new MatrixClient(config).verifyServerAdmin();
expect(check.ok).toBe(false);
// Naming the account the token belongs to is the whole point — it is what
// turns "chat is broken" into "wrong token in the env".
expect(check.actingAs).toBe('@super-admin.f15347:matrix.test');
expect(check.error).toContain('403');
});
it('rejects a token that is not valid at all', async () => {
fetchMock.mockResolvedValueOnce(
response(401, { errcode: 'M_UNKNOWN_TOKEN', error: 'Invalid access token' }),
);
const check = await new MatrixClient(config).verifyServerAdmin();
expect(check.ok).toBe(false);
expect(check.actingAs).toBeUndefined();
expect(fetchMock).toHaveBeenCalledTimes(1); // no point pinging admin after this
});
});
describe('MatrixClient.ensureUser', () => {
it('lifts the lock on a returning employee', async () => {
// A previous reconcile locked them as a leaver. Force-joining them back
// into rooms while they still cannot log in is a silent half-restore.
fetchMock
.mockResolvedValueOnce(
response(200, { name: '@naa.03f5eb:matrix.test', locked: true }),
)
.mockResolvedValueOnce(response(200, {}));
await new MatrixClient(config).ensureUser('@naa.03f5eb:matrix.test', 'naa');
expect(fetchMock).toHaveBeenCalledTimes(2);
const [url, init] = fetchMock.mock.calls[1] as [string, { body: string }];
expect(String(url)).toContain('/_synapse/admin/v2/users/');
expect(JSON.parse(init.body)).toEqual({ locked: false });
});
it('leaves an account that is not locked alone', async () => {
fetchMock.mockResolvedValueOnce(
response(200, { name: '@naa.03f5eb:matrix.test', locked: false }),
);
await new MatrixClient(config).ensureUser('@naa.03f5eb:matrix.test', 'naa');
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
describe('MatrixClient rate limiting', () => {
it('retries a 429 after the delay Synapse asks for', async () => {
// The dev outage: a reconcile is a burst of writes, Synapse throttled an
// m.space.child PUT, and one un-retried 429 threw the whole run away.
fetchMock
.mockResolvedValueOnce(response(200, { user_id: '@edrbot:matrix.test' }))
.mockResolvedValueOnce(
response(429, {
errcode: 'M_LIMIT_EXCEEDED',
error: 'Too Many Requests',
retry_after_ms: 1,
}),
)
.mockResolvedValueOnce(response(200, { users: [] }));
const check = await new MatrixClient(config).verifyServerAdmin();
expect(check.ok).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it('gives up rather than hanging on a homeserver that only ever 429s', async () => {
fetchMock.mockResolvedValue(
response(429, { errcode: 'M_LIMIT_EXCEEDED', retry_after_ms: 1 }),
);
const check = await new MatrixClient(config).verifyServerAdmin();
expect(check.ok).toBe(false);
expect(check.error).toContain('429');
});
});
describe('MatrixClient.adminCheck', () => {
it('does not re-hit Synapse on every readiness probe', async () => {
fetchMock
.mockResolvedValueOnce(response(200, { user_id: '@edrbot:matrix.test' }))
.mockResolvedValueOnce(response(200, { users: [] }));
const client = new MatrixClient(config);
const first = await client.adminCheck();
const second = await client.adminCheck();
expect(second).toBe(first);
expect(fetchMock).toHaveBeenCalledTimes(2); // whoami + admin ping, once
});
it('re-checks when forced, so boot never reads a stale verdict', async () => {
fetchMock
.mockResolvedValueOnce(response(200, { user_id: '@edrbot:matrix.test' }))
.mockResolvedValueOnce(response(200, { users: [] }))
.mockResolvedValueOnce(response(200, { user_id: '@edrbot:matrix.test' }))
.mockResolvedValueOnce(response(200, { users: [] }));
const client = new MatrixClient(config);
await client.adminCheck();
await client.adminCheck(true);
expect(fetchMock).toHaveBeenCalledTimes(4);
});
});

View File

@@ -1,4 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
import { Inject, Injectable, Logger } from '@nestjs/common';
import type { OnApplicationBootstrap } from '@nestjs/common';
import type { ConfigType } from '@nestjs/config';
import chatConfig from '../../config/chat.config';
@@ -40,8 +41,27 @@ export function chatLocalpart(userId: string, displayName: string): string {
return `${slug || 'user'}.${userId.replace(/-/g, '').slice(0, 6)}`;
}
/** Result of {@link MatrixClient.verifyServerAdmin}. */
export interface AdminCheck {
ok: boolean;
/** Who MATRIX_ADMIN_TOKEN belongs to — present whenever the token is valid
* at all, including when it is valid but carries no admin rights. */
actingAs?: string;
error?: string;
}
@Injectable()
export class MatrixClient {
export class MatrixClient implements OnApplicationBootstrap {
private readonly logger = new Logger(MatrixClient.name);
/** The token is a deploy-time fact and the readiness probe runs every few
* seconds, so {@link adminCheck} memoises for this long. */
private static readonly ADMIN_CHECK_TTL_MS = 5 * 60_000;
/** Enough to ride out Synapse's limiter; short enough that a genuinely
* wedged homeserver still fails the run rather than hanging it. */
private static readonly MAX_RATE_LIMIT_RETRIES = 5;
private adminCheckCache?: { at: number; result: AdminCheck };
constructor(
@Inject(chatConfig.KEY)
private readonly config: ConfigType<typeof chatConfig>,
@@ -73,13 +93,48 @@ export class MatrixClient {
return this.config.serverName;
}
/** MATRIX_ENABLED — read by the readiness probe to tell "off" from "broken". */
get enabled(): boolean {
return this.config.enabled;
}
/**
* Synapse answers a burst of writes with 429 + `retry_after_ms`, and a
* reconcile is nothing but a burst of writes — one run creates the space,
* #general and a room per position, then force-joins every holder into each.
* The first run against dev tripped the limiter on an `m.space.child` PUT,
* and because nothing retried, that single 429 threw the whole reconcile
* away mid-flight. On the sign-in path ChatSsoService swallows the throw, so
* the only visible symptom was an empty Element.
*
* Honour the delay Synapse asks for rather than guessing at one.
*/
private async fetchWithRetry(
url: string,
init: Parameters<typeof fetch>[1],
): Promise<Awaited<ReturnType<typeof fetch>>> {
for (let attempt = 0; ; attempt++) {
const res = await fetch(url, init);
if (res.status !== 429 || attempt >= MatrixClient.MAX_RATE_LIMIT_RETRIES) {
return res;
}
// Body is discarded either way — this response is being retried.
const body = (await res.json().catch(() => ({}))) as {
retry_after_ms?: number;
};
await new Promise((resolve) =>
setTimeout(resolve, (Number(body.retry_after_ms) || 1000) + 100),
);
}
}
private async request<T>(
method: string,
path: string,
body?: unknown,
token: string = this.config.adminToken,
): Promise<T> {
const res = await fetch(`${this.config.baseUrl}${path}`, {
const res = await this.fetchWithRetry(`${this.config.baseUrl}${path}`, {
method,
headers: {
'Content-Type': 'application/json',
@@ -103,7 +158,7 @@ export class MatrixClient {
path: string,
body: unknown,
): Promise<T> {
const res = await fetch(`${this.config.baseUrl}${path}`, {
const res = await this.fetchWithRetry(`${this.config.baseUrl}${path}`, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
@@ -123,7 +178,7 @@ export class MatrixClient {
path: string,
token?: string,
): Promise<T | null> {
const res = await fetch(`${this.config.baseUrl}${path}`, {
const res = await this.fetchWithRetry(`${this.config.baseUrl}${path}`, {
method,
headers: { Authorization: `Bearer ${token ?? this.config.adminToken}` },
});
@@ -157,6 +212,68 @@ export class MatrixClient {
return res.user_id;
}
/**
* Is MATRIX_ADMIN_TOKEN actually a *server admin* token?
*
* `whoami` cannot answer this: it returns 200 for any valid user token at
* all. Dev shipped with MATRIX_ADMIN_TOKEN holding an ordinary staff
* account's token — whoami said 200, every `/_synapse/admin/*` call said
* 403 "You are not a server admin", `ensureUser` threw, ChatSsoService
* swallowed it (by design — a failed room join must not deny anyone a
* sign-in link), and every employee got a working sign-in into a client
* with no rooms in it. Nothing else in the system noticed.
*
* So this pings an endpoint only a server admin may call, and reports who
* the token belongs to — the one fact that makes the mix-up obvious.
*/
async verifyServerAdmin(): Promise<AdminCheck> {
let actingAs: string | undefined;
try {
actingAs = await this.whoami();
await this.request('GET', '/_synapse/admin/v2/users?limit=1');
return { ok: true, actingAs };
} catch (err) {
return { ok: false, actingAs, error: (err as Error).message };
}
}
/** {@link verifyServerAdmin}, memoised for {@link ADMIN_CHECK_TTL_MS}. */
async adminCheck(force = false): Promise<AdminCheck> {
const cached = this.adminCheckCache;
if (
!force &&
cached &&
Date.now() - cached.at < MatrixClient.ADMIN_CHECK_TTL_MS
) {
return cached.result;
}
const result = await this.verifyServerAdmin();
this.adminCheckCache = { at: Date.now(), result };
return result;
}
/**
* Fail loud at boot instead of silently on every sign-in. Logged, never
* thrown: chat provisioning must not be able to stop the API from starting,
* the same contract the reconcile cron and the notification bridge hold to.
*/
async onApplicationBootstrap(): Promise<void> {
if (!this.config.enabled) return;
const check = await this.adminCheck(true);
if (check.ok) {
this.logger.log(
`MATRIX_ADMIN_TOKEN verified — server admin as ${check.actingAs}`,
);
return;
}
this.logger.error(
'MATRIX_ADMIN_TOKEN is not a server-admin token' +
(check.actingAs ? ` (it belongs to ${check.actingAs})` : '') +
`: ${check.error}. Chat provisioning will create no rooms, and every ` +
'employee who opens chat will land in an empty Element.',
);
}
/** Currently-joined user ids for a room (not full member-event state). */
async joinedMembers(roomId: string): Promise<string[]> {
const res = await this.request<{ joined: Record<string, unknown> }>(
@@ -249,11 +366,18 @@ export class MatrixClient {
* ("User not found") on an account that doesn't exist yet.
*/
async ensureUser(userId: string, displayName?: string): Promise<void> {
const existing = await this.requestOrNull<{ name: string }>(
const existing = await this.requestOrNull<{ name: string; locked?: boolean }>(
'GET',
`/_synapse/admin/v2/users/${encodeURIComponent(userId)}`,
);
if (existing) return;
if (existing) {
// A returning employee is still locked from the reconcile that saw them
// leave. Force-joining them into rooms while they cannot log in is a
// silent half-restore, and this is the one call that already knows the
// flag — so undo it here rather than making the caller ask again.
if (existing.locked) await this.setLocked(userId, false);
return;
}
await this.request(
'PUT',
`/_synapse/admin/v2/users/${encodeURIComponent(userId)}`,
@@ -293,12 +417,30 @@ export class MatrixClient {
);
}
/** Deactivating (rather than just kicking) a leaver's account revokes all their sessions. */
deactivateUser(userId: string): Promise<void> {
/**
* Lock a departed employee out of chat — reversible, unlike deactivation.
*
* This used to call `/_synapse/admin/v1/deactivate`. That revokes sessions
* the same way but cannot be undone in any useful sense on this deployment:
* reactivation wants a password, and `password_config.enabled: false` means
* there is none to set. Room memberships do not come back either. One bad
* reconcile — a half-applied IAM migration, a renamed org key — would have
* destroyed every staff account that way, permanently.
*
* Locking blocks exactly the same access (Synapse rejects the account's
* tokens with M_USER_LOCKED and refuses new logins) and is undone with a
* single PUT — see {@link ensureUser}, which lifts it automatically when
* someone comes back.
*/
lockUser(userId: string): Promise<void> {
return this.setLocked(userId, true);
}
private setLocked(userId: string, locked: boolean): Promise<void> {
return this.request(
'POST',
`/_synapse/admin/v1/deactivate/${encodeURIComponent(userId)}`,
{ erase: false },
'PUT',
`/_synapse/admin/v2/users/${encodeURIComponent(userId)}`,
{ locked },
);
}

View File

@@ -32,7 +32,9 @@ import { CreateCompanyDto } from "./dto/create-company.dto";
import { UpdateCompanyDto } from "./dto/update-company.dto";
import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto";
import type { ETradeBusinessOption } from "@edr/types";
import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto";
import { AttachEtradeBusinessDto } from "./dto/attach-etrade-business.dto";
import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto";
import {
CompanyIdentityStateDto,
@@ -268,11 +270,42 @@ export class CompaniesController {
): Promise<ResponseCompanyProfileDto[]> {
const profiles = await this.companiesService.addCompanyProfilesForUser(
user.id,
dto.types,
dto.profiles,
);
return profiles.map((p) => new ResponseCompanyProfileDto(p));
}
@Get("etrade-businesses")
@PortalCustomer()
@ApiOperation({
summary:
"The eTrade business licences under this company's TIN, for attaching to its operational profiles",
})
async listEtradeBusinesses(
@CurrentUser() user: CurrentIamUser,
): Promise<ETradeBusinessOption[]> {
return this.companiesService.listEtradeBusinessesForUser(user.id);
}
@Patch("company-profiles/:profileId/etrade-business")
@PortalCustomer()
@ApiOperation({
summary:
"Attach one of the TIN's eTrade businesses to an operational profile (re-attaching refreshes the stored snapshot)",
})
async attachEtradeBusiness(
@CurrentUser() user: CurrentIamUser,
@Param("profileId") profileId: string,
@Body() dto: AttachEtradeBusinessDto,
): Promise<ResponseCompanyProfileDto> {
const profile = await this.companiesService.attachEtradeBusinessToProfile(
user.id,
profileId,
dto.licenceNumber,
);
return new ResponseCompanyProfileDto(profile);
}
@Post("onboarding/start")
@PortalCustomer()
@ApiOperation({
@@ -329,6 +362,7 @@ export class CompaniesController {
user.id,
dto.type,
dto.businessLicense,
dto.licenceNumber,
);
return new ResponseCompanyProfileDto(profile);
}

View File

@@ -162,7 +162,16 @@ function makeService(overrides: Partial<Ctx> = {}) {
{} as never,
deps.filesService as never,
deps.fileUploadSettings as never,
{} as never,
// Only the business-licence lookup is exercised here: adding a role now
// resolves which eTrade business it operates as.
{
findBusinessOption: async (_tin: string, licenceNumber: string) => ({
licenceNumber,
tradeName: "Test Trade Name",
activity: "Freight Forwarders",
renewedTo: "7/7/2026",
}),
} as never,
deps.companyNotifier as never,
{} as never,
deps.verifayda as never,
@@ -502,7 +511,9 @@ describe("the owner is checked against the eTrade licence", () => {
describe("the freight-forwarder gate", () => {
const addForwarder = (service: CompaniesService) =>
service.addCompanyProfilesForUser("user-1", [ProfileType.freightForwarder]);
service.addCompanyProfilesForUser("user-1", [
{ type: ProfileType.freightForwarder, licenceNumber: "LIC-1" },
]);
it("blocks the role while the representative is unverified", async () => {
const { service } = makeService({ attributes: { poaDeclared: "yes" } });

View File

@@ -133,7 +133,16 @@ function makeService(overrides: Partial<Ctx> = {}) {
{} as never,
deps.filesService as never,
{} as never,
{} as never,
// Only the business-licence lookup is exercised here: adding a role now
// resolves which eTrade business it operates as.
{
findBusinessOption: async (_tin: string, licenceNumber: string) => ({
licenceNumber,
tradeName: "Test Trade Name",
activity: "Freight Forwarders",
renewedTo: "7/7/2026",
}),
} as never,
deps.companyNotifier as never,
{} as never,
{} as never,
@@ -200,6 +209,8 @@ describe("PoA delegation paper is enforced wherever PoA state changes", () => {
service.createCompanyProfileForUser(
"user-1",
ProfileType.freightForwarder,
undefined,
"LIC-1",
),
).rejects.toBeInstanceOf(BadRequestException);
});
@@ -263,6 +274,8 @@ describe("PoA delegation paper is enforced wherever PoA state changes", () => {
service.createCompanyProfileForUser(
"user-1",
ProfileType.freightForwarder,
undefined,
"LIC-1",
),
).rejects.toBeInstanceOf(BadRequestException);
});
@@ -277,6 +290,8 @@ describe("PoA delegation paper is enforced wherever PoA state changes", () => {
service.createCompanyProfileForUser(
"user-1",
ProfileType.freightForwarder,
undefined,
"LIC-1",
),
).resolves.toBeDefined();
});

View File

@@ -0,0 +1,149 @@
import { BadRequestException, NotFoundException } from "@nestjs/common";
import { CompaniesService } from "./companies.service";
import { ProfileType } from "./entities/company-profile.entity";
import { COOPERATIVE_KEY } from "./entities/company.entity";
/**
* A TIN holds many business licences; each operational profile names the one it
* trades as. What matters here is that the stored business is always eTrade's
* own record, looked up under the company's own TIN — never the client's word
* for it — and that the requirement lifts for a company eTrade knows nothing
* about.
*/
const BUSINESSES = [
{
licenceNumber: "MT/AA/14/670/128936/2007",
tradeName: "Pave Freight Forwarding",
activity: "Freight Forwarders",
renewedTo: "7/7/2026",
},
{
licenceNumber: "MT/AA/14/670/11551235/2017",
tradeName: "Pave Minerals Export",
activity: "Export trade in minerals",
renewedTo: "7/7/2026",
},
];
function makeService(attributes: Record<string, unknown> = {}) {
const company = {
id: "company-1",
tin: "0045014036",
type: "customer",
attributes,
companyProfiles: [{ id: "profile-1", type: ProfileType.exporter }],
};
const created: Record<string, unknown>[] = [];
const companyProfilesRepo = {
findByCompanyId: jest.fn(async () => created),
findByType: jest.fn(async () => null),
create: jest.fn(async (row: Record<string, unknown>) => {
created.push({ id: `profile-${created.length + 2}`, ...row });
return created[created.length - 1];
}),
update: jest.fn(async (id: string, data: Record<string, unknown>) => ({
id,
...data,
})),
};
const etradeService = {
listBusinessOptions: jest.fn(async () => BUSINESSES),
findBusinessOption: jest.fn(async (_tin: string, licenceNumber: string) => {
const match = BUSINESSES.find((b) => b.licenceNumber === licenceNumber);
if (!match) throw new BadRequestException("no such licence");
return match;
}),
};
const service = new CompaniesService(
{} as never,
companyProfilesRepo as never,
{} as never,
{} as never,
{ findByUserId: jest.fn(async () => ({ id: "ext-1", companyId: "company-1" })) } as never,
{} as never,
{} as never,
{} as never,
etradeService as never,
{} as never,
{} as never,
{} as never,
);
jest
.spyOn(service, "getCompanyInfoByUserId")
.mockImplementation(async () => ({ profile: {}, company }) as never);
// Private, but every add path goes through it; stubbing it keeps this spec on
// the business-attachment logic instead of the whole company lookup graph.
(service as unknown as Record<string, unknown>).findCompanyById = async () =>
company;
return { service, companyProfilesRepo, etradeService };
}
describe("attaching an eTrade business to a company profile", () => {
it("stores eTrade's own record for the chosen licence, not the client's", async () => {
const { service, companyProfilesRepo } = makeService();
const updated = await service.attachEtradeBusinessToProfile(
"user-1",
"profile-1",
"MT/AA/14/670/128936/2007",
);
expect(companyProfilesRepo.update).toHaveBeenCalledWith("profile-1", {
etradeBusiness: BUSINESSES[0],
});
expect(updated.etradeBusiness).toEqual(BUSINESSES[0]);
});
it("refuses a licence eTrade does not list under this TIN", async () => {
const { service } = makeService();
await expect(
service.attachEtradeBusinessToProfile("user-1", "profile-1", "SOMEONE/ELSES/LICENCE"),
).rejects.toBeInstanceOf(BadRequestException);
});
it("refuses a profile belonging to another company", async () => {
const { service } = makeService();
await expect(
service.attachEtradeBusinessToProfile("user-1", "not-mine", BUSINESSES[0].licenceNumber),
).rejects.toBeInstanceOf(NotFoundException);
});
it("the same business may back more than one profile", async () => {
const { service, etradeService } = makeService();
await service.addCompanyProfilesForUser("user-1", [
{ type: ProfileType.exporter, licenceNumber: BUSINESSES[0].licenceNumber },
{ type: ProfileType.importer, licenceNumber: BUSINESSES[0].licenceNumber },
]);
expect(etradeService.findBusinessOption).toHaveBeenCalledTimes(2);
});
});
describe("choosing a business is required when the company has one to choose", () => {
it("rejects a role added without a licence", async () => {
const { service } = makeService();
await expect(
service.addCompanyProfilesForUser("user-1", [{ type: ProfileType.exporter }]),
).rejects.toBeInstanceOf(BadRequestException);
});
it("lifts the requirement for a co-operative, which has no eTrade record", async () => {
const { service, companyProfilesRepo, etradeService } = makeService({
[COOPERATIVE_KEY]: true,
});
await service.addCompanyProfilesForUser("user-1", [
{ type: ProfileType.exporter },
]);
expect(etradeService.findBusinessOption).not.toHaveBeenCalled();
expect(companyProfilesRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ etradeBusiness: null }),
);
});
it("offers a co-operative no businesses to pick from", async () => {
const { service } = makeService({ [COOPERATIVE_KEY]: true });
await expect(service.listEtradeBusinessesForUser("user-1")).resolves.toEqual([]);
});
});

View File

@@ -84,6 +84,7 @@ export class CompaniesRepository extends BaseRepository<Company> {
createdTo,
onboardingCompleted,
hasPendingChangeRequest,
profileType,
sortBy = 'review',
sortOrder = 'DESC',
} = query;
@@ -138,20 +139,46 @@ export class CompaniesRepository extends BaseRepository<Company> {
if (search) {
const term = `%${search.trim()}%`;
// Staff search by whatever is in front of them: the company name, the
// TIN/email, a profile reference off a document — and, since a TIN holds
// many licences, the trade name or licence number of the specific
// business a role operates as. All the per-profile terms share one EXISTS
// so a match on any of them qualifies the company once.
qb.andWhere(
`(company.name ILIKE :term
OR company.tin ILIKE :term
OR company.email ILIKE :term
OR company.licence_number ILIKE :term
OR EXISTS (
SELECT 1 FROM freight.company_profiles cp
WHERE cp.company_id = company.id
AND cp.reference ILIKE :term
AND cp.deleted_at IS NULL
AND (
cp.reference ILIKE :term
OR cp.etrade_business->>'tradeName' ILIKE :term
OR cp.etrade_business->>'licenceNumber' ILIKE :term
)
))`,
{ term },
);
}
// Companies holding a given operational role. EXISTS rather than a filter
// on the joined `companyProfiles` alias: constraining the join would drop
// the company's OTHER profiles from the loaded entity, so the list would
// render an exporter-and-importer as importer-only.
if (profileType) {
qb.andWhere(
`EXISTS (
SELECT 1 FROM freight.company_profiles cp_type
WHERE cp_type.company_id = company.id
AND cp_type.deleted_at IS NULL
AND cp_type.type = :profileType
)`,
{ profileType },
);
}
// sortBy is whitelisted by @IsIn on the DTO, so it is safe to interpolate.
if (sortBy === 'review') {
// Queue ordering: actionable tiers first, newest first within each. The

View File

@@ -47,7 +47,7 @@ import { ETradeService } from "./services/etrade.service";
import { CompanyNotifierService } from "./company-notifier.service";
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
import { normalizeE164 } from "../../common/validators/is-phone-number.validator";
import type { CompanyRegistrationData } from "@edr/types";
import type { CompanyRegistrationData, ETradeBusinessOption } from "@edr/types";
import { CreateCompanyDto } from "./dto/create-company.dto";
import { UpdateCompanyDto } from "./dto/update-company.dto";
import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
@@ -343,6 +343,11 @@ export class CompaniesService {
companyId: company.id,
type: input.type,
businessLicense: input.businessLicense ?? null,
etradeBusiness: await this.resolveProfileBusiness(
company,
input.licenceNumber,
input.type,
),
status: ProfileStatus.Pending,
});
}
@@ -2149,8 +2154,9 @@ export class CompaniesService {
*/
async addCompanyProfilesForUser(
userId: string,
types: ProfileType[],
inputs: Array<{ type: ProfileType; licenceNumber?: string }>,
): Promise<CompanyProfile[]> {
const types = inputs.map((i) => i.type);
const profile = await this.profilesRepo.findByUserId(userId);
if (!profile)
throw new NotFoundException(`Profile for user ${userId} not found`);
@@ -2185,11 +2191,21 @@ export class CompaniesService {
);
}
// Which eTrade business this role operates as. Resolved (and rejected if
// absent) BEFORE the row is created, so a role never lands unattached on
// a company that has licences to pick from.
const etradeBusiness = await this.resolveProfileBusiness(
company,
inputs.find((i) => i.type === type)?.licenceNumber,
type,
);
// Self-service role adds start Pending and carry no reference — a reference
// is minted only when a backoffice reviewer approves the role.
await this.companyProfilesRepo.create({
companyId,
type,
etradeBusiness,
status: ProfileStatus.Pending,
});
}
@@ -2207,6 +2223,7 @@ export class CompaniesService {
userId: string,
type: ProfileType,
businessLicense?: string,
licenceNumber?: string,
): Promise<CompanyProfile> {
const profile = await this.profilesRepo.findByUserId(userId);
if (!profile)
@@ -2232,12 +2249,18 @@ export class CompaniesService {
);
}
if (!created) {
const etradeBusiness = await this.resolveProfileBusiness(
company,
licenceNumber,
type,
);
// New self-service roles start Pending (awaiting backoffice approval) and
// carry no reference until approved.
created = await this.companyProfilesRepo.create({
companyId,
type,
businessLicense: businessLicense ?? null,
etradeBusiness,
status: ProfileStatus.Pending,
});
}
@@ -2320,6 +2343,7 @@ export class CompaniesService {
type: p.type,
reference: p.reference ?? "",
uploaded: records.some((r) => r.code === LICENSE_CODE),
etradeBusiness: p.etradeBusiness ?? null,
};
}),
);
@@ -2331,6 +2355,19 @@ export class CompaniesService {
? []
: licenseProfiles.filter((p) => !p.uploaded);
// Which eTrade business each role operates as. Enforced here rather than at
// role creation because the wizard picks roles on its FIRST step, before a
// TIN has been entered — there is nothing to pick from yet. The customer
// attaches one on the documents step, alongside that role's licence file,
// and onboarding cannot be submitted until every role has one.
//
// Lifted for a company with no eTrade record at all: a co-operative or a
// foreign investor has no licence list, so the requirement would be
// unsatisfiable (see `usesManualRegistration`).
const missingBusinesses = usesManualRegistration(company)
? []
: licenseProfiles.filter((p) => !p.etradeBusiness);
// 4. Power of Attorney. Whether there is one at all is the company's own
// declaration — the question the wizard asks outright — and that answer is
// what decides whose identity gets verified, so an unanswered one is itself
@@ -2378,6 +2415,10 @@ export class CompaniesService {
(p) =>
`Upload a business license for your ${p.type.replace(/_/g, " ")} profile`,
),
...missingBusinesses.map(
(p) =>
`Choose which eTrade business your ${p.type.replace(/_/g, " ")} profile operates as`,
),
...missingPoaFields.map((f) => `Add your ${f.label.toLowerCase()}`),
...(missingDelegation
? [`Upload the ${POA_DELEGATION_LABEL} for your Power of Attorney`]
@@ -2416,6 +2457,8 @@ export class CompaniesService {
requiredInfo.length +
requiredDocCount +
(cooperative ? 0 : licenseProfiles.length) +
// One "which business?" item per role, on the same terms as the licences.
(usesManualRegistration(company) ? 0 : licenseProfiles.length) +
poaItemCount +
// The declaration and the verification it selects.
2;
@@ -2424,6 +2467,7 @@ export class CompaniesService {
(missingInfo.length +
missingDocs.length +
missingLicenses.length +
missingBusinesses.length +
missingPoaFields.length +
(missingDelegation || flaggedDelegation ? 1 : 0) +
missingIdentityCount);
@@ -3747,6 +3791,86 @@ export class CompaniesService {
return match?.id ?? null;
}
/**
* Resolve the eTrade business a new/updated profile is being attached to.
*
* The client sends a licence number; what gets stored is eTrade's own record
* of it, looked up under THIS company's TIN. That is the whole check — a
* licence belonging to someone else's TIN simply is not in the list, so a
* client cannot attach a profile to a business the company does not hold.
*
* Returns null (rather than throwing) for a company that registered without
* eTrade: a co-operative union or farm holds no business licence, and a
* foreign investor's licence is the Investment Commission's, not the trade
* registry's. There is no list for them to pick from, so the role is theirs
* to hold unattached — the reviewer checks their uploaded documents instead.
*/
private async resolveProfileBusiness(
company: Company,
licenceNumber: string | undefined,
type: ProfileType,
): Promise<ETradeBusinessOption | null> {
if (usesManualRegistration(company)) return null;
if (!licenceNumber) {
throw new BadRequestException(
`Choose which of your eTrade business licences the ${type.replace(/_/g, " ")} profile operates as.`,
);
}
return this.etradeService.findBusinessOption(company.tin, licenceNumber);
}
/**
* The eTrade business licences the current user's company can attach to its
* operational profiles. Empty for a company that registered without eTrade.
*/
async listEtradeBusinessesForUser(
userId: string,
): Promise<ETradeBusinessOption[]> {
const { company } = await this.getCompanyInfoByUserId(userId);
if (usesManualRegistration(company)) return [];
return this.etradeService.listBusinessOptions(company.tin);
}
/**
* Attach (or re-attach) one of the TIN's eTrade businesses to a profile.
*
* Separate from role creation because the onboarding wizard picks roles
* before the TIN is known — the business is chosen later, on the step that
* already collects each role's licence document. Re-attaching also refreshes
* the stored snapshot, which is how a renewed licence's new expiry lands.
*/
async attachEtradeBusinessToProfile(
userId: string,
profileId: string,
licenceNumber: string,
): Promise<CompanyProfile> {
const { company } = await this.getCompanyInfoByUserId(userId);
const profile = (company.companyProfiles ?? []).find(
(p) => p.id === profileId,
);
if (!profile) {
throw new NotFoundException(
`Company profile ${profileId} not found for this company`,
);
}
if (usesManualRegistration(company)) {
throw new BadRequestException(
"This company is not registered with eTrade, so it has no business licences to attach.",
);
}
const business = await this.etradeService.findBusinessOption(
company.tin,
licenceNumber,
);
const updated = await this.companyProfilesRepo.update(profile.id, {
etradeBusiness: business,
});
if (!updated) {
throw new NotFoundException(`Company profile ${profileId} not found`);
}
return updated;
}
/** Resolve a TIN's live eTrade registration data. Throws when eTrade has no matching business licence. */
private async resolveEtradeRegistration(
tin: string,

View File

@@ -1,9 +1,37 @@
import { IsArray, IsEnum, ArrayMinSize } from "class-validator";
import { Type } from "class-transformer";
import {
ArrayMinSize,
IsArray,
IsEnum,
IsOptional,
IsString,
MaxLength,
ValidateNested,
} from "class-validator";
import { ProfileType } from "../entities/company-profile.entity";
export class AddCompanyProfileInputDto {
@IsEnum(ProfileType)
type!: ProfileType;
/**
* Which of the TIN's eTrade business licences this role operates as.
*
* Optional at the DTO layer, required by the service for any company that
* HAS an eTrade record — a co-operative or investor-licence company has none
* to pick from, and rejecting them here would be wrong. See
* `CompaniesService.resolveProfileBusiness`.
*/
@IsOptional()
@IsString()
@MaxLength(120)
licenceNumber?: string;
}
export class AddCompanyProfilesDto {
@IsArray()
@ArrayMinSize(1)
@IsEnum(ProfileType, { each: true })
types!: ProfileType[];
@ValidateNested({ each: true })
@Type(() => AddCompanyProfileInputDto)
profiles!: AddCompanyProfileInputDto[];
}

View File

@@ -0,0 +1,13 @@
import { IsNotEmpty, IsString, MaxLength } from "class-validator";
export class AttachEtradeBusinessDto {
/**
* The eTrade licence number of the business this profile operates as. Checked
* against the licences eTrade lists under the company's own TIN, so an
* unknown or someone else's licence is rejected rather than stored.
*/
@IsString()
@IsNotEmpty()
@MaxLength(120)
licenceNumber!: string;
}

View File

@@ -9,4 +9,14 @@ export class CreateCompanyProfileDto {
@IsString()
@MaxLength(100)
businessLicense?: string;
/**
* Which of the TIN's eTrade business licences this role operates as. Required
* by the service for any company that has an eTrade record; see
* `AddCompanyProfileInputDto.licenceNumber`.
*/
@IsOptional()
@IsString()
@MaxLength(120)
licenceNumber?: string;
}

View File

@@ -22,6 +22,16 @@ export class CompanyProfileInputDto {
@IsString()
@MaxLength(100)
businessLicense?: string;
/**
* Which of the TIN's eTrade business licences this role operates as. Required
* by the service for any company that has an eTrade record; see
* `CompaniesService.resolveProfileBusiness`.
*/
@IsOptional()
@IsString()
@MaxLength(120)
licenceNumber?: string;
}
export class CreateCompanyWithProfileDto {

View File

@@ -15,6 +15,7 @@ import {
CompanyStatus,
CompanyType,
} from "../entities/company.entity";
import { ProfileType } from "../entities/company-profile.entity";
export class ListCompaniesQueryDto {
@ApiPropertyOptional({ default: 1 })
@@ -56,6 +57,16 @@ export class ListCompaniesQueryDto {
@IsIn(Object.values(CompanyNationality))
nationality?: CompanyNationality;
@ApiPropertyOptional({
enum: ProfileType,
description:
"Only companies holding this operational role. A company may hold " +
"several; its other roles are still returned on the row.",
})
@IsOptional()
@IsIn(Object.values(ProfileType))
profileType?: ProfileType;
@ApiPropertyOptional({ description: "Registered on or after this instant (ISO)." })
@IsOptional()
@IsDateString()

View File

@@ -8,6 +8,7 @@
* truth the wizard uses to auto-finish.
*/
import type { ETradeBusinessOption } from "@edr/types";
import {
CompanyIdentityStateDto,
PoaDeclaration,
@@ -38,6 +39,11 @@ export interface OnboardingLicenseProfile {
reference: string;
/** True when at least one business-license file is stored on the profile. */
uploaded: boolean;
/**
* The eTrade business this role operates as, once the customer has attached
* one. Null while outstanding — the wizard renders the picker off this.
*/
etradeBusiness: ETradeBusinessOption | null;
}
export interface OnboardingPoaState {

View File

@@ -6,6 +6,7 @@ import {
hasInvestorLicence,
isCooperative,
} from '../entities/company.entity';
import type { ETradeBusinessOption } from '@edr/types';
import {
CompanyProfile,
ProfileLicenseFileView,
@@ -31,6 +32,12 @@ export class ResponseCompanyProfileDto {
*/
licenseFiles: ProfileLicenseFileView[];
attributes?: Record<string, any> | null;
/**
* The eTrade business licence this role operates as, or null when nothing is
* attached yet (or the company registered without eTrade). Snapshot — see
* `CompanyProfile.etradeBusiness`.
*/
etradeBusiness?: ETradeBusinessOption | null;
/** Reviewer note when the role is rejected (drives the reapply prompt). */
reviewNote?: string | null;
createdAt: Date;
@@ -45,6 +52,7 @@ export class ResponseCompanyProfileDto {
this.businessLicense = profile.businessLicense;
this.licenseFiles = [];
this.attributes = profile.attributes;
this.etradeBusiness = profile.etradeBusiness ?? null;
this.reviewNote = profile.reviewNote ?? null;
this.createdAt = profile.createdAt;
this.updatedAt = profile.updatedAt;

View File

@@ -1,4 +1,5 @@
import { BaseEntity } from "@edr/api-common";
import type { ETradeBusinessOption } from "@edr/types";
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
import { Company } from "./company.entity";
@@ -126,6 +127,23 @@ export class CompanyProfile extends BaseEntity {
@Column({ name: "business_license_files", type: "jsonb", nullable: true })
businessLicenseFiles?: BusinessLicenseFile[] | null;
/**
* Which of the TIN's eTrade business licences this profile operates as.
*
* A TIN holds many licences split by activity, so "exporter" and "freight
* forwarder" are usually two different businesses under one company. Stored
* as a snapshot rather than a bare licence number so the trade name and
* activity render without an eTrade call — that API is slow and regularly
* down, and this is display data, not a source of truth. Re-attaching
* refreshes it.
*
* NULL when nothing is attached yet, or when the company registered without
* eTrade at all (co-operative / investor licence — see
* {@link usesManualRegistration}). One business may back several profiles.
*/
@Column({ name: "etrade_business", type: "jsonb", nullable: true })
etradeBusiness?: ETradeBusinessOption | null;
@Column({ name: "attributes", type: "jsonb", nullable: true })
attributes?: Record<string, any> | null;

View File

@@ -78,6 +78,27 @@ describe('ETradeService business selection', () => {
expect(data.businesses?.[0].activity).toBe('Export trade in minerals');
});
it("takes the selected licence's trade name as the company name", () => {
const { service } = build();
const data = service.extractRegistrationData(
{
LicenceNumber: 'MT/AA/14/670/128936/2007',
TradeName: 'Pave Freight Forwarding',
} as ETradeBusinessInfo,
companyInfo(),
);
expect(data.companyName).toBe('Pave Freight Forwarding');
});
it('falls back to the registered name when the licence has no trade name', () => {
const { service } = build();
const data = service.extractRegistrationData(
{ LicenceNumber: 'x', TradeName: ' ' } as ETradeBusinessInfo,
companyInfo(),
);
expect(data.companyName).toBe('PAVE LOGISTICS AND TRADING P L C');
});
it('lists every licence for the picker, code prefixes stripped', () => {
const { service } = build();
const data = service.extractRegistrationData(

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