Merge branch 'dev' into alpha

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

View File

@@ -35,23 +35,50 @@ const CONFIG = {
"tailwind.config.js", "tailwind.config.js",
"tailwind.config.ts", "tailwind.config.ts",
"tailwind.config.cjs", "tailwind.config.cjs",
"tailwind.config.mjs",
"tailwind.js", "tailwind.js",
"postcss.config.js", "postcss.config.js",
"postcss.config.ts",
"postcss.config.mjs", "postcss.config.mjs",
"postcss.config.cjs", "postcss.config.cjs",
"babel.config.js", "babel.config.js",
"babel.config.ts",
"babel.config.mjs",
"babel.config.cjs", "babel.config.cjs",
"next.config.js", "next.config.js",
"next.config.ts",
"next.config.mjs", "next.config.mjs",
"next.config.cjs", "next.config.cjs",
"eslint.config.js",
"eslint.config.ts",
"eslint.config.mjs",
"eslint.config.cjs",
"astro.config.mjs", "astro.config.mjs",
"astro.config.js", "astro.config.js",
"vite.config.js", "vite.config.js",
"vite.config.ts", "vite.config.ts",
"vite.config.mjs",
"vite.config.cjs",
"webpack.config.js", "webpack.config.js",
"webpack.mix.js", "webpack.mix.js",
"svelte.config.js",
"nuxt.config.ts",
], ],
// Font files — the campaign appends JS payloads to web fonts, which are
// never executed directly but are fetched by the build and used as a
// staging blob. Binary formats, so they are read as latin1.
fontExtensions: [".woff", ".woff2", ".ttf", ".otf", ".eot"],
// Minimum number of \uXXXX escape sequences in one file before it is
// treated as deliberately obfuscated. Legitimate source files use a
// handful at most; PolinRider samples carry 400600.
maxLegitUnicodeEscapes: 20,
// Any single line longer than this inside a config file means code was
// appended past the real export block.
maxLegitConfigLineLength: 1000,
// Files intentionally containing malware indicators for scanner logic/tests. // Files intentionally containing malware indicators for scanner logic/tests.
// These filenames are skipped before malware rules are evaluated. // These filenames are skipped before malware rules are evaluated.
ignoredFilenames: ["scan.js"], ignoredFilenames: ["scan.js"],
@@ -187,14 +214,166 @@ const RULES = [
}, },
}, },
{
id: "POLINRIDER-018",
severity: "CRITICAL",
description:
"Unicode-escape string obfuscation — the payload writes plain ASCII literals such as require and https as \\u0068\\u0074\\u0074\\u0070 so that grep, code review, and GitHub diff search cannot see the API calls it makes",
test(content) {
// Only printable-ASCII escapes count. Minified vendor bundles legitimately
// carry hundreds of \uXXXX escapes, but those decode to emoji, diacritics
// and CJK ranges — escaping ASCII that could have been written literally
// has no purpose other than hiding it from a reader.
const asciiEscapes = (content.match(/\\u00[0-7][0-9a-fA-F]/g) || []).filter(
(e) => {
const code = parseInt(e.slice(2), 16);
return code >= 0x20 && code <= 0x7e;
},
);
return asciiEscapes.length >= CONFIG.maxLegitUnicodeEscapes
? [
`${asciiEscapes.length} printable-ASCII \\uXXXX escapes (threshold: ${CONFIG.maxLegitUnicodeEscapes})`,
]
: [];
},
},
{
id: "POLINRIDER-019",
severity: "CRITICAL",
description:
"Ethereum dead-drop C2 resolver — the attacker wallet's latest transaction encodes the C2 IP addresses in the 'to' field, so the C2 can be rotated without touching the implant",
test(_content, _filePath, _lines, decoded) {
const iocs = [
"0xa322e5f3d311d3080e6f0121063e9adc2490ef1a",
"eth.blockscout.com",
"eth_getBlockByNumber",
"eth_getTransactionCount",
"eth_blockNumber",
"ethereum-rpc.publicnode.com",
"eth-mainnet.public.blastapi.io",
"1rpc.io/eth",
"eth.drpc.org",
];
const hay = decoded.toLowerCase();
return iocs.filter((i) => hay.includes(i));
},
},
{
id: "POLINRIDER-020",
severity: "CRITICAL",
description:
"Remote code execution stager — fetches a payload over HTTP, XOR-decrypts it, eval()s it in-process and re-launches it as a detached hidden node -e child process that survives the build exiting",
test(_content, _filePath, _lines, decoded) {
const hits = [];
if (/spawn\s*\(\s*["']node["']\s*,\s*\[\s*["']-e["']/.test(decoded))
hits.push('spawn("node", ["-e", <remote payload>])');
if (/detached\s*:\s*(!0|true)/.test(decoded))
hits.push("detached child process");
if (/stdio\s*:\s*["']ignore["']/.test(decoded))
hits.push('stdio:"ignore" (output suppressed)');
if (/\beval\s*\(\s*\w+\s*\+/.test(decoded))
hits.push("eval() of concatenated remote string");
if (/x-payload-b64/i.test(decoded))
hits.push("x-payload-b64 C2 response header");
// Only report when this is a genuine stager, not an isolated keyword.
return hits.length >= 2 ? hits : [];
},
},
{
id: "POLINRIDER-021",
severity: "CRITICAL",
description:
"Campaign marker + Node internals capture via dot notation — the implant stores its victim/campaign ID and re-exposes require/module on globalThis so later stages can load native modules from inside an ES module",
test(_content, _filePath, _lines, decoded) {
const hits = [];
const marker = decoded.match(
/global\s*\.\s*[a-zA-Z_$]\w*\s*=\s*["']([A-Z]{0,2}\d[\d-]{3,})["']/,
);
if (marker) hits.push(`campaign marker: "${marker[1]}"`);
if (/global\s*\.\s*\w+\s*=\s*require\b/.test(decoded))
hits.push("global.<x> = require");
if (/global\s*\.\s*\w+\s*=\s*module\b/.test(decoded))
hits.push("global.<x> = module");
return hits;
},
},
{
id: "POLINRIDER-022",
severity: "CRITICAL",
description:
"Web font file carrying an executable payload — .woff/.woff2 files are treated as opaque binary assets by reviewers and linters, so the campaign uses them to smuggle JavaScript past code review",
test(content, filePath) {
const ext = path.extname(filePath).toLowerCase();
if (!CONFIG.fontExtensions.includes(ext)) return [];
const hits = [];
const magic = content.slice(0, 4);
const expected = { ".woff": "wOFF", ".woff2": "wOF2", ".otf": "OTTO" };
if (expected[ext] && magic !== expected[ext]) {
hits.push(
`bad magic bytes: expected "${expected[ext]}", got "${magic.replace(/[^\x20-\x7e]/g, ".")}"`,
);
}
const codeMarkers = [
"require(",
"eval(",
"child_process",
"global.",
"createRequire",
"process.env",
];
const found = codeMarkers.filter((m) => content.includes(m));
if (found.length > 0) {
hits.push(`embedded JS markers: ${found.join(", ")}`);
}
return hits;
},
},
{
id: "POLINRIDER-023",
severity: "CRITICAL",
description:
".vscode/tasks.json configured to auto-execute on folder open — gives the campaign code execution the moment a developer opens the repo in VS Code, before any build or install command is run",
test(content, filePath) {
if (!/\.vscode[\\/]tasks\.json$/.test(filePath.replace(/\\/g, "/")))
return [];
const hits = [];
if (/"runOn"\s*:\s*"folderOpen"/.test(content))
hits.push('runOn: "folderOpen" (executes without user action)');
const cmd = content.match(/"command"\s*:\s*"([^"]{0,120})"/);
if (cmd && /node|curl|wget|powershell|bash|-e\b|eval/i.test(cmd[1]))
hits.push(`command: "${cmd[1]}"`);
return hits.length >= 1 ? hits : [];
},
},
{
id: "POLINRIDER-024",
severity: "HIGH",
description:
".gitignore lists this campaign's persistence artifacts — the implant appends these entries so its own dropped files never appear in git status and the developer never sees them",
test(content, filePath) {
if (path.basename(filePath) !== ".gitignore") return [];
const lines = content.split("\n").map((l) => l.trim());
return CONFIG.persistenceArtifacts.filter((a) => lines.includes(a)).map(
(a) => `.gitignore hides "${a}"`,
);
},
},
// ── Tier 2: Behavioral / structural indicators ───────────────────────────── // ── Tier 2: Behavioral / structural indicators ─────────────────────────────
{ {
id: "POLINRIDER-009", id: "POLINRIDER-009",
severity: "HIGH", severity: "HIGH",
description: description:
"Blockchain RPC infrastructure contact — campaign uses TRON, Aptos, and BSC as dead-drop C2 resolvers", "Blockchain RPC infrastructure contact — campaign uses TRON, Aptos, BSC and Ethereum as dead-drop C2 resolvers",
test(content) { test(_content, _filePath, _lines, decoded) {
const endpoints = [ const endpoints = [
"trongrid.io", "trongrid.io",
"aptoslabs.com", "aptoslabs.com",
@@ -202,7 +381,7 @@ const RULES = [
"bsc-rpc.publicnode.com", "bsc-rpc.publicnode.com",
"eth_getTransactionByHash", "eth_getTransactionByHash",
]; ];
return endpoints.filter((e) => content.includes(e)); return endpoints.filter((e) => decoded.includes(e));
}, },
}, },
@@ -210,11 +389,10 @@ const RULES = [
id: "POLINRIDER-010", id: "POLINRIDER-010",
severity: "HIGH", severity: "HIGH",
description: description:
"Hidden process spawn with windowsHide:true — used by InvisibleFerret / BeaverTail stager to launch detached Node.js child processes invisibly", "Hidden process spawn with windowsHide — used by InvisibleFerret / BeaverTail stager to launch detached Node.js child processes invisibly. Matches both true and its minified form !0",
test(content) { test(_content, _filePath, _lines, decoded) {
return /windowsHide\s*:\s*true/.test(content) const m = decoded.match(/windowsHide\s*:\s*(true|!0)/);
? ["windowsHide:true found"] return m ? [`windowsHide:${m[1]} found`] : [];
: [];
}, },
}, },
@@ -237,7 +415,10 @@ const RULES = [
severity: "HIGH", severity: "HIGH",
description: description:
"Payload hidden after large horizontal whitespace (>100 spaces on one line) — evasion technique to hide code off-screen in editors and GitHub diff views", "Payload hidden after large horizontal whitespace (>100 spaces on one line) — evasion technique to hide code off-screen in editors and GitHub diff views",
test(content, _filePath, lines) { test(_content, filePath, lines) {
// Binary assets contain long runs of 0x20 as padding — not an indicator.
if (CONFIG.fontExtensions.includes(path.extname(filePath).toLowerCase()))
return [];
const hits = []; const hits = [];
lines.forEach((line, i) => { lines.forEach((line, i) => {
const spaceRun = line.match(/\s{100,}/); const spaceRun = line.match(/\s{100,}/);
@@ -269,6 +450,27 @@ const RULES = [
}, },
}, },
{
id: "POLINRIDER-025",
severity: "HIGH",
description:
"Minified code appended past the end of a config file — real config files are hand-written and line-wrapped; a single multi-thousand-character line means a payload was concatenated onto the original file",
test(_content, filePath, lines) {
const base = path.basename(filePath).toLowerCase();
if (!CONFIG.targetedFilenames.some((f) => f.toLowerCase() === base))
return [];
const hits = [];
lines.forEach((line, i) => {
if (line.length > CONFIG.maxLegitConfigLineLength) {
hits.push(
`line ${i + 1}: ${line.length} chars (threshold: ${CONFIG.maxLegitConfigLineLength})`,
);
}
});
return hits;
},
},
{ {
id: "POLINRIDER-014", id: "POLINRIDER-014",
severity: "HIGH", severity: "HIGH",
@@ -330,25 +532,58 @@ const RULES = [
// ─── Scanner Engine ──────────────────────────────────────────────────────────── // ─── Scanner Engine ────────────────────────────────────────────────────────────
/**
* Resolve \uXXXX and \xXX escapes so string-matching rules see the real API
* calls. The campaign writes every literal as escapes specifically to defeat
* grep, so rules that match on plain text must run against this form.
* The decoded text is appended to the original rather than replacing it, so a
* rule can still match either representation with one pass.
*/
function deobfuscate(content) {
if (!/\\[ux]/.test(content)) return content;
const decoded = content
.replace(/\\u\{([0-9a-fA-F]{1,6})\}/g, (m, h) => {
try {
return String.fromCodePoint(parseInt(h, 16));
} catch {
return m;
}
})
.replace(/\\u([0-9a-fA-F]{4})/g, (_m, h) =>
String.fromCharCode(parseInt(h, 16)),
)
.replace(/\\x([0-9a-fA-F]{2})/g, (_m, h) =>
String.fromCharCode(parseInt(h, 16)),
);
return content + "\n/* --- deobfuscated --- */\n" + decoded;
}
function scanFile(filePath) { function scanFile(filePath) {
if (shouldIgnoreFile(filePath)) { if (shouldIgnoreFile(filePath)) {
return { filePath, findings: [], skipped: true }; return { filePath, findings: [], skipped: true };
} }
// Fonts are binary. latin1 maps bytes 1:1 to chars, so magic-byte checks and
// ASCII payload searches both work without mangling the content.
const isBinary = CONFIG.fontExtensions.includes(
path.extname(filePath).toLowerCase(),
);
let content; let content;
try { try {
content = fs.readFileSync(filePath, "utf8"); content = fs.readFileSync(filePath, isBinary ? "latin1" : "utf8");
} catch (err) { } catch (err) {
return { filePath, error: err.message, findings: [] }; return { filePath, error: err.message, findings: [] };
} }
const lines = content.split("\n"); const lines = content.split("\n");
const decoded = deobfuscate(content);
const findings = []; const findings = [];
for (const rule of RULES) { for (const rule of RULES) {
let matches; let matches;
try { try {
matches = rule.test(content, filePath, lines); matches = rule.test(content, filePath, lines, decoded);
} catch (err) { } catch (err) {
matches = [`[rule error: ${err.message}]`]; matches = [`[rule error: ${err.message}]`];
} }
@@ -388,6 +623,7 @@ function walkDir(dir, results = []) {
} else if (entry.isFile() && !shouldIgnoreFile(full)) { } else if (entry.isFile() && !shouldIgnoreFile(full)) {
const ext = path.extname(entry.name).toLowerCase(); const ext = path.extname(entry.name).toLowerCase();
const base = entry.name.toLowerCase(); const base = entry.name.toLowerCase();
const rel = full.replace(/\\/g, "/");
// Scan all JS/TS config files + any file matching a targeted name // Scan all JS/TS config files + any file matching a targeted name
const isTargetedName = CONFIG.targetedFilenames.some( const isTargetedName = CONFIG.targetedFilenames.some(
@@ -399,8 +635,18 @@ function walkDir(dir, results = []) {
const isJsLike = [".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"].includes( const isJsLike = [".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"].includes(
ext, ext,
); );
const isFont = CONFIG.fontExtensions.includes(ext);
const isGitignore = base === ".gitignore";
const isVsCodeTask = /\.vscode\/tasks\.json$/.test(rel);
if (isTargetedName || isPersistenceArtifact || isJsLike) { if (
isTargetedName ||
isPersistenceArtifact ||
isJsLike ||
isFont ||
isGitignore ||
isVsCodeTask
) {
results.push(full); results.push(full);
} }
} }
@@ -532,10 +778,141 @@ function printReport(allResults, { json = false, outputFile = null } = {}) {
return infected.length > 0; return infected.length > 0;
} }
// ─── Self-test ─────────────────────────────────────────────────────────────────
/**
* Runs the rule set against synthetic samples. Guards the two properties that
* matter: the live payload is still caught, and minified vendor bundles that
* legitimately contain \uXXXX escapes are still not flagged.
* Run with: node scan.js --self-test
*/
function selfTest() {
const os = require("os");
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "polinrider-selftest-"));
const write = (name, body) => {
const p = path.join(tmp, name);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, body);
return p;
};
const esc = (s) =>
[...s].map((c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0")).join("");
const cases = [];
// 1. The live payload shape: ASCII-escaped strings + ETH dead drop + stager.
cases.push({
name: "infected postcss.config.js",
file: write(
"infected/postcss.config.js",
`export default { plugins: {} };` +
" ".repeat(300) +
`global.i="A8-4299";global.r=require;global.m=module;` +
`const http=require("${esc("http")}"),{spawn}=require("${esc("child_process")}");` +
`S="0xa322E5f3D311D3080e6f0121063e9aDC2490Ef1a".toLowerCase(),` +
`I="${esc("https://eth.blockscout.com/api")}";` +
`rc(t,"${esc("eth_getBlockByNumber")}");eval(r+o);` +
`spawn("node",["-e",r+o],{detached:!0,stdio:"${esc("ignore")}",windowsHide:!0});`,
),
expect: true,
});
// 2. Clean config — must stay silent.
cases.push({
name: "clean postcss.config.js",
file: write(
"clean/postcss.config.js",
"export default {\n plugins: {\n tailwindcss: {},\n autoprefixer: {},\n },\n};\n",
),
expect: false,
});
// 3. Minified vendor bundle with many non-ASCII escapes — must stay silent.
cases.push({
name: "minified vendor bundle",
file: write(
"vendor/emoji.min.js",
"var e=[" +
Array.from({ length: 400 }, (_, i) => `"\\ud83d\\ude${(i % 90) + 10}"`).join(",") +
"];",
),
expect: false,
});
// 4. Font carrying an appended JS payload.
cases.push({
name: "trojanised .woff2",
file: write(
"fonts/bad.woff2",
"wOF2" + "".repeat(64) + 'require("child_process");global.x=1;eval(a+b);',
),
expect: true,
});
// 5. Clean font — correct magic, no code markers.
cases.push({
name: "clean .woff2",
file: write("fonts/good.woff2", "wOF2" + "".repeat(200)),
expect: false,
});
// 6. .gitignore hiding persistence artifacts.
cases.push({
name: ".gitignore with persistence artifacts",
file: write(
"ignore/.gitignore",
"node_modules/\ntemp_auto_push.bat\nbranch_structure.json\n",
),
expect: true,
});
// 7. VS Code task auto-executing on folder open.
cases.push({
name: ".vscode/tasks.json auto-exec",
file: write(
"vscode/.vscode/tasks.json",
JSON.stringify({
version: "2.0.0",
tasks: [
{
label: "build",
command: "node -e require('http')",
runOptions: { runOn: "folderOpen" },
},
],
}),
),
expect: true,
});
let failed = 0;
for (const c of cases) {
const { findings } = scanFile(c.file);
const detected = findings.length > 0;
const ok = detected === c.expect;
if (!ok) failed++;
const ids = findings.map((f) => f.id).join(", ") || "none";
console.log(
` ${ok ? `${ANSI.green}PASS${ANSI.reset}` : `${ANSI.red}FAIL${ANSI.reset}`} ` +
`${c.name} — expected ${c.expect ? "detection" : "clean"}, got: ${ids}`,
);
}
fs.rmSync(tmp, { recursive: true, force: true });
console.log(
failed === 0
? `\n${ANSI.green}${ANSI.bold}Self-test passed (${cases.length}/${cases.length}).${ANSI.reset}\n`
: `\n${ANSI.red}${ANSI.bold}Self-test FAILED: ${failed}/${cases.length} case(s).${ANSI.reset}\n`,
);
process.exit(failed === 0 ? 0 : 1);
}
// ─── CLI Entry Point ─────────────────────────────────────────────────────────── // ─── CLI Entry Point ───────────────────────────────────────────────────────────
function main() { function main() {
const args = process.argv.slice(2); const args = process.argv.slice(2);
if (args.includes("--self-test")) return selfTest();
const jsonFlag = args.includes("--json"); const jsonFlag = args.includes("--json");
const outputFileIdx = args.indexOf("--output"); const outputFileIdx = args.indexOf("--output");
const outputFile = outputFileIdx !== -1 ? args[outputFileIdx + 1] : null; const outputFile = outputFileIdx !== -1 ? args[outputFileIdx + 1] : null;

View File

@@ -10,8 +10,16 @@ permissions:
contents: read contents: read
jobs: jobs:
# Supply-chain gate. Every other job depends on this, so a malware detection
# blocks the entire deploy before any build, migration or container starts.
malware-scan:
name: Malware gate
uses: ./.github/workflows/malware-scan.yml
secrets: inherit
detect-changes: detect-changes:
name: Detect changed services name: Detect changed services
needs: malware-scan
runs-on: ${{ fromJson(format('["self-hosted", "{0}"]', github.ref_name)) }} runs-on: ${{ fromJson(format('["self-hosted", "{0}"]', github.ref_name)) }}
outputs: outputs:
matrix: ${{ steps.filter.outputs.matrix }} matrix: ${{ steps.filter.outputs.matrix }}
@@ -90,7 +98,7 @@ jobs:
deploy: deploy:
name: Deploy ${{ matrix.service }} name: Deploy ${{ matrix.service }}
needs: detect-changes needs: [malware-scan, detect-changes]
if: ${{ needs.detect-changes.outputs.matrix != '[]' }} if: ${{ needs.detect-changes.outputs.matrix != '[]' }}
runs-on: ${{ fromJson(format('["self-hosted", "{0}"]', github.ref_name)) }} runs-on: ${{ fromJson(format('["self-hosted", "{0}"]', github.ref_name)) }}
strategy: strategy:
@@ -102,6 +110,7 @@ jobs:
DEPLOY_USER: tria DEPLOY_USER: tria
DOCKER_BUILDKIT: "1" DOCKER_BUILDKIT: "1"
COMPOSE_DOCKER_CLI_BUILD: "1" COMPOSE_DOCKER_CLI_BUILD: "1"
ENV_MANAGER_TOKEN: ${{ secrets.ENV_MANAGER_TOKEN }}
steps: steps:
- name: Checkout - name: Checkout
@@ -127,10 +136,10 @@ jobs:
;; ;;
esac esac
- name: Sync environment from server - name: Sync environment from Env manager app
run: | run: |
chmod +x scripts/deploy/*.sh chmod +x scripts/deploy/*.sh
./scripts/deploy/sync-env-from-server.sh "${{ matrix.service }}" ./scripts/deploy/sync-env-from-env-manager.sh "${{ matrix.service }}"
- name: Set compose project name - name: Set compose project name
run: | run: |

161
.github/workflows/malware-scan.yml vendored Normal file
View File

@@ -0,0 +1,161 @@
name: Malware Scan
# Supply-chain malware gate for the PolinRider / Famous Chollima campaign.
#
# Runs standalone on every push and pull request, and is also called by
# deploy.yml as a required first job — a detection fails this workflow, which
# blocks every downstream deploy job from starting.
on:
push:
# dev and staging are already gated through deploy.yml's required
# malware-scan job — no need to scan those pushes twice.
branches-ignore:
- dev
- staging
pull_request:
workflow_call:
secrets:
TELEGRAM_BOT_TOKEN:
required: false
TELEGRAM_CHAT_ID:
required: false
permissions:
contents: read
# A detection on a ref should not be raced by a newer run of the same ref.
concurrency:
group: malware-scan-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
scan:
name: Scan for PolinRider malware
# Plain `self-hosted` — GitHub applies this label to every self-hosted
# runner automatically. The scan is host-agnostic, unlike the deploy jobs
# which pin to a branch-specific runner.
runs-on: [self-hosted, dev]
outputs:
infected: ${{ steps.scan.outputs.infected }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Verify scanner rules still work
# Fails if someone weakens a detection rule or introduces a false
# positive against minified vendor bundles.
run: node .github/scripts/scan.js --self-test
- name: Scan repository
id: scan
run: |
set -uo pipefail
# Actions runs this with `bash -e`, so the non-zero exit must be
# caught with `||` rather than read back from $? afterwards.
STATUS=0
node .github/scripts/scan.js --json --output malware-report.json . || STATUS=$?
if [ "$STATUS" -eq 0 ]; then
echo "infected=false" >> "$GITHUB_OUTPUT"
echo "No malware detected."
exit 0
fi
echo "infected=true" >> "$GITHUB_OUTPUT"
# Human-readable run for the log, so the failure is legible in the UI.
node .github/scripts/scan.js . || true
exit 1
- name: Build alert message
id: message
if: failure() && steps.scan.outputs.infected == 'true'
run: |
set -euo pipefail
FILES=$(jq -r '.results[].filePath' malware-report.json | head -20)
COUNT=$(jq -r '.infectedFiles' malware-report.json)
RULES=$(jq -r '[.results[].findings[] | select(.severity=="CRITICAL") | .id] | unique | join(", ")' malware-report.json)
{
echo "message<<EOF"
echo "🚨 POLINRIDER MALWARE DETECTED — DEPLOY BLOCKED"
echo ""
echo "Repo: ${GITHUB_REPOSITORY}"
echo "Branch: ${GITHUB_REF_NAME}"
echo "Commit: ${GITHUB_SHA}"
echo "Author: ${GITHUB_ACTOR}"
echo ""
echo "Infected files (${COUNT}):"
echo "${FILES}"
echo ""
echo "Critical rules: ${RULES:-none}"
echo ""
echo "Run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
echo ""
echo "Do NOT run pnpm install or any build on this checkout."
echo "Rotate every secret this repo's CI can reach."
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Notify Telegram
if: failure() && steps.scan.outputs.infected == 'true'
env:
BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
TEXT: ${{ steps.message.outputs.message }}
run: |
set -uo pipefail
if [ -z "${BOT_TOKEN:-}" ] || [ -z "${CHAT_ID:-}" ]; then
echo "::warning::TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID not set — skipping notification."
exit 0
fi
# No parse_mode: the payload contains characters Telegram's Markdown
# parser would reject, and a failed notification is worse than plain text.
HTTP=$(curl -sS -o /tmp/tg.out -w '%{http_code}' \
-X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
--data-urlencode "chat_id=${CHAT_ID}" \
--data-urlencode "text=${TEXT}" \
--data-urlencode "disable_web_page_preview=true") || true
if [ "${HTTP:-000}" != "200" ]; then
echo "::warning::Telegram notification failed (HTTP ${HTTP:-000}): $(cat /tmp/tg.out 2>/dev/null | head -c 300)"
else
echo "Telegram alert sent."
fi
rm -f /tmp/tg.out
- name: Upload scan report
if: always() && hashFiles('malware-report.json') != ''
uses: actions/upload-artifact@v4
with:
name: malware-report-${{ github.run_id }}
path: malware-report.json
retention-days: 30
- name: Job summary
if: always()
run: |
set -uo pipefail
RESULT="${{ steps.scan.outputs.infected }}"
if [ "$RESULT" = "true" ]; then
{
echo "## 🚨 Malware detected — deployment blocked"
echo ""
echo '```'
jq -r '.results[] | .filePath, (.findings[] | " [\(.id)] \(.severity) — \(.description)")' \
malware-report.json 2>/dev/null | head -100 || true
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
elif [ "$RESULT" = "false" ]; then
echo "## ✅ No malware detected" >> "$GITHUB_STEP_SUMMARY"
else
# The scan step never produced a verdict — treat as inconclusive
# rather than clean, so a broken scanner is never read as a pass.
echo "## ⚠️ Scan did not complete — verdict unknown" >> "$GITHUB_STEP_SUMMARY"
fi

13
.gitignore vendored
View File

@@ -52,6 +52,13 @@ RUNNING_LOCALLY.md
# Generated per-shard compose file for the integration suite (it.mjs). # Generated per-shard compose file for the integration suite (it.mjs).
integration/.it-shards.yaml integration/.it-shards.yaml
branch_structure.json
temp_auto_push.bat # private keys / certificates (EIMS INSA credentials and anything like them) — never commit
temp_interactive_push.bat *.key
*.pem
*.pem.txt
*.p12
*.pfx
*.crt
secrets/
certs/

View File

@@ -76,6 +76,12 @@ SEED_EDR_ORG=true
SEED_FREIGHT_STAFF=true SEED_FREIGHT_STAFF=true
SEED_EXPORT_DJIBOUTI_INTERCHANGE_DEMO=false SEED_EXPORT_DJIBOUTI_INTERCHANGE_DEMO=false
# Limits GET /staff/users to employees of this IAM organization (iam.organizations.key).
# Unset = every employee. A key matching no organization returns no users.
# Dev seed key: edr_freight
# Production: ETHIO_DJIBOUTI_STANDARD_GAUGE_RAILWAY_SHARE_COMPANY_001
FREIGHT_ORG_KEY=edr_freight
# MinIO (used by @tria-plc/iamapi-common for file storage) # MinIO (used by @tria-plc/iamapi-common for file storage)
MINIO_ENDPOINT=localhost MINIO_ENDPOINT=localhost
MINIO_PORT=9000 MINIO_PORT=9000
@@ -122,3 +128,79 @@ FAYDA_SESSION_TTL_MINUTES=10
EXPIRATION_TIME=15 EXPIRATION_TIME=15
ALGORITHM=RS256 ALGORITHM=RS256
EMAIL_QUEUE=email_queue EMAIL_QUEUE=email_queue
# Shared secret for service-to-service calls (payment microservice <-> freight).
# Required at boot; set ALLOW_UNAUTH_INTERNAL=true instead ONLY for local dev.
SERVICE_AUTH_TOKEN=change-me
# ── MoR EIMS e-invoicing (core.mor.gov.et) ─────────────────────────────────
# Disabled by default; every EIMS call fails fast with EIMS_NOT_CONFIGURED until enabled.
EIMS_ENABLED=false
EIMS_BASE_URL=https://core.mor.gov.et
EIMS_CLIENT_ID=
EIMS_CLIENT_SECRET=
EIMS_API_KEY=
EIMS_TIN=
# Source-system identity comes from the access token's systemNumber/systemType claims.
# Setting these turns them into expected-value checks: a mismatch against the token fails
# fast rather than one side silently winning. Leave empty to take the gateway's word.
EIMS_SYSTEM_NUMBER=
EIMS_SYSTEM_TYPE=
# Absolute paths to the INSA-issued credentials. Keep them OUTSIDE the repo; the file
# patterns are gitignored, but a path outside the working tree is safer still.
# The certificate is transmitted as base64 of this file's exact bytes — do not convert it.
EIMS_PRIVATE_KEY_PATH=
EIMS_CERTIFICATE_PATH=
# Optional tuning
EIMS_HTTP_TIMEOUT_MS=30000
EIMS_TOKEN_SKEW_SECONDS=45
# ── EIMS invoice registration (required only to register invoices) ─────────
# Seller identity: EDR's own legal details are not modelled anywhere in the DB.
# Region and Wereda are MoR *codes* (e.g. 13 / 574), not names.
EIMS_SELLER_LEGAL_NAME=
EIMS_SELLER_VAT_NUMBER=
EIMS_SELLER_PHONE=
EIMS_SELLER_EMAIL=
EIMS_SELLER_REGION=
EIMS_SELLER_WEREDA=
# Optional seller address parts; sent as null when unset.
EIMS_SELLER_CITY=
EIMS_SELLER_SUBCITY=
EIMS_SELLER_HOUSE_NUMBER=
EIMS_SELLER_LOCALITY=
# Tax treatment — REQUIRES FINANCE SIGN-OFF. The application models no tax at all
# (invoice.taxAmount is always 0), so nothing here is defaulted: registration fails
# locally, naming the missing variables, until these are set.
# Required, and deliberately unset: the choice is a tax position, not a default.
# MoR's enum (from its own 400): TOT10 TOT2 VAT15 VWHT TWHT VATEX VATWH WHOP2 WTHOI VAT0 VWTH
# Pending finance confirmation of VAT0 (zero-rated) vs VATEX (exempt).
EIMS_TAX_CODE=
EIMS_TAX_RATE_PERCENT=0
EIMS_EXCISE_TAX_VALUE=0
EIMS_INCOME_WITHHOLD_VALUE=0
EIMS_TRANSACTION_WITHHOLD_VALUE=0
# Document classification and payment presentation.
EIMS_TRANSACTION_TYPE=B2B
# Lowercase constant: MoR's oneOf branches require exactly 'goods' or 'service'.
EIMS_NATURE_OF_SUPPLIES=service
EIMS_PAYMENT_MODE=CASH
EIMS_PAYMENT_TERM=IMMIDIATE
EIMS_UNIT_DEFAULT=PCS
# MoR numeric country code for the buyer; our companies store the country name.
EIMS_BUYER_COUNTRY_CODE=
# Buyer region name -> MoR numeric code. companies.region holds names; MoR wants ^[0-9]{1,3}$.
# An unmapped region fails locally rather than being filed with a guess.
EIMS_BUYER_REGION_CODES=Addis Ababa=13
# Same mechanism for Wereda. MoR has never named a Wereda regex in an error (only Region's is
# confirmed), so this is precautionary — but an unmapped name still fails locally, not filed as a guess.
EIMS_BUYER_WEREDA_CODES=
EIMS_CASHIER_NAME=
EIMS_SALESPERSON_NAME=
# Automatic filing of issued invoices (@Cron sweep, one invoice per tick).
# Independent of EIMS_ENABLED on purpose: authentication can be live long before
# filing is. Both must be true before anything is submitted automatically.
EIMS_AUTO_SUBMIT=false
EIMS_AUTO_SUBMIT_CRON=0 */5 * * * *
# MoR rejects documents older than 3 days; the sweep will not attempt those.
EIMS_AUTO_SUBMIT_MAX_AGE_DAYS=3

View File

@@ -37,7 +37,8 @@
"iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert", "iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert",
"iam:migration:show": "pnpm run iam:typeorm:cli migration:show", "iam:migration:show": "pnpm run iam:typeorm:cli migration:show",
"migration:run": "nest build && node dist/scripts/migrate.js", "migration:run": "nest build && node dist/scripts/migrate.js",
"script": "ts-node -r tsconfig-paths/register src/scripts/main.ts" "script": "ts-node -r tsconfig-paths/register src/scripts/main.ts",
"eims:login": "ts-node -r tsconfig-paths/register src/scripts/eims-login.ts"
}, },
"dependencies": { "dependencies": {
"@edr/api-common": "workspace:*", "@edr/api-common": "workspace:*",

View File

@@ -23,6 +23,7 @@ import databaseConfig from "./config/database.config";
import telebirrConfig from "./config/telebirr.config"; import telebirrConfig from "./config/telebirr.config";
import rabbitmqConfig from "./config/rabbitmq.config"; import rabbitmqConfig from "./config/rabbitmq.config";
import faydaConfig from "./config/fayda.config"; import faydaConfig from "./config/fayda.config";
import eimsConfig from "./config/eims.config";
import { BookingsModule } from "./modules/bookings/bookings.module"; import { BookingsModule } from "./modules/bookings/bookings.module";
import { ContractsModule } from "./modules/contracts/contracts.module"; import { ContractsModule } from "./modules/contracts/contracts.module";
@@ -49,11 +50,11 @@ import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-up
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module"; import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
import { ExchangeSettingsModule } from "./modules/exchange-settings/exchange-settings.module"; import { ExchangeSettingsModule } from "./modules/exchange-settings/exchange-settings.module";
import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module"; import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module";
import { SupportContentModule } from "./modules/support-content/support-content.module";
import { OtpModule } from "./modules/otp/otp.module"; import { OtpModule } from "./modules/otp/otp.module";
import { HealthModule } from "./modules/health/health.module"; import { HealthModule } from "./modules/health/health.module";
import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module"; import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module";
import { BackofficeModule } from "./modules/backoffice/backoffice.module"; import { BackofficeModule } from "./modules/backoffice/backoffice.module";
import { DemoPermissionsModule } from "./modules/demo-permissions/demo-permissions.module";
import { FreightAuthModule } from "./modules/auth/freight-auth.module"; import { FreightAuthModule } from "./modules/auth/freight-auth.module";
import { import {
EDR_FREIGHT_APPLICATION, EDR_FREIGHT_APPLICATION,
@@ -67,6 +68,7 @@ import { FreightPositionsSeeder } from "./seed/freight-positions.seeder";
import { PaymentModule } from "./modules/payment/payment.module"; import { PaymentModule } from "./modules/payment/payment.module";
// import { PricingDataSeeder } from "./seed/pricing-data.seeder"; // import { PricingDataSeeder } from "./seed/pricing-data.seeder";
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder"; import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
import { SupportContentSeeder } from "./seed/support-content.seeder";
// import { YardFacilitiesSeeder } from "./seed/yard-facilities.seeder"; // import { YardFacilitiesSeeder } from "./seed/yard-facilities.seeder";
// import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder"; // import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder";
// import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder"; // import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder";
@@ -77,13 +79,15 @@ import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
// import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder"; // import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder";
// import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder"; // import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder";
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder"; import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
import { FreightNotificationPermissionsSeeder } from "./seed/freight-notification-permissions.seeder";
// import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder"; // import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
// import { GovCompaniesSeeder } from "./seed/gov-companies.seeder"; // import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder"; // import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-demo.seeder"; // import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-demo.seeder";
//New Trains, Wagons, Container and Cargo management modules //New Trains, Wagons, Container and Cargo management modules
import { TrainsModule } from "./modules/trains/trains.module"; import { TrainsModule } from "./modules/trains/trains.module";
import { VerifaydaModule } from "./modules/verifayda/verifayda.module"; import { VerifaydaModule } from "./modules/verifayda/verifayda.module";
import { EimsModule } from "./modules/eims/eims.module";
import { FleetHistoryModule } from "./modules/fleet-history/fleet-history.module"; import { FleetHistoryModule } from "./modules/fleet-history/fleet-history.module";
import { WagonsModule } from "./modules/wagons/wagons.module"; import { WagonsModule } from "./modules/wagons/wagons.module";
import { ContainersModule } from "./modules/container-management/containers.module"; import { ContainersModule } from "./modules/container-management/containers.module";
@@ -100,6 +104,7 @@ import { MaintenanceModule } from "./modules/maintenance/maintenance.module";
import { ComplianceModule } from "./modules/compliance/compliance.module"; import { ComplianceModule } from "./modules/compliance/compliance.module";
import { IncidentsModule } from "./modules/incidents/incidents.module"; import { IncidentsModule } from "./modules/incidents/incidents.module";
import { ProcurementModule } from "./modules/procurement/procurement.module"; import { ProcurementModule } from "./modules/procurement/procurement.module";
import { FacilitiesModule } from "./modules/facilities/facilities.module";
import { GpsTrackingModule } from "./modules/gps-tracking/gps-tracking.module"; import { GpsTrackingModule } from "./modules/gps-tracking/gps-tracking.module";
import { FirstMileModule } from "./modules/first-mile/first-mile.module"; import { FirstMileModule } from "./modules/first-mile/first-mile.module";
import { LastMileModule } from "./modules/last-mile/last-mile.module"; import { LastMileModule } from "./modules/last-mile/last-mile.module";
@@ -110,6 +115,7 @@ import { AiModule } from "./modules/ai/ai.module";
import { AuditModule } from "./modules/audit/audit.module"; import { AuditModule } from "./modules/audit/audit.module";
import { LoggerMiddleware } from "./logger.middleware"; import { LoggerMiddleware } from "./logger.middleware";
import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware"; import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware";
import { PositionTypePermissionsCache } from "./common/position-type-permissions.cache";
if (!process.env.APPLICATION_NAME) { if (!process.env.APPLICATION_NAME) {
process.env.APPLICATION_NAME = "freight"; process.env.APPLICATION_NAME = "freight";
@@ -125,6 +131,7 @@ if (!process.env.APPLICATION_NAME) {
telebirrConfig, telebirrConfig,
rabbitmqConfig, rabbitmqConfig,
faydaConfig, faydaConfig,
eimsConfig,
], ],
}), }),
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
@@ -215,11 +222,11 @@ if (!process.env.APPLICATION_NAME) {
DropdownSettingsModule, DropdownSettingsModule,
ExchangeSettingsModule, ExchangeSettingsModule,
ContractTemplatesModule, ContractTemplatesModule,
SupportContentModule,
OtpModule, OtpModule,
HealthModule, HealthModule,
RuleEngineModule, RuleEngineModule,
BackofficeModule, BackofficeModule,
DemoPermissionsModule,
FreightAuthModule, FreightAuthModule,
PaymentModule, PaymentModule,
//New Modules //New Modules
@@ -239,6 +246,7 @@ if (!process.env.APPLICATION_NAME) {
ComplianceModule, ComplianceModule,
IncidentsModule, IncidentsModule,
ProcurementModule, ProcurementModule,
FacilitiesModule,
GpsTrackingModule, GpsTrackingModule,
FirstMileModule, FirstMileModule,
LastMileModule, LastMileModule,
@@ -246,6 +254,7 @@ if (!process.env.APPLICATION_NAME) {
InterchangeDocumentsModule, InterchangeDocumentsModule,
ImportOperationsModule, ImportOperationsModule,
VerifaydaModule, VerifaydaModule,
EimsModule,
FleetHistoryModule, FleetHistoryModule,
AiModule, AiModule,
AuditModule, AuditModule,
@@ -254,8 +263,10 @@ if (!process.env.APPLICATION_NAME) {
EdrOrgSeeder, EdrOrgSeeder,
FreightPositionsSeeder, FreightPositionsSeeder,
FileUploadSettingsSeeder, FileUploadSettingsSeeder,
SupportContentSeeder,
// YardFacilitiesSeeder, // YardFacilitiesSeeder,
FreightPermissionKeyMigrationSeeder, FreightPermissionKeyMigrationSeeder,
FreightNotificationPermissionsSeeder,
// Disabled seeds — providers commented out (imports/injection/run too): // Disabled seeds — providers commented out (imports/injection/run too):
// DemoUsersSeeder, // DemoUsersSeeder,
// FreightStaffUsersSeeder, // FreightStaffUsersSeeder,
@@ -270,9 +281,12 @@ if (!process.env.APPLICATION_NAME) {
// WarehouseDemoSeeder, // WarehouseDemoSeeder,
// ExportDjiboutiInterchangeDemoSeeder, // ExportDjiboutiInterchangeDemoSeeder,
// MarshallingDemoTrainsSeeder, // MarshallingDemoTrainsSeeder,
ApprovedFirstLastMileDemoBookingsSeeder, // ApprovedFirstLastMileDemoBookingsSeeder,
PaidImportExportMileDemoSeeder, // PaidImportExportMileDemoSeeder,
LoginAudienceMiddleware, LoginAudienceMiddleware,
// Feeds position-TYPE grants to the synchronous permission checks — without
// it, staff whose permissions live on their position type resolve to none.
PositionTypePermissionsCache,
], ],
}) })
export class AppModule implements OnApplicationBootstrap { export class AppModule implements OnApplicationBootstrap {
@@ -281,8 +295,10 @@ export class AppModule implements OnApplicationBootstrap {
private readonly edrOrgSeeder: EdrOrgSeeder, private readonly edrOrgSeeder: EdrOrgSeeder,
private readonly freightPositionsSeeder: FreightPositionsSeeder, private readonly freightPositionsSeeder: FreightPositionsSeeder,
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder, private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
private readonly supportContentSeeder: SupportContentSeeder,
// private readonly yardFacilitiesSeeder: YardFacilitiesSeeder, // private readonly yardFacilitiesSeeder: YardFacilitiesSeeder,
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder, private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
private readonly freightNotificationPermissionsSeeder: FreightNotificationPermissionsSeeder,
// Disabled seeds — injections commented out (imports/provider/run too): // Disabled seeds — injections commented out (imports/provider/run too):
// private readonly demoUsersSeeder: DemoUsersSeeder, // private readonly demoUsersSeeder: DemoUsersSeeder,
// private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder, // private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
@@ -324,10 +340,24 @@ export class AppModule implements OnApplicationBootstrap {
await this.edrOrgSeeder.run(); await this.edrOrgSeeder.run();
await this.iamBaselineSeeder.run(); await this.iamBaselineSeeder.run();
await this.freightPositionsSeeder.run(); await this.freightPositionsSeeder.run();
// freightNotificationPermissions → seeds the <module>:get_notification
// keys and backfills them onto whoever
// already holds each desk's anchor
// permission. Runs LAST in this block so
// it sees a freshly-seeded catalog and
// freshly-seeded positions. Unlike the
// seeders above it is NOT gated behind
// SEED_EDR_ORG — without it every staff
// notification resolves to no one.
await this.freightNotificationPermissionsSeeder.run();
// File upload settings — keep enabled. // File upload settings — keep enabled.
await this.fileUploadSettingsSeeder.run(); await this.fileUploadSettingsSeeder.run();
// Portal help/FAQ/legal copy — keep enabled. Idempotent by emptiness, so
// it fills an empty table once and never touches admin edits afterwards.
await this.supportContentSeeder.run();
// Flags which yards can load/unload cargo (Indode, Sebeta, Modjo, Adama, // Flags which yards can load/unload cargo (Indode, Sebeta, Modjo, Adama,
// Dire Dawa). Idempotent; creates no yards. // Dire Dawa). Idempotent; creates no yards.
// await this.yardFacilitiesSeeder.run(); // await this.yardFacilitiesSeeder.run();

View File

@@ -1,7 +1,11 @@
import { applyDecorators, UseGuards } from '@nestjs/common'; import { applyDecorators, UseGuards } from '@nestjs/common';
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import { FreightPermissionGuard } from './freight-permission.guard'; import {
FreightPermissionGuard,
MixedAudienceGuard,
PortalCustomerGuard,
} from './freight-permission.guard';
import { FREIGHT_PERMS } from '../seed/freight-permissions.registry'; import { FREIGHT_PERMS } from '../seed/freight-permissions.registry';
export const BookingStaff = (permission: string | string[]) => export const BookingStaff = (permission: string | string[]) =>
@@ -18,8 +22,30 @@ export const BookingStaff = (permission: string | string[]) =>
* Read-only reference data (yard dropdowns, search filters): any signed-in * Read-only reference data (yard dropdowns, search filters): any signed-in
* staff. Menu/page visibility stays permission-gated in the frontend — this * staff. Menu/page visibility stays permission-gated in the frontend — this
* only lets forms populate their lookups. * only lets forms populate their lookups.
* Deprecated for new routes — it never checked the caller was staff. Prefer
* BookingStaff(<view key>) or MixedAudience(); kept for routes not yet swept.
*/ */
export const StaffReference = () => applyDecorators(UseGuards(JwtGuard)); export const StaffReference = () =>
applyDecorators(UseGuards(JwtGuard, FreightPermissionGuard([])));
/** Portal routes: customer accounts only; ownership scoping stays in services. */
export const PortalCustomer = () =>
applyDecorators(UseGuards(JwtGuard, PortalCustomerGuard));
/**
* Routes both audiences call (sign, shared document reads, handover): staff
* need one of the given permissions, customers pass through to the service's
* ownership checks.
*/
export const MixedAudience = (permission: string | string[]) =>
applyDecorators(
UseGuards(
JwtGuard,
MixedAudienceGuard(
Array.isArray(permission) ? permission : [permission],
),
),
);
export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view); export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view);
@@ -31,6 +57,18 @@ export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view);
export const BookingDocReviewAlert = () => export const BookingDocReviewAlert = () =>
BookingStaff(FREIGHT_PERMS.bookings.docReviewAlert); BookingStaff(FREIGHT_PERMS.bookings.docReviewAlert);
/** Staff wagon-cancellation history list (admin side). */
export const WagonCancellationView = () =>
BookingStaff(FREIGHT_PERMS.bookings.wagonCancellationView);
/** Staff void of a customer's pending (fee-unpaid) wagon cancellation. */
export const WagonCancellationVoid = () =>
BookingStaff(FREIGHT_PERMS.bookings.wagonCancellationVoid);
/** Staff rebook of a customer's wagon-cancellation credit on their behalf. */
export const WagonCancellationRebook = () =>
BookingStaff(FREIGHT_PERMS.bookings.wagonCancellationRebook);
export const TrainSchedulingView = () => export const TrainSchedulingView = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.view); BookingStaff(FREIGHT_PERMS.trainScheduling.view);
@@ -57,9 +95,14 @@ export const TrainSchedulingRulesManage = () =>
* wagons:delete, …). The legacy coarse fleet:view / fleet:manage keys remain * wagons:delete, …). The legacy coarse fleet:view / fleet:manage keys remain
* valid as a one-of fallback so existing role grants keep working. * valid as a one-of fallback so existing role grants keep working.
*/ */
export const FleetView = (granular?: string) => export const FleetView = (granular?: string | string[]) =>
BookingStaff( BookingStaff(
granular ? [granular, FREIGHT_PERMS.fleet.view] : FREIGHT_PERMS.fleet.view, granular
? [
...(Array.isArray(granular) ? granular : [granular]),
FREIGHT_PERMS.fleet.view,
]
: FREIGHT_PERMS.fleet.view,
); );
export const FleetManage = (granular?: string) => export const FleetManage = (granular?: string) =>

View File

@@ -36,4 +36,27 @@ describe('assertExportReceivedWithGrn', () => {
assertExportReceivedWithGrn(db([]), { id: 'b-1', tradeDirection: 'DOMESTIC' }), assertExportReceivedWithGrn(db([]), { id: 'b-1', tradeDirection: 'DOMESTIC' }),
).resolves.toBeUndefined(); ).resolves.toBeUndefined();
}); });
it('never blocks direct truck-to-train export — that cargo has no GRN by design', async () => {
const source = db([]);
await expect(
assertExportReceivedWithGrn(source, {
id: 'b-1',
tradeDirection: 'EXPORT',
exportHandoverMode: 'DIRECT_TO_TRAIN',
}),
).resolves.toBeUndefined();
// Direct short-circuits before querying — there is no inventory to look for.
expect(source.query as jest.Mock).not.toHaveBeenCalled();
});
it('still gates a warehouse export booking', async () => {
await expect(
assertExportReceivedWithGrn(db([]), {
id: 'b-1',
tradeDirection: 'EXPORT',
exportHandoverMode: 'WAREHOUSE',
}),
).rejects.toBeInstanceOf(BadRequestException);
});
}); });

View File

@@ -5,8 +5,15 @@ import type { DataSource, EntityManager } from 'typeorm';
export interface ExportLoadGateBooking { export interface ExportLoadGateBooking {
id: string; id: string;
tradeDirection?: string | null; tradeDirection?: string | null;
/** 'DIRECT_TO_TRAIN' skips the gate entirely; null/'WAREHOUSE' keeps it. */
exportHandoverMode?: string | null;
} }
/** Direct truck-to-train: the cargo never sees a warehouse, so it never has a GRN. */
export const DIRECT_TO_TRAIN = 'DIRECT_TO_TRAIN';
/** Warehouse-then-train: the existing flow. Also what a null mode means. */
export const WAREHOUSE = 'WAREHOUSE';
/** /**
* Export cargo may not be loaded onto its train until it has physically reached * Export cargo may not be loaded onto its train until it has physically reached
* the warehouse and been issued a GRN — whether it got there by first-mile or by * the warehouse and been issued a GRN — whether it got there by first-mile or by
@@ -21,12 +28,18 @@ export interface ExportLoadGateBooking {
* "Received with a GRN" = an inventory row that has reached the warehouse * "Received with a GRN" = an inventory row that has reached the warehouse
* (RECEIVED or any later stage) and carries a GRN, in the column or the notes * (RECEIVED or any later stage) and carries a GRN, in the column or the notes
* fallback older rows use. * fallback older rows use.
*
* Export has a second, warehouse-free shape: the customer's truck loads straight
* onto the wagon. That cargo is never received and never GRN'd, so a booking
* marked DIRECT_TO_TRAIN is outside this gate by definition — its custody is
* attested by the carriage acceptance sheet instead.
*/ */
export async function assertExportReceivedWithGrn( export async function assertExportReceivedWithGrn(
db: DataSource | EntityManager, db: DataSource | EntityManager,
booking: ExportLoadGateBooking, booking: ExportLoadGateBooking,
): Promise<void> { ): Promise<void> {
if (booking.tradeDirection !== 'EXPORT') return; if (booking.tradeDirection !== 'EXPORT') return;
if (booking.exportHandoverMode === DIRECT_TO_TRAIN) return;
const [row] = await db.query( const [row] = await db.query(
`SELECT 1 `SELECT 1

View File

@@ -8,7 +8,48 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { hasFreightPermission } from './freight-permission.util'; import { hasFreightPermission, isSuperAdmin } from './freight-permission.util';
import { readTwinOf } from '../seed/freight-permissions.registry';
// String literals on purpose (same reasoning as login-audience.middleware.ts):
// the values are wire-format constants from iam.users.user_type, and importing
// the vendored enum couples us to its package layout for no gain.
const CUSTOMER_USER_TYPES = ['individual', 'external_organization'];
const userTypeOf = (user: TCurrentUser): string | undefined =>
(user as { userType?: string }).userType;
/** Staff routes are employee-only; a missing userType (stale session) also fails. */
const isEmployee = (user: TCurrentUser): boolean =>
userTypeOf(user) === 'employee' || isSuperAdmin(user);
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
/**
* Does the caller satisfy a required permission?
*
* Holding the key outright always passes. A required `<module>:view` is ALSO
* satisfied by the weaker `<module>:read` — the key that buys API reads
* without putting the module in the backoffice sidebar — but only on a safe
* HTTP method.
*
* The method restriction is load-bearing, not caution. Nest runs class AND
* method guards, so controllers list every route key on the class gate,
* `:view` among the write keys. Without this check a `:read` holder would
* clear that class gate and then reach any write route that has no method
* gate of its own. Keying on the HTTP verb closes that by construction rather
* than by an audit that goes stale the next time a route is added.
*/
const satisfiedBy = (
user: TCurrentUser,
required: string,
method: string,
): boolean => {
if (hasFreightPermission(user, required)) return true;
if (!SAFE_METHODS.has(method)) return false;
const readTwin = readTwinOf(required);
return Boolean(readTwin && hasFreightPermission(user, readTwin));
};
export function FreightPermissionGuard( export function FreightPermissionGuard(
permissions: string[], permissions: string[],
@@ -16,15 +57,20 @@ export function FreightPermissionGuard(
@Injectable() @Injectable()
class FreightPermissionsGuard implements CanActivate { class FreightPermissionsGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean { canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<{ user?: TCurrentUser }>(); const request = context
.switchToHttp()
.getRequest<{ user?: TCurrentUser; method: string }>();
const user = request.user; const user = request.user;
if (!permissions?.length) return true;
if (!user) { if (!user) {
throw new UnauthorizedException('Authentication required'); throw new UnauthorizedException('Authentication required');
} }
if (!isEmployee(user)) {
throw new ForbiddenException('Staff account required');
}
if (permissions.some((p) => hasFreightPermission(user, p))) { if (!permissions?.length) return true;
if (permissions.some((p) => satisfiedBy(user, p, request.method))) {
return true; return true;
} }
@@ -36,3 +82,59 @@ export function FreightPermissionGuard(
return FreightPermissionsGuard; return FreightPermissionsGuard;
} }
/** Portal routes: customer accounts only (individual / external organization). */
@Injectable()
export class PortalCustomerGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<{ user?: TCurrentUser }>();
const user = request.user;
if (!user) {
throw new UnauthorizedException('Authentication required');
}
if (!CUSTOMER_USER_TYPES.includes(userTypeOf(user) ?? '')) {
throw new ForbiddenException('Customer account required');
}
return true;
}
}
/**
* Routes both audiences legitimately call (contract sign, shared document
* reads, warehouse handover). Staff callers must hold one of the given
* permissions; customer callers pass here and are scoped by the service's
* ownership checks.
*/
export function MixedAudienceGuard(permissions: string[]): Type<CanActivate> {
@Injectable()
class MixedAudiencesGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context
.switchToHttp()
.getRequest<{ user?: TCurrentUser; method: string }>();
const user = request.user;
if (!user) {
throw new UnauthorizedException('Authentication required');
}
if (CUSTOMER_USER_TYPES.includes(userTypeOf(user) ?? '')) {
return true;
}
if (!isEmployee(user)) {
throw new ForbiddenException('Unrecognized account type');
}
if (
!permissions?.length ||
permissions.some((p) => satisfiedBy(user, p, request.method))
) {
return true;
}
throw new ForbiddenException(
`Missing permission. Required one of: ${permissions.join(', ')}`,
);
}
}
return MixedAudiencesGuard;
}

View File

@@ -1,6 +1,9 @@
import { import {
assertCanApproveContractStep, assertCanApproveContractStep,
canEditContractStep, canEditContractStep,
collectPermissionKeys,
hasFreightPermission,
setPositionTypePermissionResolver,
} from './freight-permission.util'; } from './freight-permission.util';
import { FREIGHT_PERMS } from '../seed/freight-permissions.registry'; import { FREIGHT_PERMS } from '../seed/freight-permissions.registry';
@@ -49,3 +52,72 @@ describe('canEditContractStep (strict per-step edit gate)', () => {
); );
}); });
}); });
/**
* The GL lockout regression: positions created through the admin UI keep their
* grants on the position TYPE, and the JWT only ever snapshots DIRECT position
* permissions. Without the type resolver those staff resolved to zero
* permissions, so every gated route rejected them — which is what kept GL
* officers out of their own clearance detail pages.
*/
describe('collectPermissionKeys — position-type grants', () => {
const CLEARANCE = FREIGHT_PERMS.contracts.clearanceReview;
afterEach(() => {
setPositionTypePermissionResolver(() => []);
});
const glOfficer = {
roles: [],
permissions: [],
employee: {
position: {
permissions: [], // admin-created position carries NO direct grants
positionType: { key: 'commercial-global-logistics-(et)-officer' },
},
},
};
it('resolves permissions carried by the position type', () => {
setPositionTypePermissionResolver((key) =>
key === 'commercial-global-logistics-(et)-officer' ? [CLEARANCE] : [],
);
expect(collectPermissionKeys(glOfficer)).toContain(CLEARANCE);
expect(hasFreightPermission(glOfficer, CLEARANCE)).toBe(true);
});
it('handles the array-shaped employee payload too', () => {
setPositionTypePermissionResolver(() => [CLEARANCE]);
const arrayShaped = {
roles: [],
permissions: [],
employee: [
{
positions: [
{ permissions: [], positionType: { key: 'djibouti-gl-officer' } },
],
},
],
};
expect(hasFreightPermission(arrayShaped, CLEARANCE)).toBe(true);
});
it('still rejects when neither the position nor its type grants it', () => {
setPositionTypePermissionResolver(() => []);
expect(hasFreightPermission(glOfficer, CLEARANCE)).toBe(false);
});
it('keeps direct position permissions working with no resolver installed', () => {
const direct = {
roles: [],
permissions: [],
employee: { position: { permissions: [{ key: CLEARANCE }] } },
};
expect(hasFreightPermission(direct, CLEARANCE)).toBe(true);
});
});

View File

@@ -42,12 +42,41 @@ export function isFreightApprovalAdmin(user: MeLikeUser | null | undefined): boo
return isSuperAdmin(user) || isOrganizationAdmin(user); return isSuperAdmin(user) || isOrganizationAdmin(user);
} }
/** Flat permission keys from JWT / session user (roles + position permissions). */ /**
* Permissions carried by a position TYPE rather than the position itself.
*
* The JWT snapshots only DIRECT position permissions, so type-level grants —
* which is where admin-created positions keep theirs — are absent from the
* token entirely. This resolver is installed at startup
* (see `PositionTypePermissionsCache`) so the synchronous permission checks
* below can still see them. Left as a no-op resolver until then, which
* degrades to the old position-only behaviour rather than throwing.
*/
let positionTypePermissionResolver: (positionTypeKey: string) => string[] = () =>
[];
export function setPositionTypePermissionResolver(
resolver: (positionTypeKey: string) => string[],
): void {
positionTypePermissionResolver = resolver;
}
/**
* Flat permission keys from JWT / session user: roles, position permissions,
* and the grants held by each position's TYPE.
*/
export function collectPermissionKeys(user: MeLikeUser | null | undefined): string[] { export function collectPermissionKeys(user: MeLikeUser | null | undefined): string[] {
if (!user) return []; if (!user) return [];
const keys = new Set<string>(); const keys = new Set<string>();
const addTypePermissions = (positionType: PositionTypeLike | null | undefined) => {
if (!positionType?.key) return;
for (const key of positionTypePermissionResolver(positionType.key)) {
keys.add(key);
}
};
for (const p of user.permissions ?? []) { for (const p of user.permissions ?? []) {
if (p.key) keys.add(p.key); if (p.key) keys.add(p.key);
} }
@@ -63,6 +92,7 @@ export function collectPermissionKeys(user: MeLikeUser | null | undefined): stri
for (const p of pos.permissions ?? []) { for (const p of pos.permissions ?? []) {
if (p.key) keys.add(p.key); if (p.key) keys.add(p.key);
} }
addTypePermissions(pos.positionType);
} }
} }
return [...keys]; return [...keys];
@@ -71,6 +101,7 @@ export function collectPermissionKeys(user: MeLikeUser | null | undefined): stri
for (const p of employee.position?.permissions ?? []) { for (const p of employee.position?.permissions ?? []) {
if (p.key) keys.add(p.key); if (p.key) keys.add(p.key);
} }
addTypePermissions(employee.position?.positionType);
for (const delegated of employee.delegatedPositions ?? []) { for (const delegated of employee.delegatedPositions ?? []) {
for (const p of delegated.permissions ?? []) { for (const p of delegated.permissions ?? []) {
if (p.key) keys.add(p.key); if (p.key) keys.add(p.key);

View File

@@ -20,8 +20,12 @@ export class ServiceAuthGuard implements CanActivate {
private warned = false; private warned = false;
constructor() { constructor() {
if (!this.token && process.env.NODE_ENV === "production") { // Fail closed everywhere: a missing secret must never silently open the
throw new Error("SERVICE_AUTH_TOKEN must be set in production"); // internal payment surface. Local dev can opt out explicitly.
if (!this.token && process.env.ALLOW_UNAUTH_INTERNAL !== "true") {
throw new Error(
"SERVICE_AUTH_TOKEN must be set (or ALLOW_UNAUTH_INTERNAL=true for local dev)",
);
} }
} }
@@ -29,7 +33,7 @@ export class ServiceAuthGuard implements CanActivate {
if (!this.token) { if (!this.token) {
if (!this.warned) { if (!this.warned) {
this.logger.warn( this.logger.warn(
"SERVICE_AUTH_TOKEN unset — internal endpoints are UNGUARDED (dev only)", "ALLOW_UNAUTH_INTERNAL=true — internal endpoints are UNGUARDED (dev only)",
); );
this.warned = true; this.warned = true;
} }

View File

@@ -85,6 +85,48 @@ describe('computeLastMileCharge', () => {
expect(charge).toMatchObject({ mode: 'BULK', total: 60 * 26 * 25, currency: 'ETB' }); expect(charge).toMatchObject({ mode: 'BULK', total: 60 * 26 * 25, currency: 'ETB' });
}); });
it('picks the bulk rate whose distance band holds the km (half-open boundary)', () => {
const bulkNear = rate({ rateUnit: 'PER_TON_KM', rateValue: 30, minKm: 0, maxKm: 30 });
const bulkFar = rate({ rateUnit: 'PER_TON_KM', rateValue: 22, minKm: 30, maxKm: null });
const near = computeLastMileCharge({
freightType: 'BULK',
tons: 10,
km: 12,
containers: [],
liveRates: [bulkNear, bulkFar],
});
expect(near).toMatchObject({ mode: 'BULK', total: 10 * 12 * 30 });
const boundary = computeLastMileCharge({
freightType: 'BULK',
tons: 10,
km: 30,
containers: [],
liveRates: [bulkNear, bulkFar],
});
expect(boundary).toMatchObject({ total: 10 * 30 * 22 });
});
it('bulk falls back to the legacy bandless rate when no band holds the km, null when nothing covers it', () => {
const bulkNear = rate({ rateUnit: 'PER_TON_KM', rateValue: 30, minKm: 0, maxKm: 30 });
const fallback = computeLastMileCharge({
freightType: 'BULK',
tons: 10,
km: 50,
containers: [],
liveRates: [bulkNear, bulkRate], // bulkRate has no band
});
expect(fallback).toMatchObject({ total: 10 * 50 * 25 });
expect(
computeLastMileCharge({
freightType: 'BULK',
tons: 10,
km: 50,
containers: [],
liveRates: [bulkNear],
}),
).toBeNull();
});
it('returns null on mixed currencies, unknown km, and uncovered freight types', () => { it('returns null on mixed currencies, unknown km, and uncovered freight types', () => {
const usd40 = rate({ ...band40a, currency: 'USD' }); const usd40 = rate({ ...band40a, currency: 'USD' });
expect( expect(

View File

@@ -30,10 +30,12 @@ const round2 = (n: number): number => Math.round(n * 100) / 100;
/** /**
* Price a last-mile leg off the LIVE rate rules. Pure — pass the live rates in. * Price a last-mile leg off the LIVE rate rules. Pure — pass the live rates in.
* *
* BULK: one PER_TON_KM rate → price = tons × km × rate. * BULK: the PER_TON_KM rate whose distance band holds the km (a legacy
* bandless row — NULL minKm — is the fallback and prices every distance) →
* price = tons × km × rate.
* CONTAINER: per container size, the PER_KM rate whose distance band holds the * CONTAINER: per container size, the PER_KM rate whose distance band holds the
* km (bands are half-open [minKm, maxKm), NULL maxKm = open-ended) → price = * km → price = km × rate × quantity, summed across sizes.
* km × rate × quantity, summed across sizes. * Bands are half-open [minKm, maxKm), NULL maxKm = open-ended.
* *
* Returns null whenever the rules don't fully cover the shipment (no rate, a * Returns null whenever the rules don't fully cover the shipment (no rate, a
* container size without a matching band, mixed currencies, km/tons unknown) — * container size without a matching band, mixed currencies, km/tons unknown) —
@@ -56,7 +58,15 @@ export function computeLastMileCharge(input: {
if (freightType === 'BULK') { if (freightType === 'BULK') {
if (!tons || tons <= 0) return null; if (!tons || tons <= 0) return null;
const rate = candidates.find((r) => r.rateUnit === 'PER_TON_KM'); const bulkRates = candidates.filter((r) => r.rateUnit === 'PER_TON_KM');
const rate =
bulkRates.find(
(r) =>
r.minKm !== null &&
r.minKm !== undefined &&
Number(r.minKm) <= km &&
(r.maxKm === null || r.maxKm === undefined || km < Number(r.maxKm)),
) ?? bulkRates.find((r) => r.minKm === null || r.minKm === undefined);
if (!rate) return null; if (!rate) return null;
const unitRate = Number(rate.rateValue); const unitRate = Number(rate.rateValue);
const amount = round2(tons * km * unitRate); const amount = round2(tons * km * unitRate);

View File

@@ -0,0 +1,83 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { setPositionTypePermissionResolver } from './freight-permission.util';
/**
* Permissions granted to a position TYPE (`iam.position_type_permissions`).
*
* A position type is the platform's notion of a role, and positions created
* through the admin UI carry their grants there rather than on the position
* itself. The JWT only ever snapshots DIRECT position permissions, so those
* grants are invisible to `collectPermissionKeys` — staff on such a position
* resolve to zero permissions and every permission-gated route rejects them.
*
* The permission checks (`hasFreightPermission`, `FreightPermissionGuard`) are
* synchronous and sit on the request path, so the mapping is held in memory and
* refreshed periodically rather than queried per request. The dataset is tiny
* (tens of types, a few hundred rows), so a full reload is cheaper than any
* incremental scheme.
*/
@Injectable()
export class PositionTypePermissionsCache implements OnModuleInit {
private readonly logger = new Logger(PositionTypePermissionsCache.name);
/** position_type key → permission keys. Empty until the first load lands. */
private byPositionTypeKey = new Map<string, string[]>();
// ponytail: fixed 5-min refresh, no invalidation hook. A permission granted
// in the admin UI takes up to one interval to reach the guards. Wire the
// grant mutation to call `refresh()` if that lag ever matters.
private static readonly REFRESH_INTERVAL_MS = 5 * 60 * 1000;
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
async onModuleInit(): Promise<void> {
await this.refresh();
// Hand the lookup to the permission utils, whose checks are synchronous and
// therefore cannot query IAM themselves.
setPositionTypePermissionResolver((positionTypeKey) =>
this.get(positionTypeKey),
);
const timer = setInterval(() => {
void this.refresh();
}, PositionTypePermissionsCache.REFRESH_INTERVAL_MS);
// Never hold the process open for a cache refresh.
timer.unref?.();
}
/** Permission keys for a position-type key ([] when unknown/not loaded). */
get(positionTypeKey: string | undefined | null): string[] {
if (!positionTypeKey) return [];
return this.byPositionTypeKey.get(positionTypeKey) ?? [];
}
/** Reload the whole mapping. Failures keep the previous snapshot in place. */
async refresh(): Promise<void> {
try {
const rows: { position_type_key: string; permission_key: string }[] =
await this.dataSource.query(
`SELECT pt.key AS position_type_key, perm.key AS permission_key
FROM iam.position_type_permissions ptp
JOIN iam.position_types pt ON pt.id = ptp.position_type_id
JOIN iam.permissions perm ON perm.id = ptp.permission_id`,
);
const next = new Map<string, string[]>();
for (const row of rows) {
if (!row.position_type_key || !row.permission_key) continue;
const keys = next.get(row.position_type_key);
if (keys) keys.push(row.permission_key);
else next.set(row.position_type_key, [row.permission_key]);
}
this.byPositionTypeKey = next;
} catch (err) {
// iam schema unreachable — keep serving the previous snapshot rather than
// dropping every type-derived permission and locking staff out.
this.logger.warn(
`Position-type permission refresh failed: ${(err as Error).message}`,
);
}
}
}

View File

@@ -215,8 +215,11 @@ export function buildFreightMigrationDataSourceOptions(): DataSourceOptions {
}; };
} }
export default registerAs("database", (): TypeOrmModuleOptions => ({ export default registerAs(
...buildDataSourceOptions(), "database",
autoLoadEntities: true, (): TypeOrmModuleOptions => ({
migrationsRun: false, ...buildDataSourceOptions(),
})); autoLoadEntities: true,
migrationsRun: false,
}),
);

View File

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

View File

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

View File

@@ -0,0 +1,65 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Partial wagon cancellation with rebooking credit.
*
* One row per cancellation cycle on a PAID booking: the customer asks to drop
* N wagons, pays a per-wagon cancellation fee (rates row
* rate_type = 'CANCELLATION_FEE', rate_unit = 'PER_WAGON'), and the dropped cargo becomes a
* rebookable credit. The credit is redeemed by creating a fresh booking
* through the normal under-contract create path (which re-checks contract
* validity and caps), immediately marked PAID — the freight was already paid
* on the original booking, only the fee is new money.
*
* cancelled_quantities carries what was cut, in the booking's own terms:
* `{ bulkTons }` for bulk, `{ bySize: { "20": 4, "40": 3 } }` for container.
* Container numbers are NOT stored here — they are recovered at rebook time
* from the unit rows the reduction soft-deleted (same hybrid pattern as
* RemainderPlacementService).
*/
export class BookingWagonCancellations3300000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.booking_wagon_cancellations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
booking_id uuid NOT NULL REFERENCES freight.bookings(id),
rebooked_booking_id uuid REFERENCES freight.bookings(id),
wagons_cancelled numeric(6,2) NOT NULL CHECK (wagons_cancelled > 0),
weight_tons numeric(12,3) NOT NULL DEFAULT 0,
cancelled_quantities jsonb NOT NULL,
credit_amount numeric(14,2) NOT NULL DEFAULT 0,
fee_rate_id uuid REFERENCES freight.rates(id),
fee_amount numeric(14,2) NOT NULL CHECK (fee_amount >= 0),
fee_currency varchar(8) NOT NULL DEFAULT 'ETB',
fee_invoice_id uuid REFERENCES freight.invoices(id),
fee_paid_at timestamptz,
status varchar(30) NOT NULL DEFAULT 'FEE_PENDING',
reason text,
requested_by_user_id uuid,
rebooked_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
)
`);
// One open (fee-unpaid) cancellation per booking — closes the double-click
// race without app-level locking.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_open_wagon_cancellation_per_booking
ON freight.booking_wagon_cancellations (booking_id)
WHERE status = 'FEE_PENDING' AND deleted_at IS NULL
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_bwc_booking
ON freight.booking_wagon_cancellations (booking_id)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_bwc_status
ON freight.booking_wagon_cancellations (status)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_wagon_cancellations`);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,91 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Folds each help section's `media[]` array into its markdown body.
*
* Attachments used to hang off the section as a separate list, rendered after
* the text — which meant an author could not put a picture next to the sentence
* it illustrates, and had two different places to manage media. They are now
* embedded with markdown's image syntax, and the renderer picks `<img>` or
* `<video>` from the file extension.
*
* Existing entries are converted rather than dropped: an uploaded object
* becomes `![caption](minio:<key>)`, and a shipped asset path or external URL
* is kept verbatim. Sections are left alone once they have no `media` key, so
* re-running does nothing.
*/
interface LegacyMedia {
src: string;
caption?: string | null;
}
interface LegacySection {
id: string;
heading: string;
body: string;
media?: LegacyMedia[];
}
/** Uploaded objects are stored as keys; the `minio:` ref is signed on read. */
function toMarkdown(item: LegacyMedia): string {
const isStoredObject = !/^(https?:\/\/|\/)/.test(item.src);
const target = isStoredObject ? `minio:${item.src}` : item.src;
return `![${item.caption ?? ""}](${target})`;
}
export class SupportHelpInlineMedia3370000000000 implements MigrationInterface {
name = "SupportHelpInlineMedia3370000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
const rows: {
id: string;
version: number;
payload: { sections?: LegacySection[] } & Record<string, unknown>;
}[] = await queryRunner.query(`
SELECT id, version, payload
FROM freight.support_documents
WHERE slug = 'HELP'
`);
for (const row of rows) {
const sections = row.payload.sections ?? [];
if (!sections.some((section) => section.media !== undefined)) continue;
const payload = {
...row.payload,
sections: sections.map(({ media, ...section }) => {
const embeds = (media ?? []).map(toMarkdown);
return {
...section,
body: [section.body, ...embeds].filter(Boolean).join("\n\n"),
};
}),
};
const version = row.version + 1;
await queryRunner.query(
`UPDATE freight.support_documents
SET payload = $1::jsonb, version = $2, updated_at = now()
WHERE id = $3`,
[JSON.stringify(payload), version, row.id],
);
await queryRunner.query(
`INSERT INTO freight.support_document_versions
(document_id, version, payload, actor_id, note)
VALUES ($1, $2, $3::jsonb, NULL, $4)`,
[
row.id,
version,
JSON.stringify(payload),
"Moved attachments inline into the section text",
],
);
}
}
/** Not reversible — see the note on SupportHelpSections3360000000000. */
public async down(): Promise<void> {
// no-op
}
}

View File

@@ -1,15 +1,13 @@
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common'; import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Public } from '@edr/api-common';
import { BookingStaff } from '../../common/booking-guards';
import { AiBookingRequestDto } from './dto/ai-booking-request.dto'; import { AiBookingRequestDto } from './dto/ai-booking-request.dto';
import { AiBookingResult } from './types/ai-booking-result.type'; import { AiBookingResult } from './types/ai-booking-result.type';
import { MockAiService } from './mock-ai.service'; import { MockAiService } from './mock-ai.service';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
// @Public() — TODO: swap for real guard when this leaves dev/testing. @BookingStaff(FREIGHT_PERMS.bookings.view)
// Safe while public: extracts + validates text only, never creates or
// dispatches anything.
@Public()
@ApiTags('AI Assistant (mock)') @ApiTags('AI Assistant (mock)')
@Controller('ai') @Controller('ai')
export class AiController { export class AiController {

View File

@@ -35,10 +35,57 @@ export class FreightMeService {
} }
} }
/**
* Permissions granted to the position's TYPE (`iam.position_type_permissions`).
* A position type is the platform's notion of a role, and admin-created
* positions carry their grants there rather than on the position itself — but
* the JWT only ever snapshots direct position permissions. Without this, staff
* on such a position resolve to zero permissions and every permission-gated
* route rejects them (this is what locked GL officers out of their clearance
* detail pages). Resolved live from IAM, same as the position type above.
*/
private async lookupPositionTypePermissions(
positionId: string | undefined,
): Promise<string[]> {
if (!positionId) return [];
try {
const rows: { key: string }[] = await this.dataSource.query(
`SELECT DISTINCT perm.key
FROM iam.positions p
JOIN iam.position_type_permissions ptp
ON ptp.position_type_id = p.position_type_id
JOIN iam.permissions perm ON perm.id = ptp.permission_id
WHERE p.id = $1`,
[positionId],
);
return rows.map((r) => r.key).filter(Boolean);
} catch {
return []; // iam schema unreachable — degrade to position-only permissions
}
}
async getEnrichedProfile(user: TCurrentUser) { async getEnrichedProfile(user: TCurrentUser) {
const positionType = await this.lookupPositionType( const positionId = user.employee?.position?.id;
user.employee?.position?.id, const [positionType, positionTypePermissionKeys] = await Promise.all([
this.lookupPositionType(positionId),
this.lookupPositionTypePermissions(positionId),
]);
// Merge the type-level grants into the position's own permission list so
// BOTH consumers see them: `collectPermissionKeys` below, and the
// backoffice's `getPermissionKeys`, which walks this same nested array.
const positionPermissions = [
...(user.employee?.position?.permissions ?? []),
];
const seenPermissionKeys = new Set(
positionPermissions.map((p) => p?.key).filter(Boolean),
); );
for (const key of positionTypePermissionKeys) {
if (!seenPermissionKeys.has(key)) {
seenPermissionKeys.add(key);
positionPermissions.push({ key } as (typeof positionPermissions)[number]);
}
}
const employee = user.employee const employee = user.employee
? [ ? [
@@ -56,7 +103,7 @@ export class FreightMeService {
name: user.employee.position.name, name: user.employee.position.name,
isDelegate: user.employee.position.isDelegate, isDelegate: user.employee.position.isDelegate,
parentPositionId: user.employee.position.parentPositionId, parentPositionId: user.employee.position.parentPositionId,
permissions: user.employee.position.permissions ?? [], permissions: positionPermissions,
positionType, positionType,
}, },
] ]
@@ -65,7 +112,15 @@ export class FreightMeService {
] ]
: []; : [];
const permissionKeys = collectPermissionKeys(user); // `collectPermissionKeys` reads the raw token (position-level only), so
// union the type-level grants in — the backoffice prefers this flat list
// over the nested array and would otherwise still see none of them.
const permissionKeys = [
...new Set([
...collectPermissionKeys(user),
...positionTypePermissionKeys,
]),
];
return { return {
id: user.id, id: user.id,

View File

@@ -3,7 +3,8 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ListUsersQueryDto } from './dto/list-users-query.dto'; import { ListUsersQueryDto } from './dto/list-users-query.dto';
import { ListUsersService } from './list-users.service'; import { ListUsersService } from './list-users.service';
import { StaffReference } from '../../common/booking-guards'; import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
@ApiTags('auth') @ApiTags('auth')
@Controller('staff/users') @Controller('staff/users')
@@ -12,7 +13,7 @@ export class ListUsersController {
constructor(private readonly service: ListUsersService) {} constructor(private readonly service: ListUsersService) {}
@Get() @Get()
@StaffReference() @BookingStaff(FREIGHT_PERMS.staff.users.view)
@ApiOperation({ @ApiOperation({
summary: 'List IAM users (paginated) for backoffice pickers', summary: 'List IAM users (paginated) for backoffice pickers',
}) })

View File

@@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { PaginatedResponse } from '@edr/types'; import { PaginatedResponse } from '@edr/types';
import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity'; import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
@@ -19,6 +20,7 @@ import { paginateQuery } from '../../common/utils/pagination.util';
export class ListUsersService { export class ListUsersService {
constructor( constructor(
@InjectRepository(User) private readonly users: Repository<User>, @InjectRepository(User) private readonly users: Repository<User>,
private readonly config: ConfigService,
) {} ) {}
findAll(query: ListUsersQueryDto): Promise<PaginatedResponse<User>> { findAll(query: ListUsersQueryDto): Promise<PaginatedResponse<User>> {
@@ -40,6 +42,29 @@ export class ListUsersService {
]) ])
.orderBy(`user.${sortBy}`, query.sortOrder ?? 'ASC'); .orderBy(`user.${sortBy}`, query.sortOrder ?? 'ASC');
// Restrict to one IAM organization when configured. The org key differs per
// environment (dev seeds `edr_freight`, production uses the registered
// company key), so this is config rather than a constant. An unset key
// means no restriction; a key matching no organization matches no user —
// failing closed rather than silently widening to every org.
const orgKey = this.config.get<string>('FREIGHT_ORG_KEY');
if (orgKey) {
// EXISTS, not a join: a user with several employee rows would otherwise
// be returned once per row, duplicating them in the list and inflating
// `getManyAndCount`'s total.
qb.andWhere(
`EXISTS (
SELECT 1
FROM iam.employees emp
JOIN iam.organizations org ON org.id = emp.organization_id
WHERE emp.user_id = "user".id
AND org.key = :orgKey
AND org.deleted_at IS NULL
)`,
{ orgKey },
);
}
if (query.userType) { if (query.userType) {
qb.andWhere('user.userType = :userType', { userType: query.userType }); qb.andWhere('user.userType = :userType', { userType: query.userType });
} }

View File

@@ -10,18 +10,19 @@ import {
} from "@nestjs/common"; } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { FreightAdmin } from "../../common/booking-guards"; import { BookingStaff } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { BackofficeService } from "./backoffice.service"; import { BackofficeService } from "./backoffice.service";
import { CreateOrganizationUserDto } from "./dto/create-organization-user.dto"; import { CreateOrganizationUserDto } from "./dto/create-organization-user.dto";
import { UpdateEmployeeUserRolesDto } from "./dto/update-employee-user-roles.dto"; import { UpdateEmployeeUserRolesDto } from "./dto/update-employee-user-roles.dto";
@ApiTags("backoffice") @ApiTags("backoffice")
@Controller("backoffice") @Controller("backoffice")
@FreightAdmin()
export class BackofficeController { export class BackofficeController {
constructor(private readonly backofficeService: BackofficeService) {} constructor(private readonly backofficeService: BackofficeService) {}
@Post("organizations/:orgId/users") @Post("organizations/:orgId/users")
@BookingStaff([FREIGHT_PERMS.staff.employeeRegistration.create, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Create an organization user without assigning positions" }) @ApiOperation({ summary: "Create an organization user without assigning positions" })
createOrganizationUser( createOrganizationUser(
@Param("orgId", ParseUUIDPipe) organizationId: string, @Param("orgId", ParseUUIDPipe) organizationId: string,
@@ -31,6 +32,7 @@ export class BackofficeController {
} }
@Get("organizations/:orgId/employees") @Get("organizations/:orgId/employees")
@BookingStaff([FREIGHT_PERMS.staff.roleAssignment.view, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Get deduplicated organization employees for backoffice" }) @ApiOperation({ summary: "Get deduplicated organization employees for backoffice" })
getOrganizationEmployees( getOrganizationEmployees(
@Param("orgId", ParseUUIDPipe) organizationId: string, @Param("orgId", ParseUUIDPipe) organizationId: string,
@@ -44,6 +46,7 @@ export class BackofficeController {
} }
@Get("organizations/:orgId/employee-users/:userId/roles") @Get("organizations/:orgId/employee-users/:userId/roles")
@BookingStaff([FREIGHT_PERMS.staff.roleAssignment.view, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Get org-scoped roles assigned to an employee user" }) @ApiOperation({ summary: "Get org-scoped roles assigned to an employee user" })
getEmployeeUserRoles( getEmployeeUserRoles(
@Param("orgId", ParseUUIDPipe) organizationId: string, @Param("orgId", ParseUUIDPipe) organizationId: string,
@@ -53,6 +56,7 @@ export class BackofficeController {
} }
@Put("organizations/:orgId/employee-users/:userId/roles") @Put("organizations/:orgId/employee-users/:userId/roles")
@BookingStaff([FREIGHT_PERMS.staff.roleAssignment.replace, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Replace org-scoped roles assigned to an employee user" }) @ApiOperation({ summary: "Replace org-scoped roles assigned to an employee user" })
replaceEmployeeUserRoles( replaceEmployeeUserRoles(
@Param("orgId", ParseUUIDPipe) organizationId: string, @Param("orgId", ParseUUIDPipe) organizationId: string,

View File

@@ -46,7 +46,8 @@ export class BackofficeService {
/** /**
* IAM user ids of every current employee across all organizations — used by * IAM user ids of every current employee across all organizations — used by
* the notification recipients resolver's `allBackoffice` selector. * support chat for staff room membership. Notifications deliberately do NOT
* use this: they target a desk via `getEmployeeUserIdsByPermission`.
*/ */
async getAllCurrentEmployeeUserIds(): Promise<string[]> { async getAllCurrentEmployeeUserIds(): Promise<string[]> {
const employees = await this.employeeRepository.find({ const employees = await this.employeeRepository.find({
@@ -63,24 +64,67 @@ export class BackofficeService {
* IAM user ids of current employees (any org) holding ANY of the given * IAM user ids of current employees (any org) holding ANY of the given
* permission keys — used by the notification recipients resolver's * permission keys — used by the notification recipients resolver's
* `permissionKeys` selector for department/role-scoped targeting. * `permissionKeys` selector for department/role-scoped targeting.
*
* This MUST agree with the request-time guard (`hasFreightPermission`,
* common/freight-permission.util.ts), which counts four grant carriers plus
* the super_admin bypass. Counting fewer silently drops legitimate
* recipients: an earlier version joined only direct position permissions, on
* which `bookings:view` resolved to 2 users — against 21 through position
* TYPES, which is where admin-created positions actually keep their grants.
*
* Raw SQL rather than QueryBuilder because `Position.positionTypePermissions`
* declares its inverse side against PositionType, so a relation join emits
* `ptp.position_type_id = position.id` and silently matches nothing. Same
* approach as FreightMeService's position-type lookups.
*/ */
async getEmployeeUserIdsByPermission( async getEmployeeUserIdsByPermission(
permissionKeys: string[], permissionKeys: string[],
): Promise<string[]> { ): Promise<string[]> {
if (!permissionKeys.length) return []; if (!permissionKeys.length) return [];
const rows: { userId: string | null }[] = await this.employeeRepository const rows: { userId: string }[] = await this.dataSource.query(
.createQueryBuilder("employee") `WITH target AS (SELECT id FROM iam.permissions WHERE key = ANY($1))
.innerJoin("employee.employeePositions", "employeePosition") -- 1. IAM role grants (user_roles -> role_permissions).
.innerJoin("employeePosition.position", "position") SELECT e.user_id AS "userId"
.innerJoin("position.positionPermission", "positionPermission") FROM iam.employees e
.innerJoin("positionPermission.permission", "permission") JOIN iam.user_roles ur ON ur.user_id = e.user_id
.where("employee.isCurrent = :isCurrent", { isCurrent: true }) JOIN iam.role_permissions rp ON rp.role_id = ur.role_id
.andWhere("permission.key IN (:...permissionKeys)", { permissionKeys }) WHERE e.is_current AND e.user_id IS NOT NULL
.select("DISTINCT employee.user_id", "userId") AND rp.permission_id IN (SELECT id FROM target)
.getRawMany(); UNION
return rows -- 2. Direct position grants. A delegate keeps their own position AND
.map((r) => r.userId) -- gains the one they stand in for, so both columns count.
.filter((id): id is string => Boolean(id)); SELECT e.user_id
FROM iam.employees e
JOIN iam.employee_positions ep
ON ep.employee_id = e.id AND ep.is_current
JOIN iam.position_permissions pp
ON pp.position_id IN (ep.position_id, ep.delegatee_position_id)
WHERE e.is_current AND e.user_id IS NOT NULL
AND pp.permission_id IN (SELECT id FROM target)
UNION
-- 3. Position TYPE grants — where admin-created positions keep theirs.
SELECT e.user_id
FROM iam.employees e
JOIN iam.employee_positions ep
ON ep.employee_id = e.id AND ep.is_current
JOIN iam.positions p
ON p.id IN (ep.position_id, ep.delegatee_position_id)
JOIN iam.position_type_permissions ptp
ON ptp.position_type_id = p.position_type_id
WHERE e.is_current AND e.user_id IS NOT NULL
AND ptp.permission_id IN (SELECT id FROM target)
UNION
-- 4. super_admin passes every freight permission check, so mirror that
-- here or admins go blind on desks nobody else has been granted yet.
SELECT e.user_id
FROM iam.employees e
JOIN iam.user_roles ur ON ur.user_id = e.user_id
JOIN iam.roles r ON r.id = ur.role_id
WHERE e.is_current AND e.user_id IS NOT NULL
AND r.key = 'super_admin'`,
[permissionKeys],
);
return rows.map((r) => r.userId);
} }
async createOrganizationUser( async createOrganizationUser(

View File

@@ -1,25 +1,44 @@
import { import {
Body,
Controller, Controller,
Get, Get,
Param, Param,
ParseUUIDPipe, ParseUUIDPipe,
Post,
Query, Query,
Res, Res,
UploadedFile,
UseInterceptors,
} from "@nestjs/common"; } from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; import { FileInterceptor } from "@nestjs/platform-express";
import {
ApiBearerAuth,
ApiConsumes,
ApiOperation,
ApiTags,
} from "@nestjs/swagger";
import type { Response } from "express"; import type { Response } from "express";
import { CurrentUser } from "@edr/api-common"; import { CurrentUser } from "@edr/api-common";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { BookingView } from "../../common/booking-guards"; import { BookingStaff } from "../../common/booking-guards";
import { resolveAuthUserId } from "../../common/resolve-auth-user-id";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { actorLabel } from "../warehouses/current-actor.util";
import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service"; import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service";
import { BillingService } from "./billing.service"; import { BillingService } from "./billing.service";
import { FilterInvoiceDto } from "./dto/filter-invoice.dto"; import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
@ApiTags("billing") @ApiTags("billing")
@Controller("billing") @Controller("billing")
@BookingView() // Class gate lists every key its routes use: Nest runs class AND method
// guards, so a key missing here would deny before the route's own key runs.
@BookingStaff([
FREIGHT_PERMS.invoices.view,
FREIGHT_PERMS.invoices.export,
FREIGHT_PERMS.invoices.confirmOffline,
])
@ApiBearerAuth() @ApiBearerAuth()
export class BillingController { export class BillingController {
constructor( constructor(
@@ -50,7 +69,38 @@ export class BillingController {
return this.billingService.findById(id); return this.billingService.findById(id);
} }
@Get("offline-usd")
@ApiOperation({
summary:
"Finance worklist: USD invoices settled offline by bank transfer, with booking pay-window context",
})
findOfflineUsd(@Query() query: FilterInvoiceDto) {
return this.billingService.findOfflineUsdPaginated(query);
}
@Post("invoices/:id/confirm-offline")
@BookingStaff(FREIGHT_PERMS.invoices.confirmOffline)
@UseInterceptors(FileInterceptor("file"))
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary:
"Finance confirms a USD invoice paid by bank transfer — slip file required, settles the full balance",
})
confirmOffline(
@Param("id", ParseUUIDPipe) id: string,
@UploadedFile() file: Express.Multer.File | undefined,
@Body("reference") reference: string | undefined,
@CurrentUser() user: TCurrentUser,
) {
return this.billingService.confirmOfflinePayment(id, file, {
reference: reference?.trim() || null,
userId: resolveAuthUserId(user),
userName: actorLabel(user) ?? null,
});
}
@Get("invoices/:id/document") @Get("invoices/:id/document")
@BookingStaff(FREIGHT_PERMS.invoices.export)
@ApiOperation({ summary: "Download the sealed invoice PDF" }) @ApiOperation({ summary: "Download the sealed invoice PDF" })
async document(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) { async document(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.billingService.document(id); const { filename, buffer } = await this.billingService.document(id);
@@ -58,6 +108,7 @@ export class BillingController {
} }
@Get("invoices/:id/receipt") @Get("invoices/:id/receipt")
@BookingStaff(FREIGHT_PERMS.invoices.export)
@ApiOperation({ summary: "Download the sealed payment receipt PDF" }) @ApiOperation({ summary: "Download the sealed payment receipt PDF" })
async receipt(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) { async receipt(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.billingService.receipt(id); const { filename, buffer } = await this.billingService.receipt(id);

View File

@@ -13,6 +13,7 @@ import { InvoiceRepository } from "./invoice.repository";
import { InvoiceLineRepository } from "./invoice-line.repository"; import { InvoiceLineRepository } from "./invoice-line.repository";
import { PaymentModule } from "../payment/payment.module"; import { PaymentModule } from "../payment/payment.module";
import { CompaniesModule } from "../companies/companies.module"; import { CompaniesModule } from "../companies/companies.module";
import { FilesModule } from "../files/files.module";
@Module({ @Module({
imports: [ imports: [
@@ -21,6 +22,7 @@ import { CompaniesModule } from "../companies/companies.module";
CompaniesModule, CompaniesModule,
DocumentsModule, DocumentsModule,
UserTradeAccessModule, UserTradeAccessModule,
FilesModule,
], ],
controllers: [BillingController, PortalBillingController, PaymentController], controllers: [BillingController, PortalBillingController, PaymentController],
providers: [BillingService, InvoiceRepository, InvoiceLineRepository], providers: [BillingService, InvoiceRepository, InvoiceLineRepository],

View File

@@ -79,6 +79,7 @@ describe("BillingService.generateInvoice", () => {
{} as never, // payment {} as never, // payment
{} as never, // companies {} as never, // companies
{} as never, // invoiceDocuments {} as never, // invoiceDocuments
{} as never, // files
); );
}); });
@@ -140,6 +141,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
{} as never, // payment {} as never, // payment
{} as never, // companies {} as never, // companies
{} as never, // invoiceDocuments {} as never, // invoiceDocuments
{} as never, // files
); );
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
@@ -193,6 +195,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
{} as never, // payment {} as never, // payment
{} as never, // companies {} as never, // companies
{} as never, // invoiceDocuments {} as never, // invoiceDocuments
{} as never, // files
); );
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
@@ -236,6 +239,7 @@ describe("BillingService.settleByPaymentId", () => {
{} as never, // payment {} as never, // payment
{} as never, // companies {} as never, // companies
{} as never, // invoiceDocuments {} as never, // invoiceDocuments
{} as never, // files
); );
return { service, mg, events }; return { service, mg, events };
} }
@@ -347,6 +351,7 @@ describe("BillingService.recordPayment", () => {
{} as never, // payment {} as never, // payment
{} as never, // companies {} as never, // companies
{} as never, // invoiceDocuments {} as never, // invoiceDocuments
{} as never, // files
); );
return { service, mg, events }; return { service, mg, events };
} }
@@ -462,6 +467,7 @@ describe("BillingService.expirePayable — locked write runs in a transaction",
{} as never, {} as never,
{} as never, {} as never,
{} as never, {} as never,
{} as never,
); );
return { service, defaultManager, txManager, transaction }; return { service, defaultManager, txManager, transaction };
}; };
@@ -533,6 +539,7 @@ describe("BillingService.issuePayable", () => {
{} as never, {} as never,
{} as never, {} as never,
{} as never, {} as never,
{} as never,
); );
return { service, manager }; return { service, manager };
}; };
@@ -622,6 +629,7 @@ describe("BillingService — CAC Bank (OTP debit)", () => {
payment as never, payment as never,
{} as never, {} as never,
{} as never, {} as never,
{} as never,
); );
return { service, repo }; return { service, repo };
}; };
@@ -668,3 +676,67 @@ describe("BillingService — CAC Bank (OTP debit)", () => {
expect(confirmOtp).toHaveBeenCalledWith("intent-1", "123456"); expect(confirmOtp).toHaveBeenCalledWith("intent-1", "123456");
}); });
}); });
describe("BillingService — CBE bill amounts carry cents, never rounded", () => {
// CBE settles to the cent (/cbe/payment gates on amountsMatchToTheCent), so the
// bill must quote the exact balance. Rounding UP overcharged the payer by up to
// a birr; rounding DOWN underpaid while markInvoiceAsPaid still wrote paidAmount
// = totalAmount. payInvoice and billQuery must agree, or /cbe/payment mismatches.
const invoice = {
id: "inv-1",
status: Freight.InvoiceStatus.Pending,
source: Freight.InvoiceSource.Booking,
sourceId: "booking-1",
type: "PREPAID",
invoiceNumber: "INV-20260101-00001",
currency: "ETB",
// .43 — cents that must survive all the way to the bill.
balanceAmount: 12345.43,
totalAmount: 12345.43,
company: { name: "Acme PLC" },
paymentId: null,
dueAt: null,
};
const build = (payment: Record<string, unknown> = {}) => {
const repo = {
findOne: jest.fn().mockResolvedValue(invoice),
update: jest.fn().mockResolvedValue(undefined),
};
const service = new BillingService(
{ getRepository: () => repo } as never,
{} as never,
{} as never,
makeEvents() as never,
payment as never,
{} as never,
{} as never,
{} as never,
);
return { service, repo };
};
it("opens the intent for the exact balance, cents included", async () => {
const initiate = jest.fn().mockResolvedValue({
intentId: "intent-1",
immediateSuccess: false,
response: { intentId: "intent-1", status: "REQUIRES_ACTION" },
});
const { service } = build({ initiate });
await service.payInvoice("inv-1", { method: "CBE_BILL" });
expect(initiate).toHaveBeenCalledWith(
expect.objectContaining({ amountMinor: 12345.43 }),
);
});
it("quotes the same exact amount on bill-query as payInvoice opened", async () => {
const { service } = build();
await expect(service.billQuery("booking-1")).resolves.toMatchObject({
stillPayable: true,
currentAmountMinor: 12345.43,
});
});
});

View File

@@ -12,6 +12,7 @@ import { DataSource, EntityManager, In } from "typeorm";
import { Booking } from "../bookings/entities/booking.entity"; import { Booking } from "../bookings/entities/booking.entity";
import { CompaniesService } from "../companies/companies.service"; import { CompaniesService } from "../companies/companies.service";
import { FilesService } from "../files/files.service";
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util"; import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
import { PaymentService } from "../payment/payment.service"; import { PaymentService } from "../payment/payment.service";
import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto"; import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto";
@@ -35,6 +36,14 @@ export interface PayInvoiceOptions {
failureUrl?: string; failureUrl?: string;
} }
/** Booking context attached to a finance offline-USD invoice row. */
export interface OfflineUsdBookingInfo {
id: string;
reference: string;
paymentDeadline: Date | null;
paymentStatus: string;
}
/** A single manual/offline settlement to record against an invoice. */ /** A single manual/offline settlement to record against an invoice. */
export interface RecordPaymentInput { export interface RecordPaymentInput {
/** Amount settled by this payment; must be > 0. */ /** Amount settled by this payment; must be > 0. */
@@ -150,6 +159,7 @@ export class BillingService {
private readonly payment: PaymentService, private readonly payment: PaymentService,
private readonly companies: CompaniesService, private readonly companies: CompaniesService,
private readonly invoiceDocuments: InvoiceDocumentService, private readonly invoiceDocuments: InvoiceDocumentService,
private readonly files: FilesService,
) { } ) { }
// ── Reads ────────────────────────────────────────────────────────────────── // ── Reads ──────────────────────────────────────────────────────────────────
@@ -214,6 +224,146 @@ export class BillingService {
return { items, total }; return { items, total };
} }
/**
* Finance's offline-settlement worklist: USD invoices (paid by bank transfer,
* never through the gateway), open ones by default or a single status when
* filtered. Booking-sourced rows carry the booking's reference and pay-window
* deadline so the UI can show the countdown and link to the booking.
*/
async findOfflineUsdPaginated(
filter: {
status?: Freight.InvoiceStatus;
search?: string;
page?: number;
pageSize?: number;
} = {},
): Promise<{
items: (Invoice & { booking: OfflineUsdBookingInfo | null })[];
total: number;
}> {
const page = filter.page && filter.page > 0 ? filter.page : 1;
const pageSize =
filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20;
const qb = this.dataSource
.getRepository(Invoice)
.createQueryBuilder("invoice")
.leftJoinAndSelect("invoice.company", "company")
.where("UPPER(invoice.currency) = 'USD'")
.orderBy("invoice.issuedAt", "DESC")
.skip((page - 1) * pageSize)
.take(pageSize);
if (filter.status) {
qb.andWhere("invoice.status = :status", { status: filter.status });
} else {
qb.andWhere("invoice.status IN (:...open)", { open: OPEN_STATUSES });
}
if (filter.search) {
qb.andWhere(
"(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)",
{ search: `%${filter.search}%` },
);
}
const [items, total] = await qb.getManyAndCount();
const bookingIds = items
.filter((i) => i.source === "booking")
.map((i) => i.sourceId);
const bookings = bookingIds.length
? await this.dataSource.getRepository(Booking).find({
where: { id: In(bookingIds) },
select: ["id", "reference", "paymentDeadline", "paymentStatus"],
})
: [];
const byId = new Map(bookings.map((b) => [b.id, b]));
return {
items: items.map((inv) => {
const b = byId.get(inv.sourceId);
return {
...inv,
booking: b
? {
id: b.id,
reference: b.reference,
paymentDeadline: b.paymentDeadline ?? null,
paymentStatus: b.paymentStatus,
}
: null,
} as Invoice & { booking: OfflineUsdBookingInfo | null };
}),
total,
};
}
/**
* Finance confirms a USD invoice as paid by bank transfer: stores the slip
* against the invoice and settles the FULL outstanding balance through
* {@link recordPayment}, which flips the invoice to PAID and (for bookings)
* emits `booking.invoice.paid` — the same event an online payment fires, so
* the booking advances exactly as if it had been paid through the gateway.
*
* Guarded by the booking's pay window: past the deadline the booking expires
* like any unpaid one, so confirmation is refused.
*/
async confirmOfflinePayment(
invoiceId: string,
file: Express.Multer.File | undefined,
input: {
reference?: string | null;
userId?: string | null;
userName?: string | null;
},
): Promise<Invoice> {
const invoice = await this.invoices.findById(invoiceId);
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
if (invoice.currency?.toUpperCase() !== "USD") {
throw new BadRequestException(
"Offline confirmation is only for USD invoices — this invoice is paid online.",
);
}
if (!file) {
throw new BadRequestException("The bank payment slip file is required.");
}
if (invoice.source === "booking") {
const booking = await this.dataSource.getRepository(Booking).findOne({
where: { id: invoice.sourceId },
select: ["id", "paymentDeadline"],
});
const deadline = booking?.paymentDeadline;
if (deadline && new Date(deadline).getTime() < Date.now()) {
throw new BadRequestException(
"The payment window has closed — this booking can no longer be confirmed as paid.",
);
}
}
const slip = await this.files.upload({
resource: "invoice",
resourceId: invoice.id,
code: "OFFLINE_PAYMENT_SLIP",
file,
title: "Bank payment slip",
uploadedByUserId: input.userId ?? null,
uploadedByName: input.userName ?? null,
});
return this.recordPayment(invoiceId, {
amount: Number(invoice.balanceAmount),
method: "BANK_TRANSFER",
reference: input.reference || slip.name,
metadata: {
offline: true,
slipFileId: slip.id,
confirmedByUserId: input.userId ?? null,
confirmedByName: input.userName ?? null,
},
});
}
/** Invoice header plus its line items. */ /** Invoice header plus its line items. */
async findById(id: string): Promise<Invoice & { lines: InvoiceLine[] }> { async findById(id: string): Promise<Invoice & { lines: InvoiceLine[] }> {
const invoice = await this.invoices.findById(id, { const invoice = await this.invoices.findById(id, {
@@ -1191,7 +1341,11 @@ export class BillingService {
// service branches on a domain-specific reference type. // service branches on a domain-specific reference type.
referenceType: PaymentReferenceType.SHIPMENT, referenceType: PaymentReferenceType.SHIPMENT,
orderRef: invoice.invoiceNumber.replace(/-/g, "_"), orderRef: invoice.invoiceNumber.replace(/-/g, "_"),
amountMinor: Math.round(Number(invoice.balanceAmount)), // Exact balance, cents included. CBE bills this verbatim and /cbe/payment
// matches the debited amount to the cent (amountsMatchToTheCent), so any
// rounding here would overcharge the payer and leave the invoice balance
// non-zero. billQuery quotes the same unrounded value.
amountMinor: round2(Number(invoice.balanceAmount)),
currency: invoice.currency, currency: invoice.currency,
reason: `Payment for invoice ${invoice.invoiceNumber}`, reason: `Payment for invoice ${invoice.invoiceNumber}`,
method: opts.method ?? "TELEBIRR", method: opts.method ?? "TELEBIRR",
@@ -1336,7 +1490,10 @@ export class BillingService {
}); });
if (open) { if (open) {
const balance = Math.round(Number(open.balanceAmount ?? open.totalAmount)); // Unrounded, matching payInvoice — the amount CBE quotes at the counter has
// to be the amount the intent was opened for, to the cent, or /cbe/payment
// sees a mismatch.
const balance = round2(Number(open.balanceAmount ?? open.totalAmount));
const expired = open.dueAt && open.dueAt.getTime() < Date.now(); const expired = open.dueAt && open.dueAt.getTime() < Date.now();
return { return {
stillPayable: balance > 0 && !expired, stillPayable: balance > 0 && !expired,
@@ -1371,7 +1528,7 @@ export class BillingService {
return { return {
stillPayable: false, stillPayable: false,
payerName: latest.company?.name ?? null, payerName: latest.company?.name ?? null,
currentAmountMinor: Math.round(Number(latest.totalAmount)), currentAmountMinor: round2(Number(latest.totalAmount)),
currency: latest.currency, currency: latest.currency,
paymentReason: `Freight invoice ${latest.invoiceNumber}`, paymentReason: `Freight invoice ${latest.invoiceNumber}`,
reason: closedInvoiceReason(latest.status), reason: closedInvoiceReason(latest.status),

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import { BaseEntity } from "@edr/api-common"; import { BaseEntity } from "@edr/api-common";
import { Freight } from "@edr/types"; import { Freight } from "@edr/types";
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm"; import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
import type { EimsInvoiceError, EimsInvoiceStatus } from "../../eims/eims-registration.types";
import { PaymentEntity } from "../../payment/entities/payment.entity"; import { PaymentEntity } from "../../payment/entities/payment.entity";
import { Company } from "../../companies/entities/company.entity"; import { Company } from "../../companies/entities/company.entity";
import { CompanyProfile } from "../../companies/entities/company-profile.entity"; import { CompanyProfile } from "../../companies/entities/company-profile.entity";
@@ -105,4 +106,31 @@ export class Invoice extends BaseEntity {
@Column({ name: "due_at", type: "timestamptz" }) @Column({ name: "due_at", type: "timestamptz" })
dueAt!: Date; dueAt!: Date;
/** MoR EIMS registration state. Set only by the EIMS module; billing never writes these. */
@Column({ name: "eims_status", type: "varchar", length: 20, default: "NOT_SUBMITTED" })
eimsStatus!: EimsInvoiceStatus;
/** Invoice Reference Number returned by EIMS. Unique across invoices (partial index). */
@Column({ name: "eims_irn", type: "varchar", length: 64, nullable: true })
eimsIrn?: string | null;
/** The numeric `DocumentDetails.DocumentNumber` filed for this invoice. */
@Column({ name: "eims_document_number", type: "varchar", length: 16, nullable: true })
eimsDocumentNumber?: string | null;
/** The `SourceSystem.InvoiceCounter` this invoice consumed. */
@Column({ name: "eims_invoice_counter", type: "bigint", nullable: true })
eimsInvoiceCounter?: number | null;
@Column({ name: "eims_submitted_at", type: "timestamptz", nullable: true })
eimsSubmittedAt?: Date | null;
/** EIMS acknowledgement timestamp, stored verbatim — it is a Java ZonedDateTime string. */
@Column({ name: "eims_ack_date", type: "varchar", length: 64, nullable: true })
eimsAckDate?: string | null;
/** Sanitized last failure: the gateway's own error fields only, never our signed envelope. */
@Column({ name: "eims_last_error", type: "jsonb", nullable: true })
eimsLastError?: EimsInvoiceError | null;
} }

View File

@@ -12,6 +12,7 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import type { Response } from "express"; import type { Response } from "express";
import { CurrentUser } from "@edr/api-common"; import { CurrentUser } from "@edr/api-common";
import { PortalCustomer } from "../../common/booking-guards";
import { import {
type AuthUserPayload, type AuthUserPayload,
resolveAuthUserId, resolveAuthUserId,
@@ -28,6 +29,7 @@ import { ConfirmOtpDto, PayInvoiceDto } from "./dto/pay-invoice.dto";
@ApiTags("billing") @ApiTags("billing")
@ApiBearerAuth() @ApiBearerAuth()
@Controller("billing") @Controller("billing")
@PortalCustomer()
export class PortalBillingController { export class PortalBillingController {
constructor(private readonly billingService: BillingService) {} constructor(private readonly billingService: BillingService) {}

View File

@@ -20,6 +20,10 @@ import { FirstMileService } from "../first-mile/first-mile.service";
import { BookingBatchService } from "../train-scheduling/booking-batch.service"; import { BookingBatchService } from "../train-scheduling/booking-batch.service";
import { PriceLineItemDto } from "./dto/generate-price-response.dto"; import { PriceLineItemDto } from "./dto/generate-price-response.dto";
import { BookingsRepository } from "./bookings.repository"; import { BookingsRepository } from "./bookings.repository";
import {
BookingWagonCancellationService,
WAGON_CANCEL_FEE_INVOICE_TYPE,
} from "./booking-wagon-cancellation.service";
import { Booking } from "./entities/booking.entity"; import { Booking } from "./entities/booking.entity";
/** Snapshot written onto `booking.pricingBreakdown` by the pricing service. */ /** Snapshot written onto `booking.pricingBreakdown` by the pricing service. */
@@ -58,6 +62,8 @@ export class BookingInvoiceService {
private readonly firstMile: FirstMileService, private readonly firstMile: FirstMileService,
@Inject(forwardRef(() => BookingBatchService)) @Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatch: BookingBatchService, private readonly bookingBatch: BookingBatchService,
@Inject(forwardRef(() => BookingWagonCancellationService))
private readonly wagonCancellations: BookingWagonCancellationService,
) { } ) { }
/** /**
@@ -90,6 +96,21 @@ export class BookingInvoiceService {
return this.billing.generateInvoice(input); return this.billing.generateInvoice(input);
} }
/**
* Cancel the booking's open PREPAID invoice, if any — used when a
* changes-requested resubmit restates the cargo, so the re-priced booking can
* be re-invoiced. Throws when the invoice already has payments recorded
* (cargo must not change out from under recorded money).
*/
async cancelUnpaidInvoiceForBooking(bookingId: string): Promise<void> {
const existing = await this.billing.findPayable(
Freight.InvoiceSource.Booking,
bookingId,
"PREPAID",
);
if (existing) await this.billing.cancelInvoice(existing.id);
}
/** /**
* React to a booking invoice being paid — the settlement branch point. Per-type * React to a booking invoice being paid — the settlement branch point. Per-type
* reactions live here (not in the payment process): each invoice type advances * reactions live here (not in the payment process): each invoice type advances
@@ -108,6 +129,11 @@ export class BookingInvoiceService {
await this.bookingBatch.reviveOfferForInvoice(payload.invoiceId); await this.bookingBatch.reviveOfferForInvoice(payload.invoiceId);
await this.advanceBookingOnPayment(payload.sourceId); await this.advanceBookingOnPayment(payload.sourceId);
break; break;
case WAGON_CANCEL_FEE_INVOICE_TYPE:
// Partial wagon cancellation: the fee settled — reduce the booking and
// release the cancelled wagons (T2 of the cancellation cycle).
await this.wagonCancellations.onFeePaid(payload.invoiceId);
break;
default: default:
this.logger.warn( this.logger.warn(
`Unhandled booking invoice type "${payload.type}" paid (${payload.invoiceId})`, `Unhandled booking invoice type "${payload.type}" paid (${payload.invoiceId})`,

View File

@@ -1,5 +1,6 @@
import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service'; import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service';
import type { Booking } from './entities/booking.entity'; import type { Booking } from './entities/booking.entity';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
/** /**
* Who hears "Operations wants changes" depends on who owns the booking. A * Who hears "Operations wants changes" depends on who owns the booking. A
@@ -74,3 +75,39 @@ describe('BookingLifecycleNotifierService — operation changes requested', () =
expect(inbox.notify.mock.calls[0][0].recipients).toEqual({ companyId: 'co-1' }); expect(inbox.notify.mock.calls[0][0].recipients).toEqual({ companyId: 'co-1' });
}); });
}); });
/**
* Staff notifications used to go to every employee in every organization. They
* now target a desk — and the two desks are disjoint: the GL presets hold no
* bookings:view and no intake keys, so intake pings would be noise they cannot
* act on. Both branches run through the same `inAppStaff` helper, which is the
* easy place to lose the distinction again.
*/
describe('BookingLifecycleNotifierService — staff desk targeting', () => {
const booking = () =>
({ id: 'b-1', reference: 'BKG-0001', companyId: 'co-1' }) as Booking;
let inbox: { notify: jest.Mock };
let service: BookingLifecycleNotifierService;
beforeEach(() => {
inbox = { notify: jest.fn().mockResolvedValue(undefined) };
service = new BookingLifecycleNotifierService(
{ directSend: jest.fn().mockResolvedValue(undefined) } as never,
inbox as never,
{ query: jest.fn().mockResolvedValue([]) } as never,
);
});
it('routes intake items to the booking desk and clearance items to the clearance desk', () => {
service.submittedToStaff(booking());
service.clearanceDocsUploadedToStaff(booking());
expect(inbox.notify.mock.calls[0][0].recipients).toEqual({
permissionKeys: [FREIGHT_PERMS.bookings.getNotification],
});
expect(inbox.notify.mock.calls[1][0].recipients).toEqual({
permissionKeys: [FREIGHT_PERMS.bookings.clearanceGetNotification],
});
});
});

View File

@@ -10,7 +10,17 @@ import {
import { Booking } from './entities/booking.entity'; import { Booking } from './entities/booking.entity';
import { NotificationsService } from '../notifications/notifications.service'; import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util'; import { resolveCompanyNotifyContact } from '../notifications/resolve-company-phone.util';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
/**
* Clearance items are worked by the GL desks, which hold no bookings:view and
* no intake keys — so they take their own selector rather than the booking
* desk's. Every override using this deep-links to a clearance page.
*/
const CLEARANCE_DESK = {
permissionKeys: [FREIGHT_PERMS.bookings.clearanceGetNotification],
};
/** /**
* Customer + staff notifications for the booking lifecycle: review, clearance * Customer + staff notifications for the booking lifecycle: review, clearance
@@ -45,10 +55,13 @@ export class BookingLifecycleNotifierService {
logLabel: string, logLabel: string,
): Promise<void> { ): Promise<void> {
this.logger.log(`${logLabel}${this.ref(b)}`); this.logger.log(`${logLabel}${this.ref(b)}`);
const phone = b.companyId // Both channels come from the same resolver: the company row's own columns
? await resolveCompanyNotifyPhone(this.dataSource, b.companyId) // are only half the story (see companyNotifyEmailExpr), and reading them off
: null; // the loaded entity silently dropped every mail to a company whose address
const email = b.company?.email ?? b.company?.generalManagerEmail ?? null; // lives in `attributes`.
const { phone, email } = b.companyId
? await resolveCompanyNotifyContact(this.dataSource, b.companyId)
: { phone: null, email: null };
if (phone) { if (phone) {
try { try {
@@ -89,7 +102,11 @@ export class BookingLifecycleNotifierService {
}); });
} }
/** Persist + push an in-app item to every backoffice staff user. */ /**
* Persist + push an in-app item to the booking desk — staff holding
* `bookings:get_notification`. Callers whose item belongs to a different desk
* override `recipients` (see {@link CLEARANCE_DESK}).
*/
private inAppStaff( private inAppStaff(
b: Booking, b: Booking,
title: string, title: string,
@@ -97,7 +114,7 @@ export class BookingLifecycleNotifierService {
overrides: Partial<NotifyInput> = {}, overrides: Partial<NotifyInput> = {},
): void { ): void {
void this.inbox.notify({ void this.inbox.notify({
recipients: { allBackoffice: true }, recipients: { permissionKeys: [FREIGHT_PERMS.bookings.getNotification] },
audience: NotificationAudience.BACKOFFICE, audience: NotificationAudience.BACKOFFICE,
type: NotificationType.REQUEST_SUBMITTED, type: NotificationType.REQUEST_SUBMITTED,
title, title,
@@ -274,6 +291,7 @@ export class BookingLifecycleNotifierService {
`the customs declaration can be filed.${note ? ` Note: "${note}"` : ''}`; `the customs declaration can be filed.${note ? ` Note: "${note}"` : ''}`;
this.logger.log(`TRANSIT ASSIGNEE REQUESTED — ${this.ref(b)}`); this.logger.log(`TRANSIT ASSIGNEE REQUESTED — ${this.ref(b)}`);
this.inAppStaff(b, `Transit assignee needed — ${b.reference}`, msg, { this.inAppStaff(b, `Transit assignee needed — ${b.reference}`, msg, {
recipients: CLEARANCE_DESK,
type: NotificationType.CLEARANCE_REVIEW, type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/gl-djibouti/clearance/${b.id}`, link: `/dashboard/gl-djibouti/clearance/${b.id}`,
}); });
@@ -288,6 +306,7 @@ export class BookingLifecycleNotifierService {
`The customs declaration can now be filed.`; `The customs declaration can now be filed.`;
this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${this.ref(b)}`); this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${this.ref(b)}`);
this.inAppStaff(b, `Transit assignee set — ${b.reference}`, msg, { this.inAppStaff(b, `Transit assignee set — ${b.reference}`, msg, {
recipients: CLEARANCE_DESK,
type: NotificationType.CLEARANCE_REVIEW, type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/bookings/${b.id}/clearance`, link: `/dashboard/bookings/${b.id}/clearance`,
}); });
@@ -395,6 +414,7 @@ export class BookingLifecycleNotifierService {
'Clearance documents uploaded', 'Clearance documents uploaded',
`Customer uploaded clearance documents for booking ${this.ref(b)} — review them in the clearance queue.`, `Customer uploaded clearance documents for booking ${this.ref(b)} — review them in the clearance queue.`,
{ {
recipients: CLEARANCE_DESK,
type: NotificationType.CLEARANCE_REVIEW, type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/bookings/${b.id}/clearance`, link: `/dashboard/bookings/${b.id}/clearance`,
}, },
@@ -422,6 +442,7 @@ export class BookingLifecycleNotifierService {
`The customer requested a change to the draft declaration on booking ${this.ref(b)}: ` + `The customer requested a change to the draft declaration on booking ${this.ref(b)}: ` +
`"${note}". Send a corrected draft from the clearance page.`; `"${note}". Send a corrected draft from the clearance page.`;
this.inAppStaff(b, `Draft declaration change requested — ${this.ref(b)}`, msg, { this.inAppStaff(b, `Draft declaration change requested — ${this.ref(b)}`, msg, {
recipients: CLEARANCE_DESK,
type: NotificationType.CLEARANCE_REVIEW, type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/bookings/${b.id}/clearance`, link: `/dashboard/bookings/${b.id}/clearance`,
}); });
@@ -440,6 +461,7 @@ export class BookingLifecycleNotifierService {
'Payment slip uploaded', 'Payment slip uploaded',
`Customer uploaded the ${label} payment slip for booking ${this.ref(b)}.`, `Customer uploaded the ${label} payment slip for booking ${this.ref(b)}.`,
{ {
recipients: CLEARANCE_DESK,
type: NotificationType.PAYMENT_RECEIVED, type: NotificationType.PAYMENT_RECEIVED,
link: `/dashboard/bookings/${b.id}/clearance`, link: `/dashboard/bookings/${b.id}/clearance`,
}, },

View File

@@ -279,7 +279,10 @@ export class BookingPricingService {
return { return {
lineItems, lineItems,
totalAmount: total, // Grand total keeps its cents, matching the line items it sums — rounding
// to whole birr made the total disagree with the breakdown (135,375.61 of
// lines shown as a 135,376.00 total) and CBE bills this figure to the cent.
totalAmount: round2(total),
currency: booking.paymentCurrency, currency: booking.paymentCurrency,
usedRates: [...usedRatesMap.values()], usedRates: [...usedRatesMap.values()],
appliedModifiers: ruleResult.appliedModifiers, appliedModifiers: ruleResult.appliedModifiers,

View File

@@ -8,6 +8,9 @@ import {
Optional, Optional,
} from "@nestjs/common"; } from "@nestjs/common";
import { EventEmitter2, OnEvent } from "@nestjs/event-emitter"; import { EventEmitter2, OnEvent } from "@nestjs/event-emitter";
import { DataSource } from "typeorm";
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { import {
BookingBatchService, BookingBatchService,
@@ -56,11 +59,18 @@ export class BookingTransitionService {
private readonly bookingClearanceService: BookingClearanceService, private readonly bookingClearanceService: BookingClearanceService,
@Inject(forwardRef(() => ClearanceWorkflowService)) @Inject(forwardRef(() => ClearanceWorkflowService))
private readonly workflowService: ClearanceWorkflowService, private readonly workflowService: ClearanceWorkflowService,
private readonly invoiceService: BookingInvoiceService, // forwardRef: booking-invoice.service now pulls in the wagon-cancellation
// service, whose cross-module imports close a require cycle through this
// file — without it the class is undefined at decorator time.
@Inject(forwardRef(() => BookingInvoiceService))
private readonly invoiceService: BookingInvoiceService,
private readonly containerValidationService: ContainerValidationService, private readonly containerValidationService: ContainerValidationService,
private readonly notifier: BookingLifecycleNotifierService, private readonly notifier: BookingLifecycleNotifierService,
private readonly events: EventEmitter2, private readonly events: EventEmitter2,
@Optional() private readonly milestoneService?: ClearanceMilestoneService, @Optional() private readonly milestoneService?: ClearanceMilestoneService,
// Optional + last so the hand-constructed service in *.spec.ts files keeps
// compiling; Nest injects it normally at runtime.
@Optional() private readonly dataSource?: DataSource,
) {} ) {}
private isPhasedCustoms(booking: Booking): boolean { private isPhasedCustoms(booking: Booking): boolean {
@@ -429,6 +439,20 @@ export class BookingTransitionService {
return fresh; return fresh;
} }
/**
* Customer self-service cancel, allowed only before payment — no fee.
* SELECTED_FOR_BATCH releases the wagon hold immediately; earlier statuses
* take the plain cancel path (open invoices expired, nothing reserved yet).
* Anything past payment falls through to cancel()'s status assertion.
*/
async customerCancel(bookingId: string, reason?: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
if (booking.status === "SELECTED_FOR_BATCH") {
return this.cancelHold(bookingId, reason);
}
return this.cancel(bookingId, reason ?? "Customer cancelled before payment");
}
async cancel(bookingId: string, reason: string): Promise<Booking> { async cancel(bookingId: string, reason: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId); const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [ assertBookingStatus(booking, [
@@ -978,6 +1002,15 @@ export class BookingTransitionService {
// booking through the space checks below AND is persisted so the accept / // booking through the space checks below AND is persisted so the accept /
// reserve path locks onto that train (pickExportSchedule honors it). // reserve path locks onto that train (pickExportSchedule honors it).
const requestedId = isExportTrain ? (requestedTrainScheduleId ?? null) : null; const requestedId = isExportTrain ? (requestedTrainScheduleId ?? null) : null;
// Export rail rides the exact train the customer picked — never an
// auto-assigned one. Both portal flows (clearance + contract completion)
// surface a picker, so a missing id is an invalid submission, not a
// legitimate "let the system choose".
if (isExportTrain && !requestedId) {
throw new BadRequestException(
"Select a train for the chosen shipment day.",
);
}
const scheduledBooking = { const scheduledBooking = {
...booking, ...booking,
scheduledDate: date, scheduledDate: date,
@@ -1244,6 +1277,12 @@ export class BookingTransitionService {
/** Flat list of physical container numbers on this booking (for the /** Flat list of physical container numbers on this booking (for the
* customer truck-assignment container picker). */ * customer truck-assignment container picker). */
containerNumbers: string[]; containerNumbers: string[];
/** The allocated train, when the booking is placed on a schedule. */
trainSchedule?: {
trainNumber: string | null;
reference: string | null;
scheduledDepartureDate: Date | null;
} | null;
} }
> { > {
// This enrichment runs AFTER the transition has committed. A failure here // This enrichment runs AFTER the transition has committed. A failure here
@@ -1296,6 +1335,32 @@ export class BookingTransitionService {
`enrichBookingResponse: batch-offer lookup failed for ${booking.id}: ${(err as Error).message}`, `enrichBookingResponse: batch-offer lookup failed for ${booking.id}: ${(err as Error).message}`,
); );
} }
// Allocated train: number + schedule reference for the detail headers
// (portal and backoffice). Degrades to null like every fragile field here.
let trainSchedule: {
trainNumber: string | null;
reference: string | null;
scheduledDepartureDate: Date | null;
} | null = null;
if (booking.trainScheduleId && this.dataSource) {
try {
const s = await this.dataSource.getRepository(TrainSchedule).findOne({
where: { id: booking.trainScheduleId },
});
if (s) {
trainSchedule = {
trainNumber: s.trainNumber ?? null,
reference: s.reference ?? null,
scheduledDepartureDate: s.scheduledDepartureDate ?? null,
};
}
} catch (err) {
this.logger.warn(
`enrichBookingResponse: train-schedule lookup failed for ${booking.id}: ${(err as Error).message}`,
);
}
}
// Physical container numbers entered at booking time (booking_container // Physical container numbers entered at booking time (booking_container
// units), flattened for the customer truck-assignment container picker. // units), flattened for the customer truck-assignment container picker.
const containerNumbers = (booking.bookingContainers ?? []) const containerNumbers = (booking.bookingContainers ?? [])
@@ -1310,6 +1375,7 @@ export class BookingTransitionService {
nextStep, nextStep,
activeBatchOffer, activeBatchOffer,
containerNumbers, containerNumbers,
trainSchedule,
}; };
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,85 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, SelectQueryBuilder } from 'typeorm';
import { BookingWagonCancellation } from './entities/booking-wagon-cancellation.entity';
export interface WagonCancellationListFilter {
status?: string[];
/** Booking reference / company name search (staff list). */
search?: string;
companyId?: string;
bookingId?: string;
from?: Date;
to?: Date;
page?: number;
pageSize?: number;
}
@Injectable()
export class BookingWagonCancellationsRepository extends BaseRepository<BookingWagonCancellation> {
constructor(
@InjectRepository(BookingWagonCancellation)
repository: Repository<BookingWagonCancellation>,
) {
super(repository);
}
/** The one open (fee-unpaid) cancellation of a booking, if any. */
findOpenForBooking(bookingId: string): Promise<BookingWagonCancellation | null> {
return this.repository.findOne({
where: { bookingId, status: 'FEE_PENDING' },
});
}
findByFeeInvoiceId(feeInvoiceId: string): Promise<BookingWagonCancellation | null> {
return this.repository.findOne({ where: { feeInvoiceId } });
}
/** Paged history — staff see everything, customers are scoped by companyId. */
async list(
filter: WagonCancellationListFilter,
): Promise<{ items: BookingWagonCancellation[]; total: number }> {
const page = Math.max(1, filter.page ?? 1);
const pageSize = Math.min(100, Math.max(1, filter.pageSize ?? 10));
const qb = this.baseQuery();
if (filter.bookingId) {
qb.andWhere('(bwc.booking_id = :bookingId OR bwc.rebooked_booking_id = :bookingId)', {
bookingId: filter.bookingId,
});
}
if (filter.companyId) {
qb.andWhere('booking.company_id = :companyId', { companyId: filter.companyId });
}
if (filter.status?.length) {
qb.andWhere('bwc.status IN (:...statuses)', { statuses: filter.status });
}
if (filter.search) {
qb.andWhere('(booking.reference ILIKE :search OR company.name ILIKE :search)', {
search: `%${filter.search}%`,
});
}
if (filter.from) qb.andWhere('bwc.created_at >= :from', { from: filter.from });
if (filter.to) qb.andWhere('bwc.created_at <= :to', { to: filter.to });
// Property path (not raw column): skip/take builds a distinct-id subquery
// and the ORDER BY must resolve inside it.
const [items, total] = await qb
.orderBy('bwc.createdAt', 'DESC')
.skip((page - 1) * pageSize)
.take(pageSize)
.getManyAndCount();
return { items, total };
}
private baseQuery(): SelectQueryBuilder<BookingWagonCancellation> {
return this.repository
.createQueryBuilder('bwc')
.leftJoinAndSelect('bwc.booking', 'booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('bwc.rebookedBooking', 'rebookedBooking')
.leftJoinAndSelect('bwc.feeInvoice', 'feeInvoice');
}
}

View File

@@ -15,13 +15,17 @@ import {
UnauthorizedException, UnauthorizedException,
UploadedFile, UploadedFile,
UploadedFiles, UploadedFiles,
UseGuards,
UseInterceptors, UseInterceptors,
} from '@nestjs/common'; } from '@nestjs/common';
import { CurrentUser } from '@edr/api-common'; import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; import {
import { BookingStaff, BookingView } from '../../common/booking-guards'; BookingStaff,
BookingView,
MixedAudience,
PortalCustomer,
WagonCancellationView,
} from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express'; import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express';
import { import {
@@ -73,7 +77,14 @@ import { LastMileService } from '../last-mile/last-mile.service';
import { GenerateGrnDto } from './dto/generate-grn.dto'; import { GenerateGrnDto } from './dto/generate-grn.dto';
import { ContainerReceiptService } from './container-receipt.service'; import { ContainerReceiptService } from './container-receipt.service';
import { SignContractDto } from './dto/sign-contract.dto'; import { SignContractDto } from './dto/sign-contract.dto';
import { SetExportHandoverModeDto } from './dto/set-export-handover-mode.dto';
import { UpdateBookingDto } from './dto/update-booking.dto'; import { UpdateBookingDto } from './dto/update-booking.dto';
import { BookingWagonCancellationService } from './booking-wagon-cancellation.service';
import {
FilterWagonCancellationsDto,
RebookCancelledWagonsDto,
RequestWagonCancellationDto,
} from './dto/wagon-cancellation.dto';
import { import {
type AuthUserPayload, type AuthUserPayload,
resolveAuthUserId, resolveAuthUserId,
@@ -153,9 +164,11 @@ export class BookingsController {
private readonly firstMileService: FirstMileService, private readonly firstMileService: FirstMileService,
private readonly lastMileService: LastMileService, private readonly lastMileService: LastMileService,
private readonly userTradeAccessService: UserTradeAccessService, private readonly userTradeAccessService: UserTradeAccessService,
private readonly wagonCancellationService: BookingWagonCancellationService,
) {} ) {}
@Post() @Post()
@MixedAudience([])
@UseInterceptors(AnyFilesInterceptor()) @UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data") @ApiConsumes("multipart/form-data")
@ApiOperation({ summary: "Create a new freight booking (DRAFT)" }) @ApiOperation({ summary: "Create a new freight booking (DRAFT)" })
@@ -196,6 +209,7 @@ export class BookingsController {
} }
@Patch(":id") @Patch(":id")
@MixedAudience([])
@UseInterceptors(AnyFilesInterceptor()) @UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data") @ApiConsumes("multipart/form-data")
@ApiOperation({ @ApiOperation({
@@ -212,6 +226,7 @@ export class BookingsController {
} }
@Get() @Get()
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({ summary: "List freight bookings (paginated)" }) @ApiOperation({ summary: "List freight bookings (paginated)" })
async findAll( async findAll(
@Query() filter: FilterBookingDto, @Query() filter: FilterBookingDto,
@@ -287,6 +302,7 @@ export class BookingsController {
} }
@Get("my") @Get("my")
@PortalCustomer()
@ApiOperation({ @ApiOperation({
summary: "List the current customer's bookings ready for payment", summary: "List the current customer's bookings ready for payment",
description: description:
@@ -317,6 +333,7 @@ export class BookingsController {
} }
@Get("reference-data") @Get("reference-data")
@MixedAudience([])
@ApiOperation({ summary: "Booking form catalog" }) @ApiOperation({ summary: "Booking form catalog" })
@ApiOkResponse({ type: BookingReferenceDataDto }) @ApiOkResponse({ type: BookingReferenceDataDto })
getReferenceData(): Promise<BookingReferenceDataDto> { getReferenceData(): Promise<BookingReferenceDataDto> {
@@ -324,6 +341,7 @@ export class BookingsController {
} }
@Get("by-reference/:reference") @Get("by-reference/:reference")
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({ summary: "Get booking by reference" }) @ApiOperation({ summary: "Get booking by reference" })
async findByReference( async findByReference(
@Param("reference") reference: string, @Param("reference") reference: string,
@@ -341,6 +359,7 @@ export class BookingsController {
} }
@Get(":id") @Get(":id")
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({ summary: "Get booking by ID" }) @ApiOperation({ summary: "Get booking by ID" })
async findOne( async findOne(
@Param("id", ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,
@@ -362,6 +381,7 @@ export class BookingsController {
} }
@Get(':id/available-days') @Get(':id/available-days')
@MixedAudience([])
@ApiOperation({ @ApiOperation({
summary: summary:
'Days bookable for THIS booking (cargo-aware wagon-TYPE gate; days only, no capacity counts)', 'Days bookable for THIS booking (cargo-aware wagon-TYPE gate; days only, no capacity counts)',
@@ -384,6 +404,7 @@ export class BookingsController {
} }
@Get(':id/day-availability') @Get(':id/day-availability')
@MixedAudience([])
@ApiOperation({ @ApiOperation({
summary: summary:
'Advisory free-wagon count for a shipment day (planning hint, not enforced). ' + 'Advisory free-wagon count for a shipment day (planning hint, not enforced). ' +
@@ -409,6 +430,7 @@ export class BookingsController {
} }
@Get(':id/mile-summary') @Get(':id/mile-summary')
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({ @ApiOperation({
summary: 'First/last-mile operational summary for a booking (customer-safe)', summary: 'First/last-mile operational summary for a booking (customer-safe)',
}) })
@@ -436,6 +458,7 @@ export class BookingsController {
} }
@Post(':id/customer-truck-assignment') @Post(':id/customer-truck-assignment')
@PortalCustomer()
@ApiOperation({ summary: 'Customer assigns external truck and driver for terminal pickup' }) @ApiOperation({ summary: 'Customer assigns external truck and driver for terminal pickup' })
async assignCustomerTruck( async assignCustomerTruck(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@@ -451,6 +474,7 @@ export class BookingsController {
} }
@Get(':id/customer-truck-assignment/freight-order') @Get(':id/customer-truck-assignment/freight-order')
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({ @ApiOperation({
summary: summary:
'Download freight order copies. The 2 gate copies always print; ?copies=1,2,8 adds waybill-style copies (catalog indexes 1-8).', 'Download freight order copies. The 2 gate copies always print; ?copies=1,2,8 adds waybill-style copies (catalog indexes 1-8).',
@@ -477,6 +501,7 @@ export class BookingsController {
} }
@Get(':id/carriage-acceptance-sheet') @Get(':id/carriage-acceptance-sheet')
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({ @ApiOperation({
summary: summary:
'Download the carriage acceptance sheet (one per booking, lists every allocated wagon)', 'Download the carriage acceptance sheet (one per booking, lists every allocated wagon)',
@@ -496,7 +521,156 @@ export class BookingsController {
res.send(buffer); res.send(buffer);
} }
@Get(':id/wagons')
@ApiOperation({
summary:
'Allocated wagons for a booking (JSON) — empty until the paid booking is placed on a train',
})
async wagonAllocations(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
return this.bookingsService.wagonAllocations(id);
}
// ── Partial wagon cancellation (paid bookings) ────────────────────────────
// Customer endpoints are ownership-scoped (no portal permission keys); the
// staff history/void/rebook variants are permission-gated below.
@Post(':id/wagon-cancellations/preview')
@ApiOperation({ summary: 'Preview the fee/credit of a partial wagon cancellation (no writes)' })
async previewWagonCancellation(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RequestWagonCancellationDto,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
return this.wagonCancellationService.previewCancellation(id, dto);
}
@Post(':id/wagon-cancellations')
@ApiOperation({
summary:
'Request a partial wagon cancellation on a PAID booking — opens the cancellation-fee invoice; wagons are released only once the fee settles',
})
async requestWagonCancellation(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RequestWagonCancellationDto,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
return this.wagonCancellationService.requestCancellation(id, dto, user?.id);
}
@Get(':id/wagon-cancellations')
@ApiOperation({ summary: 'Wagon-cancellation history of one booking (owner or staff)' })
async listBookingWagonCancellations(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
const staff =
hasFreightPermission(user, FREIGHT_PERMS.bookings.view) ||
hasFreightPermission(user, FREIGHT_PERMS.bookings.wagonCancellationView);
if (!staff) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
return this.wagonCancellationService.list({ bookingId: id, pageSize: 100 });
}
@Get('wagon-cancellations/my')
@ApiOperation({ summary: 'Wagon-cancellation history of the calling customer (paginated, filterable)' })
async listMyWagonCancellations(
@Query() filter: FilterWagonCancellationsDto,
@CurrentUser() user: TCurrentUser,
) {
const companyId = await this.bookingsService.resolveCustomerCompanyId(user?.id ?? '');
if (!companyId) throw new ForbiddenException('No customer company for this user.');
return this.wagonCancellationService.list({
companyId,
status: filter.statuses,
search: filter.search,
from: filter.from ? new Date(filter.from) : undefined,
to: filter.to ? new Date(filter.to) : undefined,
page: filter.page,
pageSize: filter.pageSize,
});
}
@Get('wagon-cancellations/history')
@WagonCancellationView()
@ApiOperation({ summary: 'All wagon cancellations (staff, paginated, filterable)' })
async listAllWagonCancellations(@Query() filter: FilterWagonCancellationsDto) {
return this.wagonCancellationService.list({
status: filter.statuses,
search: filter.search,
from: filter.from ? new Date(filter.from) : undefined,
to: filter.to ? new Date(filter.to) : undefined,
page: filter.page,
pageSize: filter.pageSize,
});
}
@Post('wagon-cancellations/:cancellationId/withdraw')
@ApiOperation({ summary: 'Withdraw a fee-pending wagon cancellation (owner, or staff with the void permission)' })
async withdrawWagonCancellation(
@Param('cancellationId', ParseUUIDPipe) cancellationId: string,
@CurrentUser() user: TCurrentUser,
) {
await this.assertWagonCancellationActor(
cancellationId,
user,
FREIGHT_PERMS.bookings.wagonCancellationVoid,
);
return this.wagonCancellationService.withdraw(cancellationId);
}
@Post('wagon-cancellations/:cancellationId/rebook')
@ApiOperation({
summary:
'Rebook a wagon-cancellation credit: pick a shipment day only — the new booking is created under the contract and marked PAID (freight already paid; contract must still be valid)',
})
async rebookWagonCancellation(
@Param('cancellationId', ParseUUIDPipe) cancellationId: string,
@Body() dto: RebookCancelledWagonsDto,
@CurrentUser() user: TCurrentUser,
) {
await this.assertWagonCancellationActor(
cancellationId,
user,
FREIGHT_PERMS.bookings.wagonCancellationRebook,
);
return this.wagonCancellationService.rebook(cancellationId, dto, user?.id);
}
/** Owner-or-staff gate shared by the per-cancellation actions. */
private async assertWagonCancellationActor(
cancellationId: string,
user: TCurrentUser,
staffPermission: string,
): Promise<void> {
if (hasFreightPermission(user, staffPermission)) return;
const row = await this.wagonCancellationService.findById(cancellationId);
const booking = await this.bookingsService.findById(row.bookingId);
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
@Get(':id/customer-trucks') @Get(':id/customer-trucks')
@MixedAudience([
FREIGHT_PERMS.bookings.view,
FREIGHT_PERMS.bookings.operations,
FREIGHT_PERMS.warehouseInventory.view,
])
@ApiOperation({ summary: 'List customer self-haul trucks (multi-truck) for a booking' }) @ApiOperation({ summary: 'List customer self-haul trucks (multi-truck) for a booking' })
async listCustomerTrucks( async listCustomerTrucks(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@@ -510,6 +684,7 @@ export class BookingsController {
} }
@Post(':id/customer-trucks') @Post(':id/customer-trucks')
@PortalCustomer()
@ApiOperation({ summary: 'Add a customer self-haul truck carrying 12 of the booking containers' }) @ApiOperation({ summary: 'Add a customer self-haul truck carrying 12 of the booking containers' })
async addCustomerTruck( async addCustomerTruck(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@@ -524,6 +699,7 @@ export class BookingsController {
} }
@Post(':id/customer-trucks/bulk') @Post(':id/customer-trucks/bulk')
@PortalCustomer()
@ApiOperation({ summary: 'Bulk add customer trucks from array payload (Excel parsed)' }) @ApiOperation({ summary: 'Bulk add customer trucks from array payload (Excel parsed)' })
async bulkAddCustomerTrucks( async bulkAddCustomerTrucks(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@@ -538,6 +714,7 @@ export class BookingsController {
} }
@Patch(':id/customer-trucks/:assignmentId') @Patch(':id/customer-trucks/:assignmentId')
@PortalCustomer()
@ApiOperation({ summary: 'Edit a not-yet-arrived customer truck (plate/driver/type + containers)' }) @ApiOperation({ summary: 'Edit a not-yet-arrived customer truck (plate/driver/type + containers)' })
async updateCustomerTruck( async updateCustomerTruck(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@@ -553,6 +730,7 @@ export class BookingsController {
} }
@Delete(':id/customer-trucks/:assignmentId') @Delete(':id/customer-trucks/:assignmentId')
@PortalCustomer()
@ApiOperation({ summary: 'Remove a not-yet-arrived customer truck from a booking' }) @ApiOperation({ summary: 'Remove a not-yet-arrived customer truck from a booking' })
async removeCustomerTruck( async removeCustomerTruck(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@@ -567,6 +745,7 @@ export class BookingsController {
} }
@Get(':id/customer-trucks/loadable-containers') @Get(':id/customer-trucks/loadable-containers')
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({ summary: 'Booking containers not yet loaded onto a truck' }) @ApiOperation({ summary: 'Booking containers not yet loaded onto a truck' })
async loadableContainers( async loadableContainers(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@@ -579,7 +758,20 @@ export class BookingsController {
return this.customerTruckService.getLoadableContainers(id); return this.customerTruckService.getLoadableContainers(id);
} }
@Patch(':id/export-handover-mode')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({
summary: 'Export only: choose direct truck-to-train (no warehouse, no GRN) or warehouse first',
})
setExportHandoverMode(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: SetExportHandoverModeDto,
) {
return this.bookingsService.setExportHandoverMode(id, dto.exportHandoverMode);
}
@Post(':id/customer-trucks/:assignmentId/load') @Post(':id/customer-trucks/:assignmentId/load')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Truck_dispatch: load selected containers onto a truck (staff)' }) @ApiOperation({ summary: 'Truck_dispatch: load selected containers onto a truck (staff)' })
async loadCustomerTruck( async loadCustomerTruck(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@@ -594,6 +786,7 @@ export class BookingsController {
} }
@Post(':id/customer-trucks/:assignmentId/depart') @Post(':id/customer-trucks/:assignmentId/depart')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ @ApiOperation({
summary: 'Register an import truck leaving: containers loaded + weighed gross (staff)', summary: 'Register an import truck leaving: containers loaded + weighed gross (staff)',
}) })
@@ -611,6 +804,7 @@ export class BookingsController {
} }
@Get(':id/received-pending-grn') @Get(':id/received-pending-grn')
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({ summary: 'Containers received into port but not yet on a GRN' }) @ApiOperation({ summary: 'Containers received into port but not yet on a GRN' })
async receivedPendingGrn( async receivedPendingGrn(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@@ -624,6 +818,7 @@ export class BookingsController {
} }
@Post(':id/generate-grn') @Post(':id/generate-grn')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ @ApiOperation({
summary: summary:
'Generate a GRN over the received containers (all received, or a subset) — one GRN per batch', 'Generate a GRN over the received containers (all received, or a subset) — one GRN per batch',
@@ -641,6 +836,7 @@ export class BookingsController {
} }
@Get(':id/tracking') @Get(':id/tracking')
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({ @ApiOperation({
summary: "Shipment tracking timeline for a booking", summary: "Shipment tracking timeline for a booking",
description: description:
@@ -663,6 +859,7 @@ export class BookingsController {
} }
@Delete(":id") @Delete(":id")
@MixedAudience([])
@HttpCode(204) @HttpCode(204)
@ApiOperation({ summary: "Soft-delete DRAFT booking" }) @ApiOperation({ summary: "Soft-delete DRAFT booking" })
remove(@Param("id", ParseUUIDPipe) id: string) { remove(@Param("id", ParseUUIDPipe) id: string) {
@@ -670,6 +867,7 @@ export class BookingsController {
} }
@Post(":id/documents") @Post(":id/documents")
@MixedAudience([])
@UseInterceptors(AnyFilesInterceptor()) @UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data") @ApiConsumes("multipart/form-data")
@ApiOperation({ summary: "Upload documents for a booking (DRAFT only)" }) @ApiOperation({ summary: "Upload documents for a booking (DRAFT only)" })
@@ -682,6 +880,7 @@ export class BookingsController {
} }
@Post(":id/generate-price") @Post(":id/generate-price")
@MixedAudience([])
@ApiOperation({ @ApiOperation({
summary: "Generate price preview (DRAFT or CHANGES_REQUESTED)", summary: "Generate price preview (DRAFT or CHANGES_REQUESTED)",
description: description:
@@ -693,6 +892,7 @@ export class BookingsController {
} }
@Post(":id/submit") @Post(":id/submit")
@MixedAudience([])
@ApiOperation({ @ApiOperation({
summary: "Customer submit booking", summary: "Customer submit booking",
description: description:
@@ -704,6 +904,7 @@ export class BookingsController {
} }
@Post(":id/confirm-submit") @Post(":id/confirm-submit")
@MixedAudience([])
@ApiOperation({ @ApiOperation({
summary: "Confirm submit after price change", summary: "Confirm submit after price change",
description: description:
@@ -715,6 +916,7 @@ export class BookingsController {
} }
@Post(":id/reject") @Post(":id/reject")
@PortalCustomer()
@ApiOperation({ @ApiOperation({
summary: "Customer reject price estimate", summary: "Customer reject price estimate",
description: description:
@@ -745,6 +947,7 @@ export class BookingsController {
} }
@Get(':id/clearance') @Get(':id/clearance')
@MixedAudience([FREIGHT_PERMS.bookings.clearanceView, FREIGHT_PERMS.bookings.reviewDocuments])
@ApiOperation({ @ApiOperation({
summary: summary:
"Document-clearance grid (required docs + upload + GL review status)", "Document-clearance grid (required docs + upload + GL review status)",
@@ -754,6 +957,7 @@ export class BookingsController {
} }
@Post(":id/clearance/documents") @Post(":id/clearance/documents")
@PortalCustomer()
@UseInterceptors(AnyFilesInterceptor()) @UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data") @ApiConsumes("multipart/form-data")
@ApiOperation({ @ApiOperation({
@@ -770,7 +974,10 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking); return this.transitionService.enrichBookingResponse(booking);
} }
// Customer requests the operation; GL ET also resubmits here on the
// customer's behalf after operations requests changes (BookingChangesRequestedAlert).
@Post(":id/clearance/proceed") @Post(":id/clearance/proceed")
@MixedAudience(FREIGHT_PERMS.contracts.createBooking)
@ApiOperation({ @ApiOperation({
summary: summary:
"Customer requests operation with a schedule day " + "Customer requests operation with a schedule day " +
@@ -789,6 +996,7 @@ export class BookingsController {
} }
@Get(":id/export-trains") @Get(":id/export-trains")
@MixedAudience([])
@ApiOperation({ @ApiOperation({
summary: summary:
"Export train picker: the day's export trains on the booking's corridor " + "Export train picker: the day's export trains on the booking's corridor " +
@@ -990,6 +1198,7 @@ export class BookingsController {
} }
@Post(':id/clearance/draft-declaration/accept') @Post(':id/clearance/draft-declaration/accept')
@PortalCustomer()
@ApiOperation({ @ApiOperation({
summary: summary:
'Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia', 'Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia',
@@ -1000,6 +1209,7 @@ export class BookingsController {
} }
@Post(':id/clearance/draft-declaration/change') @Post(':id/clearance/draft-declaration/change')
@PortalCustomer()
@ApiOperation({ @ApiOperation({
summary: summary:
'Customer requests a change to the draft customs declaration with a reason — GL Ethiopia sends a corrected draft (repeatable)', 'Customer requests a change to the draft customs declaration with a reason — GL Ethiopia sends a corrected draft (repeatable)',
@@ -1026,6 +1236,7 @@ export class BookingsController {
} }
@Post(':id/clearance/duty-slip') @Post(':id/clearance/duty-slip')
@PortalCustomer()
@UseInterceptors(FileInterceptor('file')) @UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data') @ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Customer uploads duty/tax payment slip on booking' }) @ApiOperation({ summary: 'Customer uploads duty/tax payment slip on booking' })
@@ -1177,7 +1388,7 @@ export class BookingsController {
} }
@Post(":id/government-expedite") @Post(":id/government-expedite")
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept) @BookingStaff(FREIGHT_PERMS.bookings.governmentExpedite)
@ApiOperation({ @ApiOperation({
summary: "Expedite government booking to PAID / ELIGIBLE for scheduling", summary: "Expedite government booking to PAID / ELIGIBLE for scheduling",
}) })
@@ -1201,6 +1412,7 @@ export class BookingsController {
} }
@Get(":id/contract/view") @Get(":id/contract/view")
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOkResponse({ type: ContractViewDto }) @ApiOkResponse({ type: ContractViewDto })
@ApiOperation({ summary: "Contract HTML view for portal and backoffice" }) @ApiOperation({ summary: "Contract HTML view for portal and backoffice" })
getContractView( getContractView(
@@ -1212,6 +1424,7 @@ export class BookingsController {
} }
@Get(":id/contract/document") @Get(":id/contract/document")
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({ summary: "Download contract PDF" }) @ApiOperation({ summary: "Download contract PDF" })
async downloadContractDocument( async downloadContractDocument(
@Param("id", ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,
@@ -1227,6 +1440,7 @@ export class BookingsController {
} }
@Get(":id/contract") @Get(":id/contract")
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({ summary: "Download contract file (alias)" }) @ApiOperation({ summary: "Download contract file (alias)" })
async downloadContract( async downloadContract(
@Param("id", ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,
@@ -1236,7 +1450,7 @@ export class BookingsController {
} }
@Post(":id/contract/sign") @Post(":id/contract/sign")
@UseGuards(JwtGuard) @MixedAudience(FREIGHT_PERMS.bookings.signStaff)
@ApiOperation({ summary: "Apply digital signature (customer or staff)" }) @ApiOperation({ summary: "Apply digital signature (customer or staff)" })
async signContract( async signContract(
@Param("id", ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,
@@ -1257,18 +1471,21 @@ export class BookingsController {
} }
@Get(":id/contract/signatures") @Get(":id/contract/signatures")
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({ summary: "List contract signatures" }) @ApiOperation({ summary: "List contract signatures" })
getContractSignatures(@Param("id", ParseUUIDPipe) id: string) { getContractSignatures(@Param("id", ParseUUIDPipe) id: string) {
return this.contractService.getSignatures(id); return this.contractService.getSignatures(id);
} }
@Get(":id/summary") @Get(":id/summary")
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({ summary: "Contract summary string for dashboard" }) @ApiOperation({ summary: "Contract summary string for dashboard" })
getSummary(@Param("id", ParseUUIDPipe) id: string) { getSummary(@Param("id", ParseUUIDPipe) id: string) {
return this.contractService.getSummary(id); return this.contractService.getSummary(id);
} }
@Post(":id/customer/sign") @Post(":id/customer/sign")
@PortalCustomer()
@ApiOperation({ @ApiOperation({
summary: "Customer digital signature (deprecated — use POST contract/sign)", summary: "Customer digital signature (deprecated — use POST contract/sign)",
}) })
@@ -1335,7 +1552,21 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking); return this.transitionService.enrichBookingResponse(booking);
} }
@Post(":id/customer-cancel")
@ApiOperation({
summary:
"Customer cancels their own booking before payment — no cancellation fee",
})
async customerCancel(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: RejectBookingDto,
) {
const booking = await this.transitionService.customerCancel(id, dto.reason);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/cancel-hold") @Post(":id/cancel-hold")
@PortalCustomer()
@ApiOperation({ @ApiOperation({
summary: summary:
"Customer cancels an unpaid hold (SELECTED_FOR_BATCH → CANCELLED); " + "Customer cancels an unpaid hold (SELECTED_FOR_BATCH → CANCELLED); " +
@@ -1350,18 +1581,21 @@ export class BookingsController {
} }
@Post(":id/consolidation") @Post(":id/consolidation")
@PortalCustomer()
@ApiOperation({ summary: "Request freight consolidation" }) @ApiOperation({ summary: "Request freight consolidation" })
requestConsolidation(@Param("id", ParseUUIDPipe) id: string) { requestConsolidation(@Param("id", ParseUUIDPipe) id: string) {
return this.bookingsService.requestConsolidation(id); return this.bookingsService.requestConsolidation(id);
} }
@Delete(":id/consolidation") @Delete(":id/consolidation")
@PortalCustomer()
@ApiOperation({ summary: "Remove consolidation pairing" }) @ApiOperation({ summary: "Remove consolidation pairing" })
removeConsolidation(@Param("id", ParseUUIDPipe) id: string) { removeConsolidation(@Param("id", ParseUUIDPipe) id: string) {
return this.bookingsService.removeConsolidation(id); return this.bookingsService.removeConsolidation(id);
} }
@Get(":id/consolidation") @Get(":id/consolidation")
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({ summary: "Get consolidation details" }) @ApiOperation({ summary: "Get consolidation details" })
getConsolidationDetails(@Param("id", ParseUUIDPipe) id: string) { getConsolidationDetails(@Param("id", ParseUUIDPipe) id: string) {
return this.bookingsService.getConsolidationDetails(id); return this.bookingsService.getConsolidationDetails(id);

View File

@@ -25,6 +25,7 @@ import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.se
import { BookingTransitionService } from './booking-transition.service'; import { BookingTransitionService } from './booking-transition.service';
import { NotificationsModule } from '../notifications/notifications.module'; import { NotificationsModule } from '../notifications/notifications.module';
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
import { BookingAllocationController } from './booking-allocation.controller';
import { BookingsController } from './bookings.controller'; import { BookingsController } from './bookings.controller';
// import { PayController } from './pay.controller'; // import { PayController } from './pay.controller';
import { BookingsRepository } from './bookings.repository'; import { BookingsRepository } from './bookings.repository';
@@ -38,6 +39,9 @@ import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
import { BookingContractSignature } from './entities/booking-contract-signature.entity'; import { BookingContractSignature } from './entities/booking-contract-signature.entity';
import { BookingReviewNote } from './entities/booking-review-note.entity'; import { BookingReviewNote } from './entities/booking-review-note.entity';
import { Booking } from './entities/booking.entity'; import { Booking } from './entities/booking.entity';
import { BookingWagonCancellation } from './entities/booking-wagon-cancellation.entity';
import { BookingWagonCancellationsRepository } from './booking-wagon-cancellations.repository';
import { BookingWagonCancellationService } from './booking-wagon-cancellation.service';
import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity'; import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity';
import { CustomerTruckContainer } from './entities/customer-truck-container.entity'; import { CustomerTruckContainer } from './entities/customer-truck-container.entity';
import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository'; import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository';
@@ -65,6 +69,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
BookingReviewNote, BookingReviewNote,
BookingContractSignature, BookingContractSignature,
BookingContainerAllocation, BookingContainerAllocation,
BookingWagonCancellation,
CustomerTruckAssignment, CustomerTruckAssignment,
CustomerTruckContainer, CustomerTruckContainer,
]), ]),
@@ -88,7 +93,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
SignaturesModule, SignaturesModule,
registerExchangeModule(), registerExchangeModule(),
], ],
controllers: [BookingsController], controllers: [BookingsController, BookingAllocationController],
providers: [ providers: [
BookingsService, BookingsService,
BookingsRepository, BookingsRepository,
@@ -109,6 +114,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
CustomerTruckAssignmentsRepository, CustomerTruckAssignmentsRepository,
CustomerTruckService, CustomerTruckService,
ContainerReceiptService, ContainerReceiptService,
BookingWagonCancellationsRepository,
BookingWagonCancellationService,
], ],
exports: [ exports: [
BookingsService, BookingsService,
@@ -120,6 +127,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
ConsolidationService, ConsolidationService,
CustomerTruckService, CustomerTruckService,
ContainerReceiptService, ContainerReceiptService,
BookingWagonCancellationService,
], ],
}) })
export class BookingsModule { } export class BookingsModule { }

View File

@@ -13,7 +13,7 @@ import { insertWithGeneratedReference } from '@edr/api-common';
// import { CustomersService } from '../customers/customers.service'; // import { CustomersService } from '../customers/customers.service';
import { CompaniesService } from '../companies/companies.service'; import { CompaniesService } from '../companies/companies.service';
import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity'; import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
import { eatDay } from '../train-scheduling/batch-window.util'; import { eatDay } from '../train-scheduling/batch-window.util';
import { FilesService } from '../files/files.service'; import { FilesService } from '../files/files.service';
import { MinioService } from '../minio/minio.service'; import { MinioService } from '../minio/minio.service';
@@ -28,7 +28,7 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
import { DataSource, In } from 'typeorm'; import { DataSource, In } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { assertExportReceivedWithGrn } from '../../common/export-received-gate'; import { assertExportReceivedWithGrn, DIRECT_TO_TRAIN } from '../../common/export-received-gate';
import { Yard } from '../rule-engine/entities/yard.entity'; import { Yard } from '../rule-engine/entities/yard.entity';
import { ServiceType } from '../rule-engine/entities/service-type.entity'; import { ServiceType } from '../rule-engine/entities/service-type.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
@@ -92,6 +92,7 @@ interface CarriageAcceptanceWagonRow {
interface CarriageAcceptanceReceivedRow { interface CarriageAcceptanceReceivedRow {
allocatedWeightTons: string | null; allocatedWeightTons: string | null;
containerNumbers: string | null; containerNumbers: string | null;
sealNumbers?: string | null;
} }
const URGENT_PRIORITY_THRESHOLD = 1000; const URGENT_PRIORITY_THRESHOLD = 1000;
@@ -285,10 +286,28 @@ export class BookingsService {
// Receipt is proven by the warehouse GRN, but the GRN is warehouse paperwork // Receipt is proven by the warehouse GRN, but the GRN is warehouse paperwork
// and never appears on this sheet — it is only the signal that EDR has taken // and never appears on this sheet — it is only the signal that EDR has taken
// the cargo, which is what the customer's sheet attests to. // the cargo, which is what the customer's sheet attests to.
const isDirectExport =
booking.tradeDirection === 'EXPORT' && booking.exportHandoverMode === DIRECT_TO_TRAIN;
const pendingWagons = wagons.length === 0; const pendingWagons = wagons.length === 0;
if (pendingWagons) { if (pendingWagons) {
const receivedLines: CarriageAcceptanceReceivedRow[] = // Direct truck-to-train cargo never enters the warehouse, so there is no
booking.tradeDirection === 'EXPORT' // GRN'd inventory to build the sheet from. Choosing direct handover is
// itself the acceptance, so the sheet issues off the containers the
// customer declared on the booking — freight.containers only gains rows at
// allocation, by which point the wagon query above already serves.
const receivedLines: CarriageAcceptanceReceivedRow[] = isDirectExport
? await this.dataSource.query(
`SELECT NULL::numeric AS "allocatedWeightTons",
unit.container_number AS "containerNumbers",
unit.seal_number AS "sealNumbers"
FROM freight.booking_container_units unit
JOIN freight.booking_container line
ON line.id = unit.booking_container_id AND line.deleted_at IS NULL
WHERE line.booking_id = $1 AND unit.deleted_at IS NULL
ORDER BY unit.container_number`,
[bookingId],
)
: booking.tradeDirection === 'EXPORT'
? await this.dataSource.query( ? await this.dataSource.query(
`SELECT inv.weight AS "allocatedWeightTons", `SELECT inv.weight AS "allocatedWeightTons",
c.container_number AS "containerNumbers" c.container_number AS "containerNumbers"
@@ -304,6 +323,17 @@ export class BookingsService {
[bookingId], [bookingId],
) )
: []; : [];
// Bulk direct cargo has no containers — one line carrying the booking's
// declared weight still makes a valid sheet. bulkTotalWeightTons only
// holds the real tonnage for PER_ITEM break-bulk; everywhere else (PER_TON
// bulk and every container booking) the VGM column is the weight.
if (isDirectExport && receivedLines.length === 0) {
const totalWeight = booking.bulkTotalWeightTons ?? booking.cargoTotalWeightVgm;
receivedLines.push({
allocatedWeightTons: totalWeight == null ? null : String(totalWeight),
containerNumbers: null,
});
}
if (receivedLines.length === 0) { if (receivedLines.length === 0) {
throw new BadRequestException( throw new BadRequestException(
booking.tradeDirection === 'EXPORT' booking.tradeDirection === 'EXPORT'
@@ -324,7 +354,7 @@ export class BookingsService {
marshalledAt: null, marshalledAt: null,
arrivalAt: null, arrivalAt: null,
containerNumbers: row.containerNumbers, containerNumbers: row.containerNumbers,
sealNumbers: null, sealNumbers: row.sealNumbers ?? null,
})); }));
} }
@@ -339,6 +369,64 @@ export class BookingsService {
}; };
} }
/**
* Allocated wagons of a booking as JSON — the portal's "Wagons" tab. Same
* join chain as the carriage acceptance sheet, but structured (containers as
* an array per wagon, bulk load description when the wagon carries bulk).
* Empty array until the booking has been allocated onto a train.
*/
async wagonAllocations(bookingId: string): Promise<unknown[]> {
return this.dataSource.query(
`SELECT a.id AS "allocationId",
tsw.sequence_no AS "sequenceNo",
w.wagon_number AS "wagonNumber",
COALESCE(wt.name, wt.code) AS "wagonType",
wt.code AS "wagonTypeCode",
wt.tare_weight_tons AS "tareWeightTons",
tsw.capacity_tons AS "capacityTons",
tsw.length_meters AS "lengthMeters",
a.allocated_weight_tons AS "allocatedWeightTons",
a.load_type AS "loadType",
a.status AS "status",
s.train_number AS "trainNumber",
s.scheduled_departure_date AS "departureAt",
so.label AS "originStation",
sd.label AS "destinationStation",
bl.cargo_description AS "bulkCargoDescription",
bl.quantity AS "bulkQuantity",
COALESCE(
json_agg(
json_build_object(
'containerNumber', ci.container_number,
'sealNumber', ci.seal_number,
'positionOnWagon', ci.position_on_wagon,
'grossWeightTons', ci.gross_weight_tons
) ORDER BY ci.position_on_wagon, ci.container_number
) FILTER (WHERE ci.id IS NOT NULL),
'[]'
) AS "containers"
FROM freight.wagon_booking_allocations a
JOIN freight.train_set_wagons tsw
ON tsw.id = a.train_set_wagon_id AND tsw.deleted_at IS NULL
LEFT JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id
LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id
LEFT JOIN freight.train_schedules s
ON s.train_set_id = tsw.train_set_id AND s.deleted_at IS NULL
LEFT JOIN freight.yards so ON so.id = s.origin_station_id
LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id
LEFT JOIN freight.wagon_allocation_container_items ci
ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL
LEFT JOIN freight.wagon_allocation_bulk_loads bl
ON bl.wagon_booking_allocation_id = a.id AND bl.deleted_at IS NULL
WHERE a.booking_id = $1 AND a.deleted_at IS NULL
GROUP BY tsw.id, a.id, w.wagon_number, wt.name, wt.code, wt.tare_weight_tons,
s.train_number, s.scheduled_departure_date, so.label, sd.label,
bl.cargo_description, bl.quantity
ORDER BY tsw.sequence_no`,
[bookingId],
);
}
/** /**
* Split the booking amount across its wagons, proportional to allocated weight * Split the booking amount across its wagons, proportional to allocated weight
* (equal shares when no weights are recorded). The last row absorbs the rounding * (equal shares when no weights are recorded). The last row absorbs the rounding
@@ -366,7 +454,10 @@ export class BookingsService {
const departureStation = booking.originYard?.label ?? booking.originYard?.code ?? '-'; const departureStation = booking.originYard?.label ?? booking.originYard?.code ?? '-';
const arrivalStation = booking.destinationYard?.label ?? booking.destinationYard?.code ?? '-'; const arrivalStation = booking.destinationYard?.label ?? booking.destinationYard?.code ?? '-';
const cargoName = booking.cargoType?.cargoTypeName ?? booking.cargoFreeText ?? '-'; // Container bookings carry no cargo type or free text — name the freight type
// rather than printing a dash in the Cargo Name column.
const cargoName =
booking.cargoType?.cargoTypeName ?? booking.cargoFreeText ?? booking.freightType ?? '-';
const currency = booking.paymentCurrency ?? 'ETB'; const currency = booking.paymentCurrency ?? 'ETB';
const totalAmount = Number(booking.adjustedTotalAmount ?? booking.totalAmount) || 0; const totalAmount = Number(booking.adjustedTotalAmount ?? booking.totalAmount) || 0;
const prices = this.splitAmountAcrossWagons( const prices = this.splitAmountAcrossWagons(
@@ -409,6 +500,28 @@ export class BookingsService {
) )
.join(''); .join('');
// The totals belong in <tbody>, not <tfoot>: the Chromium-less fallback
// renderer only parses tbody rows, so a <tfoot> silently drops every footer
// figure from the printed sheet.
const totalsRow = `<tr class="totals">
<td>TOT</td>
<td>${wagons.length} ${pendingWagons ? 'received lines' : 'wagons'}</td>
<td>${
pendingWagons
? 'pending marshalling'
: `full ${fullWagons} / empty ${wagons.length - fullWagons}`
}</td>
<td class="num">${num(totals.tare, 2)}</td>
<td class="num">${num(totals.length)}</td>
<td class="num">${num(totals.capacity)}</td>
<td></td>
<td>Gross ${num(totals.tare + totals.load)} T</td>
<td></td>
<td></td>
<td></td>
<td class="num">${money(totalAmount)}</td>
</tr>`;
return `<!doctype html> return `<!doctype html>
<html> <html>
<head> <head>
@@ -432,7 +545,7 @@ export class BookingsService {
th { background: #f8fafc; color: #475569; text-align: left; } th { background: #f8fafc; color: #475569; text-align: left; }
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; } th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
.num { text-align: right; } .num { text-align: right; }
tfoot td { background: #f8fafc; font-weight: 700; } tr.totals td { background: #f8fafc; font-weight: 700; }
.notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; } .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; } .signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; }
.line { border-top: 1px solid #334155; padding-top: 7px; font-size: 9px; color: #475569; min-height: 34px; } .line { border-top: 1px solid #334155; padding-top: 7px; font-size: 9px; color: #475569; min-height: 34px; }
@@ -480,21 +593,8 @@ export class BookingsService {
</thead> </thead>
<tbody> <tbody>
${rows} ${rows}
${totalsRow}
</tbody> </tbody>
<tfoot>
<tr>
<td colspan="3">${
pendingWagons
? `Received lines: ${wagons.length} — wagons pending marshalling`
: `Total wagons: ${wagons.length} (full ${fullWagons} / empty ${wagons.length - fullWagons})`
}</td>
<td class="num">${num(totals.tare, 2)}</td>
<td class="num">${num(totals.length)}</td>
<td class="num">${num(totals.capacity)}</td>
<td colspan="5">Gross weight (tare + load): ${num(totals.tare + totals.load)} T</td>
<td class="num">${money(totalAmount)}</td>
</tr>
</tfoot>
</table> </table>
<div class="notice"> <div class="notice">
@@ -1425,7 +1525,46 @@ export class BookingsService {
tradeDirection, tradeDirection,
); );
} }
if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate); // Re-pinning the departure day on an edit (e.g. fixing a CHANGES_REQUESTED
// booking) must obey the same gate as creation: the route needs an OPEN
// departure on that EAT day that can carry the cargo. Skipped when the day
// didn't change, for general contracts (period-based, no pinned day) and
// for intercity (staff assign a passing train later).
if (dto.scheduledDate) {
const day = eatDay(new Date(dto.scheduledDate));
const dayChanged =
!existing.scheduledDate || eatDay(existing.scheduledDate) !== day;
if (
dayChanged &&
existing.bookingType !== 'GENERAL_CONTRACT' &&
tradeDirection !== 'DOMESTIC'
) {
const { hasDeparture, hasCompatible } =
await this.trainSchedulingService.checkDayCargoCompatibility(
originYardId,
destinationYardId,
day,
{
freightType: freightType as 'CONTAINER' | 'BULK',
cargoTypeId,
containerTypeIds: containers
.map((c) => c.containerTypeId)
.filter((cid): cid is string => Boolean(cid)),
},
);
if (!hasDeparture) {
throw new BadRequestException(
'No departures available on the selected day for this route',
);
}
if (!hasCompatible) {
throw new BadRequestException(
'No wagon on the selected day can carry this cargo type — please choose another day',
);
}
}
updates.scheduledDate = new Date(dto.scheduledDate);
}
if (dto.estimatedShipmentDate) if (dto.estimatedShipmentDate)
updates.estimatedShipmentDate = new Date(dto.estimatedShipmentDate); updates.estimatedShipmentDate = new Date(dto.estimatedShipmentDate);
if (dto.startDate) updates.startDate = new Date(dto.startDate); if (dto.startDate) updates.startDate = new Date(dto.startDate);
@@ -1888,6 +2027,47 @@ export class BookingsService {
} }
/** Get a single booking by ID with files. */ /** Get a single booking by ID with files. */
/**
* EXPORT only. Choose how the cargo reaches the train. DIRECT_TO_TRAIN takes
* the booking out of the warehouse flow entirely — no receipt, no GRN, and the
* carriage acceptance sheet becomes issuable straight away.
*
* Switching to direct is refused once the goods are already in the shed:
* inventory exists, so the cargo demonstrably went the warehouse route and its
* GRN paperwork must stand.
*/
async setExportHandoverMode(
bookingId: string,
mode: string,
): Promise<{ bookingId: string; exportHandoverMode: string }> {
const booking = await this.bookingsRepository.findById(bookingId);
if (!booking) {
throw new NotFoundException(`Booking ${bookingId} not found`);
}
if ((booking.tradeDirection ?? '').toUpperCase() !== 'EXPORT') {
throw new BadRequestException('Handover mode applies to export bookings only');
}
if (mode === DIRECT_TO_TRAIN) {
const [stored]: Array<{ one: number }> = await this.dataSource.query(
`SELECT 1 AS one
FROM freight.warehouse_inventory
WHERE booking_id = $1 AND deleted_at IS NULL
LIMIT 1`,
[bookingId],
);
if (stored) {
throw new BadRequestException(
'This booking already has cargo in the warehouse, so it cannot be switched to direct truck-to-train',
);
}
}
await this.dataSource.query(
`UPDATE freight.bookings SET export_handover_mode = $2, updated_at = NOW() WHERE id = $1`,
[bookingId, mode],
);
return { bookingId, exportHandoverMode: mode };
}
async findById(id: string): Promise<Booking> { async findById(id: string): Promise<Booking> {
const booking = await this.bookingsRepository.findByIdWithFiles(id); const booking = await this.bookingsRepository.findByIdWithFiles(id);
if (!booking) { if (!booking) {

View File

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

View File

@@ -0,0 +1,112 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayNotEmpty,
IsArray,
IsDateString,
IsIn,
IsInt,
IsNumber,
IsOptional,
IsString,
IsUUID,
MaxLength,
Min,
ValidateNested,
} from 'class-validator';
import { WAGON_CANCELLATION_STATUSES } from '../entities/booking-wagon-cancellation.entity';
export class CancelContainerLineDto {
@ApiProperty({ description: 'Container size (ft) as stored on the booking line, e.g. "20", "40"' })
@IsString()
containerSize!: string;
@ApiProperty({ description: 'How many units of this size to cancel' })
@IsInt()
@Min(1)
quantity!: number;
}
export class RequestWagonCancellationDto {
@ApiPropertyOptional({
description:
'Cancel SPECIFIC allocated wagons: wagon_booking_allocation ids from GET /bookings/:id/wagons. ' +
'When set, wagons/containers are derived from the selected wagons and the other fields are ignored.',
type: [String],
})
@IsOptional()
@IsArray()
@ArrayNotEmpty()
@IsUUID('4', { each: true })
wagonAllocationIds?: string[];
@ApiPropertyOptional({
description: 'BULK bookings: number of wagons to cancel (tons derived proportionally)',
})
@IsOptional()
@IsNumber()
@Min(0.5)
wagons?: number;
@ApiPropertyOptional({
description: 'CONTAINER bookings: units to cancel per size (wagons derived per size)',
type: [CancelContainerLineDto],
})
@IsOptional()
@IsArray()
@ArrayNotEmpty()
@ValidateNested({ each: true })
@Type(() => CancelContainerLineDto)
containers?: CancelContainerLineDto[];
@ApiPropertyOptional({ description: 'Customer reason for the cancellation' })
@IsOptional()
@IsString()
@MaxLength(1000)
reason?: string;
}
export class RebookCancelledWagonsDto {
@ApiProperty({ description: 'Shipment day the credit is rebooked onto (ISO date)' })
@IsDateString()
scheduledDate!: string;
}
export class FilterWagonCancellationsDto {
@ApiPropertyOptional({ enum: WAGON_CANCELLATION_STATUSES, isArray: true })
@IsOptional()
@IsArray()
@IsIn(WAGON_CANCELLATION_STATUSES as readonly string[], { each: true })
statuses?: string[];
@ApiPropertyOptional({ description: 'Booking reference / company name search' })
@IsOptional()
@IsString()
@MaxLength(120)
search?: string;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
from?: string;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
to?: string;
@ApiPropertyOptional({ default: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@ApiPropertyOptional({ default: 10 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
pageSize?: number;
}

View File

@@ -0,0 +1,137 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Invoice } from '../../billing/entities/invoice.entity';
import { Rate } from '../../rule-engine/entities/rate.entity';
import { Booking } from './booking.entity';
export const WAGON_CANCELLATION_STATUSES = [
// Requested; fee invoice open; wagons still allocated to the customer.
'FEE_PENDING',
// Fee settled; booking reduced, wagons freed; credit waiting for a rebook.
'CREDIT_AVAILABLE',
// Credit redeemed into a new PAID booking (rebookedBookingId).
'REBOOKED',
// Customer/staff voided the request before paying the fee. Nothing changed.
'WITHDRAWN',
// Reserved for a future expiry policy; not set by code today.
'EXPIRED',
] as const;
export type WagonCancellationStatus = (typeof WAGON_CANCELLATION_STATUSES)[number];
/** Snapshot of one physical container unit cut by the cancellation. */
export interface CancelledUnitSnapshot {
containerSize: string;
containerNumber: string;
sealNumber?: string | null;
vgmTons: number;
isHazardous: boolean;
isReefer: boolean;
}
/** What the cancellation cut, in the booking's own quantity terms. */
export interface CancelledQuantities {
/** Bulk bookings: tons cut (PER_ITEM cargo: item count, matching cargoTotalWeightVgm). */
bulkTons?: number;
/** Container bookings: units cut per container size. */
bySize?: Record<string, number>;
/**
* Container bookings: the exact physical units cut. Snapshotted at request
* time when the customer picked specific wagons, otherwise at fee settlement
* (LIFO trim). The rebook reconstructs the new booking from THESE — never
* from a soft-deleted-row scan, which could pick up units dropped by an
* unrelated batch split on the same booking.
*/
units?: CancelledUnitSnapshot[];
/**
* Specific-wagon cancellation: the wagon_booking_allocation ids the customer
* picked in the Wagons tab. T2 releases exactly these (fallback to
* newest-first for any id that no longer exists, e.g. after a re-batch).
*/
allocationIds?: string[];
/**
* The wagon allocations were already released from the schedule at REQUEST
* time (policy: wagons free up immediately; the fee is still owed before the
* credit can be rebooked). Tells T2 to skip its release step so it never
* deletes wagons the batch engine re-assigned in between.
*/
releasedAtRequest?: boolean;
}
/**
* One partial-wagon-cancellation cycle on a PAID booking — the audit trail and
* the state machine. The credit itself is not a wallet balance: redeeming it
* creates a real booking through the under-contract create path and marks it
* PAID (see BookingWagonCancellationService).
*/
@Entity({ schema: 'freight', name: 'booking_wagon_cancellations' })
@Index(['bookingId'])
@Index(['status'])
export class BookingWagonCancellation extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@ManyToOne(() => Booking)
@JoinColumn({ name: 'booking_id' })
booking?: Booking;
@Column({ name: 'rebooked_booking_id', type: 'uuid', nullable: true })
rebookedBookingId?: string | null;
@ManyToOne(() => Booking, { nullable: true })
@JoinColumn({ name: 'rebooked_booking_id' })
rebookedBooking?: Booking | null;
@Column({ name: 'wagons_cancelled', type: 'numeric', precision: 6, scale: 2 })
wagonsCancelled!: number;
@Column({ name: 'weight_tons', type: 'numeric', precision: 12, scale: 3, default: 0 })
weightTons!: number;
@Column({ name: 'cancelled_quantities', type: 'jsonb' })
cancelledQuantities!: CancelledQuantities;
/**
* The freight value of the cancelled part at the ORIGINAL booking's price —
* informational (shown to the customer as "credit worth"); no refund is ever
* issued from it, the credit is redeemed by rebooking.
*/
@Column({ name: 'credit_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
creditAmount!: number;
@Column({ name: 'fee_rate_id', type: 'uuid', nullable: true })
feeRateId?: string | null;
@ManyToOne(() => Rate, { nullable: true })
@JoinColumn({ name: 'fee_rate_id' })
feeRate?: Rate | null;
@Column({ name: 'fee_amount', type: 'numeric', precision: 14, scale: 2 })
feeAmount!: number;
@Column({ name: 'fee_currency', type: 'varchar', length: 8, default: 'ETB' })
feeCurrency!: string;
@Column({ name: 'fee_invoice_id', type: 'uuid', nullable: true })
feeInvoiceId?: string | null;
@ManyToOne(() => Invoice, { nullable: true })
@JoinColumn({ name: 'fee_invoice_id' })
feeInvoice?: Invoice | null;
@Column({ name: 'fee_paid_at', type: 'timestamptz', nullable: true })
feePaidAt?: Date | null;
@Column({ name: 'status', type: 'varchar', length: 30, default: 'FEE_PENDING' })
status!: string;
@Column({ name: 'reason', type: 'text', nullable: true })
reason?: string | null;
@Column({ name: 'requested_by_user_id', type: 'uuid', nullable: true })
requestedByUserId?: string | null;
@Column({ name: 'rebooked_at', type: 'timestamptz', nullable: true })
rebookedAt?: Date | null;
}

View File

@@ -302,6 +302,18 @@ export class Booking extends BaseEntity {
@Column({ name: 'customer_truck_arrived_at', type: 'timestamptz', nullable: true }) @Column({ name: 'customer_truck_arrived_at', type: 'timestamptz', nullable: true })
customerTruckArrivedAt?: Date | null; customerTruckArrivedAt?: Date | null;
/**
* EXPORT only. How the cargo reaches the train:
* - DIRECT_TO_TRAIN — the customer's truck loads straight onto the wagon. No
* warehouse, so no GRN is ever raised and the carriage acceptance sheet is
* the only document handed over.
* - WAREHOUSE (also null) — received into the warehouse and GRN'd first.
*
* Null is treated as WAREHOUSE so existing bookings keep the GRN gate.
*/
@Column({ name: 'export_handover_mode', type: 'varchar', length: 20, nullable: true })
exportHandoverMode?: string | null;
/** /**
* Did the goods need re-handling in the warehouse? Recorded by warehouse * Did the goods need re-handling in the warehouse? Recorded by warehouse
* staff after unloading. Only `true` bills the DOUBLE_HANDLING_FEE rule; * staff after unloading. Only `true` bills the DOUBLE_HANDLING_FEE rule;

View File

@@ -20,7 +20,14 @@ import { CargoesService } from './cargoes.service';
@ApiTags('cargoes') @ApiTags('cargoes')
@Controller('cargoes') @Controller('cargoes')
@FleetView(FREIGHT_PERMS.cargoes.view) // Class gate lists every key its routes use: Nest runs class AND method
// guards, so a key missing here would deny before the route's own key runs.
@FleetView([
FREIGHT_PERMS.cargoes.view,
FREIGHT_PERMS.cargoes.create,
FREIGHT_PERMS.cargoes.update,
FREIGHT_PERMS.cargoes.delete,
])
export class CargoesController { export class CargoesController {
constructor(private readonly cargoesService: CargoesService) {} constructor(private readonly cargoesService: CargoesService) {}

View File

@@ -11,7 +11,6 @@ import {
HttpCode, HttpCode,
HttpStatus, HttpStatus,
UseInterceptors, UseInterceptors,
UseGuards,
UploadedFiles, UploadedFiles,
BadRequestException, BadRequestException,
NotFoundException, NotFoundException,
@@ -20,8 +19,7 @@ import { AnyFilesInterceptor } from "@nestjs/platform-express";
import { ApiOperation, ApiTags, ApiConsumes } from "@nestjs/swagger"; import { ApiOperation, ApiTags, ApiConsumes } from "@nestjs/swagger";
import { CurrentUser } from "@edr/api-common"; import { CurrentUser } from "@edr/api-common";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard"; import { BookingStaff, MixedAudience, PortalCustomer } from "../../common/booking-guards";
import { BookingStaff } from "../../common/booking-guards";
import { import {
assertFreightPermission, assertFreightPermission,
hasFreightPermission, hasFreightPermission,
@@ -118,6 +116,7 @@ export class CompaniesController {
} }
@Get("getInfo") @Get("getInfo")
@PortalCustomer()
@ApiOperation({ summary: "Get company info for the current user" }) @ApiOperation({ summary: "Get company info for the current user" })
async getInfo( async getInfo(
@CurrentUser() user: CurrentIamUser, @CurrentUser() user: CurrentIamUser,
@@ -131,6 +130,7 @@ export class CompaniesController {
} }
@Get("profile") @Get("profile")
@PortalCustomer()
@ApiOperation({ summary: "Get flattened profile for the settings page" }) @ApiOperation({ summary: "Get flattened profile for the settings page" })
async getProfile( async getProfile(
@CurrentUser() user: CurrentIamUser, @CurrentUser() user: CurrentIamUser,
@@ -146,6 +146,7 @@ export class CompaniesController {
} }
@Get("profile/change-request") @Get("profile/change-request")
@PortalCustomer()
@ApiOperation({ @ApiOperation({
summary: "Current user's open profile change request (pending/rejected)", summary: "Current user's open profile change request (pending/rejected)",
}) })
@@ -161,6 +162,7 @@ export class CompaniesController {
} }
@Post("company-profiles/:profileId/reapply") @Post("company-profiles/:profileId/reapply")
@PortalCustomer()
@ApiOperation({ @ApiOperation({
summary: "Resubmit a rejected operational role for approval (→ pending)", summary: "Resubmit a rejected operational role for approval (→ pending)",
}) })
@@ -176,6 +178,7 @@ export class CompaniesController {
} }
@Get("dashboard") @Get("dashboard")
@PortalCustomer()
@ApiOperation({ @ApiOperation({
summary: summary:
"Get portal dashboard KPIs (delivered, spend, freight volume) for the current user", "Get portal dashboard KPIs (delivered, spend, freight volume) for the current user",
@@ -191,6 +194,7 @@ export class CompaniesController {
} }
@Post("fetch-etrade-info") @Post("fetch-etrade-info")
@PortalCustomer()
@ApiOperation({ summary: "Fetch company info from eTrade by TIN" }) @ApiOperation({ summary: "Fetch company info from eTrade by TIN" })
async fetchETradeInfo( async fetchETradeInfo(
@CurrentUser() user: CurrentIamUser, @CurrentUser() user: CurrentIamUser,
@@ -211,6 +215,7 @@ export class CompaniesController {
} }
@Patch("profile") @Patch("profile")
@PortalCustomer()
@ApiOperation({ summary: "Update profile (flattened settings page)" }) @ApiOperation({ summary: "Update profile (flattened settings page)" })
async updateProfile( async updateProfile(
@CurrentUser() user: CurrentIamUser, @CurrentUser() user: CurrentIamUser,
@@ -220,6 +225,7 @@ export class CompaniesController {
} }
@Post("company-profiles") @Post("company-profiles")
@PortalCustomer()
@ApiOperation({ @ApiOperation({
summary: summary:
"Add operational profile(s) (importer/exporter/forwarder) to the current user's company", "Add operational profile(s) (importer/exporter/forwarder) to the current user's company",
@@ -236,6 +242,7 @@ export class CompaniesController {
} }
@Post("onboarding/start") @Post("onboarding/start")
@PortalCustomer()
@ApiOperation({ @ApiOperation({
summary: summary:
"Begin onboarding: create a draft company + profile + role(s) so later steps can save incrementally", "Begin onboarding: create a draft company + profile + role(s) so later steps can save incrementally",
@@ -261,6 +268,7 @@ export class CompaniesController {
} }
@Post("company-profile") @Post("company-profile")
@PortalCustomer()
@ApiOperation({ @ApiOperation({
summary: summary:
"Create a single operational profile for the current user's company. The role starts pending and does not become the active mode", "Create a single operational profile for the current user's company. The role starts pending and does not become the active mode",
@@ -278,6 +286,7 @@ export class CompaniesController {
} }
@Post("company-profiles/:profileId/license") @Post("company-profiles/:profileId/license")
@PortalCustomer()
@UseInterceptors(AnyFilesInterceptor()) @UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data") @ApiConsumes("multipart/form-data")
@ApiOperation({ @ApiOperation({
@@ -298,6 +307,7 @@ export class CompaniesController {
} }
@Post("company-profiles/:profileId/license/:fileId/replace") @Post("company-profiles/:profileId/license/:fileId/replace")
@PortalCustomer()
@UseInterceptors(AnyFilesInterceptor()) @UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data") @ApiConsumes("multipart/form-data")
@ApiOperation({ @ApiOperation({
@@ -324,6 +334,7 @@ export class CompaniesController {
} }
@Delete("company-profiles/:profileId/license/:fileId") @Delete("company-profiles/:profileId/license/:fileId")
@PortalCustomer()
@ApiOperation({ @ApiOperation({
summary: summary:
"Remove a business-license file (staged for review on an approved company).", "Remove a business-license file (staged for review on an approved company).",
@@ -341,6 +352,7 @@ export class CompaniesController {
} }
@Get("company-profiles/:profileId/license") @Get("company-profiles/:profileId/license")
@PortalCustomer()
@ApiOperation({ @ApiOperation({
summary: "List business-license documents (with review state) for a profile", summary: "List business-license documents (with review state) for a profile",
}) })
@@ -352,6 +364,7 @@ export class CompaniesController {
} }
@Get("poa-delegation") @Get("poa-delegation")
@PortalCustomer()
@ApiOperation({ @ApiOperation({
summary: summary:
"List the Power of Attorney delegation letter (with review state) for the current user's company", "List the Power of Attorney delegation letter (with review state) for the current user's company",
@@ -363,6 +376,7 @@ export class CompaniesController {
} }
@Post("poa-delegation") @Post("poa-delegation")
@PortalCustomer()
@UseInterceptors(AnyFilesInterceptor()) @UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data") @ApiConsumes("multipart/form-data")
@ApiOperation({ @ApiOperation({
@@ -383,6 +397,7 @@ export class CompaniesController {
} }
@Delete("poa-delegation/:fileId") @Delete("poa-delegation/:fileId")
@PortalCustomer()
@ApiOperation({ @ApiOperation({
summary: summary:
"Remove the Power of Attorney delegation letter (staged for review on an approved company).", "Remove the Power of Attorney delegation letter (staged for review on an approved company).",
@@ -395,6 +410,7 @@ export class CompaniesController {
} }
@Post("identity/fayda/complete") @Post("identity/fayda/complete")
@PortalCustomer()
@ApiOperation({ @ApiOperation({
summary: summary:
"Bind a completed Fayda verification to the company's owner or Power of Attorney. " + "Bind a completed Fayda verification to the company's owner or Power of Attorney. " +
@@ -405,10 +421,14 @@ export class CompaniesController {
@CurrentUser() user: CurrentIamUser, @CurrentUser() user: CurrentIamUser,
@Body() dto: CompleteIdentityVerificationDto, @Body() dto: CompleteIdentityVerificationDto,
): Promise<CompanyIdentityStateDto> { ): Promise<CompanyIdentityStateDto> {
return this.companiesService.completeIdentityVerification(user.id, dto); return this.companiesService.completeIdentityVerification(user.id, dto, {
email: user.email,
phoneNumber: user.phoneNumber,
});
} }
@Post("identity/gm/same-as-owner") @Post("identity/gm/same-as-owner")
@PortalCustomer()
@ApiOperation({ @ApiOperation({
summary: summary:
"Declare the General Manager is the company's owner, copying the owner's verified identity across. " + "Declare the General Manager is the company's owner, copying the owner's verified identity across. " +
@@ -417,10 +437,14 @@ export class CompaniesController {
async setGmSameAsOwner( async setGmSameAsOwner(
@CurrentUser() user: CurrentIamUser, @CurrentUser() user: CurrentIamUser,
): Promise<CompanyIdentityStateDto> { ): Promise<CompanyIdentityStateDto> {
return this.companiesService.setGmSameAsOwner(user.id); return this.companiesService.setGmSameAsOwner(user.id, {
email: user.email,
phoneNumber: user.phoneNumber,
});
} }
@Delete("identity/gm") @Delete("identity/gm")
@PortalCustomer()
@ApiOperation({ @ApiOperation({
summary: summary:
"Clear the General Manager's identity — the \"same as owner\" declaration or a verification, and the details either wrote. " + "Clear the General Manager's identity — the \"same as owner\" declaration or a verification, and the details either wrote. " +
@@ -432,7 +456,38 @@ export class CompaniesController {
return this.companiesService.clearGmIdentity(user.id); return this.companiesService.clearGmIdentity(user.id);
} }
@Post("identity/poa/same-as-owner")
@PortalCustomer()
@ApiOperation({
summary:
"Declare the Power of Attorney is the company's owner, copying the owner's identity across. " +
"Waives the DARS delegation paper — nobody delegates to themselves. " +
"Refused for an Ethiopian company whose owner is not Fayda-verified yet: its representative must be verified, and there would be nothing proven to copy.",
})
async setPoaSameAsOwner(
@CurrentUser() user: CurrentIamUser,
): Promise<CompanyIdentityStateDto> {
return this.companiesService.setPoaSameAsOwner(user.id, {
email: user.email,
phoneNumber: user.phoneNumber,
});
}
@Delete("identity/poa/same-as-owner")
@PortalCustomer()
@ApiOperation({
summary:
"Undo the Power of Attorney \"same as owner\" declaration and the identity it copied, leaving the representative open to be verified in their own right. " +
"Unlike DELETE identity/fayda/poa this is allowed for a freight forwarder — it is how they change who represents them — and leaves the delegation paper on file.",
})
async clearPoaSameAsOwner(
@CurrentUser() user: CurrentIamUser,
): Promise<CompanyIdentityStateDto> {
return this.companiesService.clearPoaSameAsOwner(user.id);
}
@Delete("identity/fayda/poa") @Delete("identity/fayda/poa")
@PortalCustomer()
@ApiOperation({ @ApiOperation({
summary: summary:
"Remove the company's Power of Attorney — the verified identity, its details and the delegation paper together. " + "Remove the company's Power of Attorney — the verified identity, its details and the delegation paper together. " +
@@ -445,6 +500,7 @@ export class CompaniesController {
} }
@Patch("onboarding-step") @Patch("onboarding-step")
@PortalCustomer()
@ApiOperation({ summary: "Persist the user's current onboarding wizard step" }) @ApiOperation({ summary: "Persist the user's current onboarding wizard step" })
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
async setOnboardingStep( async setOnboardingStep(
@@ -455,6 +511,7 @@ export class CompaniesController {
} }
@Get("onboarding/requirements") @Get("onboarding/requirements")
@PortalCustomer()
@ApiOperation({ @ApiOperation({
summary: summary:
"What the current user's company still needs to finish onboarding (server-driven documents + outstanding items)", "What the current user's company still needs to finish onboarding (server-driven documents + outstanding items)",
@@ -466,6 +523,7 @@ export class CompaniesController {
} }
@Post("onboarding/complete") @Post("onboarding/complete")
@PortalCustomer()
@ApiOperation({ summary: "Mark the current user's onboarding as complete" }) @ApiOperation({ summary: "Mark the current user's onboarding as complete" })
async completeOnboarding( async completeOnboarding(
@CurrentUser() user: CurrentIamUser, @CurrentUser() user: CurrentIamUser,
@@ -477,6 +535,7 @@ export class CompaniesController {
// Used by portal // Used by portal
@Post("create") @Post("create")
@PortalCustomer()
@ApiOperation({ @ApiOperation({
summary: summary:
"Create a company with its associated external profile (onboarding)", "Create a company with its associated external profile (onboarding)",
@@ -591,7 +650,11 @@ export class CompaniesController {
* permission still needs the applicant's documents. * permission still needs the applicant's documents.
*/ */
@Get(":companyId/documents") @Get(":companyId/documents")
@UseGuards(JwtGuard) @MixedAudience([
FREIGHT_PERMS.customers.view,
FREIGHT_PERMS.contracts.view,
FREIGHT_PERMS.bookings.view,
])
@ApiOperation({ summary: "List documents uploaded for a company" }) @ApiOperation({ summary: "List documents uploaded for a company" })
async listDocuments( async listDocuments(
@Param("companyId", ParseUUIDPipe) companyId: string, @Param("companyId", ParseUUIDPipe) companyId: string,
@@ -661,6 +724,7 @@ export class CompaniesController {
} }
@Post(":companyId/documents") @Post(":companyId/documents")
@MixedAudience(FREIGHT_PERMS.customers.update)
@UseInterceptors(AnyFilesInterceptor()) @UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data") @ApiConsumes("multipart/form-data")
@ApiOperation({ summary: "Upload documents for a company (onboarding)" }) @ApiOperation({ summary: "Upload documents for a company (onboarding)" })

View File

@@ -147,6 +147,10 @@ function makeService(overrides: Partial<Ctx> = {}) {
verifayda: { verifayda: {
completeVerification: jest.fn(async () => ctx.verification), completeVerification: jest.fn(async () => ctx.verification),
}, },
// Onboarding requirements resolve the nationality's document set through
// this; no setting means no company documents, which keeps those cases
// focused on the identity/PoA half of the list.
fileUploadSettings: { getByCode: jest.fn(async () => null) },
}; };
const service = new CompaniesService( const service = new CompaniesService(
@@ -157,7 +161,7 @@ function makeService(overrides: Partial<Ctx> = {}) {
deps.profilesRepo as never, deps.profilesRepo as never,
{} as never, {} as never,
deps.filesService as never, deps.filesService as never,
{} as never, deps.fileUploadSettings as never,
{} as never, {} as never,
deps.companyNotifier as never, deps.companyNotifier as never,
{} as never, {} as never,
@@ -225,8 +229,10 @@ describe("Fayda identity verification binds a person to the company", () => {
expect(state.owner.verified).toBe(true); expect(state.owner.verified).toBe(true);
}); });
it("refuses to make one identity both owner and PoA", async () => { it("lets one identity be both owner and PoA", async () => {
const { service } = makeService({ // An owner who represents their own company is the ordinary small-business
// case, not a conflict — the same answer the GM has always been allowed.
const { service, ctx } = makeService({
attributes: { ownerFaydaSub: "same-person" }, attributes: { ownerFaydaSub: "same-person" },
verification: { verification: {
purpose: "VERIFY", purpose: "VERIFY",
@@ -236,13 +242,14 @@ describe("Fayda identity verification binds a person to the company", () => {
}, },
}); });
await expect( const state = await service.completeIdentityVerification("user-1", {
service.completeIdentityVerification("user-1", { subject: "poa",
subject: "poa", code: "c",
code: "c", state: "s",
state: "s", });
}),
).rejects.toBeInstanceOf(BadRequestException); expect(state.poa.verified).toBe(true);
expect(ctx.attributes.poaFaydaSub).toBe("same-person");
}); });
it("stages an owner re-verification for review on an approved company", async () => { it("stages an owner re-verification for review on an approved company", async () => {
@@ -322,41 +329,15 @@ describe("Fayda identity verification binds a person to the company", () => {
// registered phone) with nothing at all. OWNER_VERIFIED is exactly that // registered phone) with nothing at all. OWNER_VERIFIED is exactly that
// shape: a sub, no contact details. // shape: a sub, no contact details.
it("keeps company contact details a Fayda verification never supplied", async () => { it("keeps company contact details a Fayda verification never supplied", async () => {
const { service, deps } = makeService({ const { deps } = makeService({
attributes: { ...OWNER_VERIFIED }, attributes: { ...OWNER_VERIFIED },
}); });
await expect(
service.updateProfile("user-1", {
companyEmail: "account@example.com",
companyPhone: "+251911777777",
} as never),
).resolves.toBeDefined();
const [, patch] = deps.companiesRepo.update.mock.calls.at(-1)!; const [, patch] = deps.companiesRepo.update.mock.calls.at(-1)!;
expect(patch.email).toBe("account@example.com"); expect(patch.email).toBe("account@example.com");
expect(patch.phone).toBe("+251911777777"); expect(patch.phone).toBe("+251911777777");
}); });
it("overwrites company contact details the verification did supply", async () => {
const { service, deps } = makeService({
attributes: {
...OWNER_VERIFIED,
ownerEmail: "abebe@example.com",
ownerPhone: "+251911000000",
},
});
await expect(
service.updateProfile("user-1", {
companyEmail: "someone-else@example.com",
companyPhone: "+251911999999",
} as never),
).resolves.toBeDefined();
const [, patch] = deps.companiesRepo.update.mock.calls.at(-1)!;
expect(patch.email).toBe("abebe@example.com");
expect(patch.phone).toBe("+251911000000");
});
// "Same as owner" copies `ownerEmail ?? null` onto the GM while setting // "Same as owner" copies `ownerEmail ?? null` onto the GM while setting
// `gmFaydaSub`. Locking that null made generalManagerEmail required by // `gmFaydaSub`. Locking that null made generalManagerEmail required by
// onboarding, hidden by the portal's link card and unwritable at once. // onboarding, hidden by the portal's link card and unwritable at once.
@@ -380,6 +361,93 @@ describe("Fayda identity verification binds a person to the company", () => {
).resolves.toBeDefined(); ).resolves.toBeDefined();
}); });
// Fayda's email and phone claims are optional and routinely absent. The owner
// is who the company is reached through and the step renders no input for
// their contact details, so the onboarding account — already OTP-proven —
// stands in rather than leaving the company unreachable.
describe("account contact details stand in for absent Fayda claims", () => {
const noContactClaims = {
purpose: "VERIFY",
verified: true,
sub: "new-sub",
fullName: "Haile Gebrselassie",
address: "Addis Ababa",
};
const account = {
email: "account@example.com",
phoneNumber: "+251911777777",
};
it("falls back to the account for an owner Fayda gave no email or phone", async () => {
const { service, ctx } = makeService({ verification: noContactClaims });
await service.completeIdentityVerification(
"user-1",
{ subject: "owner", code: "c", state: "s" },
account,
);
expect(ctx.attributes.ownerEmail).toBe("account@example.com");
expect(ctx.attributes.ownerPhone).toBe("+251911777777");
});
it("prefers the Fayda claim over the account when there is one", async () => {
const { service, ctx } = makeService();
await service.completeIdentityVerification(
"user-1",
{ subject: "owner", code: "c", state: "s" },
account,
);
expect(ctx.attributes.ownerEmail).toBe("haile@example.com");
expect(ctx.attributes.ownerPhone).toBe("+251922000000");
});
it("leaves the PoA alone — the account is not that person", async () => {
const { service, ctx } = makeService({ verification: noContactClaims });
await service.completeIdentityVerification(
"user-1",
{ subject: "poa", code: "c", state: "s" },
account,
);
expect(ctx.attributes.poaEmail).toBeUndefined();
expect(ctx.attributes.poaPhone).toBeUndefined();
});
// Owners verified before the fallback existed hold blank contacts. Copying
// those blanks onto the GM makes generalManagerEmail required by onboarding
// with no field anywhere to satisfy it.
it("fills the GM copy from the account when the stored owner has no contacts", async () => {
const { service, ctx } = makeService({
attributes: { ...OWNER_VERIFIED },
});
await service.setGmSameAsOwner("user-1", account);
expect(ctx.attributes.gmEmail).toBe("account@example.com");
expect(ctx.attributes.generalManagerEmail).toBe("account@example.com");
expect(ctx.attributes.generalManagerPhone).toBe("+251911777777");
});
it("keeps the stored owner contacts when the GM copy has them", async () => {
const { service, ctx } = makeService({
attributes: {
...OWNER_VERIFIED,
ownerEmail: "abebe@example.com",
ownerPhone: "+251911000111",
},
});
await service.setGmSameAsOwner("user-1", account);
expect(ctx.attributes.generalManagerEmail).toBe("abebe@example.com");
expect(ctx.attributes.generalManagerPhone).toBe("+251911000111");
});
});
it("never locks or gates the general manager — it is not the verified subject", async () => { it("never locks or gates the general manager — it is not the verified subject", async () => {
// GM is a plain typed role; the portal offers a "same as owner" copy, but // GM is a plain typed role; the portal offers a "same as owner" copy, but
// the backend must not treat it as identity-owned or require it verified. // the backend must not treat it as identity-owned or require it verified.
@@ -636,9 +704,8 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
}); });
it("lets the GM verify as the same human as the owner", async () => { it("lets the GM verify as the same human as the owner", async () => {
// The owner/PoA collision check exists because self-delegation is not // One human in every role is the ordinary small-business shape, so
// delegation. It must not fire here: the GM being the owner is a supported // verifying with the owner's own Fayda sub has to succeed.
// answer, so verifying with the owner's own Fayda sub has to succeed.
const { service, ctx } = makeService({ const { service, ctx } = makeService({
attributes: { ...OWNER_VERIFIED }, attributes: { ...OWNER_VERIFIED },
verification: { verification: {
@@ -662,25 +729,42 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
expect(ctx.attributes.generalManagerName).toBe("Abebe Bikila"); expect(ctx.attributes.generalManagerName).toBe("Abebe Bikila");
}); });
it("still refuses a PoA who is the owner", async () => { it("declares the PoA is the owner, copying the verified identity across", async () => {
// The GM exemption above must not have widened into the PoA. const { service, ctx } = makeService({ attributes: { ...OWNER_VERIFIED } });
const { service } = makeService({
attributes: { ...OWNER_VERIFIED }, const state = await service.setPoaSameAsOwner("user-1");
verification: {
purpose: "VERIFY", expect(state.poaSameAsOwner).toBe(true);
verified: true, expect(state.poa.verified).toBe(true);
sub: "owner-sub", expect(ctx.attributes.poaFaydaSub).toBe(OWNER_VERIFIED.ownerFaydaSub);
fullName: "Abebe Bikila", expect(ctx.attributes.poaName).toBe(OWNER_VERIFIED.ownerName);
}, });
it("refuses to declare the PoA is the owner while an Ethiopian owner is unverified", async () => {
// Its representative must be Fayda-verified, so a declaration here would
// record one that could never satisfy the gate.
const { service } = makeService({ attributes: {} });
await expect(service.setPoaSameAsOwner("user-1")).rejects.toBeInstanceOf(
BadRequestException,
);
});
it("undoes the PoA \"same as owner\" declaration without touching a real verification", async () => {
const { service, ctx } = makeService({
attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED },
}); });
await expect( // No declaration in place: the verified representative must survive.
service.completeIdentityVerification("user-1", { await service.clearPoaSameAsOwner("user-1");
subject: "poa", expect(ctx.attributes.poaFaydaSub).toBe(POA_VERIFIED.poaFaydaSub);
code: "c",
state: "s", await service.setPoaSameAsOwner("user-1");
}), const state = await service.clearPoaSameAsOwner("user-1");
).rejects.toBeInstanceOf(BadRequestException);
expect(state.poaSameAsOwner).toBe(false);
expect(state.poa.verified).toBe(false);
expect(ctx.attributes.poaFaydaSub).toBeNull();
}); });
it("reports a pre-existing typed GM as unverified rather than blank", async () => { it("reports a pre-existing typed GM as unverified rather than blank", async () => {
@@ -702,6 +786,73 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
expect(state.gm.email).toBe("legacy@example.com"); expect(state.gm.email).toBe("legacy@example.com");
}); });
// The mirror image, and the reason the fallback above is gated on the GM
// being unverified: a manager Fayda proved but supplied no email for types
// one instead, and the portal decides whether to render that input by asking
// whether the identity holds one. Reading the typed column back as part of
// the verified identity would answer "yes" the moment it was saved — the
// input would vanish and a typo could never be corrected.
it("keeps a verified GM's typed email out of the verified identity", async () => {
const { service, company } = makeService({
attributes: {
...OWNER_VERIFIED,
gmFaydaSub: "gm-sub",
gmFaydaVerifiedAt: "2026-07-03T00:00:00.000Z",
gmName: "Derartu Tulu",
generalManagerName: "Derartu Tulu",
generalManagerEmail: "typed@example.com",
},
});
const state = service.getCompanyIdentityState(company() as never);
expect(state.gm.verified).toBe(true);
expect(state.gm.name).toBe("Derartu Tulu");
expect(state.gm.email).toBeNull();
});
it("never stands the account in for a GM Fayda gave no email", async () => {
// Deliberate: the account is the person onboarding, not necessarily the
// manager. The portal asks for the email instead.
const { service, ctx } = makeService({
attributes: { ...OWNER_VERIFIED },
verification: {
purpose: "VERIFY",
verified: true,
sub: "gm-sub",
fullName: "Derartu Tulu",
phoneNumber: "+251911222333",
},
});
const state = await service.completeIdentityVerification(
"user-1",
{ subject: "gm", code: "c", state: "s" },
{ email: "account@example.com", phoneNumber: "+251911777777" },
);
expect(ctx.attributes.gmEmail).toBeUndefined();
expect(state.gm.verified).toBe(true);
expect(state.gm.email).toBeNull();
});
it("accepts the email typed for a GM whose verification carried none", async () => {
const { service, ctx } = makeService({
attributes: {
...OWNER_VERIFIED,
gmFaydaSub: "gm-sub",
gmName: "Derartu Tulu",
generalManagerName: "Derartu Tulu",
},
});
await service.updateProfile("user-1", {
generalManagerEmail: "gm@example.com",
} as never);
expect(ctx.attributes.generalManagerEmail).toBe("gm@example.com");
});
it("does not let an unproven GM block the company from trading", async () => { it("does not let an unproven GM block the company from trading", async () => {
// The GM names who to talk to, not what the company may do. Capturing it // The GM names who to talk to, not what the company may do. Capturing it
// through Fayda changed how it is collected, not whether it gates. // through Fayda changed how it is collected, not whether it gates.
@@ -714,3 +865,94 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
).resolves.toBeDefined(); ).resolves.toBeDefined();
}); });
}); });
/**
* Fayda's email and phone claims are optional, so a *verified* representative
* can still be missing the details `REQUIRED_POA_FIELDS` demands. The PoA step
* renders an input for whatever the verification did not supply — so onboarding
* has to report them outstanding, rather than letting a freight forwarder
* submit an incomplete representative and be refused its next PoA edit for it.
*/
describe("onboarding requirements name the PoA details Fayda did not supply", () => {
const POA_VERIFIED_NO_CONTACTS = {
poaFaydaSub: "poa-sub",
poaFaydaVerifiedAt: "2026-07-02T00:00:00.000Z",
poaName: "Tirunesh Dibaba",
};
it("reports the missing email and phone for a freight forwarder", async () => {
const { service } = makeService({
attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED_NO_CONTACTS },
profileTypes: [ProfileType.freightForwarder],
files: [paper()],
});
const req = await service.getOnboardingRequirements("user-1");
expect(req.poa.missingFields.map((f) => f.key)).toEqual([
"poaEmail",
"poaPhone",
]);
expect(req.poa.complete).toBe(false);
expect(req.outstanding).toEqual(
expect.arrayContaining(["Add your poa email", "Add your poa phone"]),
);
});
it("clears once they are typed", async () => {
const { service } = makeService({
attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED },
profileTypes: [ProfileType.freightForwarder],
files: [paper()],
});
const req = await service.getOnboardingRequirements("user-1");
expect(req.poa.missingFields).toEqual([]);
expect(req.poa.complete).toBe(true);
expect(req.outstanding).not.toContain("Add your poa email");
});
// Fayda's email claim is optional and the GM's verification has no account to
// fall back on, so demanding one blocked a manager the government had already
// proved. `companyNotifyEmailExpr` resolves the address from the contact
// person or the registering account instead, so nothing needs this filled.
it("does not hold a company back for a general manager with no email", async () => {
const { service } = makeService({
attributes: {
...OWNER_VERIFIED,
gmFaydaSub: "gm-sub",
generalManagerName: "Derartu Tulu",
generalManagerPhone: "+251911222333",
},
});
const req = await service.getOnboardingRequirements("user-1");
expect(req.companyInfo.missingFields.map((f) => f.key)).not.toContain(
"generalManagerEmail",
);
expect(req.outstanding).not.toContain("Add your general manager email");
});
it("still holds it back for the manager's name and phone", async () => {
const { service } = makeService({ attributes: { ...OWNER_VERIFIED } });
const req = await service.getOnboardingRequirements("user-1");
expect(req.companyInfo.missingFields.map((f) => f.key)).toEqual(
expect.arrayContaining(["generalManagerName", "generalManagerPhone"]),
);
});
// An importer that never named a representative owes nothing here — the step
// is one it may walk straight past.
it("asks nothing of a company with no PoA at all", async () => {
const { service } = makeService({ attributes: { ...OWNER_VERIFIED } });
const req = await service.getOnboardingRequirements("user-1");
expect(req.poa.missingFields).toEqual([]);
expect(req.poa.complete).toBe(true);
});
});

View File

@@ -174,6 +174,31 @@ describe("PoA delegation paper is enforced wherever PoA state changes", () => {
).resolves.toBeDefined(); ).resolves.toBeDefined();
}); });
it("waives the paper when the owner represents the company themselves", async () => {
// Nobody delegates to themselves, so a self-declared PoA owes no DARS
// paper — the representative's own details are still required.
const { service } = makeService({
attributes: { ...VERIFIED_IDENTITIES, poaSameAsOwner: true },
});
await expect(
service.updateProfile("user-1", POA as never),
).resolves.toBeDefined();
});
it("grants the forwarder role to a self-represented company with no paper", async () => {
const { service } = makeService({
attributes: { ...VERIFIED_IDENTITIES, ...POA, poaSameAsOwner: true },
});
await expect(
service.createCompanyProfileForUser(
"user-1",
ProfileType.freightForwarder,
),
).resolves.toBeDefined();
});
it("rejects a paper the reviewer sent back for correction", async () => { it("rejects a paper the reviewer sent back for correction", async () => {
const { service } = makeService({ files: [paper("change_requested")] }); const { service } = makeService({ files: [paper("change_requested")] });

View File

@@ -133,12 +133,6 @@ const IDENTITY_PREFIX: Record<IdentitySubject, string> = {
gm: "gm", gm: "gm",
}; };
const IDENTITY_LABEL: Record<IdentitySubject, string> = {
owner: "owner",
poa: "Power of Attorney",
gm: "General Manager",
};
/** /**
* Typed GM columns a GM verification also writes. Three notifier services mail * Typed GM columns a GM verification also writes. Three notifier services mail
* `company.generalManagerEmail` directly, so leaving these behind would mean a * `company.generalManagerEmail` directly, so leaving these behind would mean a
@@ -231,41 +225,42 @@ export class CompaniesService {
label: string; label: string;
get: (company: Company) => unknown; get: (company: Company) => unknown;
}[] = [ }[] = [
{ {
key: "tinNumber", key: "tinNumber",
label: "Company TIN", label: "Company TIN",
get: (c) => (c.tin && !c.tin.startsWith("D") ? c.tin : null), get: (c) => (c.tin && !c.tin.startsWith("D") ? c.tin : null),
}, },
{ key: "companyEmail", label: "Company email", get: (c) => c.email }, { key: "companyAddress", label: "Company address", get: (c) => c.address },
{ key: "companyPhone", label: "Company phone", get: (c) => c.phone }, { key: "fanNumber", label: "FAN number", get: (c) => c.fanNumber },
{ key: "companyAddress", label: "Company address", get: (c) => c.address }, {
{ key: "fanNumber", label: "FAN number", get: (c) => c.fanNumber }, key: "contactPersonName",
{ label: "Contact person name",
key: "contactPersonName", get: (c) => c.attributes?.contactPersonName,
label: "Contact person name", },
get: (c) => c.attributes?.contactPersonName, {
}, key: "contactPersonPhone",
{ label: "Contact person phone",
key: "contactPersonPhone", get: (c) => c.attributes?.contactPersonPhone,
label: "Contact person phone", },
get: (c) => c.attributes?.contactPersonPhone, {
}, key: "generalManagerName",
{ label: "General manager name",
key: "generalManagerName", get: (c) => c.attributes?.generalManagerName,
label: "General manager name", },
get: (c) => c.attributes?.generalManagerName, // The manager's EMAIL is deliberately absent. It was demanded because the
}, // notifiers were believed to mail it, and Fayda's email claim is optional
{ // — so a manager the government proved without one blocked the whole
key: "generalManagerEmail", // submission over an address nothing could produce. `companyNotifyEmailExpr`
label: "General manager email", // now resolves the address itself and falls through to the contact
get: (c) => c.attributes?.generalManagerEmail, // person's, then to the registering account's (which signup guarantees),
}, // so nothing depends on this being filled. It is still collected and still
{ // preferred when present; it just no longer holds the company hostage.
key: "generalManagerPhone", {
label: "General manager phone", key: "generalManagerPhone",
get: (c) => c.attributes?.generalManagerPhone, label: "General manager phone",
}, get: (c) => c.attributes?.generalManagerPhone,
]; },
];
/** The nationality-based document setting code for a company. */ /** The nationality-based document setting code for a company. */
private documentSettingCodeFor( private documentSettingCodeFor(
@@ -314,8 +309,6 @@ export class CompaniesService {
fanNumber: dto.fanNumber ?? null, fanNumber: dto.fanNumber ?? null,
country: dto.companyLocation ?? "Ethiopia", country: dto.companyLocation ?? "Ethiopia",
address: dto.companyAddress ?? null, address: dto.companyAddress ?? null,
phone: normalizeE164(dto.companyPhone) ?? null,
email: dto.companyEmail ?? null,
attributes: dto.attributes ?? null, attributes: dto.attributes ?? null,
}); });
@@ -492,7 +485,8 @@ export class CompaniesService {
async findCompanyById(id: string): Promise<Company> { async findCompanyById(id: string): Promise<Company> {
const company = await this.companiesRepo.findById(id); const company = await this.companiesRepo.findById(id);
if (!company) throw new NotFoundException(`Company ${id} not found`); if (!company) throw new NotFoundException(`Company ${id} not found`);
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(id); company.companyProfiles =
await this.companyProfilesRepo.findByCompanyId(id);
// External profiles carry the onboarding flag the backoffice gates // External profiles carry the onboarding flag the backoffice gates
// approval decisions on (see ResponseCompanyDto.onboardingCompleted). // approval decisions on (see ResponseCompanyDto.onboardingCompleted).
company.profiles = await this.profilesRepo.findByCompanyId(id); company.profiles = await this.profilesRepo.findByCompanyId(id);
@@ -515,9 +509,7 @@ export class CompaniesService {
); );
} }
if (profile.status !== ProfileStatus.Active) { if (profile.status !== ProfileStatus.Active) {
throw new BadRequestException( throw new BadRequestException("Selected company profile is not active");
"Selected company profile is not active",
);
} }
return profile; return profile;
} }
@@ -760,11 +752,6 @@ export class CompaniesService {
}; };
const keys: string[] = []; const keys: string[] = [];
if (attrs.ownerFaydaSub) {
// The Company-column mirrors of the owner's verified contact details.
if (held("ownerEmail")) keys.push("companyEmail");
if (held("ownerPhone")) keys.push("companyPhone");
}
for (const subject of IDENTITY_SUBJECTS) { for (const subject of IDENTITY_SUBJECTS) {
if (!attrs[`${IDENTITY_PREFIX[subject]}FaydaSub`]) continue; if (!attrs[`${IDENTITY_PREFIX[subject]}FaydaSub`]) continue;
keys.push(...IDENTITY_OWNED_FIELDS[subject].filter(held)); keys.push(...IDENTITY_OWNED_FIELDS[subject].filter(held));
@@ -789,9 +776,6 @@ export class CompaniesService {
if (dto.nationality !== undefined) if (dto.nationality !== undefined)
companyUpdates.nationality = dto.nationality; companyUpdates.nationality = dto.nationality;
if (dto.companyName !== undefined) companyUpdates.name = dto.companyName; if (dto.companyName !== undefined) companyUpdates.name = dto.companyName;
if (dto.companyEmail !== undefined) companyUpdates.email = dto.companyEmail;
if (dto.companyPhone !== undefined)
companyUpdates.phone = normalizeE164(dto.companyPhone);
if (dto.companyLocation !== undefined) if (dto.companyLocation !== undefined)
companyUpdates.country = dto.companyLocation; companyUpdates.country = dto.companyLocation;
if (dto.companyAddress !== undefined) if (dto.companyAddress !== undefined)
@@ -809,7 +793,9 @@ export class CompaniesService {
if (dto.contactPersonPhone !== undefined) if (dto.contactPersonPhone !== undefined)
attrUpdates.contactPersonPhone = normalizeE164(dto.contactPersonPhone); attrUpdates.contactPersonPhone = normalizeE164(dto.contactPersonPhone);
if (dto.contactVerifiedPhone !== undefined) if (dto.contactVerifiedPhone !== undefined)
attrUpdates.contactVerifiedPhone = normalizeE164(dto.contactVerifiedPhone); attrUpdates.contactVerifiedPhone = normalizeE164(
dto.contactVerifiedPhone,
);
if (dto.generalManagerName !== undefined) if (dto.generalManagerName !== undefined)
attrUpdates.generalManagerName = dto.generalManagerName; attrUpdates.generalManagerName = dto.generalManagerName;
if (dto.generalManagerEmail !== undefined) if (dto.generalManagerEmail !== undefined)
@@ -820,7 +806,8 @@ export class CompaniesService {
if (dto.poaPhone !== undefined) if (dto.poaPhone !== undefined)
attrUpdates.poaPhone = normalizeE164(dto.poaPhone); attrUpdates.poaPhone = normalizeE164(dto.poaPhone);
if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail; if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail;
if (dto.poaLocation !== undefined) attrUpdates.poaLocation = dto.poaLocation; if (dto.poaLocation !== undefined)
attrUpdates.poaLocation = dto.poaLocation;
if (dto.poaAddress !== undefined) attrUpdates.poaAddress = dto.poaAddress; if (dto.poaAddress !== undefined) attrUpdates.poaAddress = dto.poaAddress;
if (dto.licenceNumber !== undefined) if (dto.licenceNumber !== undefined)
@@ -857,12 +844,6 @@ export class CompaniesService {
Object.assign(attrUpdates, dto.faydaIdentity); Object.assign(attrUpdates, dto.faydaIdentity);
} }
// companyEmail/companyPhone are the Company-column mirrors of the owner's
// verified contact details (the portal derives and submits them, it never
// lets the customer type them once verified) — lock them the same way
// ownerEmail/ownerPhone themselves are locked below, once there is a
// verified owner to lock them to.
//
// Keyed on the verified VALUE, not on `ownerFaydaSub`: Fayda's email and // Keyed on the verified VALUE, not on `ownerFaydaSub`: Fayda's email and
// phone claims are optional, so a verification can prove the person while // phone claims are optional, so a verification can prove the person while
// supplying neither (see completeIdentityVerification's conditional // supplying neither (see completeIdentityVerification's conditional
@@ -872,9 +853,8 @@ export class CompaniesService {
// forever, and re-verifying could never clear it because Fayda still has // forever, and re-verifying could never clear it because Fayda still has
// nothing to return. // nothing to return.
if (attrUpdates.ownerFaydaSub) { if (attrUpdates.ownerFaydaSub) {
if (attrUpdates.ownerEmail && dto.companyEmail !== undefined) if (attrUpdates.ownerEmail) companyUpdates.email = attrUpdates.ownerEmail;
companyUpdates.email = attrUpdates.ownerEmail; if (attrUpdates.ownerPhone)
if (attrUpdates.ownerPhone && dto.companyPhone !== undefined)
companyUpdates.phone = normalizeE164(String(attrUpdates.ownerPhone)); companyUpdates.phone = normalizeE164(String(attrUpdates.ownerPhone));
} }
@@ -1086,9 +1066,7 @@ export class CompaniesService {
} }
/** List a company's change requests, newest first (backoffice review). */ /** List a company's change requests, newest first (backoffice review). */
async listChangeRequests( async listChangeRequests(companyId: string): Promise<CompanyChangeRequest[]> {
companyId: string,
): Promise<CompanyChangeRequest[]> {
await this.findCompanyById(companyId); await this.findCompanyById(companyId);
return this.changeRequestRepo.findByCompanyId(companyId); return this.changeRequestRepo.findByCompanyId(companyId);
} }
@@ -1150,8 +1128,7 @@ export class CompaniesService {
reviewerId?: string, reviewerId?: string,
): Promise<CompanyChangeRequest> { ): Promise<CompanyChangeRequest> {
const request = await this.changeRequestRepo.findById(id); const request = await this.changeRequestRepo.findById(id);
if (!request) if (!request) throw new NotFoundException(`Change request ${id} not found`);
throw new NotFoundException(`Change request ${id} not found`);
if (request.status !== ChangeRequestStatus.Pending) { if (request.status !== ChangeRequestStatus.Pending) {
throw new BadRequestException( throw new BadRequestException(
`Change request ${id} is already ${request.status}`, `Change request ${id} is already ${request.status}`,
@@ -1164,7 +1141,10 @@ export class CompaniesService {
const snapshot = (request.snapshot ?? {}) as Partial<UpdateProfileDto>; const snapshot = (request.snapshot ?? {}) as Partial<UpdateProfileDto>;
await this.assertTinAvailable(company, snapshot.tin); await this.assertTinAvailable(company, snapshot.tin);
const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, snapshot); const companyUpdates = this.mapProfileDtoToCompanyUpdates(
company,
snapshot,
);
await this.companiesRepo.update(company.id, companyUpdates); await this.companiesRepo.update(company.id, companyUpdates);
await this.applyLicenseChanges(request); await this.applyLicenseChanges(request);
await this.applyDocumentChanges(request); await this.applyDocumentChanges(request);
@@ -1294,7 +1274,12 @@ export class CompaniesService {
); );
} }
if (documentChanges.length > 0) { if (documentChanges.length > 0) {
await this.recordCompanyRevision(company, {}, submittedBy, documentChanges); await this.recordCompanyRevision(
company,
{},
submittedBy,
documentChanges,
);
} }
return uploaded; return uploaded;
} }
@@ -1414,7 +1399,11 @@ export class CompaniesService {
status: ChangeRequestStatus.Pending, status: ChangeRequestStatus.Pending,
}); });
if (company) { if (company) {
this.companyNotifier.changeRequestSubmitted(company, existing.id, false); this.companyNotifier.changeRequestSubmitted(
company,
existing.id,
false,
);
} }
} else { } else {
const history = await this.changeRequestRepo.findByCompanyId(companyId); const history = await this.changeRequestRepo.findByCompanyId(companyId);
@@ -1446,8 +1435,7 @@ export class CompaniesService {
reviewerId?: string, reviewerId?: string,
): Promise<CompanyChangeRequest> { ): Promise<CompanyChangeRequest> {
const request = await this.changeRequestRepo.findById(id); const request = await this.changeRequestRepo.findById(id);
if (!request) if (!request) throw new NotFoundException(`Change request ${id} not found`);
throw new NotFoundException(`Change request ${id} not found`);
if (request.status !== ChangeRequestStatus.Pending) { if (request.status !== ChangeRequestStatus.Pending) {
throw new BadRequestException( throw new BadRequestException(
`Change request ${id} is already ${request.status}`, `Change request ${id} is already ${request.status}`,
@@ -1486,8 +1474,7 @@ export class CompaniesService {
reviewerId?: string, reviewerId?: string,
): Promise<CompanyChangeRequest> { ): Promise<CompanyChangeRequest> {
const request = await this.changeRequestRepo.findById(id); const request = await this.changeRequestRepo.findById(id);
if (!request) if (!request) throw new NotFoundException(`Change request ${id} not found`);
throw new NotFoundException(`Change request ${id} not found`);
if (request.status !== ChangeRequestStatus.Pending) { if (request.status !== ChangeRequestStatus.Pending) {
throw new BadRequestException( throw new BadRequestException(
`Change request ${id} is already ${request.status}`, `Change request ${id} is already ${request.status}`,
@@ -1569,10 +1556,7 @@ export class CompaniesService {
const reactivating = const reactivating =
status === ProfileStatus.Active && status === ProfileStatus.Active &&
existing.status === ProfileStatus.Suspended; existing.status === ProfileStatus.Suspended;
if ( if ((status === ProfileStatus.Suspended || reactivating) && !note?.trim()) {
(status === ProfileStatus.Suspended || reactivating) &&
!note?.trim()
) {
throw new BadRequestException( throw new BadRequestException(
status === ProfileStatus.Suspended status === ProfileStatus.Suspended
? "A message explaining the suspension is required — the customer will see it." ? "A message explaining the suspension is required — the customer will see it."
@@ -1594,7 +1578,9 @@ export class CompaniesService {
existing.status === ProfileStatus.Pending || existing.status === ProfileStatus.Pending ||
existing.status === ProfileStatus.Rejected; existing.status === ProfileStatus.Rejected;
if (awaitingReview) { if (awaitingReview) {
const owners = await this.profilesRepo.findByCompanyId(existing.companyId); const owners = await this.profilesRepo.findByCompanyId(
existing.companyId,
);
if (owners.length > 0 && !owners.some((o) => o.onboardingCompleted)) { if (owners.length > 0 && !owners.some((o) => o.onboardingCompleted)) {
throw new BadRequestException( throw new BadRequestException(
"This customer hasn't finished onboarding yet. Their roles can be reviewed once they submit their application.", "This customer hasn't finished onboarding yet. Their roles can be reviewed once they submit their application.",
@@ -1652,11 +1638,17 @@ export class CompaniesService {
const names = pending.map((f) => f.name).join(", "); const names = pending.map((f) => f.name).join(", ");
throw new BadRequestException( throw new BadRequestException(
`This role has ${pending.length} document(s) awaiting customer correction (${names}). ` + `This role has ${pending.length} document(s) awaiting customer correction (${names}). ` +
`Approve it once the customer has re-uploaded them, or withdraw the change request first.`, `Approve it once the customer has re-uploaded them, or withdraw the change request first.`,
); );
} }
return this.applyProfileStatus(manager, existing, status, note, reviewerId); return this.applyProfileStatus(
manager,
existing,
status,
note,
reviewerId,
);
}); });
} }
@@ -1992,7 +1984,9 @@ export class CompaniesService {
.map((f) => ({ key: f.key, label: f.label })); .map((f) => ({ key: f.key, label: f.label }));
// 2. Nationality-based company documents + which are already uploaded. // 2. Nationality-based company documents + which are already uploaded.
const documentSettingCode = this.documentSettingCodeFor(company.nationality); const documentSettingCode = this.documentSettingCodeFor(
company.nationality,
);
const [setting, uploadedFiles] = await Promise.all([ const [setting, uploadedFiles] = await Promise.all([
this.fileUploadSettingsService this.fileUploadSettingsService
.getByCode(documentSettingCode) .getByCode(documentSettingCode)
@@ -2045,18 +2039,37 @@ export class CompaniesService {
const poaProvided = POA_ATTRIBUTES.some((k) => const poaProvided = POA_ATTRIBUTES.some((k) =>
(company.attributes?.[k] as string | undefined)?.trim(), (company.attributes?.[k] as string | undefined)?.trim(),
); );
// No company types its PoA details — they arrive from the Fayda
// verification whatever the nationality — so reporting them as missing
// fields would ask for something no form offers. The identity block below
// reports "verify your PoA" instead.
const missingPoaFields: typeof REQUIRED_POA_FIELDS = [];
const delegation = await this.getPoaDelegationState(company.id); const delegation = await this.getPoaDelegationState(company.id);
const delegationDue = poaRequired || poaProvided; // "There is a representative" and "a paper is owed for them" used to be the
// same condition. They part company once the owner represents the company
// themselves: the representative's details are still required, but nobody
// delegates to themselves, so no DARS paper is due (`assertPoaDelegationSatisfied`
// returns on the same flag — the two must agree).
const poaDue = poaRequired || poaProvided;
const delegationDue = poaDue && !identity.poaSameAsOwner;
// The representative's details normally arrive from their Fayda
// verification — but Fayda's email and phone claims are optional and
// routinely come back empty, and the PoA step renders an input for whatever
// the verification did not supply. So these are askable after all, and are
// reported outstanding once a PoA is required or provided; reporting
// nothing here let a freight forwarder finish onboarding with a
// representative the API's own `REQUIRED_POA_FIELDS` calls incomplete, then
// 400'd their next PoA edit for it.
const missingPoaFields = poaDue
? REQUIRED_POA_FIELDS.filter(
(f) => !(company.attributes?.[f.key] as string | undefined)?.trim(),
)
: [];
const missingDelegation = delegationDue && !delegation.onFile; const missingDelegation = delegationDue && !delegation.onFile;
// A paper the reviewer sent back is not evidence — the customer has to // A paper the reviewer sent back is not evidence — the customer has to
// replace it before the application counts as complete. // replace it before the application counts as complete.
const flaggedDelegation = delegationDue && delegation.flagged; const flaggedDelegation = delegationDue && delegation.flagged;
// Mirrors `poaProven` in buildCompanyIdentityState — see the note there.
const poaProven = identity.faydaRequired
? identity.poa.verified
: identity.poa.verified || Boolean(identity.poa.name?.trim());
const outstanding = [ const outstanding = [
...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`), ...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`),
...missingDocs.map((d) => `Upload your ${d.fileLabel}`), ...missingDocs.map((d) => `Upload your ${d.fileLabel}`),
@@ -2069,13 +2082,26 @@ export class CompaniesService {
? [`Upload the ${POA_DELEGATION_LABEL} for your Power of Attorney`] ? [`Upload the ${POA_DELEGATION_LABEL} for your Power of Attorney`]
: []), : []),
...(flaggedDelegation ...(flaggedDelegation
? [`Re-upload your ${POA_DELEGATION_LABEL} — EDR asked for a correction`] ? [
`Re-upload your ${POA_DELEGATION_LABEL} — EDR asked for a correction`,
]
: []), : []),
...(identity.faydaRequired && !identity.owner.verified ...(identity.faydaRequired && !identity.owner.verified
? ["Verify the company owner's identity with Fayda"] ? ["Verify the company owner's identity with Fayda"]
: []), : []),
...((poaRequired || poaProvided) && !identity.poa.verified // Nationality-aware, exactly like `poaProven` in
? ["Verify your Power of Attorney's identity with Fayda"] // buildCompanyIdentityState and the check in `assertIdentityVerified`:
// Fayda is an Ethiopian national ID, so a foreign company's typed
// representative has to count. Demanding a verification here regardless
// made this list disagree with the rule actually enforced, and left a
// foreign freight forwarder unable to submit — asked for a Fayda
// verification its representative may have no way to obtain.
...((poaRequired || poaProvided) && !poaProven
? [
identity.faydaRequired
? "Verify your Power of Attorney's identity with Fayda"
: "Name your Power of Attorney, or verify them with Fayda",
]
: []), : []),
...(identity.passportRequired && !identity.owner.passportNumber ...(identity.passportRequired && !identity.owner.passportNumber
? ["Add the company owner's passport number"] ? ["Add the company owner's passport number"]
@@ -2086,20 +2112,26 @@ export class CompaniesService {
// fields, required documents, one license per operational profile, and the // fields, required documents, one license per operational profile, and the
// PoA details/paper whenever those are mandatory. // PoA details/paper whenever those are mandatory.
const requiredDocCount = documents.filter((d) => d.isRequired).length; const requiredDocCount = documents.filter((d) => d.isRequired).length;
const poaItemCount = delegationDue ? 1 : 0; // The delegation paper plus the representative's own required details —
// `completed` below subtracts every one of those it is still missing, so
// leaving them out of the total would make the bar understate progress.
const poaItemCount =
(poaDue ? REQUIRED_POA_FIELDS.length : 0) + (delegationDue ? 1 : 0);
// One item per identity credential the company has to prove: the owner // One item per identity credential the company has to prove: the owner
// always (Fayda for Ethiopian, passport for foreign), plus the PoA once // always (Fayda for Ethiopian, passport for foreign), plus the PoA once
// there is one — that one is Fayda whatever the nationality. // there is one — Fayda for an Ethiopian company, a named representative
// for a foreign one, same rule as `poaProven` above. Counting a foreign
// company's typed PoA as unproven here left the progress bar permanently
// short of 100% on an item it had already satisfied.
const ownerCredentialDue = const ownerCredentialDue =
identity.faydaRequired || identity.passportRequired; identity.faydaRequired || identity.passportRequired;
const ownerCredentialProven = identity.faydaRequired const ownerCredentialProven = identity.faydaRequired
? identity.owner.verified ? identity.owner.verified
: Boolean(identity.owner.passportNumber); : Boolean(identity.owner.passportNumber);
const identityItemCount = const identityItemCount = (ownerCredentialDue ? 1 : 0) + (poaDue ? 1 : 0);
(ownerCredentialDue ? 1 : 0) + (delegationDue ? 1 : 0);
const missingIdentityCount = const missingIdentityCount =
(ownerCredentialDue && !ownerCredentialProven ? 1 : 0) + (ownerCredentialDue && !ownerCredentialProven ? 1 : 0) +
(delegationDue && !identity.poa.verified ? 1 : 0); (poaDue && !poaProven ? 1 : 0);
const total = const total =
requiredInfo.length + requiredInfo.length +
requiredDocCount + requiredDocCount +
@@ -2118,12 +2150,16 @@ export class CompaniesService {
return new OnboardingRequirementsResponseDto({ return new OnboardingRequirementsResponseDto({
documentSettingCode, documentSettingCode,
nationality: company.nationality ?? CompanyNationality.Ethiopian, nationality: company.nationality ?? CompanyNationality.Ethiopian,
companyInfo: { complete: missingInfo.length === 0, missingFields: missingInfo }, companyInfo: {
complete: missingInfo.length === 0,
missingFields: missingInfo,
},
documents, documents,
licenseProfiles, licenseProfiles,
poa: { poa: {
required: poaRequired, required: poaRequired,
provided: poaProvided, provided: poaProvided,
delegationLetterRequired: delegationDue,
delegationLetterUploaded: delegation.onFile, delegationLetterUploaded: delegation.onFile,
delegationLetterFlagged: delegation.flagged, delegationLetterFlagged: delegation.flagged,
missingFields: missingPoaFields, missingFields: missingPoaFields,
@@ -2160,7 +2196,7 @@ export class CompaniesService {
if (!requirements.isComplete) { if (!requirements.isComplete) {
throw new BadRequestException( throw new BadRequestException(
requirements.outstanding[0] ?? requirements.outstanding[0] ??
"Your onboarding is incomplete. Please complete all required steps before submitting.", "Your onboarding is incomplete. Please complete all required steps before submitting.",
); );
} }
@@ -2169,7 +2205,10 @@ export class CompaniesService {
const profiles = await this.companyProfilesRepo.findByCompanyId(companyId); const profiles = await this.companyProfilesRepo.findByCompanyId(companyId);
for (const cp of profiles) { for (const cp of profiles) {
if (cp.status !== ProfileStatus.Pending) { if (cp.status !== ProfileStatus.Pending) {
await this.companyProfilesRepo.updateStatus(cp.id, ProfileStatus.Pending); await this.companyProfilesRepo.updateStatus(
cp.id,
ProfileStatus.Pending,
);
} }
} }
@@ -2195,12 +2234,12 @@ export class CompaniesService {
case CompanyStatus.Suspended: case CompanyStatus.Suspended:
throw new ForbiddenException( throw new ForbiddenException(
`Your company account is suspended — you can't create ${action} right now. ` + `Your company account is suspended — you can't create ${action} right now. ` +
`Please contact EDR support for details.`, `Please contact EDR support for details.`,
); );
case CompanyStatus.Blacklisted: case CompanyStatus.Blacklisted:
throw new ForbiddenException( throw new ForbiddenException(
`Your company account is blacklisted — you can't create ${action}. ` + `Your company account is blacklisted — you can't create ${action}. ` +
`Please contact EDR support.`, `Please contact EDR support.`,
); );
default: default:
throw new ForbiddenException( throw new ForbiddenException(
@@ -2229,8 +2268,7 @@ export class CompaniesService {
switch (profile.status) { switch (profile.status) {
case ProfileStatus.Suspended: case ProfileStatus.Suspended:
throw new ForbiddenException( throw new ForbiddenException(
`Your ${role} role is suspended${ `Your ${role} role is suspended${profile.reviewNote ? `${profile.reviewNote}` : ""
profile.reviewNote ? `${profile.reviewNote}` : ""
}. Your other roles are unaffected. Please contact EDR support to resolve this.`, }. Your other roles are unaffected. Please contact EDR support to resolve this.`,
); );
case ProfileStatus.Blacklisted: case ProfileStatus.Blacklisted:
@@ -2239,8 +2277,7 @@ export class CompaniesService {
); );
case ProfileStatus.Rejected: case ProfileStatus.Rejected:
throw new ForbiddenException( throw new ForbiddenException(
`Your ${role} role was rejected${ `Your ${role} role was rejected${profile.reviewNote ? `${profile.reviewNote}` : ""
profile.reviewNote ? `${profile.reviewNote}` : ""
}. Amend and resubmit it from your settings page.`, }. Amend and resubmit it from your settings page.`,
); );
default: default:
@@ -2499,9 +2536,7 @@ export class CompaniesService {
LICENSE_RESOURCE, LICENSE_RESOURCE,
); );
return records return records
.filter( .filter((r) => r.code === LICENSE_CODE || r.code === LICENSE_PENDING_CODE)
(r) => r.code === LICENSE_CODE || r.code === LICENSE_PENDING_CODE,
)
.map((r) => ({ .map((r) => ({
id: r.id, id: r.id,
name: r.name, name: r.name,
@@ -2668,11 +2703,17 @@ export class CompaniesService {
if (missing.length > 0) { if (missing.length > 0) {
throw new BadRequestException( throw new BadRequestException(
`A freight forwarder acts on other companies' behalf, so a Power of Attorney is required. ` + `A freight forwarder acts on other companies' behalf, so a Power of Attorney is required. ` +
`Add the ${missing.map((f) => f.label.toLowerCase()).join(", ")} first.`, `Add the ${missing.map((f) => f.label.toLowerCase()).join(", ")} first.`,
); );
} }
} }
// Nobody delegates to themselves: an owner representing their own company
// has no delegation to evidence, so the DARS paper is not owed. The
// representative's own details are still required above — a forwarder's
// counterparties need someone to contact either way.
if (attributes?.poaSameAsOwner) return;
const { onFile, flagged } = await this.getPoaDelegationState( const { onFile, flagged } = await this.getPoaDelegationState(
companyId, companyId,
opts.ignoreFileIds, opts.ignoreFileIds,
@@ -2680,13 +2721,13 @@ export class CompaniesService {
if (!onFile) { if (!onFile) {
throw new BadRequestException( throw new BadRequestException(
`Upload the ${POA_DELEGATION_LABEL} for the Power of Attorney` + `Upload the ${POA_DELEGATION_LABEL} for the Power of Attorney` +
(opts.requirePoa ? " — it is required for freight forwarders." : "."), (opts.requirePoa ? " — it is required for freight forwarders." : "."),
); );
} }
if (flagged) { if (flagged) {
throw new BadRequestException( throw new BadRequestException(
`The ${POA_DELEGATION_LABEL} on file needs to be corrected. ` + `The ${POA_DELEGATION_LABEL} on file needs to be corrected. ` +
`Re-upload it before continuing.`, `Re-upload it before continuing.`,
); );
} }
} }
@@ -2729,6 +2770,12 @@ export class CompaniesService {
async completeIdentityVerification( async completeIdentityVerification(
userId: string, userId: string,
dto: CompleteIdentityVerificationDto, dto: CompleteIdentityVerificationDto,
/**
* The signed-in account, used as the owner's fallback contact details.
* Optional so the callers that only have a user id keep compiling — they
* simply get no fallback.
*/
account?: { email?: string; phoneNumber?: string },
): Promise<CompanyIdentityStateDto> { ): Promise<CompanyIdentityStateDto> {
const { company } = await this.getCompanyInfoByUserId(userId); const { company } = await this.getCompanyInfoByUserId(userId);
const prefix = IDENTITY_PREFIX[dto.subject]; const prefix = IDENTITY_PREFIX[dto.subject];
@@ -2743,22 +2790,29 @@ export class CompaniesService {
); );
} }
// The owner delegating power of attorney to themselves is not a // An owner who is also the company's representative is a supported answer,
// delegation — it would let one identity satisfy both halves of the check. // not a conflict — the same way the GM is very often the owner. Small
// Only owner/PoA collide this way: the GM is very often the owner, and // companies routinely have one human in all three roles, and the portal's
// saying so is a supported answer rather than a conflict, so it is left out // "same as owner" cards exist precisely so they can say so. No identity
// of this check entirely. // here is refused for colliding with another.
if (dto.subject === "owner" || dto.subject === "poa") {
const other: IdentitySubject = dto.subject === "poa" ? "owner" : "poa";
const otherSub = company.attributes?.[`${IDENTITY_PREFIX[other]}FaydaSub`];
if (otherSub && otherSub === result.sub) {
throw new BadRequestException(
`This identity is already registered as the company's ${IDENTITY_LABEL[other]}. The Power of Attorney must be a different person from the owner.`,
);
}
}
const now = new Date().toISOString(); const now = new Date().toISOString();
// Fayda's email and phone claims are optional and routinely come back empty.
// For the owner that leaves the company with no contact details at all: the
// step renders no input for them (they are the verification's output), and
// "same as owner" then copies those blanks onto `generalManagerEmail` /
// `generalManagerPhone`, which `REQUIRED_COMPANY_INFO` demands at submit —
// an unfixable dead end. The account doing the onboarding is the one contact
// we always have, and it is already OTP-proven, so it stands in.
//
// Owner only: the PoA and the GM are other people, and the registering
// account's address is not theirs to wear.
const isOwner = dto.subject === "owner";
const email = result.email || (isOwner ? account?.email : undefined);
const phone =
result.phoneNumber || (isOwner ? account?.phoneNumber : undefined);
const identity: VerifiedIdentityAttributes = { const identity: VerifiedIdentityAttributes = {
[`${prefix}FaydaSub`]: result.sub, [`${prefix}FaydaSub`]: result.sub,
[`${prefix}FaydaVerifiedAt`]: now, [`${prefix}FaydaVerifiedAt`]: now,
@@ -2766,8 +2820,12 @@ export class CompaniesService {
[`${prefix}Gender`]: result.gender ?? null, [`${prefix}Gender`]: result.gender ?? null,
// The verified payload owns the person's details from here on. // The verified payload owns the person's details from here on.
...(result.fullName ? { [`${prefix}Name`]: result.fullName } : {}), ...(result.fullName ? { [`${prefix}Name`]: result.fullName } : {}),
...(result.email ? { [`${prefix}Email`]: result.email } : {}), ...(email ? { [`${prefix}Email`]: email } : {}),
...(result.phoneNumber ? { [`${prefix}Phone`]: result.phoneNumber } : {}), // Fayda returns whatever the national registry holds, which is routinely a
// local number ("0911223344"). Every typed phone in this service is stored
// E.164, and `@IsValidPhone()` rejects anything else — so a raw claim here
// becomes a value the portal reads back and cannot resubmit.
...(phone ? { [`${prefix}Phone`]: normalizeE164(phone) } : {}),
...(result.address ? { [`${prefix}Address`]: result.address } : {}), ...(result.address ? { [`${prefix}Address`]: result.address } : {}),
}; };
@@ -2776,6 +2834,12 @@ export class CompaniesService {
// mail `generalManagerEmail`), and clears any earlier "same as owner" // mail `generalManagerEmail`), and clears any earlier "same as owner"
// declaration — verifying in their own right is the GM answering for // declaration — verifying in their own right is the GM answering for
// themselves. // themselves.
// Verifying the representative in their own right answers the question the
// "same as owner" declaration answered, so the declaration goes.
if (dto.subject === "poa") {
identity.poaSameAsOwner = false;
}
if (dto.subject === "gm") { if (dto.subject === "gm") {
identity.gmSameAsOwner = false; identity.gmSameAsOwner = false;
if (result.fullName) identity.generalManagerName = result.fullName; if (result.fullName) identity.generalManagerName = result.fullName;
@@ -2817,7 +2881,13 @@ export class CompaniesService {
* proven identity to copy, only typed text that would arrive wearing a * proven identity to copy, only typed text that would arrive wearing a
* verified badge. * verified badge.
*/ */
async setGmSameAsOwner(userId: string): Promise<CompanyIdentityStateDto> { async setGmSameAsOwner(
userId: string,
/** Same fallback as {@link completeIdentityVerification}, for owners
* verified before that fallback existed — their stored contact details are
* blank, and copying blanks here would block the submit. */
account?: { email?: string; phoneNumber?: string },
): Promise<CompanyIdentityStateDto> {
const { company } = await this.getCompanyInfoByUserId(userId); const { company } = await this.getCompanyInfoByUserId(userId);
const attrs = company.attributes ?? {}; const attrs = company.attributes ?? {};
const ownerSub = attrs.ownerFaydaSub as string | undefined; const ownerSub = attrs.ownerFaydaSub as string | undefined;
@@ -2827,20 +2897,23 @@ export class CompaniesService {
); );
} }
const ownerEmail = (attrs.ownerEmail as string | undefined) || account?.email || null;
const ownerPhone = (attrs.ownerPhone as string | undefined) || account?.phoneNumber || null;
const copied: Record<string, unknown> = { const copied: Record<string, unknown> = {
gmSameAsOwner: true, gmSameAsOwner: true,
gmFaydaSub: ownerSub, gmFaydaSub: ownerSub,
gmFaydaVerifiedAt: attrs.ownerFaydaVerifiedAt ?? new Date().toISOString(), gmFaydaVerifiedAt: attrs.ownerFaydaVerifiedAt ?? new Date().toISOString(),
gmName: attrs.ownerName ?? null, gmName: attrs.ownerName ?? null,
gmEmail: attrs.ownerEmail ?? null, gmEmail: ownerEmail,
gmPhone: attrs.ownerPhone ?? null, gmPhone: ownerPhone ? normalizeE164(ownerPhone) : null,
gmAddress: attrs.ownerAddress ?? null, gmAddress: attrs.ownerAddress ?? null,
gmBirthdate: attrs.ownerBirthdate ?? null, gmBirthdate: attrs.ownerBirthdate ?? null,
gmGender: attrs.ownerGender ?? null, gmGender: attrs.ownerGender ?? null,
// Kept in step for the notifiers, same as a GM verification does. // Kept in step for the notifiers, same as a GM verification does.
generalManagerName: attrs.ownerName ?? null, generalManagerName: attrs.ownerName ?? null,
generalManagerEmail: attrs.ownerEmail ?? null, generalManagerEmail: ownerEmail,
generalManagerPhone: attrs.ownerPhone ?? null, generalManagerPhone: ownerPhone ? normalizeE164(ownerPhone) : null,
}; };
const updated = await this.companiesRepo.update(company.id, { const updated = await this.companiesRepo.update(company.id, {
@@ -2883,6 +2956,118 @@ export class CompaniesService {
return this.getCompanyIdentityState(updated); return this.getCompanyIdentityState(updated);
} }
/**
* Declare that the company's Power of Attorney is its owner.
*
* An owner representing their own company is the ordinary case for a small
* business, so this is a supported answer rather than the conflict it used to
* be refused as. Two shapes, matching {@link setGmSameAsOwner}:
*
* - A Fayda-verified owner is a proven identity, so it is copied outright —
* the representative inherits the verification instead of the same human
* being sent through Fayda a second time.
* - A foreign company's owner is backed by a typed passport, so there is
* nothing proven to copy. The declaration is still recorded (it is what
* waives the DARS paper) and whatever owner details exist come across; the
* portal types the rest, which `poaProven` accepts for a foreign company.
*
* Refused for an Ethiopian company whose owner is not verified yet: Fayda is
* mandatory for its representative, so a declaration there would record a
* representative that could never satisfy the gate.
*/
async setPoaSameAsOwner(
userId: string,
/** Same fallback as {@link completeIdentityVerification} — an owner whose
* Fayda claims carried no email/phone has none stored, and copying blanks
* onto a freight forwarder's PoA would block the submit on
* `REQUIRED_POA_FIELDS`. */
account?: { email?: string; phoneNumber?: string },
): Promise<CompanyIdentityStateDto> {
const { company } = await this.getCompanyInfoByUserId(userId);
const attrs = company.attributes ?? {};
const state = buildCompanyIdentityState(company);
const ownerSub = attrs.ownerFaydaSub as string | undefined;
if (state.faydaRequired && !ownerSub) {
throw new BadRequestException(
"Verify the company owner with Fayda first — there is no proven identity to reuse yet.",
);
}
const ownerEmail = (attrs.ownerEmail as string | undefined) || account?.email;
const ownerPhone =
(attrs.ownerPhone as string | undefined) || account?.phoneNumber;
// Only non-blank values are copied: a blank here would overwrite something
// the portal typed for a foreign company, whose owner has no verified
// claims to draw on.
const copied: Record<string, unknown> = { poaSameAsOwner: true };
const copy = (key: string, value: unknown) => {
if (value !== null && value !== undefined && value !== "")
copied[key] = value;
};
copy("poaName", attrs.ownerName);
copy("poaEmail", ownerEmail);
copy("poaPhone", ownerPhone ? normalizeE164(ownerPhone) : undefined);
copy("poaAddress", attrs.ownerAddress);
if (ownerSub) {
copied.poaFaydaSub = ownerSub;
copied.poaFaydaVerifiedAt =
attrs.ownerFaydaVerifiedAt ?? new Date().toISOString();
copy("poaBirthdate", attrs.ownerBirthdate);
copy("poaGender", attrs.ownerGender);
}
const updated = await this.companiesRepo.update(company.id, {
attributes: { ...attrs, ...copied },
});
if (!updated)
throw new NotFoundException(`Company ${company.id} not found`);
updated.companyProfiles = company.companyProfiles;
return this.getCompanyIdentityState(updated);
}
/**
* Undo the PoA "same as owner" declaration, clearing the identity it copied
* so a different representative can be verified (or typed, for a foreign
* company).
*
* Separate from {@link removePoaIdentity}, which drops the representative and
* their paper and is refused to a freight forwarder. Undoing a declaration is
* how a forwarder changes its mind about who represents it, so it must stay
* open to them — the submit gate still refuses a forwarder that never names a
* replacement. The delegation paper is left alone for the same reason: the
* company still owes one, now for whoever comes next.
*
* A no-op when no declaration is in place: a stray call must not wipe a
* representative who verified in their own right.
*/
async clearPoaSameAsOwner(userId: string): Promise<CompanyIdentityStateDto> {
const { company } = await this.getCompanyInfoByUserId(userId);
const attrs = { ...(company.attributes ?? {}) };
if (!attrs.poaSameAsOwner) return this.getCompanyIdentityState(company);
attrs.poaSameAsOwner = false;
for (const key of [
...POA_ATTRIBUTES,
"poaFaydaSub",
"poaFaydaVerifiedAt",
"poaBirthdate",
"poaGender",
]) {
attrs[key] = null;
}
const updated = await this.companiesRepo.update(company.id, {
attributes: attrs,
});
if (!updated)
throw new NotFoundException(`Company ${company.id} not found`);
updated.companyProfiles = company.companyProfiles;
return this.getCompanyIdentityState(updated);
}
/** /**
* Drop the Power of Attorney entirely — the verified identity, the details it * Drop the Power of Attorney entirely — the verified identity, the details it
* wrote and the delegation paper together. * wrote and the delegation paper together.
@@ -2904,7 +3089,7 @@ export class CompaniesService {
); );
} }
const cleared: Record<string, unknown> = {}; const cleared: Record<string, unknown> = { poaSameAsOwner: false };
for (const key of [ for (const key of [
...POA_ATTRIBUTES, ...POA_ATTRIBUTES,
"poaFaydaSub", "poaFaydaSub",
@@ -2951,8 +3136,8 @@ export class CompaniesService {
const snapshot = { const snapshot = {
...(existing?.snapshot ?? {}), ...(existing?.snapshot ?? {}),
faydaIdentity: { faydaIdentity: {
...(((existing?.snapshot ?? {}) as Record<string, any>) ...(((existing?.snapshot ?? {}) as Record<string, any>).faydaIdentity ??
.faydaIdentity ?? {}), {}),
...identity, ...identity,
}, },
}; };
@@ -3364,7 +3549,10 @@ export class CompaniesService {
"We couldn't find a business license for this TIN with eTrade. Please double-check the number and try again.", "We couldn't find a business license for this TIN with eTrade. Please double-check the number and try again.",
); );
} }
return this.etradeService.extractRegistrationData(businessInfo, companyInfo); return this.etradeService.extractRegistrationData(
businessInfo,
companyInfo,
);
} }
async fetchETradeData(tin: string, excludeCompanyId?: string) { async fetchETradeData(tin: string, excludeCompanyId?: string) {
@@ -3396,7 +3584,9 @@ export class CompaniesService {
const tin = dto.tin ?? company.tin; const tin = dto.tin ?? company.tin;
const registration = await this.resolveEtradeRegistration(tin); const registration = await this.resolveEtradeRegistration(tin);
const fresh: Partial<Record<(typeof ETRADE_SOURCED_FIELDS)[number], string>> = { const fresh: Partial<
Record<(typeof ETRADE_SOURCED_FIELDS)[number], string>
> = {
companyName: registration.companyName, companyName: registration.companyName,
licenceNumber: registration.licenceNumber, licenceNumber: registration.licenceNumber,
statusDescription: registration.statusDescription, statusDescription: registration.statusDescription,

View File

@@ -10,7 +10,8 @@ import {
import { Company, CompanyStatus } from "./entities/company.entity"; import { Company, CompanyStatus } from "./entities/company.entity";
import { NotificationsService } from "../notifications/notifications.service"; import { NotificationsService } from "../notifications/notifications.service";
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service"; import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
import { resolveCompanyNotifyPhone } from "../notifications/resolve-company-phone.util"; import { resolveCompanyNotifyContact } from "../notifications/resolve-company-phone.util";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
/** Account statuses that lock the customer out and therefore must be told to them. */ /** Account statuses that lock the customer out and therefore must be told to them. */
const PUNITIVE_STATUSES: readonly CompanyStatus[] = [ const PUNITIVE_STATUSES: readonly CompanyStatus[] = [
@@ -37,8 +38,13 @@ export class CompanyNotifierService {
/** Send SMS + email to the company contact; log-only on failure. */ /** Send SMS + email to the company contact; log-only on failure. */
private async notifyContact(company: Company, message: string): Promise<void> { private async notifyContact(company: Company, message: string): Promise<void> {
const phone = await resolveCompanyNotifyPhone(this.dataSource, company.id); // One resolver for both channels — the company row's own email column is
const email = company.email ?? company.generalManagerEmail ?? null; // only set for a Fayda-verified owner (see companyNotifyEmailExpr), which
// left an approval/suspension notice unsent to everyone else.
const { phone, email } = await resolveCompanyNotifyContact(
this.dataSource,
company.id,
);
if (phone) { if (phone) {
try { try {
@@ -175,12 +181,9 @@ export class CompanyNotifierService {
// ── Backoffice-facing: work has arrived back in the review queue ──────────── // ── Backoffice-facing: work has arrived back in the review queue ────────────
/** /**
* Persist + push an in-app item to every backoffice staff user, deep-linked to * Persist + push an in-app item to the customer desk — staff holding
* the customer's detail page. * `customers:get_notification` — deep-linked to the customer's detail page,
* * which is itself gated on `customers:view`.
* The recipient resolver has no role/permission targeting (see
* `notification-recipients.service.ts`) — `allBackoffice` is the narrowest
* selector available, so marketing is reached by notifying all staff.
*/ */
private notifyStaff( private notifyStaff(
company: Company, company: Company,
@@ -189,7 +192,7 @@ export class CompanyNotifierService {
data: Record<string, unknown> = {}, data: Record<string, unknown> = {},
): void { ): void {
void this.inbox.notify({ void this.inbox.notify({
recipients: { allBackoffice: true }, recipients: { permissionKeys: [FREIGHT_PERMS.customers.getNotification] },
audience: NotificationAudience.BACKOFFICE, audience: NotificationAudience.BACKOFFICE,
type: NotificationType.REQUEST_SUBMITTED, type: NotificationType.REQUEST_SUBMITTED,
title, title,

View File

@@ -75,6 +75,12 @@ export class CompanyIdentityStateDto {
@ApiProperty({ type: IdentityVerificationStateDto }) @ApiProperty({ type: IdentityVerificationStateDto })
poa!: IdentityVerificationStateDto; poa!: IdentityVerificationStateDto;
@ApiProperty({
description:
"True when the Power of Attorney is the company's owner, declared through the portal's \"same as owner\" copy. Waives the DARS delegation paper — nobody delegates to themselves.",
})
poaSameAsOwner!: boolean;
@ApiProperty({ @ApiProperty({
type: IdentityVerificationStateDto, type: IdentityVerificationStateDto,
description: description:
@@ -143,12 +149,18 @@ function stateFor(
birthdate: read(`${p}Birthdate`), birthdate: read(`${p}Birthdate`),
gender: read(`${p}Gender`), gender: read(`${p}Gender`),
}; };
if (subject !== "gm") return state; if (subject !== "gm" || state.verified) return state;
// Companies onboarded before the GM was verifiable have typed details and no // Companies onboarded before the GM was verifiable have typed details and no
// `gm*` attributes at all. Report those rather than a blank card — they are // `gm*` attributes at all. Report those rather than a blank card — they are
// still what the notifiers mail — leaving `verified` false so the portal // still what the notifiers mail — leaving `verified` false so the portal
// offers the upgrade instead of pretending the identity is proven. // offers the upgrade instead of pretending the identity is proven.
//
// Only for such an unverified GM, which is the whole population this exists
// for. Merging the typed columns into a *verified* manager's state would read
// back the email the portal asked them to type when Fayda supplied none, and
// the input offering it — keyed on that value being absent — would vanish the
// moment it was saved, leaving a typo uncorrectable.
return { return {
...state, ...state,
name: state.name ?? read(GM_TYPED_KEYS.name), name: state.name ?? read(GM_TYPED_KEYS.name),
@@ -190,6 +202,7 @@ export function buildCompanyIdentityState(
const gm = stateFor(attrs, "gm"); const gm = stateFor(attrs, "gm");
const gmSameAsOwner = Boolean(attrs.gmSameAsOwner); const gmSameAsOwner = Boolean(attrs.gmSameAsOwner);
const poaSameAsOwner = Boolean(attrs.poaSameAsOwner);
const ownerProven = faydaRequired const ownerProven = faydaRequired
? owner.verified ? owner.verified
@@ -214,6 +227,7 @@ export function buildCompanyIdentityState(
passportRequired, passportRequired,
owner, owner,
poa, poa,
poaSameAsOwner,
gm, gm,
gmSameAsOwner, gmSameAsOwner,
complete, complete,

View File

@@ -1,9 +1,18 @@
import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsEnum, IsArray, ValidateNested, ArrayMinSize } from 'class-validator'; import {
import { Type } from 'class-transformer'; IsString,
import { CompanyType } from '../entities/company.entity'; IsNotEmpty,
import { ProfileType } from '../entities/company-profile.entity'; IsOptional,
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; MaxLength,
import { IsTin } from '../../../common/validators/is-tin.validator'; IsBoolean,
IsEnum,
IsArray,
ValidateNested,
ArrayMinSize,
} from "class-validator";
import { Type } from "class-transformer";
import { CompanyType } from "../entities/company.entity";
import { ProfileType } from "../entities/company-profile.entity";
import { IsTin } from "../../../common/validators/is-tin.validator";
export class CompanyProfileInputDto { export class CompanyProfileInputDto {
@IsEnum(ProfileType) @IsEnum(ProfileType)
@@ -24,17 +33,6 @@ export class CreateCompanyWithProfileDto {
@MaxLength(200) @MaxLength(200)
companyName!: string; companyName!: string;
@IsOptional()
@IsEmail()
@MaxLength(150)
companyEmail?: string;
@IsOptional()
@IsString()
@MaxLength(20)
@IsValidPhone()
companyPhone?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(32) @MaxLength(32)
@@ -46,7 +44,7 @@ export class CreateCompanyWithProfileDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@IsTin({ message: 'TIN must be exactly 10 digits' }) @IsTin({ message: "TIN must be exactly 10 digits" })
tin?: string; tin?: string;
@IsOptional() @IsOptional()

View File

@@ -42,6 +42,12 @@ export interface OnboardingPoaState {
required: boolean; required: boolean;
/** True once any PoA detail has been entered. */ /** True once any PoA detail has been entered. */
provided: boolean; provided: boolean;
/**
* True when the DARS delegation paper is owed — a PoA exists (or is
* mandatory) and is not the owner themselves. An owner representing their own
* company delegates to nobody, so there is no delegation to evidence.
*/
delegationLetterRequired: boolean;
/** True when the DARS delegation paper is stored for the company. */ /** True when the DARS delegation paper is stored for the company. */
delegationLetterUploaded: boolean; delegationLetterUploaded: boolean;
/** True when a reviewer sent the paper back for correction. */ /** True when a reviewer sent the paper back for correction. */

View File

@@ -2,21 +2,19 @@ import {
buildCompanyIdentityState, buildCompanyIdentityState,
CompanyIdentityStateDto, CompanyIdentityStateDto,
} from "./complete-identity-verification.dto"; } from "./complete-identity-verification.dto";
import { Company } from '../entities/company.entity'; import { Company } from "../entities/company.entity";
import { ExternalProfile } from '../entities/external-profile.entity'; import { ExternalProfile } from "../entities/external-profile.entity";
import { import {
ChangeRequestStatus, ChangeRequestStatus,
CompanyChangeRequest, CompanyChangeRequest,
} from '../entities/company-change-request.entity'; } from "../entities/company-change-request.entity";
import { ResponseCompanyProfileDto } from './response-company.dto'; import { ResponseCompanyProfileDto } from "./response-company.dto";
export class ProfileResponseDto { export class ProfileResponseDto {
companyId: string; companyId: string;
companyName: string; companyName: string;
companyType: string; companyType: string;
nationality: string | null; nationality: string | null;
companyEmail: string | null;
companyPhone: string | null;
companyLocation: string; companyLocation: string;
companyAddress: string | null; companyAddress: string | null;
tinNumber: string; tinNumber: string;
@@ -89,8 +87,6 @@ export class ProfileResponseDto {
this.companyProfiles = this.companyProfiles =
company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)) ?? company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)) ??
[]; [];
this.companyEmail = company.email ?? null;
this.companyPhone = company.phone ?? null;
this.companyLocation = company.country; this.companyLocation = company.country;
this.companyAddress = company.address ?? null; this.companyAddress = company.address ?? null;
this.tinNumber = company.tin; this.tinNumber = company.tin;
@@ -128,9 +124,9 @@ export class ProfileResponseDto {
const openReview = const openReview =
changeRequest && changeRequest &&
(changeRequest.status === ChangeRequestStatus.Pending || (changeRequest.status === ChangeRequestStatus.Pending ||
changeRequest.status === ChangeRequestStatus.Rejected || changeRequest.status === ChangeRequestStatus.Rejected ||
changeRequest.status === ChangeRequestStatus.ChangesRequested) changeRequest.status === ChangeRequestStatus.ChangesRequested)
? changeRequest ? changeRequest
: null; : null;
this.reviewStatus = this.reviewStatus =

View File

@@ -1,8 +1,16 @@
import { IsString, IsOptional, IsEmail, MaxLength, IsEnum, IsIn } from 'class-validator'; import {
import { ETHIOPIAN_REGIONS, type EthiopianRegion } from '@edr/types'; IsString,
import { CompanyNationality } from '../entities/company.entity'; IsOptional,
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; IsEmail,
import { IsTin } from '../../../common/validators/is-tin.validator'; MaxLength,
IsEnum,
IsIn,
Matches,
} from "class-validator";
import { ETHIOPIAN_REGIONS, type EthiopianRegion } from "@edr/types";
import { CompanyNationality } from "../entities/company.entity";
import { IsValidPhone } from "../../../common/validators/is-phone-number.validator";
import { IsTin } from "../../../common/validators/is-tin.validator";
export class UpdateProfileDto { export class UpdateProfileDto {
@IsOptional() @IsOptional()
@@ -14,17 +22,6 @@ export class UpdateProfileDto {
@MaxLength(200) @MaxLength(200)
companyName?: string; companyName?: string;
@IsOptional()
@IsEmail()
@MaxLength(150)
companyEmail?: string;
@IsOptional()
@IsString()
@MaxLength(20)
@IsValidPhone()
companyPhone?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(32) @MaxLength(32)
@@ -36,12 +33,16 @@ export class UpdateProfileDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@IsTin({ message: 'TIN must be exactly 10 digits' }) @IsTin({ message: "TIN must be exactly 10 digits" })
tin?: string; tin?: string;
// Ethiopian VAT registration numbers are 10 digits, the same shape as the
// TIN. Both portal forms enforce that; without it here the API happily stored
// whatever a stale client sent, and the two layers disagreed about what the
// column may hold.
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(50) @Matches(/^\d{10}$/, { message: "VAT number must be exactly 10 digits" })
vatNumber?: string; vatNumber?: string;
// `fanNumber` is deliberately absent: the FAN is the Fayda number of the // `fanNumber` is deliberately absent: the FAN is the Fayda number of the

View File

@@ -1,5 +1,7 @@
import { Controller, Post, Get, Patch, Delete, Body, Param, Query } from '@nestjs/common'; import { Controller, Post, Get, Patch, Delete, Body, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger'; import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { ComplianceService } from './compliance.service'; import { ComplianceService } from './compliance.service';
import { import {
CreateComplianceRecordDto, CreateComplianceRecordDto,
@@ -9,10 +11,17 @@ import { ComplianceType } from './entities/compliance-record.entity';
@ApiTags('Vehicle Compliance') @ApiTags('Vehicle Compliance')
@Controller('compliance') @Controller('compliance')
// Class gate lists every key its routes use: Nest runs class AND method
// guards, so a key missing here would deny before the route's own key runs.
@BookingStaff([
FREIGHT_PERMS.compliance.view,
FREIGHT_PERMS.compliance.manage,
])
export class ComplianceController { export class ComplianceController {
constructor(private readonly complianceService: ComplianceService) {} constructor(private readonly complianceService: ComplianceService) {}
@Post() @Post()
@BookingStaff(FREIGHT_PERMS.compliance.manage)
@ApiOperation({ summary: 'Create a compliance record' }) @ApiOperation({ summary: 'Create a compliance record' })
create(@Body() dto: CreateComplianceRecordDto) { create(@Body() dto: CreateComplianceRecordDto) {
return this.complianceService.create(dto); return this.complianceService.create(dto);
@@ -40,12 +49,14 @@ export class ComplianceController {
} }
@Patch(':id') @Patch(':id')
@BookingStaff(FREIGHT_PERMS.compliance.manage)
@ApiOperation({ summary: 'Update a compliance record' }) @ApiOperation({ summary: 'Update a compliance record' })
update(@Param('id') id: string, @Body() dto: UpdateComplianceRecordDto) { update(@Param('id') id: string, @Body() dto: UpdateComplianceRecordDto) {
return this.complianceService.update(id, dto); return this.complianceService.update(id, dto);
} }
@Delete(':id') @Delete(':id')
@BookingStaff(FREIGHT_PERMS.compliance.manage)
@ApiOperation({ summary: 'Soft-delete a compliance record' }) @ApiOperation({ summary: 'Soft-delete a compliance record' })
remove(@Param('id') id: string) { remove(@Param('id') id: string) {
return this.complianceService.remove(id); return this.complianceService.remove(id);

View File

@@ -17,7 +17,12 @@ import { FilterConsignmentDto } from "./dto/filter-consignment.dto";
@ApiTags("consignments") @ApiTags("consignments")
@Controller("consignments") @Controller("consignments")
@FleetView(FREIGHT_PERMS.consignments.view) // Class gate lists every key its routes use: Nest runs class AND method
// guards, so a key missing here would deny before the route's own key runs.
@FleetView([
FREIGHT_PERMS.consignments.view,
FREIGHT_PERMS.consignments.create,
])
export class ConsignmentsController { export class ConsignmentsController {
constructor(private readonly consignmentsService: ConsignmentsService) {} constructor(private readonly consignmentsService: ConsignmentsService) {}

View File

@@ -19,7 +19,14 @@ import { ContainersService } from './containers.service';
@ApiTags('containers') @ApiTags('containers')
@Controller('containers') @Controller('containers')
@FleetView(FREIGHT_PERMS.containers.view) // Class gate lists every key its routes use: Nest runs class AND method
// guards, so a key missing here would deny before the route's own key runs.
@FleetView([
FREIGHT_PERMS.containers.view,
FREIGHT_PERMS.containers.create,
FREIGHT_PERMS.containers.update,
FREIGHT_PERMS.containers.delete,
])
export class ContainersController { export class ContainersController {
constructor(private readonly containersService: ContainersService) {} constructor(private readonly containersService: ContainersService) {}

View File

@@ -32,7 +32,7 @@ export class Container extends BaseEntity {
type: 'varchar', type: 'varchar',
nullable: true, nullable: true,
}) })
sealNumber!: string | null; sealNumber!: string | null;
@Column({ type: 'varchar', default: 'AVAILABLE' }) @Column({ type: 'varchar', default: 'AVAILABLE' })
status!: string; // AVAILABLE, LOADED, IN_TRANSIT, MAINTENANCE, DAMAGED status!: string; // AVAILABLE, LOADED, IN_TRANSIT, MAINTENANCE, DAMAGED

View File

@@ -3,6 +3,8 @@ import {
Controller, Controller,
Delete, Delete,
Get, Get,
HttpCode,
HttpStatus,
Param, Param,
Patch, Patch,
Post, Post,
@@ -10,44 +12,90 @@ import {
} from "@nestjs/common"; } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { FreightAdmin } from "../../common/booking-guards"; import { BookingStaff } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { ContractTemplatesService } from "./contract-templates.service"; import { ContractTemplatesService } from "./contract-templates.service";
import { import {
CreateArticleDto, CreateArticleDto,
CreateContractTemplateDto,
PreviewContractTemplateDto, PreviewContractTemplateDto,
ReplaceArticlesDto, ReplaceArticlesDto,
UpdateArticleDto, UpdateArticleDto,
UpdateContractTemplateDto, UpdateContractTemplateDto,
} from "./dto/contract-template.dto"; } from "./dto/contract-template.dto";
// `view` opens the Templates page; `read` is API-read-only for other pages
// that show template data; create/update/delete gate each write. `manage` is
// the legacy write key and keeps working for roles that already hold it.
const TEMPLATE_READ = [
FREIGHT_PERMS.settings.contractTemplates.view,
FREIGHT_PERMS.settings.contractTemplates.read,
FREIGHT_PERMS.settings.contractTemplates.update,
FREIGHT_PERMS.settings.contractTemplates.manage,
FREIGHT_PERMS.admin,
];
const TEMPLATE_UPDATE = [
FREIGHT_PERMS.settings.contractTemplates.update,
FREIGHT_PERMS.settings.contractTemplates.manage,
FREIGHT_PERMS.admin,
];
@ApiTags("contract-templates") @ApiTags("contract-templates")
@Controller("contract-templates") @Controller("contract-templates")
export class ContractTemplatesController { export class ContractTemplatesController {
constructor(private readonly service: ContractTemplatesService) {} constructor(private readonly service: ContractTemplatesService) {}
// Reads stay open to authenticated staff (the backoffice Templates tab);
// writes are admin-guarded like other freight configuration resources.
@Get() @Get()
@ApiOperation({ summary: "List the six contract document templates" }) @BookingStaff(TEMPLATE_READ)
@ApiOperation({ summary: "List contract templates (system container + staff-created bulk)" })
list() { list() {
return this.service.list(); return this.service.list();
} }
@Post()
@BookingStaff([
FREIGHT_PERMS.settings.contractTemplates.create,
FREIGHT_PERMS.settings.contractTemplates.manage,
FREIGHT_PERMS.admin,
])
@ApiOperation({
summary:
"Create a bulk contract template for a (cargo type, customs option) pair",
})
create(@Body() dto: CreateContractTemplateDto) {
return this.service.create(dto);
}
@Get(":code") @Get(":code")
@BookingStaff(TEMPLATE_READ)
@ApiOperation({ summary: "Get one contract template by code" }) @ApiOperation({ summary: "Get one contract template by code" })
getByCode(@Param("code") code: string) { getByCode(@Param("code") code: string) {
return this.service.getByCode(code); return this.service.getByCode(code);
} }
@Patch(":code") @Patch(":code")
@FreightAdmin() @BookingStaff(TEMPLATE_UPDATE)
@ApiOperation({ summary: "Update template metadata (name, title, recitals, active flag)" }) @ApiOperation({ summary: "Update template metadata (name, title, recitals, active flag)" })
update(@Param("code") code: string, @Body() dto: UpdateContractTemplateDto) { update(@Param("code") code: string, @Body() dto: UpdateContractTemplateDto) {
return this.service.update(code, dto); return this.service.update(code, dto);
} }
@Delete(":code")
@BookingStaff([
FREIGHT_PERMS.settings.contractTemplates.delete,
FREIGHT_PERMS.admin,
])
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({
summary: "Delete a staff-created bulk template (system templates refuse)",
})
remove(@Param("code") code: string) {
return this.service.remove(code);
}
@Post(":code/preview") @Post(":code/preview")
@BookingStaff(TEMPLATE_READ)
@ApiOperation({ @ApiOperation({
summary: "Render an HTML preview of the template against mock contract data", summary: "Render an HTML preview of the template against mock contract data",
}) })
@@ -61,21 +109,21 @@ export class ContractTemplatesController {
/* ------------------------- article routes ------------------------- */ /* ------------------------- article routes ------------------------- */
@Put(":code/articles") @Put(":code/articles")
@FreightAdmin() @BookingStaff(TEMPLATE_UPDATE)
@ApiOperation({ summary: "Replace the full ordered article list (used for reorder)" }) @ApiOperation({ summary: "Replace the full ordered article list (used for reorder)" })
replaceArticles(@Param("code") code: string, @Body() dto: ReplaceArticlesDto) { replaceArticles(@Param("code") code: string, @Body() dto: ReplaceArticlesDto) {
return this.service.replaceArticles(code, dto.articles); return this.service.replaceArticles(code, dto.articles);
} }
@Post(":code/articles") @Post(":code/articles")
@FreightAdmin() @BookingStaff(TEMPLATE_UPDATE)
@ApiOperation({ summary: "Add an article to the template" }) @ApiOperation({ summary: "Add an article to the template" })
addArticle(@Param("code") code: string, @Body() dto: CreateArticleDto) { addArticle(@Param("code") code: string, @Body() dto: CreateArticleDto) {
return this.service.addArticle(code, dto); return this.service.addArticle(code, dto);
} }
@Patch(":code/articles/:articleId") @Patch(":code/articles/:articleId")
@FreightAdmin() @BookingStaff(TEMPLATE_UPDATE)
@ApiOperation({ summary: "Update an article's title or body" }) @ApiOperation({ summary: "Update an article's title or body" })
updateArticle( updateArticle(
@Param("code") code: string, @Param("code") code: string,
@@ -86,7 +134,7 @@ export class ContractTemplatesController {
} }
@Delete(":code/articles/:articleId") @Delete(":code/articles/:articleId")
@FreightAdmin() @BookingStaff(TEMPLATE_UPDATE)
@ApiOperation({ summary: "Remove an article from the template" }) @ApiOperation({ summary: "Remove an article from the template" })
removeArticle( removeArticle(
@Param("code") code: string, @Param("code") code: string,

View File

@@ -3,10 +3,8 @@ import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm"; import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm"; import { Repository } from "typeorm";
import { import { CargoType } from "../rule-engine/entities/cargo-type.entity";
ContractTemplate, import { ContractTemplate } from "./entities/contract-template.entity";
ContractTemplateCode,
} from "./entities/contract-template.entity";
@Injectable() @Injectable()
export class ContractTemplatesRepository extends BaseRepository<ContractTemplate> { export class ContractTemplatesRepository extends BaseRepository<ContractTemplate> {
@@ -17,12 +15,51 @@ export class ContractTemplatesRepository extends BaseRepository<ContractTemplate
super(repository); super(repository);
} }
findByCode(code: ContractTemplateCode): Promise<ContractTemplate | null> { findByCode(code: string): Promise<ContractTemplate | null> {
return this.repository.findOne({ where: { code } }); return this.repository.findOne({ where: { code } });
} }
override findAll(): Promise<ContractTemplate[]> { override findAll(): Promise<ContractTemplate[]> {
return this.repository.find({ order: { code: "ASC" } }); return this.repository.find({
relations: { cargoType: true },
order: { code: "ASC" },
});
}
findByCargoCombo(
cargoTypeId: string,
withCustoms: boolean,
): Promise<ContractTemplate | null> {
return this.repository.findOne({ where: { cargoTypeId, withCustoms } });
}
/**
* The active bulk template covering this cargo type: written against the
* cargo type itself or against its parent group (the two are mutually
* exclusive, so at most one row matches).
*/
findActiveBulkTemplate(
cargoTypeId: string,
withCustoms: boolean,
): Promise<ContractTemplate | null> {
return this.repository
.createQueryBuilder("t")
.where("t.is_active = true")
.andWhere("t.with_customs = :withCustoms", { withCustoms })
.andWhere(
`(t.cargo_type_id = :cargoTypeId OR t.cargo_type_id = (
SELECT c.parent_group_id FROM freight.cargo_types c
WHERE c.id = :cargoTypeId AND c.deleted_at IS NULL
))`,
{ cargoTypeId },
)
.getOne();
}
findCargoType(id: string): Promise<CargoType | null> {
return this.repository.manager
.getRepository(CargoType)
.findOne({ where: { id } });
} }
async saveTemplate(template: ContractTemplate): Promise<ContractTemplate> { async saveTemplate(template: ContractTemplate): Promise<ContractTemplate> {

View File

@@ -1,4 +1,9 @@
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { ContractRendererService } from "../../contracts/contract-renderer.service"; import { ContractRendererService } from "../../contracts/contract-renderer.service";
@@ -11,6 +16,7 @@ import {
import { ContractTemplatesRepository } from "./contract-templates.repository"; import { ContractTemplatesRepository } from "./contract-templates.repository";
import { import {
CreateArticleDto, CreateArticleDto,
CreateContractTemplateDto,
PreviewContractTemplateDto, PreviewContractTemplateDto,
ReplaceArticleDto, ReplaceArticleDto,
UpdateArticleDto, UpdateArticleDto,
@@ -53,13 +59,17 @@ export class ContractTemplatesService {
async list(): Promise<ContractTemplate[]> { async list(): Promise<ContractTemplate[]> {
const templates = await this.repository.findAll(); const templates = await this.repository.findAll();
const rank = new Map(CONTRACT_TEMPLATE_CODES.map((code, i) => [code, i] as const)); const rank = new Map(CONTRACT_TEMPLATE_CODES.map((code, i) => [code, i] as const));
return templates.sort( // Seeded container templates first in canonical order, then staff-created
(a, b) => (rank.get(a.code) ?? 99) - (rank.get(b.code) ?? 99), // bulk templates alphabetically.
); return templates.sort((a, b) => {
const ra = rank.get(a.code as ContractTemplateCode) ?? 99;
const rb = rank.get(b.code as ContractTemplateCode) ?? 99;
return ra !== rb ? ra - rb : a.name.localeCompare(b.name);
});
} }
async getByCode(code: string): Promise<ContractTemplate> { async getByCode(code: string): Promise<ContractTemplate> {
const template = await this.repository.findByCode(this.assertCode(code)); const template = await this.repository.findByCode(code?.toUpperCase() ?? "");
if (!template) { if (!template) {
throw new NotFoundException(`Contract template ${code} not found`); throw new NotFoundException(`Contract template ${code} not found`);
} }
@@ -67,15 +77,93 @@ export class ContractTemplatesService {
} }
/** /**
* The active template used when generating a contract document for the given * Staff-created bulk template for one (cargo type, customs option) pair.
* direction/freight/customs triple; null when missing or deactivated (the * The cargo type must have hasContractTemplate enabled and the combination
* renderer then falls back to the built-in generic layout). * must not already exist — the same commodity + customs pairing is edited,
* never duplicated.
*/
async create(dto: CreateContractTemplateDto): Promise<ContractTemplate> {
const cargoType = await this.repository.findCargoType(dto.cargoTypeId);
if (!cargoType) {
throw new NotFoundException(`Cargo type ${dto.cargoTypeId} not found`);
}
if (!cargoType.hasContractTemplate) {
throw new BadRequestException(
`"${cargoType.cargoTypeName}" does not allow contract templates — enable "has contract template" on the cargo type first`,
);
}
const variant = dto.withCustoms ? "with" : "without";
const existing = await this.repository.findByCargoCombo(
dto.cargoTypeId,
dto.withCustoms,
);
if (existing) {
throw new ConflictException(
`A "${cargoType.cargoTypeName}" template ${variant} customs clearing already exists — edit that template instead`,
);
}
const template = new ContractTemplate();
template.code = `BULK_${cargoType.code}_${dto.withCustoms ? "CUSTOMS" : "NO_CUSTOMS"}`.toUpperCase();
template.name =
dto.name ??
`${cargoType.cargoTypeName} Bulk Contract (${variant} customs clearing)`;
template.description = dto.description ?? null;
template.documentTitle = dto.withCustoms
? `${cargoType.cargoTypeName} Transportation and Customs Clearance Services`
: `${cargoType.cargoTypeName} Transportation Services`;
template.whereasClauses = [];
template.articles = [];
template.isActive = true;
template.cargoTypeId = cargoType.id;
template.withCustoms = dto.withCustoms;
template.isSystem = false;
try {
return await this.repository.saveTemplate(template);
} catch (error) {
// Partial unique index backstop for concurrent creates of the same combo.
if ((error as { code?: string })?.code === "23505") {
throw new ConflictException(
`A "${cargoType.cargoTypeName}" template ${variant} customs clearing already exists — edit that template instead`,
);
}
throw error;
}
}
/** Bulk templates only — the five seeded container templates are permanent. */
async remove(code: string): Promise<void> {
const template = await this.getByCode(code);
if (template.isSystem) {
throw new BadRequestException(
"System container templates cannot be deleted",
);
}
await this.repository.softDelete(template.id);
}
/**
* The active template used when generating a contract document. Container
* contracts resolve through the fixed direction/customs codes; bulk contracts
* resolve through the staff-created template for the contract's cargo type
* (or its parent group) and customs option. Null when nothing matches or the
* match is deactivated (the renderer then falls back to the built-in generic
* layout).
*/ */
async findActiveForContract( async findActiveForContract(
tradeDirection?: string | null, tradeDirection?: string | null,
freightType?: string | null, freightType?: string | null,
customsClearingEnabled?: boolean | null, customsClearingEnabled?: boolean | null,
cargoTypeId?: string | null,
): Promise<ContractTemplate | null> { ): Promise<ContractTemplate | null> {
const isBulk = (freightType ?? "").toUpperCase().includes("BULK");
if (isBulk) {
if (!cargoTypeId) return null;
return this.repository.findActiveBulkTemplate(
cargoTypeId,
Boolean(customsClearingEnabled),
);
}
const code = contractTemplateCodeFor( const code = contractTemplateCodeFor(
tradeDirection, tradeDirection,
freightType, freightType,
@@ -180,16 +268,32 @@ export class ContractTemplatesService {
: this.sorted(template.articles), : this.sorted(template.articles),
}; };
const view = this.buildMockView(template.code, dynamicTemplate); const view = this.buildMockView(template, dynamicTemplate);
return { html: this.renderer.render(view) }; return { html: this.renderer.render(view) };
} }
/**
* Registry key the mock preview renders against. Staff-created bulk
* templates aren't in the fixed code map — they preview against the
* representative bulk import pack matching their customs option.
*/
private previewKeyFor(template: ContractTemplate): string {
if (template.cargoTypeId) {
return template.withCustoms
? "IMP_BULK_USD_FORWARDING"
: "IMP_BULK_USD_TRANSPORT_ONLY";
}
return PREVIEW_TEMPLATE_KEYS[template.code as ContractTemplateCode];
}
private buildMockView( private buildMockView(
code: ContractTemplateCode, template: ContractTemplate,
dynamicTemplate: ContractDynamicTemplateView, dynamicTemplate: ContractDynamicTemplateView,
): ContractViewModel { ): ContractViewModel {
const meta = getTemplateMeta(PREVIEW_TEMPLATE_KEYS[code]); const code = template.code;
const isBulk = code.endsWith("_BULK"); const previewKey = this.previewKeyFor(template);
const meta = getTemplateMeta(previewKey);
const isBulk = Boolean(template.cargoTypeId) || code.includes("BULK");
const now = new Date(); const now = new Date();
// Representative rate schedule so the admin preview shows the live-rate // Representative rate schedule so the admin preview shows the live-rate
@@ -200,7 +304,7 @@ export class ContractTemplatesService {
bookingId: "00000000-0000-0000-0000-000000000000", bookingId: "00000000-0000-0000-0000-000000000000",
reference: "EDR/CT/2026/0042", reference: "EDR/CT/2026/0042",
status: "CONTRACT_READY", status: "CONTRACT_READY",
templateKey: PREVIEW_TEMPLATE_KEYS[code], templateKey: previewKey,
template: { ...meta, title: dynamicTemplate.name, templateFile: "edr-dynamic.hbs" }, template: { ...meta, title: dynamicTemplate.name, templateFile: "edr-dynamic.hbs" },
contractDate: now.toLocaleDateString("en-GB", { contractDate: now.toLocaleDateString("en-GB", {
day: "numeric", day: "numeric",
@@ -275,7 +379,7 @@ export class ContractTemplatesService {
} }
/** Static, representative rate schedule for the admin preview only. */ /** Static, representative rate schedule for the admin preview only. */
private mockRateSchedule(code: ContractTemplateCode, isBulk: boolean): RateSchedule { private mockRateSchedule(code: string, isBulk: boolean): RateSchedule {
const dir = code.startsWith("IMPORT") const dir = code.startsWith("IMPORT")
? "import" ? "import"
: code.startsWith("EXPORT") : code.startsWith("EXPORT")
@@ -311,16 +415,6 @@ export class ContractTemplatesService {
}; };
} }
private assertCode(code: string): ContractTemplateCode {
const upper = code?.toUpperCase() as ContractTemplateCode;
if (!CONTRACT_TEMPLATE_CODES.includes(upper)) {
throw new BadRequestException(
`Unknown contract template code "${code}". Valid codes: ${CONTRACT_TEMPLATE_CODES.join(", ")}`,
);
}
return upper;
}
private sorted(articles: ContractTemplateArticle[]): ContractTemplateArticle[] { private sorted(articles: ContractTemplateArticle[]): ContractTemplateArticle[] {
return [...(articles ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); return [...(articles ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
} }

View File

@@ -6,12 +6,41 @@ import {
IsInt, IsInt,
IsOptional, IsOptional,
IsString, IsString,
IsUUID,
MaxLength, MaxLength,
Min, Min,
MinLength, MinLength,
ValidateNested, ValidateNested,
} from "class-validator"; } from "class-validator";
export class CreateContractTemplateDto {
@ApiProperty({
description:
"Bulk cargo type this template is written for (must have hasContractTemplate enabled)",
format: "uuid",
})
@IsUUID()
cargoTypeId!: string;
@ApiProperty({
description: "Whether this is the with-customs-clearing variant",
})
@IsBoolean()
withCustoms!: boolean;
@ApiPropertyOptional({ description: "Display name (derived from the cargo type when omitted)" })
@IsOptional()
@IsString()
@MinLength(3)
@MaxLength(200)
name?: string;
@ApiPropertyOptional({ description: "Short description shown on the template card" })
@IsOptional()
@IsString()
description?: string;
}
export class UpdateContractTemplateDto { export class UpdateContractTemplateDto {
@ApiPropertyOptional({ description: "Display name of the template" }) @ApiPropertyOptional({ description: "Display name of the template" })
@IsOptional() @IsOptional()

View File

@@ -1,11 +1,18 @@
import { BaseEntity } from "@edr/api-common"; import { BaseEntity } from "@edr/api-common";
import { Column, Entity, Index } from "typeorm"; import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
import { CargoType } from "../../rule-engine/entities/cargo-type.entity";
/** /**
* The ten canonical contract document templates. Import and export split by * The five seeded container templates (import/export split by customs
* customs clearing (× freight type = 8); intercity does not, because it is a * clearing; intercity is domestic, crosses no border, so it has a single
* purely domestic Ethiopian movement that crosses no border and therefore has * template). These are system rows: always present, never deletable.
* no customs leg at all (× freight type = 2). *
* Bulk templates are NOT seeded — staff create them per bulk cargo type
* (`cargoTypeId`) and customs option (`withCustoms`), one template per
* combination. Their codes are generated as BULK_<cargo code>_(NO_)CUSTOMS.
* The retired direction-keyed bulk codes remain listed so old frozen document
* snapshots still label correctly.
* *
* Contracts store DOMESTIC for intercity movements; the template layer labels * Contracts store DOMESTIC for intercity movements; the template layer labels
* those INTERCITY to match the commercial vocabulary used on the printed * those INTERCITY to match the commercial vocabulary used on the printed
@@ -76,10 +83,12 @@ export function contractTemplateCodeFor(
} }
@Entity({ schema: "freight", name: "contract_templates" }) @Entity({ schema: "freight", name: "contract_templates" })
@Index(["code"], { unique: true }) // Uniqueness lives in partial DB indexes (live rows only): code, and
// (cargo_type_id, with_customs) for staff-created bulk templates.
@Index(["code"])
export class ContractTemplate extends BaseEntity { export class ContractTemplate extends BaseEntity {
@Column({ name: "code", type: "varchar", length: 40, unique: true }) @Column({ name: "code", type: "varchar", length: 80 })
code!: ContractTemplateCode; code!: string;
@Column({ name: "name", type: "varchar", length: 200 }) @Column({ name: "name", type: "varchar", length: 200 })
name!: string; name!: string;
@@ -100,4 +109,20 @@ export class ContractTemplate extends BaseEntity {
@Column({ name: "is_active", type: "boolean", default: true }) @Column({ name: "is_active", type: "boolean", default: true })
isActive!: boolean; isActive!: boolean;
/** Bulk templates only: the cargo type this template is written for. */
@Column({ name: "cargo_type_id", type: "uuid", nullable: true })
cargoTypeId?: string | null;
@ManyToOne(() => CargoType, { nullable: true })
@JoinColumn({ name: "cargo_type_id" })
cargoType?: CargoType | null;
/** Bulk templates only: whether this is the with-customs-clearing variant. */
@Column({ name: "with_customs", type: "boolean", nullable: true })
withCustoms?: boolean | null;
/** The five seeded container templates — cannot be deleted. */
@Column({ name: "is_system", type: "boolean", default: false })
isSystem!: boolean;
} }

View File

@@ -0,0 +1,115 @@
import { ContractBookingService } from './contract-booking.service';
/**
* OPERATION_CHANGES_REQUESTED resubmit with restated cargo. Operations can ask
* for the cargo itself to change, so a completion payload that restates
* containers must cancel the unpaid invoice, wipe the persisted cargo and
* re-run the fresh-completion path (re-persist, re-price, re-invoice). A
* payload without cargo keeps the day-only resubmit behavior.
*/
describe('ContractBookingService — changes-requested resubmit restating cargo', () => {
const CONTRACT = {
id: 'c-1',
reference: 'CTR-1',
contractKind: 'GENERAL',
freightType: 'CONTAINER',
tradeDirection: 'IMPORT',
customsClearingEnabled: false,
contractValidUntil: null,
cargoScope: [],
};
const bookingWithCargo = () => ({
id: 'b-1',
contractId: 'c-1',
reference: 'BKG-1',
status: 'OPERATION_CHANGES_REQUESTED',
bookingContainers: [{ containerSize: '20FT', quantity: 4 }],
cargoTotalWeightVgm: 80,
originYardId: 'y-o',
destinationYardId: 'y-d',
});
function makeService() {
const bookingsRepository = {
findByIdWithFiles: jest.fn().mockResolvedValue(bookingWithCargo()),
deleteContainers: jest.fn().mockResolvedValue(undefined),
update: jest.fn().mockResolvedValue(undefined),
};
const invoiceService = {
cancelUnpaidInvoiceForBooking: jest.fn().mockResolvedValue(undefined),
};
const trainSchedulingService = {
assertBookingWindowOpen: jest.fn().mockResolvedValue(undefined),
};
const contractsRepository = {
findByIdWithRelations: jest.fn().mockResolvedValue(CONTRACT),
};
const service = new ContractBookingService(
contractsRepository as never,
bookingsRepository as never,
{} as never, // bookingPricingService
{} as never, // consolidationService
{} as never, // containerTypesService
{} as never, // ruleEngineService
{} as never, // milestoneService
invoiceService as never,
{} as never, // bookingNotifier
{} as never, // dataSource
trainSchedulingService as never,
{} as never, // bookingBatchService
{} as never, // bookingTransitionService
);
return { service, bookingsRepository, invoiceService };
}
// Both paths dead-end into a downstream private assert we replace with a
// sentinel — which path threw tells us which branch the resubmit took.
const SENTINEL = new Error('reached-branch');
it('restated cargo cancels the invoice, wipes cargo and re-runs fresh completion', async () => {
const { service, bookingsRepository, invoiceService } = makeService();
// First gate inside the fresh-completion (!hasCargo) path.
jest
.spyOn(
service as never as { assertWithinQuantityCap: () => Promise<void> },
'assertWithinQuantityCap',
)
.mockRejectedValue(SENTINEL);
await expect(
service.completeUnderContract('c-1', 'b-1', {
scheduledDate: new Date().toISOString(),
containers: [{ containerSize: '20FT', quantity: 2 }],
} as never),
).rejects.toBe(SENTINEL);
expect(invoiceService.cancelUnpaidInvoiceForBooking).toHaveBeenCalledWith('b-1');
expect(bookingsRepository.deleteContainers).toHaveBeenCalledWith('b-1');
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', {
cargoTotalWeightVgm: 0,
});
});
it('a day-only resubmit keeps the persisted cargo and invoice untouched', async () => {
const { service, bookingsRepository, invoiceService } = makeService();
// First call inside the day-only (hasCargo) resubmit path.
jest
.spyOn(
service as never as {
assertPersistedContainersAvailable: () => Promise<void>;
},
'assertPersistedContainersAvailable',
)
.mockRejectedValue(SENTINEL);
await expect(
service.completeUnderContract('c-1', 'b-1', {
scheduledDate: new Date().toISOString(),
} as never),
).rejects.toBe(SENTINEL);
expect(invoiceService.cancelUnpaidInvoiceForBooking).not.toHaveBeenCalled();
expect(bookingsRepository.deleteContainers).not.toHaveBeenCalled();
});
});

View File

@@ -24,7 +24,7 @@ import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
import { BookingInvoiceService } from '../bookings/booking-invoice.service'; import { BookingInvoiceService } from '../bookings/booking-invoice.service';
import { validate20ftWeightPairing } from '../bookings/container-pairing.util'; import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity'; import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
import { BookingBatchService } from '../train-scheduling/booking-batch.service'; import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { eatDay } from '../train-scheduling/batch-window.util'; import { eatDay } from '../train-scheduling/batch-window.util';
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
@@ -98,6 +98,9 @@ export class ContractBookingService {
private readonly containerTypesService: ContainerTypesService, private readonly containerTypesService: ContainerTypesService,
private readonly ruleEngineService: RuleEngineService, private readonly ruleEngineService: RuleEngineService,
private readonly milestoneService: ClearanceMilestoneService, private readonly milestoneService: ClearanceMilestoneService,
// forwardRef: part of the booking-invoice ⇄ wagon-cancellation ⇄ contracts
// require cycle (see BookingTransitionService).
@Inject(forwardRef(() => BookingInvoiceService))
private readonly invoiceService: BookingInvoiceService, private readonly invoiceService: BookingInvoiceService,
private readonly bookingNotifier: BookingLifecycleNotifierService, private readonly bookingNotifier: BookingLifecycleNotifierService,
private readonly dataSource: DataSource, private readonly dataSource: DataSource,
@@ -692,11 +695,30 @@ export class ContractBookingService {
}); });
const freightType = contract.freightType; const freightType = contract.freightType;
const hasCargo = let hasCargo =
(booking.bookingContainers?.length ?? 0) > 0 || (booking.bookingContainers?.length ?? 0) > 0 ||
Number(booking.cargoTotalWeightVgm) > 0; Number(booking.cargoTotalWeightVgm) > 0;
const warnings: string[] = []; const warnings: string[] = [];
// Operations may return a booking asking for the CARGO to change (fewer or
// more containers), not just the day. A resubmit whose payload restates the
// cargo therefore starts the completion over: cancel the unpaid invoice
// first (it throws if money is already recorded — cargo must not change
// under a paid invoice), then wipe the persisted cargo so the fresh-
// completion path below re-persists, re-prices and re-invoices from the
// payload. A resubmit without cargo keeps today's day-only behavior.
const restatesCargo = Boolean(
dto.containers?.length || dto.bulkLines?.length,
);
if (hasCargo && restatesCargo) {
await this.invoiceService.cancelUnpaidInvoiceForBooking(booking.id);
await this.bookingsRepository.deleteContainers(booking.id);
await this.bookingsRepository.update(booking.id, {
cargoTotalWeightVgm: 0,
} as never);
hasCargo = false;
}
// EXPORT rides whole or not at all (no split concept): the chosen day must // EXPORT rides whole or not at all (no split concept): the chosen day must
// have a single open train that carries the whole booking. First completion // have a single open train that carries the whole booking. First completion
// sizes from the dto's cargo; a changes-requested resubmit (cargo already // sizes from the dto's cargo; a changes-requested resubmit (cargo already
@@ -1863,6 +1885,9 @@ export class ContractBookingService {
async validateShipment( async validateShipment(
contractId: string, contractId: string,
dto: CreateBookingUnderContractDto, dto: CreateBookingUnderContractDto,
// Completion/resubmit preview: the booking being completed must not clash
// with its own persisted containers.
excludeBookingId?: string,
): Promise<{ ): Promise<{
overweightLines: Array<{ overweightLines: Array<{
containerTypeCode: string; containerTypeCode: string;
@@ -2024,6 +2049,7 @@ export class ContractBookingService {
originYardId: route?.originYardId, originYardId: route?.originYardId,
destinationYardId: route?.destinationYardId, destinationYardId: route?.destinationYardId,
}, },
excludeBookingId,
); );
containerClashErrors = clashes.map( containerClashErrors = clashes.map(
(c) => (c) =>

View File

@@ -1,5 +1,6 @@
import { ContractExpiryService } from './contract-expiry.service'; import { ContractExpiryService } from './contract-expiry.service';
import type { Contract } from './entities/contract.entity'; import type { Contract } from './entities/contract.entity';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
/** /**
* The reminder must warn each customer once, ten days out, and must never let a * The reminder must warn each customer once, ten days out, and must never let a
@@ -60,4 +61,18 @@ describe('ContractExpiryService — expiry reminder', () => {
inbox.notify.mockRejectedValue(new Error('inbox down')); inbox.notify.mockRejectedValue(new Error('inbox down'));
await expect(service.remindExpiringContracts()).resolves.toBeUndefined(); await expect(service.remindExpiringContracts()).resolves.toBeUndefined();
}); });
// The sweep-failure alert is staff-facing. It used to go to every employee;
// it belongs to the people who would notice expired contracts still listed
// as active, i.e. the contract desk.
it('alerts the contract desk when the sweep itself fails', async () => {
repo.expireLapsedContracts.mockRejectedValue(new Error('deadlock'));
await service.expireLapsedContracts();
expect(inbox.notify).toHaveBeenCalledTimes(1);
expect(inbox.notify.mock.calls[0][0].recipients).toEqual({
permissionKeys: [FREIGHT_PERMS.contracts.getNotification],
});
});
}); });

View File

@@ -4,6 +4,7 @@ import { NotificationAudience, NotificationType } from '@edr/types';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { ContractsRepository } from './contracts.repository'; import { ContractsRepository } from './contracts.repository';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
/** /**
* How many days before a contract lapses the customer is reminded. Mirrored by * How many days before a contract lapses the customer is reminded. Mirrored by
@@ -79,7 +80,11 @@ export class ContractExpiryService {
); );
try { try {
await this.inbox.notify({ await this.inbox.notify({
recipients: { allBackoffice: true }, // The people who would notice expired contracts still listed as
// active are the ones working the contract desk.
recipients: {
permissionKeys: [FREIGHT_PERMS.contracts.getNotification],
},
audience: NotificationAudience.BACKOFFICE, audience: NotificationAudience.BACKOFFICE,
type: NotificationType.GENERIC, type: NotificationType.GENERIC,
title: 'Contract expiry sweep failed', title: 'Contract expiry sweep failed',

View File

@@ -10,7 +10,17 @@ import {
import { Contract } from './entities/contract.entity'; import { Contract } from './entities/contract.entity';
import { NotificationsService } from '../notifications/notifications.service'; import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util'; import { resolveCompanyNotifyContact } from '../notifications/resolve-company-phone.util';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
/**
* Clearance items are worked by the GL desks, which hold no intake keys — so
* they take their own selector rather than the contract desk's. Every override
* using this deep-links to a clearance or shipment-request page.
*/
const CLEARANCE_DESK = {
permissionKeys: [FREIGHT_PERMS.contracts.clearanceGetNotification],
};
/** /**
* Customer + staff notifications for the contract lifecycle. Every customer * Customer + staff notifications for the contract lifecycle. Every customer
@@ -42,10 +52,11 @@ export class ContractNotifierService {
logLabel: string, logLabel: string,
): Promise<void> { ): Promise<void> {
this.logger.log(`${logLabel}${this.ref(c)}`); this.logger.log(`${logLabel}${this.ref(c)}`);
const phone = c.companyId // One resolver for both channels — the company row's own email column is
? await resolveCompanyNotifyPhone(this.dataSource, c.companyId) // only set for a Fayda-verified owner (see companyNotifyEmailExpr).
: null; const { phone, email } = c.companyId
const email = c.company?.email ?? c.company?.generalManagerEmail ?? null; ? await resolveCompanyNotifyContact(this.dataSource, c.companyId)
: { phone: null, email: null };
if (phone) { if (phone) {
try { try {
@@ -86,7 +97,11 @@ export class ContractNotifierService {
}); });
} }
/** Persist + push an in-app item to every backoffice staff user. */ /**
* Persist + push an in-app item to the contract desk — staff holding
* `contracts:get_notification`. Callers whose item belongs to a different
* desk override `recipients` (see {@link CLEARANCE_DESK}).
*/
private inAppStaff( private inAppStaff(
c: Contract, c: Contract,
title: string, title: string,
@@ -94,7 +109,7 @@ export class ContractNotifierService {
overrides: Partial<NotifyInput> = {}, overrides: Partial<NotifyInput> = {},
): void { ): void {
void this.inbox.notify({ void this.inbox.notify({
recipients: { allBackoffice: true }, recipients: { permissionKeys: [FREIGHT_PERMS.contracts.getNotification] },
audience: NotificationAudience.BACKOFFICE, audience: NotificationAudience.BACKOFFICE,
type: NotificationType.REQUEST_SUBMITTED, type: NotificationType.REQUEST_SUBMITTED,
title, title,
@@ -230,6 +245,7 @@ export class ContractNotifierService {
`the customs declaration can be filed.${note ? ` Note: "${note}"` : ''}`; `the customs declaration can be filed.${note ? ` Note: "${note}"` : ''}`;
this.logger.log(`TRANSIT ASSIGNEE REQUESTED — ${c.reference}`); this.logger.log(`TRANSIT ASSIGNEE REQUESTED — ${c.reference}`);
this.inAppStaff(c, `Transit assignee needed — ${c.reference}`, msg, { this.inAppStaff(c, `Transit assignee needed — ${c.reference}`, msg, {
recipients: CLEARANCE_DESK,
type: NotificationType.CLEARANCE_REVIEW, type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/gl-djibouti/clearance/${c.id}`, link: `/dashboard/gl-djibouti/clearance/${c.id}`,
}); });
@@ -248,6 +264,7 @@ export class ContractNotifierService {
`The customs declaration can now be filed.`; `The customs declaration can now be filed.`;
this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${c.reference}`); this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${c.reference}`);
this.inAppStaff(c, `Transit assignee set — ${c.reference}`, msg, { this.inAppStaff(c, `Transit assignee set — ${c.reference}`, msg, {
recipients: CLEARANCE_DESK,
type: NotificationType.CLEARANCE_REVIEW, type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/contracts/clearance/${c.id}`, link: `/dashboard/contracts/clearance/${c.id}`,
}); });
@@ -264,6 +281,7 @@ export class ContractNotifierService {
`"${note}". Review and re-advise the amount on the clearance page.`; `"${note}". Review and re-advise the amount on the clearance page.`;
this.logger.log(`DUTY DISPUTED — ${c.reference}`); this.logger.log(`DUTY DISPUTED — ${c.reference}`);
this.inAppStaff(c, `Duty disputed on ${c.reference}`, msg, { this.inAppStaff(c, `Duty disputed on ${c.reference}`, msg, {
recipients: CLEARANCE_DESK,
type: NotificationType.CLEARANCE_REVIEW, type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/contracts/clearance/${c.id}`, link: `/dashboard/contracts/clearance/${c.id}`,
}); });
@@ -321,6 +339,7 @@ export class ContractNotifierService {
'Clearance documents uploaded', 'Clearance documents uploaded',
`Customer uploaded clearance documents for contract ${this.ref(c)} — review them in the clearance queue.`, `Customer uploaded clearance documents for contract ${this.ref(c)} — review them in the clearance queue.`,
{ {
recipients: CLEARANCE_DESK,
type: NotificationType.CLEARANCE_REVIEW, type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/contracts/clearance/${c.id}`, link: `/dashboard/contracts/clearance/${c.id}`,
}, },
@@ -334,6 +353,7 @@ export class ContractNotifierService {
'Duty slip uploaded', 'Duty slip uploaded',
`Customer uploaded the duty & tax payment slip for contract ${this.ref(c)}.`, `Customer uploaded the duty & tax payment slip for contract ${this.ref(c)}.`,
{ {
recipients: CLEARANCE_DESK,
type: NotificationType.PAYMENT_RECEIVED, type: NotificationType.PAYMENT_RECEIVED,
link: `/dashboard/contracts/clearance/${c.id}`, link: `/dashboard/contracts/clearance/${c.id}`,
}, },
@@ -347,6 +367,9 @@ export class ContractNotifierService {
'New shipment request', 'New shipment request',
`Shipment request ${requestRef} was filed under contract ${this.ref(c)} and awaits GL review.`, `Shipment request ${requestRef} was filed under contract ${this.ref(c)} and awaits GL review.`,
{ {
// GL reviews these, and the shipment-requests page is gated on
// contracts:create_booking — a key only the GL Ethiopia preset holds.
recipients: CLEARANCE_DESK,
link: `/dashboard/shipment-requests/${requestId}`, link: `/dashboard/shipment-requests/${requestId}`,
data: { contractId: c.id, requestId, reference: requestRef }, data: { contractId: c.id, requestId, reference: requestRef },
}, },

View File

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

View File

@@ -424,6 +424,8 @@ export class ContractTransitionService {
contract.tradeDirection, contract.tradeDirection,
contract.freightType, contract.freightType,
contract.customsClearingEnabled, contract.customsClearingEnabled,
// Bulk templates are keyed by the contract's cargo type.
(contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId,
); );
if (!active) return null; if (!active) return null;
return { return {

View File

@@ -14,12 +14,10 @@ import {
UnauthorizedException, UnauthorizedException,
UploadedFiles, UploadedFiles,
UploadedFile, UploadedFile,
UseGuards,
UseInterceptors, UseInterceptors,
} from '@nestjs/common'; } from '@nestjs/common';
import { CurrentUser } from '@edr/api-common'; import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express'; import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express'; import type { Response } from 'express';
import { import {
@@ -32,7 +30,7 @@ import {
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { actorLabel } from '../warehouses/current-actor.util'; import { actorLabel } from '../warehouses/current-actor.util';
import { BookingStaff } from '../../common/booking-guards'; import { BookingStaff, MixedAudience, PortalCustomer } from '../../common/booking-guards';
import { ContractDocumentHistoryService } from './contract-document-history.service'; import { ContractDocumentHistoryService } from './contract-document-history.service';
import { import {
FREIGHT_PERMS, FREIGHT_PERMS,
@@ -127,6 +125,7 @@ export class ContractsController {
} }
@Get('booking-requests/:reqId') @Get('booking-requests/:reqId')
@MixedAudience(FREIGHT_PERMS.contracts.view)
@ApiOperation({ summary: 'A single shipment request' }) @ApiOperation({ summary: 'A single shipment request' })
getBookingRequest(@Param('reqId', ParseUUIDPipe) reqId: string) { getBookingRequest(@Param('reqId', ParseUUIDPipe) reqId: string) {
return this.bookingRequestService.findOne(reqId); return this.bookingRequestService.findOne(reqId);
@@ -159,6 +158,7 @@ export class ContractsController {
} }
@Post('booking-requests/:reqId/cancel') @Post('booking-requests/:reqId/cancel')
@PortalCustomer()
@ApiOperation({ summary: 'Customer cancels their own pending shipment request' }) @ApiOperation({ summary: 'Customer cancels their own pending shipment request' })
cancelBookingRequest( cancelBookingRequest(
@Param('reqId', ParseUUIDPipe) reqId: string, @Param('reqId', ParseUUIDPipe) reqId: string,
@@ -168,6 +168,7 @@ export class ContractsController {
} }
@Post(':id/booking-requests') @Post(':id/booking-requests')
@PortalCustomer()
@ApiOperation({ summary: 'Customer submits a shipment request on a GENERAL customs contract' }) @ApiOperation({ summary: 'Customer submits a shipment request on a GENERAL customs contract' })
submitBookingRequest( submitBookingRequest(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@@ -178,12 +179,14 @@ export class ContractsController {
} }
@Get(':id/booking-requests') @Get(':id/booking-requests')
@MixedAudience(FREIGHT_PERMS.contracts.view)
@ApiOperation({ summary: 'List the shipment requests on a contract' }) @ApiOperation({ summary: 'List the shipment requests on a contract' })
listBookingRequests(@Param('id', ParseUUIDPipe) id: string) { listBookingRequests(@Param('id', ParseUUIDPipe) id: string) {
return this.bookingRequestService.listForContract(id); return this.bookingRequestService.listForContract(id);
} }
@Post() @Post()
@MixedAudience([])
@UseInterceptors(AnyFilesInterceptor()) @UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data') @ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Create a new contract (DRAFT) with routes + cargo scope' }) @ApiOperation({ summary: 'Create a new contract (DRAFT) with routes + cargo scope' })
@@ -203,6 +206,7 @@ export class ContractsController {
} }
@Get() @Get()
@MixedAudience([])
@ApiOperation({ summary: 'List contracts (paginated)' }) @ApiOperation({ summary: 'List contracts (paginated)' })
async findAll( async findAll(
@Query() filter: FilterContractDto, @Query() filter: FilterContractDto,
@@ -247,6 +251,7 @@ export class ContractsController {
} }
@Get('my') @Get('my')
@PortalCustomer()
@ApiOperation({ summary: "List the current customer's contracts" }) @ApiOperation({ summary: "List the current customer's contracts" })
async findMy( async findMy(
@CurrentUser() user: AuthUserPayload, @CurrentUser() user: AuthUserPayload,
@@ -301,6 +306,7 @@ export class ContractsController {
} }
@Get(':id') @Get(':id')
@MixedAudience(FREIGHT_PERMS.contracts.view)
@ApiOperation({ summary: 'Get contract by ID (routes, cargo scope, unit rates)' }) @ApiOperation({ summary: 'Get contract by ID (routes, cargo scope, unit rates)' })
async findOne( async findOne(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@@ -319,6 +325,7 @@ export class ContractsController {
} }
@Patch(':id') @Patch(':id')
@MixedAudience([])
@UseInterceptors(AnyFilesInterceptor()) @UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data') @ApiConsumes('multipart/form-data')
@ApiOperation({ @ApiOperation({
@@ -337,6 +344,7 @@ export class ContractsController {
} }
@Delete(':id') @Delete(':id')
@MixedAudience([])
@HttpCode(204) @HttpCode(204)
@ApiOperation({ summary: 'Soft-delete DRAFT contract' }) @ApiOperation({ summary: 'Soft-delete DRAFT contract' })
remove(@Param('id', ParseUUIDPipe) id: string) { remove(@Param('id', ParseUUIDPipe) id: string) {
@@ -344,6 +352,7 @@ export class ContractsController {
} }
@Post(':id/documents') @Post(':id/documents')
@MixedAudience([])
@UseInterceptors(AnyFilesInterceptor()) @UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data') @ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Upload intake documents for a contract (DRAFT only)' }) @ApiOperation({ summary: 'Upload intake documents for a contract (DRAFT only)' })
@@ -355,18 +364,21 @@ export class ContractsController {
} }
@Post(':id/generate-price') @Post(':id/generate-price')
@MixedAudience([])
@ApiOperation({ summary: 'Generate unit-rate breakdown (no totals at contract phase)' }) @ApiOperation({ summary: 'Generate unit-rate breakdown (no totals at contract phase)' })
generatePrice(@Param('id', ParseUUIDPipe) id: string) { generatePrice(@Param('id', ParseUUIDPipe) id: string) {
return this.pricingService.generatePrice(id); return this.pricingService.generatePrice(id);
} }
@Post(':id/submit') @Post(':id/submit')
@MixedAudience([])
@ApiOperation({ summary: 'Customer submit contract (freezes contract_rate_snapshots)' }) @ApiOperation({ summary: 'Customer submit contract (freezes contract_rate_snapshots)' })
submit(@Param('id', ParseUUIDPipe) id: string) { submit(@Param('id', ParseUUIDPipe) id: string) {
return this.transitionService.submit(id); return this.transitionService.submit(id);
} }
@Post(':id/confirm-submit') @Post(':id/confirm-submit')
@MixedAudience([])
@ApiOperation({ summary: 'Confirm submit after a price change' }) @ApiOperation({ summary: 'Confirm submit after a price change' })
confirmSubmit(@Param('id', ParseUUIDPipe) id: string) { confirmSubmit(@Param('id', ParseUUIDPipe) id: string) {
return this.transitionService.confirmSubmit(id); return this.transitionService.confirmSubmit(id);
@@ -427,10 +439,7 @@ export class ContractsController {
// real boundary: it admits only the approver whose step is currently pending // real boundary: it admits only the approver whose step is currently pending
// (edit rights hand off down the chain on each approval). // (edit rights hand off down the chain on each approval).
@Put(':id/document/articles') @Put(':id/document/articles')
@BookingStaff([ @BookingStaff(FREIGHT_PERMS.contracts.editDocument)
FREIGHT_PERMS.contracts.view,
...bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept),
])
@ApiOperation({ @ApiOperation({
summary: summary:
'Edit this contract\'s document articles only (per-contract; never touches the six shared templates)', 'Edit this contract\'s document articles only (per-contract; never touches the six shared templates)',
@@ -515,6 +524,7 @@ export class ContractsController {
} }
@Post(':id/cancel') @Post(':id/cancel')
@PortalCustomer()
@ApiOperation({ @ApiOperation({
summary: 'Customer cancels their own contract (blocked while a booking is live)', summary: 'Customer cancels their own contract (blocked while a booking is live)',
}) })
@@ -591,6 +601,7 @@ export class ContractsController {
} }
@Get(':id/contract/view') @Get(':id/contract/view')
@MixedAudience(FREIGHT_PERMS.contracts.view)
@ApiOperation({ summary: 'Contract PDF view-model + rendered HTML for signing' }) @ApiOperation({ summary: 'Contract PDF view-model + rendered HTML for signing' })
async getContractView( async getContractView(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@@ -630,6 +641,7 @@ export class ContractsController {
} }
@Get(':id/contract/document') @Get(':id/contract/document')
@MixedAudience(FREIGHT_PERMS.contracts.view)
@ApiOperation({ summary: 'Download contract PDF' }) @ApiOperation({ summary: 'Download contract PDF' })
async downloadContractDocument( async downloadContractDocument(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@@ -653,7 +665,7 @@ export class ContractsController {
} }
@Post(':id/contract/send-signing-otp') @Post(':id/contract/send-signing-otp')
@UseGuards(JwtGuard) @MixedAudience(bothFreightTypes(FREIGHT_PERMS.contracts.signStaff))
@ApiOperation({ @ApiOperation({
summary: summary:
"Send the sudo-mode signing OTP to the contract company's registered phone (server picks the number)", "Send the sudo-mode signing OTP to the contract company's registered phone (server picks the number)",
@@ -666,7 +678,7 @@ export class ContractsController {
} }
@Post(':id/contract/sign') @Post(':id/contract/sign')
@UseGuards(JwtGuard) @MixedAudience(bothFreightTypes(FREIGHT_PERMS.contracts.signStaff))
@ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' }) @ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' })
async signContract( async signContract(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@@ -691,6 +703,7 @@ export class ContractsController {
} }
@Post(':id/renew') @Post(':id/renew')
@PortalCustomer()
@ApiOperation({ summary: 'Create a renewal draft linked via renewalOfId' }) @ApiOperation({ summary: 'Create a renewal draft linked via renewalOfId' })
async renew( async renew(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@@ -712,12 +725,14 @@ export class ContractsController {
// ── Pre-booking clearance (Path B, doc §15.2.1) ──────────────────────────── // ── Pre-booking clearance (Path B, doc §15.2.1) ────────────────────────────
@Get(':id/clearance') @Get(':id/clearance')
@MixedAudience(FREIGHT_PERMS.contracts.view)
@ApiOperation({ summary: 'Pre-booking clearance document grid on the contract' }) @ApiOperation({ summary: 'Pre-booking clearance document grid on the contract' })
getClearance(@Param('id', ParseUUIDPipe) id: string) { getClearance(@Param('id', ParseUUIDPipe) id: string) {
return this.clearanceService.getClearanceView(id); return this.clearanceService.getClearanceView(id);
} }
@Post(':id/clearance/documents') @Post(':id/clearance/documents')
@PortalCustomer()
@UseInterceptors(AnyFilesInterceptor()) @UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data') @ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Customer uploads clearance documents (fieldname = document key)' }) @ApiOperation({ summary: 'Customer uploads clearance documents (fieldname = document key)' })
@@ -901,6 +916,7 @@ export class ContractsController {
} }
@Post(':id/clearance/duty/dispute') @Post(':id/clearance/duty/dispute')
@PortalCustomer()
@ApiOperation({ @ApiOperation({
summary: summary:
'Customer disputes the advised duty/tax with a reason — reopens the step so GL Ethiopia can re-advise (repeatable)', 'Customer disputes the advised duty/tax with a reason — reopens the step so GL Ethiopia can re-advise (repeatable)',
@@ -914,6 +930,7 @@ export class ContractsController {
} }
@Post(':id/clearance/duty-slip') @Post(':id/clearance/duty-slip')
@PortalCustomer()
@UseInterceptors(FileInterceptor('file')) @UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data') @ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Customer uploads duty/tax payment slip on contract' }) @ApiOperation({ summary: 'Customer uploads duty/tax payment slip on contract' })
@@ -1057,15 +1074,23 @@ export class ContractsController {
// ── Booking under contract (Path A customer / Path B GL ET) ──────────────── // ── Booking under contract (Path A customer / Path B GL ET) ────────────────
@Post(':id/bookings') @Post(':id/bookings')
// Path A is a customer flow — both audiences must reach the service, whose
// assertGate decides per role. Staff still need contracts:create_booking.
@MixedAudience(FREIGHT_PERMS.contracts.createBooking)
@ApiOperation({ @ApiOperation({
summary: summary:
'Create a shipment booking under a contract — Path A (customer) or Path B (GL Ethiopia).', 'Create a shipment booking under a contract — Path A (customer) or Path B (GL Ethiopia).',
}) })
createBooking( async createBooking(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@Body() dto: CreateBookingUnderContractDto, @Body() dto: CreateBookingUnderContractDto,
@CurrentUser() user: AuthUserPayload, @CurrentUser() user: TCurrentUser & { sub?: string },
) { ) {
// Customer callers may only book on their own contract.
if (!hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking)) {
const contract = await this.contractsService.findById(id);
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
}
// The service decides the execution path from the contract: // The service decides the execution path from the contract:
// Path A (customs disabled) → customer/staff create; status checks apply. // Path A (customs disabled) → customer/staff create; status checks apply.
// Path B (customs enabled) → GL Ethiopia only, once clearance is ready. // Path B (customs enabled) → GL Ethiopia only, once clearance is ready.
@@ -1078,15 +1103,25 @@ export class ContractsController {
} }
@Post(':id/bookings/initiate') @Post(':id/bookings/initiate')
// Customer initiates their own ONE_TIME instance; GL initiates on customs
// contracts — the service's assertGate decides per role, so both audiences
// must reach it. Staff still need contracts:create_booking.
@MixedAudience(FREIGHT_PERMS.contracts.createBooking)
@ApiOperation({ @ApiOperation({
summary: summary:
'Initiate a bare booking instance under an import/export contract (ONE_TIME or GENERAL) — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS). ONE_TIME customs instances are opened by the customer (or GL); GENERAL customs comes from a shipment request.', 'Initiate a bare booking instance under an import/export contract (ONE_TIME or GENERAL) — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS). ONE_TIME customs instances are opened by the customer (or GL); GENERAL customs comes from a shipment request.',
}) })
initiateBooking( async initiateBooking(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@Body() dto: CreateBookingUnderContractDto, @Body() dto: CreateBookingUnderContractDto,
@CurrentUser() user: AuthUserPayload, @CurrentUser() user: TCurrentUser & { sub?: string },
) { ) {
// Customer callers may only initiate on their own contract; the service's
// assertGate then decides what a customer may do on it.
if (!hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking)) {
const contract = await this.contractsService.findById(id);
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
}
return this.contractBookingService.initiateUnderContract( return this.contractBookingService.initiateUnderContract(
id, id,
{ contractRouteId: dto?.contractRouteId }, { contractRouteId: dto?.contractRouteId },
@@ -1096,16 +1131,24 @@ export class ContractsController {
} }
@Post(':id/bookings/:bookingId/complete') @Post(':id/bookings/:bookingId/complete')
// Customers complete their own initiated (non-customs) instances; the
// service keeps customs completion GL-only via the actor's permissions.
@MixedAudience(FREIGHT_PERMS.contracts.createBooking)
@ApiOperation({ @ApiOperation({
summary: summary:
'Complete an initiated booking after Operations finalized its clearance — cargo + binding day, window and departure checks, pricing and invoicing.', 'Complete an initiated booking after Operations finalized its clearance — cargo + binding day, window and departure checks, pricing and invoicing.',
}) })
completeBooking( async completeBooking(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@Param('bookingId', ParseUUIDPipe) bookingId: string, @Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: CreateBookingUnderContractDto, @Body() dto: CreateBookingUnderContractDto,
@CurrentUser() user: AuthUserPayload, @CurrentUser() user: TCurrentUser & { sub?: string },
) { ) {
// Customer callers may only complete bookings on their own contract.
if (!hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking)) {
const contract = await this.contractsService.findById(id);
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
}
// Customs (Path B) instances may only be completed by GL Ethiopia — the // Customs (Path B) instances may only be completed by GL Ethiopia — the
// service checks the actor's contracts:create_booking permission. // service checks the actor's contracts:create_booking permission.
return this.contractBookingService.completeUnderContract( return this.contractBookingService.completeUnderContract(
@@ -1117,6 +1160,7 @@ export class ContractsController {
} }
@Post(':id/validate-shipment') @Post(':id/validate-shipment')
@MixedAudience([FREIGHT_PERMS.contracts.createBooking, FREIGHT_PERMS.contracts.view])
@ApiOperation({ @ApiOperation({
summary: summary:
'Pre-create validation + authoritative price preview: full booking price breakdown (rail, first/last mile, surcharges), overweight lines and 20ft weight-pairing errors for a shipment payload (no booking created).', 'Pre-create validation + authoritative price preview: full booking price breakdown (rail, first/last mile, surcharges), overweight lines and 20ft weight-pairing errors for a shipment payload (no booking created).',
@@ -1124,11 +1168,15 @@ export class ContractsController {
validateShipment( validateShipment(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@Body() dto: CreateBookingUnderContractDto, @Body() dto: CreateBookingUnderContractDto,
// Completion/resubmit preview: exclude this booking's own persisted
// containers from the same-train clash check.
@Query('bookingId') bookingId?: string,
) { ) {
return this.contractBookingService.validateShipment(id, dto); return this.contractBookingService.validateShipment(id, dto, bookingId);
} }
@Get(':id/capacity') @Get(':id/capacity')
@MixedAudience(FREIGHT_PERMS.contracts.view)
@ApiOperation({ @ApiOperation({
summary: summary:
'Remaining bookable quantity per cargo line (GENERAL draw-down cap, or the outstanding remainder of a split ONE_TIME contract)', 'Remaining bookable quantity per cargo line (GENERAL draw-down cap, or the outstanding remainder of a split ONE_TIME contract)',
@@ -1141,12 +1189,14 @@ export class ContractsController {
// ── Clearance milestones (doc §11.3, §12.2) ──────────────────────────────── // ── Clearance milestones (doc §11.3, §12.2) ────────────────────────────────
@Get(':id/milestones') @Get(':id/milestones')
@MixedAudience(FREIGHT_PERMS.contracts.view)
@ApiOperation({ summary: 'Pre-booking clearance milestones for a contract cycle' }) @ApiOperation({ summary: 'Pre-booking clearance milestones for a contract cycle' })
listContractMilestones(@Param('id', ParseUUIDPipe) id: string) { listContractMilestones(@Param('id', ParseUUIDPipe) id: string) {
return this.milestoneService.listForContract(id); return this.milestoneService.listForContract(id);
} }
@Get('bookings/:bookingId/milestones') @Get('bookings/:bookingId/milestones')
@MixedAudience(FREIGHT_PERMS.contracts.view)
@ApiOperation({ summary: 'Post-booking clearance milestones for a shipment booking' }) @ApiOperation({ summary: 'Post-booking clearance milestones for a shipment booking' })
listBookingMilestones(@Param('bookingId', ParseUUIDPipe) bookingId: string) { listBookingMilestones(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.milestoneService.listForBooking(bookingId); return this.milestoneService.listForBooking(bookingId);
@@ -1280,7 +1330,7 @@ export class ContractsController {
} }
@Post('bookings/:bookingId/final-invoice') @Post('bookings/:bookingId/final-invoice')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) @BookingStaff(FREIGHT_PERMS.contracts.finalInvoiceRaise)
@UseInterceptors(FileInterceptor('file')) @UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data') @ApiConsumes('multipart/form-data')
@ApiOperation({ @ApiOperation({
@@ -1307,6 +1357,7 @@ export class ContractsController {
} }
@Post('bookings/:bookingId/final-invoice/approve') @Post('bookings/:bookingId/final-invoice/approve')
@PortalCustomer()
@ApiOperation({ @ApiOperation({
summary: 'Customer approves the drafted final invoice — unlocks the payment slip', summary: 'Customer approves the drafted final invoice — unlocks the payment slip',
}) })
@@ -1321,6 +1372,7 @@ export class ContractsController {
} }
@Post('bookings/:bookingId/final-invoice-slip') @Post('bookings/:bookingId/final-invoice-slip')
@PortalCustomer()
@UseInterceptors(FileInterceptor('file')) @UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data') @ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Customer attaches the payment slip for the final invoice' }) @ApiOperation({ summary: 'Customer attaches the payment slip for the final invoice' })
@@ -1332,10 +1384,7 @@ export class ContractsController {
} }
@Post('bookings/:bookingId/final-invoice/confirm') @Post('bookings/:bookingId/final-invoice/confirm')
@BookingStaff([ @BookingStaff(FREIGHT_PERMS.contracts.finalInvoiceConfirm)
FREIGHT_PERMS.contracts.clearanceDjActions,
FREIGHT_PERMS.contracts.clearanceEtActions,
])
@ApiOperation({ summary: 'GL (ET or DJ) confirms the payment slip — settles the final invoice' }) @ApiOperation({ summary: 'GL (ET or DJ) confirms the payment slip — settles the final invoice' })
confirmFinalInvoicePaid( confirmFinalInvoicePaid(
@Param('bookingId', ParseUUIDPipe) bookingId: string, @Param('bookingId', ParseUUIDPipe) bookingId: string,
@@ -1378,6 +1427,7 @@ export class ContractsController {
} }
@Post('bookings/:bookingId/second-duty-slip') @Post('bookings/:bookingId/second-duty-slip')
@PortalCustomer()
@UseInterceptors(FileInterceptor('file')) @UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data') @ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Customer attaches the additional duty/tax payment slip' }) @ApiOperation({ summary: 'Customer attaches the additional duty/tax payment slip' })
@@ -1403,6 +1453,7 @@ export class ContractsController {
} }
@Post('bookings/:bookingId/duty-slip') @Post('bookings/:bookingId/duty-slip')
@PortalCustomer()
@UseInterceptors(AnyFilesInterceptor()) @UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data') @ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Customer uploads the duty/tax payment slip' }) @ApiOperation({ summary: 'Customer uploads the duty/tax payment slip' })
@@ -1419,6 +1470,7 @@ export class ContractsController {
} }
@Get('bookings/:bookingId/incidents') @Get('bookings/:bookingId/incidents')
@MixedAudience(FREIGHT_PERMS.contracts.view)
@ApiOperation({ summary: 'List cargo exception/damage reports for a shipment' }) @ApiOperation({ summary: 'List cargo exception/damage reports for a shipment' })
listIncidents(@Param('bookingId', ParseUUIDPipe) bookingId: string) { listIncidents(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.glOperationsService.listIncidents(bookingId); return this.glOperationsService.listIncidents(bookingId);

View File

@@ -104,15 +104,19 @@ export class ContractsRepository extends BaseRepository<Contract> {
.andWhere('contract.status NOT IN (:...terminal)', { .andWhere('contract.status NOT IN (:...terminal)', {
terminal: TERMINAL_CONTRACT_STATUSES, terminal: TERMINAL_CONTRACT_STATUSES,
}) })
// A ONE_TIME contract allows a single booking, so once that booking // A ONE_TIME contract allows a single booking, so once that booking is
// exists the contract is spent and can never carry another shipment. // PAID the contract is spent and can never carry another shipment.
// Without this it kept blocking new requests on the same service type + // Without this it kept blocking new requests on the same service type +
// route until its validity lapsed — locking a customer out of a lane for // route until its validity lapsed — locking a customer out of a lane for
// the rest of the term after one completed shipment. // the rest of the term after one completed shipment.
// Payment is the gate, not the booking row: a DRAFT or abandoned unpaid
// booking must keep the contract blocking, otherwise a customer holds an
// unpaid booking and requests an identical contract alongside it.
.andWhere( .andWhere(
`(contract.contract_kind <> 'ONE_TIME' OR NOT EXISTS ( `(contract.contract_kind <> 'ONE_TIME' OR NOT EXISTS (
SELECT 1 FROM freight.bookings b SELECT 1 FROM freight.bookings b
WHERE b.contract_id = contract.id AND b.deleted_at IS NULL WHERE b.contract_id = contract.id AND b.deleted_at IS NULL
AND b.payment_status = 'PAID'
))`, ))`,
) )
.getMany(); .getMany();

View File

@@ -1,22 +0,0 @@
import { Controller, Get, UseGuards } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { PermissionGuard } from "@tria-plc/api-common/modules/auth/services/permission.guard";
@ApiTags("demo-permissions")
@Controller()
export class DemoPermissionsController {
@Get("test_user1")
@ApiOperation({ summary: "Permission demo (can:demo:user1)" })
@UseGuards(PermissionGuard(["can:demo:user1"]))
testUser1() {
return { ok: true, permission: "can:demo:user1" };
}
@Get("test_user2")
@ApiOperation({ summary: "Permission demo (can:demo:user2)" })
@UseGuards(PermissionGuard(["can:demo:user2"]))
testUser2() {
return { ok: true, permission: "can:demo:user2" };
}
}

View File

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

View File

@@ -24,7 +24,14 @@ import { FleetHistoryService } from '../fleet-history/fleet-history.service';
@ApiTags('drivers') @ApiTags('drivers')
@ApiBearerAuth() @ApiBearerAuth()
@Controller('drivers') @Controller('drivers')
@BookingStaff(FREIGHT_PERMS.drivers.view) // Class gate lists every key its routes use: Nest runs class AND method
// guards, so a key missing here would deny before the route's own key runs.
@BookingStaff([
FREIGHT_PERMS.drivers.view,
FREIGHT_PERMS.drivers.create,
FREIGHT_PERMS.drivers.update,
FREIGHT_PERMS.drivers.delete,
])
export class DriversController { export class DriversController {
constructor( constructor(
private readonly driversService: DriversService, private readonly driversService: DriversService,

View File

@@ -14,7 +14,8 @@ import {
} from "@nestjs/common"; } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { FreightAdmin } from "../../common/booking-guards"; import { BookingStaff } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { CreateDropdownOptionDto } from "./dto/create-dropdown-option.dto"; import { CreateDropdownOptionDto } from "./dto/create-dropdown-option.dto";
import { CreateDropdownSettingDto } from "./dto/create-dropdown-setting.dto"; import { CreateDropdownSettingDto } from "./dto/create-dropdown-setting.dto";
import { ListDropdownSettingsQueryDto } from "./dto/list-dropdown-settings-query.dto"; import { ListDropdownSettingsQueryDto } from "./dto/list-dropdown-settings-query.dto";
@@ -58,14 +59,14 @@ export class DropdownSettingsController {
} }
@Post() @Post()
@FreightAdmin() @BookingStaff([FREIGHT_PERMS.settings.dropdown.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Create a new dropdown setting" }) @ApiOperation({ summary: "Create a new dropdown setting" })
create(@Body() dto: CreateDropdownSettingDto) { create(@Body() dto: CreateDropdownSettingDto) {
return this.service.create(dto); return this.service.create(dto);
} }
@Patch(":id") @Patch(":id")
@FreightAdmin() @BookingStaff([FREIGHT_PERMS.settings.dropdown.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Update a dropdown setting's metadata" }) @ApiOperation({ summary: "Update a dropdown setting's metadata" })
update( update(
@Param("id", ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,
@@ -75,7 +76,7 @@ export class DropdownSettingsController {
} }
@Delete(":id") @Delete(":id")
@FreightAdmin() @BookingStaff([FREIGHT_PERMS.settings.dropdown.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Soft-delete a dropdown setting" }) @ApiOperation({ summary: "Soft-delete a dropdown setting" })
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
remove(@Param("id", ParseUUIDPipe) id: string) { remove(@Param("id", ParseUUIDPipe) id: string) {
@@ -85,7 +86,7 @@ export class DropdownSettingsController {
/* ------------------------- option routes ------------------------- */ /* ------------------------- option routes ------------------------- */
@Put(":id/options") @Put(":id/options")
@FreightAdmin() @BookingStaff([FREIGHT_PERMS.settings.dropdown.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Replace the full option list for a setting" }) @ApiOperation({ summary: "Replace the full option list for a setting" })
replaceOptions( replaceOptions(
@Param("id", ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,
@@ -95,7 +96,7 @@ export class DropdownSettingsController {
} }
@Post(":id/options") @Post(":id/options")
@FreightAdmin() @BookingStaff([FREIGHT_PERMS.settings.dropdown.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Append a single option to a setting" }) @ApiOperation({ summary: "Append a single option to a setting" })
addOption( addOption(
@Param("id", ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,
@@ -105,7 +106,7 @@ export class DropdownSettingsController {
} }
@Patch("options/:optionId") @Patch("options/:optionId")
@FreightAdmin() @BookingStaff([FREIGHT_PERMS.settings.dropdown.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Update a single option" }) @ApiOperation({ summary: "Update a single option" })
updateOption( updateOption(
@Param("optionId", ParseUUIDPipe) optionId: string, @Param("optionId", ParseUUIDPipe) optionId: string,
@@ -115,7 +116,7 @@ export class DropdownSettingsController {
} }
@Delete("options/:optionId") @Delete("options/:optionId")
@FreightAdmin() @BookingStaff([FREIGHT_PERMS.settings.dropdown.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Soft-delete a single option" }) @ApiOperation({ summary: "Soft-delete a single option" })
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
removeOption(@Param("optionId", ParseUUIDPipe) optionId: string) { removeOption(@Param("optionId", ParseUUIDPipe) optionId: string) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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