merge conflict resolved
BIN
.portal-flows-assets/00-invoice-detail-pay.png
Normal file
|
After Width: | Height: | Size: 173 KiB |
BIN
.portal-flows-assets/01-invoices-due-full.png
Normal file
|
After Width: | Height: | Size: 238 KiB |
BIN
.portal-flows-assets/02-login-full.png
Normal file
|
After Width: | Height: | Size: 673 KiB |
BIN
.portal-flows-assets/03-hager-invoices-empty-full.png
Normal file
|
After Width: | Height: | Size: 223 KiB |
BIN
.portal-flows-assets/04-contracts-list-full.png
Normal file
|
After Width: | Height: | Size: 272 KiB |
BIN
.portal-flows-assets/05-home-full.png
Normal file
|
After Width: | Height: | Size: 270 KiB |
BIN
.portal-flows-assets/06-contract-detail-signed-full.png
Normal file
|
After Width: | Height: | Size: 362 KiB |
BIN
.portal-flows-assets/07-contract-step1-full.png
Normal file
|
After Width: | Height: | Size: 200 KiB |
BIN
.portal-flows-assets/08-contract-step1-filled-full.png
Normal file
|
After Width: | Height: | Size: 234 KiB |
BIN
.portal-flows-assets/09-contract-step2-full.png
Normal file
|
After Width: | Height: | Size: 237 KiB |
BIN
.portal-flows-assets/10-contract-step3-review-full.png
Normal file
|
After Width: | Height: | Size: 261 KiB |
BIN
.portal-flows-assets/11-contract-duplicate-blocked-full.png
Normal file
|
After Width: | Height: | Size: 230 KiB |
BIN
.portal-flows-assets/11b-quotation-approve-full.png
Normal file
|
After Width: | Height: | Size: 386 KiB |
BIN
.portal-flows-assets/13-bookings-list-full.png
Normal file
|
After Width: | Height: | Size: 198 KiB |
BIN
.portal-flows-assets/14-booking-documents-modal-full.png
Normal file
|
After Width: | Height: | Size: 289 KiB |
BIN
.portal-flows-assets/15-nati-contracts-list-full.png
Normal file
|
After Width: | Height: | Size: 378 KiB |
BIN
.portal-flows-assets/16-nati-contracts-list-actions.png
Normal file
|
After Width: | Height: | Size: 326 KiB |
BIN
.portal-flows-assets/17-initiate-booking-confirm.png
Normal file
|
After Width: | Height: | Size: 291 KiB |
BIN
.portal-flows-assets/18-nati-bookings-list-actions.png
Normal file
|
After Width: | Height: | Size: 343 KiB |
BIN
.portal-flows-assets/19-complete-booking-cargo-full.png
Normal file
|
After Width: | Height: | Size: 222 KiB |
312
.portal-flows-assets/portal-flows.html
Normal 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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>
|
||||
25
.portal-flows-assets/render.mjs
Normal 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
BIN
INV-20260812-00005-thermal.pdf
Normal 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",
|
||||
|
||||
@@ -117,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";
|
||||
@@ -259,6 +260,7 @@ if (!process.env.APPLICATION_NAME) {
|
||||
FirstMileModule,
|
||||
LastMileModule,
|
||||
LastMileRequestsModule,
|
||||
EmptyReturnRequestsModule,
|
||||
InterchangeDocumentsModule,
|
||||
ImportOperationsModule,
|
||||
VerifaydaModule,
|
||||
|
||||
148
apps/edr-freight-api/src/common/freight-jwt.guard.spec.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,7 +39,60 @@ export const SELF_HAUL_CONFLICT_MESSAGE =
|
||||
'This booking is delivered by the customer’s 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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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/],
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]),
|
||||
];
|
||||
|
||||
|
||||
@@ -959,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,
|
||||
@@ -1036,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);
|
||||
|
||||
@@ -38,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";
|
||||
@@ -50,6 +53,7 @@ import {
|
||||
applySettlement,
|
||||
invoicePaymentMethodExpr,
|
||||
round2,
|
||||
settlementReferences,
|
||||
} from "./invoice-settlement.util";
|
||||
import { InvoiceRepository } from "./invoice.repository";
|
||||
|
||||
@@ -123,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[];
|
||||
@@ -311,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)", {
|
||||
@@ -337,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 });
|
||||
@@ -600,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;
|
||||
@@ -623,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
|
||||
@@ -713,6 +775,7 @@ export class BillingService {
|
||||
} as OfflineUsdInvoiceRow;
|
||||
}),
|
||||
total,
|
||||
outstanding,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -958,7 +1021,9 @@ export class BillingService {
|
||||
{
|
||||
label: "Wagons",
|
||||
value:
|
||||
booking.wagonsRequired != null ? String(booking.wagonsRequired) : null,
|
||||
booking.wagonsRequired != null
|
||||
? String(booking.wagonsRequired)
|
||||
: null,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -985,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 }]
|
||||
@@ -1017,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
|
||||
@@ -1031,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 {
|
||||
@@ -1043,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 },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1311,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.");
|
||||
}
|
||||
@@ -1341,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,
|
||||
@@ -1352,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);
|
||||
@@ -1412,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);
|
||||
@@ -1933,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);
|
||||
@@ -1999,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(
|
||||
@@ -2179,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 });
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 & 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 · 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>`;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
148
apps/edr-freight-api/src/modules/bookings/booking-content.sql.ts
Normal 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)`;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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)`;
|
||||
}
|
||||
@@ -2024,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,
|
||||
|
||||
@@ -86,6 +86,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";
|
||||
@@ -858,7 +859,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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
@@ -2072,6 +2155,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,
|
||||
|
||||
@@ -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>');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>` : ''
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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" } });
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -5,6 +5,7 @@ import { firstValueFrom } from "rxjs";
|
||||
import {
|
||||
ETradeCompanyInfo,
|
||||
ETradeBusinessInfo,
|
||||
ETradeBusinessOption,
|
||||
CompanyRegistrationData,
|
||||
normalizeRegion,
|
||||
} from "@edr/types";
|
||||
@@ -102,10 +103,16 @@ export class ETradeService {
|
||||
}
|
||||
|
||||
/**
|
||||
* `companyInfo` carries the registered organization name (`BusinessName`);
|
||||
* `businessInfo` only carries the licence's `TradeName`. Pass both so the
|
||||
* company name resolves to the legal entity rather than the trade name — and
|
||||
* never to `ManagerNameEng`, which is the manager's personal name.
|
||||
* `businessInfo` carries the selected licence's `TradeName`; `companyInfo`
|
||||
* carries the registered organization name (`BusinessName`). The company name
|
||||
* resolves to the trade name of the licence the customer picked — a TIN
|
||||
* routinely trades under a name that is not its registered one, and the
|
||||
* business they selected is the one they operate as here. `BusinessName` is
|
||||
* the fallback, because eTrade leaves `TradeName` blank on plenty of licences.
|
||||
* Never `ManagerNameEng`, which is the manager's personal name.
|
||||
*
|
||||
* Callers that need the legal entity (tax filings, EIMS seller details) must
|
||||
* read `companyInfo.BusinessName` themselves — it is not this field.
|
||||
*/
|
||||
extractRegistrationData(
|
||||
businessInfo: ETradeBusinessInfo,
|
||||
@@ -115,7 +122,7 @@ export class ETradeService {
|
||||
|
||||
return {
|
||||
companyName:
|
||||
companyInfo?.BusinessName?.trim() || businessInfo.TradeName?.trim() || "",
|
||||
businessInfo.TradeName?.trim() || companyInfo?.BusinessName?.trim() || "",
|
||||
licenceNumber: businessInfo.LicenceNumber,
|
||||
statusDescription: businessInfo.StatusDescription,
|
||||
dateRegistered: businessInfo.DateRegistered,
|
||||
@@ -137,17 +144,57 @@ export class ETradeService {
|
||||
regularPhone: businessInfo.AddressInfo?.RegularPhone || "",
|
||||
managerName: primaryManager?.ManagerNameEng || "",
|
||||
managerPhone: primaryManager?.RegularPhone || "",
|
||||
businesses: (companyInfo?.Businesses ?? []).map((b) => ({
|
||||
licenceNumber: b.LicenceNumber,
|
||||
tradeName: b.TradesName?.trim() || "",
|
||||
activity: (b.SubGroups ?? [])
|
||||
// Some descriptions repeat the code inline ("(65611)Import trade …").
|
||||
// eTrade also puts null entries in this array, so every hop is optional.
|
||||
.map((g) => g?.Description?.replace(/^\(\d+\)\s*/, "").trim())
|
||||
.filter(Boolean)
|
||||
.join(", "),
|
||||
renewedTo: b.RenewedTo || "",
|
||||
})),
|
||||
businesses: (companyInfo?.Businesses ?? []).map(toBusinessOption),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Every business licence held under a TIN, as the customer picks them.
|
||||
*
|
||||
* Split out from {@link extractRegistrationData} because attaching a business
|
||||
* to a company profile needs the list alone — no licence detail fetch, so one
|
||||
* eTrade call instead of two.
|
||||
*/
|
||||
async listBusinessOptions(tin: string): Promise<ETradeBusinessOption[]> {
|
||||
const companyInfo = await this.getCompanyInfoByTin(tin);
|
||||
return (companyInfo.Businesses ?? []).map(toBusinessOption);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one of the TIN's licences, or throw if eTrade does not list it.
|
||||
*
|
||||
* This is the trust boundary for a client-supplied licence number: a profile
|
||||
* may only ever be attached to a business eTrade actually holds under that
|
||||
* TIN, so the snapshot that gets stored is eTrade's own data, never the
|
||||
* client's.
|
||||
*/
|
||||
async findBusinessOption(
|
||||
tin: string,
|
||||
licenceNumber: string,
|
||||
): Promise<ETradeBusinessOption> {
|
||||
const options = await this.listBusinessOptions(tin);
|
||||
const match = options.find((b) => b.licenceNumber === licenceNumber);
|
||||
if (!match) {
|
||||
throw new BadRequestException(
|
||||
`eTrade lists no business licence "${licenceNumber}" under TIN ${tin}.`,
|
||||
);
|
||||
}
|
||||
return match;
|
||||
}
|
||||
}
|
||||
|
||||
function toBusinessOption(
|
||||
b: ETradeCompanyInfo["Businesses"][number],
|
||||
): ETradeBusinessOption {
|
||||
return {
|
||||
licenceNumber: b.LicenceNumber,
|
||||
tradeName: b.TradesName?.trim() || "",
|
||||
activity: (b.SubGroups ?? [])
|
||||
// Some descriptions repeat the code inline ("(65611)Import trade …").
|
||||
// eTrade also puts null entries in this array, so every hop is optional.
|
||||
.map((g) => g?.Description?.replace(/^\(\d+\)\s*/, "").trim())
|
||||
.filter(Boolean)
|
||||
.join(", "),
|
||||
renewedTo: b.RenewedTo || "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2250,7 +2250,9 @@ export class ContractBookingService {
|
||||
unitRepo.create({
|
||||
bookingContainerId: containerRow.id,
|
||||
containerNumber: unit.containerNumber,
|
||||
sealNumber: unit.sealNumber ?? null,
|
||||
// Legacy units recovered by the remainder placement can still
|
||||
// arrive sealless — keep those null rather than empty-string.
|
||||
sealNumber: unit.sealNumber?.trim() || null,
|
||||
vgmTons: unit.vgmTons,
|
||||
isHazardous: unit.isHazardous ?? false,
|
||||
isReefer: unit.isReefer ?? false,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
IsEmail,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
@@ -32,10 +33,12 @@ export class CreateContainerUnitDto {
|
||||
})
|
||||
containerNumber!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@ApiProperty({ description: 'Seal number — required on every container, import and export alike.' })
|
||||
@IsString()
|
||||
sealNumber?: string;
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@IsNotEmpty({ message: 'sealNumber is required' })
|
||||
@MaxLength(64)
|
||||
sealNumber!: string;
|
||||
|
||||
@ApiProperty({ description: 'VGM in tons', minimum: 0 })
|
||||
@IsNumber()
|
||||
|
||||
@@ -134,6 +134,7 @@ export class EimsBulkRegistrationService {
|
||||
region: invoice.company?.region,
|
||||
zone: invoice.company?.zone,
|
||||
woreda: invoice.company?.woreda,
|
||||
kebele: invoice.company?.kebele,
|
||||
});
|
||||
return { invoice, documentType, relatedDocument, buyerGeo };
|
||||
});
|
||||
|
||||