Warehouse Enhancemendt

This commit is contained in:
hagiye
2026-06-20 11:49:22 +03:00
1393 changed files with 334619 additions and 20282 deletions

583
.github/scripts/scan.js vendored Normal file
View File

@@ -0,0 +1,583 @@
/**
* PolinRider / Famous Chollima Supply-Chain Malware Scanner
*
* Detects the specific injection pattern used in the PolinRider campaign
* attributed to North Korean APT (Void Dokkaebi / Famous Chollima / UNC5342).
*
* IOCs sourced from:
* - Direct analysis of the injected tailwind.config.js sample
* - Socket Security report (May 2026) on roberts/leads compromise
* - PolinRider technical analysis report (Trend Micro / safedep.io)
*
* Zero external dependencies — runs on any Node.js >= 14.
*/
"use strict";
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
// ─── Configuration ────────────────────────────────────────────────────────────
const CONFIG = {
// Maximum legitimate size for JS config files.
// Real tailwind/postcss/babel configs are rarely > 3 KB.
// Injected files jump to 58 KB instantly.
maxLegitConfigBytes: 3072,
// Minimum whitespace run on a single line that signals hidden payload.
// The campaign uses ~280510 spaces to push payload off-screen.
minSuspiciousInlineSpaces: 100,
// Files that are high-value injection targets for this campaign.
targetedFilenames: [
"tailwind.config.js",
"tailwind.config.ts",
"tailwind.config.cjs",
"tailwind.js",
"postcss.config.js",
"postcss.config.mjs",
"postcss.config.cjs",
"babel.config.js",
"babel.config.cjs",
"next.config.js",
"next.config.mjs",
"next.config.cjs",
"astro.config.mjs",
"astro.config.js",
"vite.config.js",
"vite.config.ts",
"webpack.config.js",
"webpack.mix.js",
],
// Files intentionally containing malware indicators for scanner logic/tests.
// These filenames are skipped before malware rules are evaluated.
ignoredFilenames: ["scan.js"],
// Filesystem paths that indicate active persistence mechanisms
persistenceArtifacts: [
"temp_auto_push.bat",
"temp_interactive_push.bat",
"branch_structure.json",
// Note: queue.bat and .plist are OS-level; checked separately
],
};
// ─── IOC Definitions ──────────────────────────────────────────────────────────
/**
* Each rule has:
* id unique rule identifier for reporting
* severity CRITICAL | HIGH | MEDIUM
* description human-readable explanation
* test(content, filePath, lines) returns array of match details or []
*/
const RULES = [
// ── Tier 1: Definitive campaign signatures ─────────────────────────────────
{
id: "POLINRIDER-001",
severity: "CRITICAL",
description:
"PolinRider string-shuffler variable _$_1e42 — present in every known sample of this campaign",
test(content) {
const matches = [];
const re = /_\$_1e42/g;
let m;
while ((m = re.exec(content)) !== null) {
matches.push(`offset ${m.index}`);
}
return matches;
},
},
{
id: "POLINRIDER-002",
severity: "CRITICAL",
description:
"PolinRider campaign marker global['!'] assignment — used to route C2 traffic",
test(content) {
const matches = [];
const re = /global\s*\[\s*['"]!\s*['"]\s*\]\s*=/g;
let m;
while ((m = re.exec(content)) !== null) {
const snippet = content
.slice(m.index, m.index + 40)
.replace(/\n/g, "\\n");
matches.push(`"${snippet}"`);
}
return matches;
},
},
{
id: "POLINRIDER-003",
severity: "CRITICAL",
description:
'PolinRider shuffler seed string "rmcej%otb%" — embedded in the string-decryption bootstrap of the specific variant targeting this repo',
test(content) {
return content.includes("rmcej%otb%") ? ["seed string found"] : [];
},
},
{
id: "POLINRIDER-004",
severity: "CRITICAL",
description:
"Known C2 IP addresses associated with PolinRider infrastructure",
test(content) {
const knownC2 = ["198.105.127.210", "166.88.54.158", "23.27.202.27"];
return knownC2.filter((ip) => content.includes(ip));
},
},
{
id: "POLINRIDER-005",
severity: "CRITICAL",
description:
"Known TRON blockchain wallet addresses used as dead-drop C2 resolvers",
test(content) {
const wallets = [
"TMfKQEd7TJJa5xNZJZ2Lep838vrzrs7mAP",
"TXfxHUet9pJVU1BgVkBAbrES4YUc1nGzcG",
];
return wallets.filter((w) => content.includes(w));
},
},
{
id: "POLINRIDER-006",
severity: "CRITICAL",
description:
"Known Aptos blockchain addresses used as fallback dead-drop resolvers",
test(content) {
const addrs = [
"0xbe037400670fbf1c32364f762975908dc43eeb38759263e7dfcdabc76380811e",
"0x3f0e5781d0855fb460661ac63257376db1941b2bb522499e4757ecb3ebd5dce3",
];
return addrs.filter((a) => content.includes(a));
},
},
{
id: "POLINRIDER-007",
severity: "CRITICAL",
description:
"Known XOR decryption keys used to decrypt the second-stage payload from BSC transactions",
test(content) {
const keys = ["2[gWfGj;<:-93Z^C", "m6:tTh^D)cBz?NM]"];
return keys.filter((k) => content.includes(k));
},
},
{
id: "POLINRIDER-008",
severity: "CRITICAL",
description:
"Known SHA-256 hash of compromised tailwind.js file (Socket Security, 2026-05-31)",
test(content) {
const knownHashes = new Set([
"96afdba882046385242cbed46871e41147c8055c5d9eff7460847b2c01a77dc3",
"522b28a2f78771715497ba53729d4ab9a50e982322c391379f3bddf7c8cb363f",
]);
const hash = crypto.createHash("sha256").update(content).digest("hex");
return knownHashes.has(hash) ? [`SHA-256: ${hash}`] : [];
},
},
// ── Tier 2: Behavioral / structural indicators ─────────────────────────────
{
id: "POLINRIDER-009",
severity: "HIGH",
description:
"Blockchain RPC infrastructure contact — campaign uses TRON, Aptos, and BSC as dead-drop C2 resolvers",
test(content) {
const endpoints = [
"trongrid.io",
"aptoslabs.com",
"bsc-dataseed.binance.org",
"bsc-rpc.publicnode.com",
"eth_getTransactionByHash",
];
return endpoints.filter((e) => content.includes(e));
},
},
{
id: "POLINRIDER-010",
severity: "HIGH",
description:
"Hidden process spawn with windowsHide:true — used by InvisibleFerret / BeaverTail stager to launch detached Node.js child processes invisibly",
test(content) {
return /windowsHide\s*:\s*true/.test(content)
? ["windowsHide:true found"]
: [];
},
},
{
id: "POLINRIDER-011",
severity: "HIGH",
description:
"Duplicate createRequire injection at file top — campaign restores require() for ES module environments by prepending two identical import statements",
test(content) {
const matches =
content.match(/import\s*\{\s*createRequire\s*\}\s*from/g) || [];
return matches.length >= 2
? [`Found ${matches.length} duplicate createRequire imports`]
: [];
},
},
{
id: "POLINRIDER-012",
severity: "HIGH",
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",
test(content, _filePath, lines) {
const hits = [];
lines.forEach((line, i) => {
const spaceRun = line.match(/\s{100,}/);
if (spaceRun) {
hits.push(`line ${i + 1}: ${spaceRun[0].length} consecutive spaces`);
}
});
return hits;
},
},
{
id: "POLINRIDER-013",
severity: "HIGH",
description:
"Config file size anomaly — legitimate tailwind/postcss/babel configs are < 3 KB; injected files jump to 58 KB",
test(content, filePath) {
const bytes = Buffer.byteLength(content, "utf8");
const base = path.basename(filePath).toLowerCase();
const isTargeted = CONFIG.targetedFilenames.some(
(f) => f.toLowerCase() === base,
);
if (isTargeted && bytes > CONFIG.maxLegitConfigBytes) {
return [
`${bytes} bytes (threshold: ${CONFIG.maxLegitConfigBytes} bytes)`,
];
}
return [];
},
},
{
id: "POLINRIDER-014",
severity: "HIGH",
description:
"Persistence artifact detected — files used by temp_auto_push.bat to rewrite git history and propagate infection to all branches",
test(_content, filePath) {
const base = path.basename(filePath);
return CONFIG.persistenceArtifacts.includes(base) ? [base] : [];
},
},
// ── Tier 3: Supporting behavioral indicators ───────────────────────────────
{
id: "POLINRIDER-015",
severity: "MEDIUM",
description:
"Campaign marker pattern — numeric string assigned to global['!'], used to select C2 tier (alpha/beta/fallback)",
test(content) {
const matches = [];
// Matches patterns like '8-3317', '9-0264-2', '8-3946-1', 'A4-1928'
const re =
/global\s*\[\s*['"]!\s*['"]\s*\]\s*=\s*['"]([A-Z]?\d[\d-]+)['"]/g;
let m;
while ((m = re.exec(content)) !== null) {
matches.push(`marker value: "${m[1]}"`);
}
return matches;
},
},
{
id: "POLINRIDER-016",
severity: "MEDIUM",
description:
"sfL obfuscation function — secondary string-shuffler present in multi-stage loader variant",
test(content) {
// sfL appears as a named function used to decode the larger payload blob
const occurrences = (content.match(/\bsfL\b/g) || []).length;
return occurrences >= 3 ? [`sfL referenced ${occurrences} times`] : [];
},
},
{
id: "POLINRIDER-017",
severity: "MEDIUM",
description:
"global require/module injection — bootloader dynamically restores Node.js internals to bypass ES module restrictions",
test(content) {
const hits = [];
if (/global\s*\[.*\]\s*=\s*require/.test(content))
hits.push("global[x] = require");
if (/global\s*\[.*module.*\]\s*=\s*module/.test(content))
hits.push("global[x] = module");
return hits;
},
},
];
// ─── Scanner Engine ────────────────────────────────────────────────────────────
function scanFile(filePath) {
if (shouldIgnoreFile(filePath)) {
return { filePath, findings: [], skipped: true };
}
let content;
try {
content = fs.readFileSync(filePath, "utf8");
} catch (err) {
return { filePath, error: err.message, findings: [] };
}
const lines = content.split("\n");
const findings = [];
for (const rule of RULES) {
let matches;
try {
matches = rule.test(content, filePath, lines);
} catch (err) {
matches = [`[rule error: ${err.message}]`];
}
if (matches && matches.length > 0) {
findings.push({
id: rule.id,
severity: rule.severity,
description: rule.description,
matches,
});
}
}
return { filePath, findings };
}
function shouldIgnoreFile(filePath) {
const base = path.basename(filePath).toLowerCase();
return CONFIG.ignoredFilenames.some((f) => f.toLowerCase() === base);
}
function walkDir(dir, results = []) {
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return results;
}
for (const entry of entries) {
if (entry.name === "node_modules" || entry.name === ".git") continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
walkDir(full, results);
} else if (entry.isFile() && !shouldIgnoreFile(full)) {
const ext = path.extname(entry.name).toLowerCase();
const base = entry.name.toLowerCase();
// Scan all JS/TS config files + any file matching a targeted name
const isTargetedName = CONFIG.targetedFilenames.some(
(f) => f.toLowerCase() === base,
);
const isPersistenceArtifact = CONFIG.persistenceArtifacts.some(
(f) => f.toLowerCase() === base,
);
const isJsLike = [".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"].includes(
ext,
);
if (isTargetedName || isPersistenceArtifact || isJsLike) {
results.push(full);
}
}
}
return results;
}
// ─── Reporting ─────────────────────────────────────────────────────────────────
const SEVERITY_RANK = { CRITICAL: 3, HIGH: 2, MEDIUM: 1 };
const ANSI = {
reset: "\x1b[0m",
bold: "\x1b[1m",
red: "\x1b[31m",
yellow: "\x1b[33m",
cyan: "\x1b[36m",
green: "\x1b[32m",
dim: "\x1b[2m",
};
function colorSeverity(sev) {
if (sev === "CRITICAL") return `${ANSI.bold}${ANSI.red}${sev}${ANSI.reset}`;
if (sev === "HIGH") return `${ANSI.yellow}${sev}${ANSI.reset}`;
return `${ANSI.cyan}${sev}${ANSI.reset}`;
}
function printReport(allResults, { json = false, outputFile = null } = {}) {
const infected = allResults.filter(
(r) => r.findings && r.findings.length > 0,
);
const errors = allResults.filter((r) => r.error);
if (json) {
const report = {
scannedAt: new Date().toISOString(),
totalFilesScanned: allResults.length,
infectedFiles: infected.length,
results: infected,
errors,
};
const out = JSON.stringify(report, null, 2);
if (outputFile) {
fs.writeFileSync(outputFile, out);
console.log(`JSON report written to: ${outputFile}`);
} else {
console.log(out);
}
return infected.length > 0;
}
// Human-readable output
console.log(
`\n${ANSI.bold}╔══════════════════════════════════════════════════════════╗`,
);
console.log(`║ PolinRider / Famous Chollima Malware Scanner ║`);
console.log(
`╚══════════════════════════════════════════════════════════╝${ANSI.reset}`,
);
console.log(
`${ANSI.dim}Scanned ${allResults.length} files · ${new Date().toISOString()}${ANSI.reset}\n`,
);
if (infected.length === 0) {
console.log(
`${ANSI.green}${ANSI.bold}✓ No infections detected.${ANSI.reset}\n`,
);
} else {
console.log(
`${ANSI.red}${ANSI.bold}✗ INFECTION DETECTED in ${infected.length} file(s)${ANSI.reset}\n`,
);
for (const result of infected) {
// Sort findings by severity descending
const sorted = [...result.findings].sort(
(a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity],
);
const topSev = sorted[0].severity;
console.log(
` ${colorSeverity(topSev)} ${ANSI.bold}${result.filePath}${ANSI.reset}`,
);
for (const f of sorted) {
console.log(
` ${ANSI.dim}[${f.id}]${ANSI.reset} ${colorSeverity(f.severity)}${f.description}`,
);
for (const m of f.matches) {
console.log(`${m}`);
}
}
console.log();
}
console.log(`${ANSI.bold}Remediation steps:${ANSI.reset}`);
console.log(
` 1. Immediately isolate the affected machine from the network.`,
);
console.log(
` 2. Do NOT run npm install, npm build, or any script on this repo.`,
);
console.log(
` 3. Check for running node.exe / node processes with obfuscated args.`,
);
console.log(
` 4. Remove all code after the legitimate config closing block.`,
);
console.log(
` 5. Remove duplicate 'import { createRequire }' lines at file top.`,
);
console.log(
` 6. Recover the git repository from a clean local clone (see docs).`,
);
console.log(
` 7. Revoke ALL secrets, tokens, and credentials in .env and CI.`,
);
console.log(
` 8. See full remediation guide in the attached incident report.\n`,
);
}
if (errors.length > 0) {
console.log(`${ANSI.yellow}Scan errors (${errors.length}):${ANSI.reset}`);
for (const e of errors) {
console.log(` ${e.filePath}: ${e.error}`);
}
console.log();
}
return infected.length > 0;
}
// ─── CLI Entry Point ───────────────────────────────────────────────────────────
function main() {
const args = process.argv.slice(2);
const jsonFlag = args.includes("--json");
const outputFileIdx = args.indexOf("--output");
const outputFile = outputFileIdx !== -1 ? args[outputFileIdx + 1] : null;
// Positional args after flags are scan targets
const targets = args.filter(
(a) => !a.startsWith("--") && args[args.indexOf(a) - 1] !== "--output",
);
if (targets.length === 0) {
console.error(
"Usage: scan.js [--json] [--output report.json] <path> [path...]",
);
console.error(
" path can be a file or directory (directories are walked recursively)",
);
process.exit(1);
}
const filesToScan = [];
for (const target of targets) {
if (!fs.existsSync(target)) {
console.error(`Path not found: ${target}`);
process.exit(1);
}
const stat = fs.statSync(target);
if (stat.isDirectory()) {
const found = walkDir(target);
filesToScan.push(...found);
} else {
filesToScan.push(target);
}
}
// Deduplicate
const unique = [...new Set(filesToScan)];
const allResults = unique.map(scanFile);
const infected = printReport(allResults, { json: jsonFlag, outputFile });
// Exit code 1 if any infection found — used by CI to block deployments
process.exit(infected ? 1 : 0);
}
main();

View File

@@ -1,66 +1,131 @@
name: Deploy Stacks
on:
push:
branches:
- main
- dev
- staging
paths:
- "apps/edr-freight-api/**"
- "apps/edr-freight-web/**"
- "apps/edr-passenger-api/**"
- "apps/edr-passenger-web/**"
- "packages/**"
- "infrastructure/docker/Dockerfile.web"
- "infrastructure/nginx/**"
- "docker-compose.yaml"
- "pnpm-lock.yaml"
- "scripts/deploy/**"
- ".github/workflows/deploy.yml"
workflow_dispatch:
concurrency:
group: deploy-${{ github.ref_name }}
cancel-in-progress: true
permissions:
contents: read
jobs:
detect-changes:
name: Detect changed services
runs-on: self-hosted
outputs:
matrix: ${{ steps.filter.outputs.matrix }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Determine changed services
id: filter
run: |
set -euo pipefail
ALL_SERVICES=(
"freight-api"
"freight-portal"
"freight-backoffice"
"passenger-api"
"passenger-portal"
"passenger-backoffice"
"payment-api"
)
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
JSON=$(printf '%s\n' "${ALL_SERVICES[@]}" | jq -R . | jq -sc .)
echo "matrix=${JSON}" >> "$GITHUB_OUTPUT"
exit 0
fi
CHANGED=$(git diff --name-only HEAD~1 HEAD)
echo "=== Changed files ==="
echo "$CHANGED"
echo "====================="
SERVICES=()
NON_DEPLOYABLE_PATTERN="^docs/|^README[.]md$|^DEPLOYMENT[.]md$|^CLAUDE[.]md$|^checkpoint[.]md$|^orgstructure[.]md$|^ITMLS_DB_Design[.]md$|.*[.]md$|^[.]eslintrc|^[.]prettierrc|^[.]editorconfig|^[.]gitignore|^[.]gitattributes|^commitlint[.]config[.]js$"
GLOBAL_PATTERN="^[.]github/|^docker-compose[.]yaml$|^turbo[.]json$|^tsconfig[.]json$|^tsconfig[.]base[.]json$|^pnpm-workspace[.]yaml$|^pnpm-lock[.]yaml$|^package[.]json$|^[.]env([.][a-z]+)?$|^packages/|^infrastructure/|^scripts/deploy/|^wagon[.][^/]*[.]ts$|^cargo[.][^/]*[.]ts$|^container[.][^/]*[.]ts$|^use-[^/]*[.]ts$|^[^/]*[.]service[.]ts$|^[^/]*[.]entity[.]ts$|^[^/]*-types[.]ts$"
DEPLOYABLE=$(echo "$CHANGED" | grep -vE "$NON_DEPLOYABLE_PATTERN" || true)
if [ -z "$DEPLOYABLE" ]; then
echo "Only non-deployable files changed. Skipping deploy."
echo "matrix=[]" >> "$GITHUB_OUTPUT"
exit 0
fi
if echo "$CHANGED" | grep -qE "$GLOBAL_PATTERN"; then
echo "Global file(s) changed — deploying all services."
JSON=$(printf '%s\n' "${ALL_SERVICES[@]}" | jq -R . | jq -sc .)
echo "matrix=${JSON}" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "$CHANGED" | grep -q "^apps/edr-freight-api/" && SERVICES+=("freight-api")
echo "$CHANGED" | grep -q "^apps/edr-freight-web-portal/" && SERVICES+=("freight-portal")
echo "$CHANGED" | grep -q "^apps/edr-freight-web-backoffice/" && SERVICES+=("freight-backoffice")
echo "$CHANGED" | grep -q "^apps/edr-passenger-api/" && SERVICES+=("passenger-api")
echo "$CHANGED" | grep -q "^apps/edr-passenger-web/portal/" && SERVICES+=("passenger-portal")
echo "$CHANGED" | grep -q "^apps/edr-passenger-web/backoffice/" && SERVICES+=("passenger-backoffice")
echo "$CHANGED" | grep -q "^apps/edr-payment-api/" && SERVICES+=("payment-api")
SERVICES=($(printf '%s\n' "${SERVICES[@]}" | sort -u))
if [ ${#SERVICES[@]} -eq 0 ]; then
echo "No deployable service changes detected."
echo "matrix=[]" >> "$GITHUB_OUTPUT"
else
echo "Services to deploy: ${SERVICES[*]}"
JSON=$(printf '%s\n' "${SERVICES[@]}" | jq -R . | jq -sc .)
echo "matrix=${JSON}" >> "$GITHUB_OUTPUT"
fi
deploy:
name: Deploy ${{ matrix.service }}
needs: detect-changes
if: ${{ needs.detect-changes.outputs.matrix != '[]' }}
runs-on: self-hosted
strategy:
fail-fast: false
matrix:
include:
- project: edr-freight
build_env_file: freight-web.build.env
service: freight-api
# - project: edr-freight
# build_env_file: freight-web.build.env
# service: freight-portal
# - project: edr-freight
# build_env_file: freight-web.build.env
# service: freight-backoffice
- project: edr-passenger
build_env_file: passenger-web.build.env
service: passenger-api
- project: edr-passenger
build_env_file: passenger-web.build.env
service: passenger-portal
- project: edr-passenger
build_env_file: passenger-web.build.env
service: passenger-backoffice
service: ${{ fromJson(needs.detect-changes.outputs.matrix) }}
env:
PROJECT: ${{ matrix.project }}
BRANCH: ${{ github.ref_name }}
DEPLOY_USER: tria
BUILD_ENV_FILE: ${{ matrix.build_env_file }}
DOCKER_BUILDKIT: "1"
COMPOSE_DOCKER_CLI_BUILD: "1"
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Resolve project and build env file
run: |
case "${{ matrix.service }}" in
freight-api|freight-portal|freight-backoffice)
echo "PROJECT=edr-freight" >> "$GITHUB_ENV"
echo "BUILD_ENV_FILE=freight-web.build.env" >> "$GITHUB_ENV"
;;
passenger-api|passenger-portal|passenger-backoffice)
echo "PROJECT=edr-passenger" >> "$GITHUB_ENV"
echo "BUILD_ENV_FILE=passenger-web.build.env" >> "$GITHUB_ENV"
;;
payment-api)
echo "PROJECT=edr-payment" >> "$GITHUB_ENV"
echo "BUILD_ENV_FILE=payment-web.build.env" >> "$GITHUB_ENV"
;;
*)
echo "Unknown service: ${{ matrix.service }}" && exit 1
;;
esac
- name: Sync environment from server
run: |
chmod +x scripts/deploy/*.sh
@@ -80,12 +145,12 @@ jobs:
- name: Build ${{ matrix.service }}
run: |
set -euo pipefail
docker compose --project-name "${COMPOSE_PROJECT_NAME}" build --no-cache "${{ matrix.service }}"
docker compose --project-name "${COMPOSE_PROJECT_NAME}" build "${{ matrix.service }}"
- name: Deploy ${{ matrix.service }}
run: |
set -euo pipefail
docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}"
docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}" --force-recreate
- name: Remove npm credentials from workspace
if: always()

3
.gitignore vendored
View File

@@ -24,9 +24,6 @@ coverage/
.idea/
.vscode/
.npmrc
branch_structure.json
temp_auto_push.bat
temp_interactive_push.bat
# emacs cache files
*~

6
.gitmodules vendored Normal file
View File

@@ -0,0 +1,6 @@
[submodule "user-management"]
path = user-management
url = git@github.com:Tria-plc/iamui.git
[submodule "apps/edr-freight-web/backoffice/user-management"]
path = apps/edr-freight-web/backoffice/user-management
url = git@github.com:Tria-plc/iamui.git

View File

@@ -12,6 +12,7 @@ Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Fre
| `edr-freight-web/portal` | `@edr/freight-portal` | React frontend for freight customer/portal users | 5173 |
| `edr-freight-web/backoffice` | `@edr/freight-backoffice` | React frontend for freight backoffice employees | 5183 |
| `edr-passenger-api` | `@edr/passenger-api` | NestJS API for passenger management | 3002 |
| `edr-payment-api` | `@edr/payment-api` | NestJS payment microservice (intents, webhooks) | 3003 |
| `edr-passenger-web/portal` | `@edr/passenger-portal` | React frontend for passenger customer/portal users | 5174 |
| `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | React frontend for passenger backoffice employees | 5184 |
@@ -73,6 +74,7 @@ The `@CurrentUser`, `@Roles`, and `@Public` decorators in `@edr/api-common` are
- `edr-freight-web/portal`: 5173
- `edr-freight-web/backoffice`: 5183
- `edr-passenger-api`: 3002
- `edr-payment-api`: 3003
- `edr-passenger-web/portal`: 5174
- `edr-passenger-web/backoffice`: 5184
@@ -80,6 +82,7 @@ The `@CurrentUser`, `@Roles`, and `@Public` decorators in `@edr/api-common` are
- `postgres-freight` (port 5433): database `edr_freight` — freight API only.
- `postgres-passenger` (port 5434): database `edr_passenger` — passenger API only.
- `edr_payment` schema — lives in the same Postgres database as the domain system (whatever the passenger `DATABASE_URL` points at) but is owned exclusively by `apps/edr-payment-api`. Dedicated DB user, no cross-schema FKs, domain apps have no grants on it (see `docs/payment-service/`).
- Each app owns its own DB. No cross-database joins; cross-domain data flows through API calls or message queues.
## Adding a new module to a NestJS app

View File

@@ -61,15 +61,16 @@ The sync script validates this and fails if missing.
### Build env files (optional)
Used for build-time variables (example: Vite API URLs), with `export` syntax:
Used for additional build-time variables (example: Vite API URL for freight web), with `export` syntax:
```bash
export FREIGHT_VITE_API_URL=https://freight-api.example.com/api
export PASSENGER_VITE_API_URL=https://passenger-api.example.com
```
These are injected into `GITHUB_ENV` during workflow execution.
> **Passenger web:** `NEXT_PUBLIC_API_URL` does **not** need a separate build env file. Place it directly in the service runtime env file (`passenger-portal.env` / `passenger-backoffice.env`) and the sync script will forward it to the build automatically.
## Docker Compose Port Mapping
`docker-compose.yaml` uses per-service env variables for host/container port mappings:
@@ -83,6 +84,51 @@ These are injected into `GITHUB_ENV` during workflow execution.
`scripts/deploy/sync-env-from-server.sh` extracts `PORT` from each synced `.env` and exports the corresponding `*_PORT` variable to `GITHUB_ENV`.
## Passenger Web Docker Configuration
The passenger web apps (portal and backoffice) are deployed as **Next.js applications** using a dedicated Dockerfile:
- Dockerfile: `infrastructure/docker/Dockerfile.passenger-web`
- Apps: `apps/edr-passenger-web/portal` and `apps/edr-passenger-web/backoffice`
### Key differences from freight-web
| Aspect | Freight Web | Passenger Web |
| --- | --- | --- |
| Framework | Vite (SPA) | Next.js (SSR/SSG) |
| Deployment | Static export + nginx | Node.js server |
| Dockerfile | `Dockerfile.web` | `Dockerfile.passenger-web` |
| Final port (container) | 80 (nginx) | driven by `PORT` in service `.env` |
| Build arg | `TURBO_FILTER` | `APP_PACKAGE` + `APP_PATH` + `NEXT_PUBLIC_API_URL` |
### Build arguments
The Dockerfile accepts the following build args:
- `APP_PACKAGE`: Turbo package filter (e.g., `@edr/passenger-portal`)
- `APP_PATH`: App directory path (e.g., `apps/edr-passenger-web/portal`)
- `NEXT_PUBLIC_API_URL`: API URL visible to browser — sourced from `NEXT_PUBLIC_API_URL` in the service `.env` file
### Port mapping
Both host and container ports are driven by `PORT` in the service env file. The sync script reads `PORT`, exports `PASSENGER_PORTAL_PORT` / `PASSENGER_BACKOFFICE_PORT` to `GITHUB_ENV`, and `docker-compose.yaml` uses those variables for both sides of the mapping:
```
${PASSENGER_PORTAL_PORT:-5174}:${PASSENGER_PORTAL_PORT:-5174}
```
This ensures `docker ps` shows `0.0.0.0:<port>-><port>/tcp` with matching ports.
### Runtime
The final image runs:
```bash
npx next start
```
Next.js reads `PORT` from the runtime environment (supplied via `env_file` in docker-compose) to determine which port to listen on.
## GitHub Actions Deployment Flow
Workflow file: `.github/workflows/deploy.yml`

View File

@@ -280,7 +280,6 @@ Authentication is provided by an external `@edr/iamui-common` / `@tria-plc/iamap
pnpm install
```
<<<<<<< HEAD
### 3. Environment Configuration
```bash
# Copy environment template
@@ -439,7 +438,7 @@ The API uses two authentication schemes:
| **Routes** | `/routes` | JWT/IAM | Reusable route templates with ordered stops |
| **Schedules** | `/schedules` | JWT/IAM | Trip schedules, fare rules, status updates |
| **Fleet** | `/fleet` | JWT/IAM | Train services, coaches, seat configurations |
| **Seat Classes** | `/seat-classes` | Public/JWT/IAM | Seat class management and configuration |
| **Seat Classes** | `/seat-classes` or `/classes` | Public/JWT/IAM | Seat class management and configuration |
| **Segment Seats** | `/segments/seats` | Public/JWT | Segment-based seat availability and booking |
| **Agents** | `/agents` | IAM | Agent booking, shifts, commissions, reconciliation |
| **Fraud Detection** | `/fraud` | IAM | Fraud alerts, rules management, user blocking |
@@ -949,7 +948,6 @@ For technical support or questions:
---
**Built with ❤️ for Ethio-Djibouti Railway**
=======
### Start local databases
```bash
@@ -1022,4 +1020,3 @@ pnpm dev:passenger # passenger API + portal + backoffice
- **One DB per domain** — no cross-database joins.
See [`CLAUDE.md`](./CLAUDE.md) for the deeper developer guide used during AI-assisted contributions.
>>>>>>> b9cfce70fe17b5066ae5320cfcc595bf3c253467

View File

@@ -14,12 +14,16 @@
"test": "jest",
"test:e2e": "jest --config ./test/jest-e2e.json",
"seed:wagons": "ts-node -r tsconfig-paths/register src/scripts/seed-edr-wagons.ts",
"type-check": "tsc --noEmit"
"type-check": "tsc --noEmit",
"seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts",
"seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts",
"seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh"
},
"dependencies": {
"@edr/api-common": "workspace:*",
"@edr/payment-providers": "workspace:*",
"@edr/types": "workspace:*",
"@golevelup/nestjs-rabbitmq": "^5.5.0",
"@nestjs/axios": "^4.0.1",
"@nestjs/common": "^11.0.0",
"@nestjs/config": "^4.0.0",
@@ -28,10 +32,11 @@
"@nestjs/mapped-types": "^2.1.1",
"@nestjs/microservices": "^11.0.0",
"@nestjs/platform-express": "^11.0.0",
"@nestjs/schedule": "^6.1.3",
"@nestjs/swagger": "^11.4.2",
"@nestjs/typeorm": "^11.0.1",
"@tria-plc/api-common": "^1.4.0",
"@tria-plc/iamapi-common": "^0.5.1",
"@tria-plc/api-common": "^1.4.3",
"@tria-plc/iamapi-common": "^0.6.6",
"amqp-connection-manager": "^5.0.0",
"amqplib": "^2.0.1",
"axios": "^1.16.1",
@@ -43,7 +48,9 @@
"pg": "^8.13.0",
"puppeteer": "^24.2.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
"rxjs": "^7.8.1",
"typeorm": "^0.3.30"
},
"devDependencies": {
"@edr/api-common": "workspace:*",
@@ -66,7 +73,6 @@
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typeorm": "^0.3.30",
"typescript": "^5.5.4"
},
"jest": {

View File

@@ -1,6 +1,7 @@
import { Module, OnApplicationBootstrap } from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
import { ScheduleModule } from "@nestjs/schedule";
import { DataSource, DataSourceOptions } from "typeorm";
import { ensurePostgresSchemas } from "./config/ensure-postgres-schemas";
import { IamModule, DataSeeder } from "@tria-plc/iamapi-common";
@@ -9,8 +10,10 @@ import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.
import appConfig from "./config/app.config";
import databaseConfig from "./config/database.config";
import telebirrConfig from "./config/telebirr.config";
import rabbitmqConfig from "./config/rabbitmq.config";
import { BookingsModule } from "./modules/bookings/bookings.module";
import { SignaturesModule } from "./modules/signatures/signatures.module";
import { FilesModule } from "./modules/files/files.module";
import { ConsignmentsModule } from "./modules/consignments/consignments.module";
@@ -44,6 +47,10 @@ import { PaymentModule } from "./modules/payment/payment.module";
import { DemoBookingsSeeder } from "./seed/demo-bookings.seeder";
import { PricingDataSeeder } from "./seed/pricing-data.seeder";
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder";
import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder";
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
//New Trains, Wagons, Container and Cargo management modules
import { TrainsModule } from "./modules/trains/trains.module";
import { WagonsModule } from './modules/wagons/wagons.module';
@@ -52,13 +59,18 @@ import { CargoesModule } from './modules/cargoes/cargoes.module';
import { RoutesModule } from './modules/routes/routes.module';
import { WarehousesModule } from './modules/warehouses/warehouses.module';
import { OverviewModule } from './modules/overview/overview.module';
import { FacilitiesModule } from './modules/facilities/facilities.module';
import { OverviewModule } from './modules/overview/overview.module';
import { VehiclesModule } from './modules/vehicles/vehicles.module';
import { DriversModule } from './modules/drivers/drivers.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [appConfig, databaseConfig, telebirrConfig],
load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig],
}),
ScheduleModule.forRoot(),
// EventEmitterModule.forRoot(),
TypeOrmModule.forRootAsync({
inject: [ConfigService],
@@ -79,6 +91,7 @@ import { OverviewModule } from './modules/overview/overview.module';
permissions: EDR_FREIGHT_PERMISSIONS,
}),
BookingsModule,
SignaturesModule,
FilesModule,
ConsignmentsModule,
LocomotivesModule,
@@ -108,8 +121,24 @@ import { OverviewModule } from './modules/overview/overview.module';
RoutesModule,
WarehousesModule,
OverviewModule,
FacilitiesModule,
WarehousesModule,
OverviewModule,
VehiclesModule,
DriversModule,
],
providers: [
EdrOrgSeeder,
DemoUsersSeeder,
FreightStaffUsersSeeder,
DemoBookingsSeeder,
PricingDataSeeder,
FileUploadSettingsSeeder,
FreightPermissionKeyMigrationSeeder,
DemoFreightDataSeeder,
IndodeFacilitySeeder,
Batch14TestDataSeeder,
],
providers: [EdrOrgSeeder, DemoUsersSeeder,FreightStaffUsersSeeder, DemoBookingsSeeder, PricingDataSeeder, FileUploadSettingsSeeder],
})
export class AppModule implements OnApplicationBootstrap {
constructor(
@@ -120,9 +149,14 @@ export class AppModule implements OnApplicationBootstrap {
private readonly demoBookingsSeeder: DemoBookingsSeeder,
private readonly pricingDataSeeder: PricingDataSeeder,
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
private readonly indodeFacilitySeeder: IndodeFacilitySeeder,
private readonly batch14TestDataSeeder: Batch14TestDataSeeder,
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
) { }
async onApplicationBootstrap() {
await this.freightPermissionKeyMigrationSeeder.run();
await this.seeder.run();
await this.edrOrgSeeder.run();
await this.demoUsersSeeder.run();
@@ -130,5 +164,10 @@ export class AppModule implements OnApplicationBootstrap {
await this.demoBookingsSeeder.run();
await this.pricingDataSeeder.run();
await this.fileUploadSettingsSeeder.run();
await this.indodeFacilitySeeder.run();
await this.batch14TestDataSeeder.run();
// Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users.
// Each block self-guards on an empty-table check, so this is safe every boot.
await this.demoFreightDataSeeder.run();
}
}

View File

@@ -21,3 +21,10 @@ export const TrainSchedulingView = () =>
export const TrainSchedulingManage = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.manage);
export const FleetView = () => BookingStaff(FREIGHT_PERMS.fleet.view);
export const FleetManage = () => BookingStaff(FREIGHT_PERMS.fleet.manage);
/** Org-administration endpoints (user mgmt, billing config, company CRUD, settings). */
export const FreightAdmin = () => BookingStaff(FREIGHT_PERMS.admin);

View File

@@ -0,0 +1,21 @@
import { deriveTradeDirection } from './derive-trade-direction.util';
describe('deriveTradeDirection', () => {
it('returns IMPORT when origin is Djibouti', () => {
expect(deriveTradeDirection({ country: 'Djibouti' }, { country: 'Ethiopia' })).toBe(
'IMPORT',
);
});
it('returns EXPORT when destination is Djibouti and origin is not', () => {
expect(deriveTradeDirection({ country: 'Ethiopia' }, { country: 'Djibouti' })).toBe(
'EXPORT',
);
});
it('returns DOMESTIC for intra-Ethiopia routes', () => {
expect(deriveTradeDirection({ country: 'Ethiopia' }, { country: 'Ethiopia' })).toBe(
'DOMESTIC',
);
});
});

View File

@@ -0,0 +1,20 @@
import type { ScheduleTradeDirection } from '@edr/types';
type YardLike = { country?: string | null };
/** Derive booking/schedule trade direction from origin and destination yard countries. */
export function deriveTradeDirection(
originYard: YardLike,
destinationYard: YardLike,
): ScheduleTradeDirection {
const originCountry = originYard.country?.trim();
const destinationCountry = destinationYard.country?.trim();
if (originCountry === 'Djibouti') {
return 'IMPORT';
}
if (destinationCountry === 'Djibouti' && originCountry !== 'Djibouti') {
return 'EXPORT';
}
return 'DOMESTIC';
}

View File

@@ -0,0 +1,51 @@
import {
CanActivate,
ExecutionContext,
Injectable,
Logger,
UnauthorizedException,
} from "@nestjs/common";
import { timingSafeEqual } from "node:crypto";
import { Request } from "express";
/**
* Shared-secret guard for endpoints only the payment microservice may call
* (e.g. /internal/payments/mark-paid). The secret is the same SERVICE_AUTH_TOKEN
* the payment service uses on its own internal surface.
*/
@Injectable()
export class ServiceAuthGuard implements CanActivate {
private readonly logger = new Logger(ServiceAuthGuard.name);
private readonly token = process.env.SERVICE_AUTH_TOKEN ?? "";
private warned = false;
constructor() {
if (!this.token && process.env.NODE_ENV === "production") {
throw new Error("SERVICE_AUTH_TOKEN must be set in production");
}
}
canActivate(context: ExecutionContext): boolean {
if (!this.token) {
if (!this.warned) {
this.logger.warn(
"SERVICE_AUTH_TOKEN unset — internal endpoints are UNGUARDED (dev only)",
);
this.warned = true;
}
return true;
}
const request = context.switchToHttp().getRequest<Request>();
const header = request.headers["x-service-token"];
const bearer = request.headers.authorization?.replace(/^Bearer\s+/i, "");
const presented = (Array.isArray(header) ? header[0] : header) ?? bearer ?? "";
const expected = Buffer.from(this.token);
const actual = Buffer.from(presented);
const valid =
expected.length === actual.length && timingSafeEqual(expected, actual);
if (!valid) throw new UnauthorizedException("Invalid service token");
return true;
}
}

View File

@@ -14,4 +14,18 @@ export default registerAs("app", () => ({
maxTrainLengthMeters: numberFromEnv("TRAIN_SCHEDULING_MAX_LENGTH_METERS", 760),
maxWagonsPerTrain: numberFromEnv("TRAIN_SCHEDULING_MAX_WAGONS_PER_TRAIN", 53),
},
cbeExchange: {
/** ethio.forex CBET page — scraped for USD buying/selling rates. */
scrapeUrl:
process.env.CBE_EXCHANGE_SCRAPE_URL ??
process.env.CBE_EXCHANGE_API_URL ??
"https://ethio.forex/bank/CBET",
/** @deprecated use scrapeUrl — kept for backward-compatible config reads */
apiUrl:
process.env.CBE_EXCHANGE_SCRAPE_URL ??
process.env.CBE_EXCHANGE_API_URL ??
"https://ethio.forex/bank/CBET",
fallbackRate: numberFromEnv("CBE_EXCHANGE_FALLBACK_RATE", 130),
cacheTtlMs: numberFromEnv("CBE_EXCHANGE_CACHE_TTL_MS", 3_600_000),
},
}));

View File

@@ -0,0 +1,11 @@
import { registerAs } from '@nestjs/config';
/**
* RabbitMQ connection for the payment-event consumer (payment microservice -> freight).
* Points at the dedicated `payment` vhost on the shared broker.
*/
export default registerAs('rabbitmq', () => ({
url: process.env.PAYMENT_RABBITMQ_URL ?? 'amqp://localhost:5672/payment',
/** Max unacked payment events held by this consumer at once. */
prefetch: parseInt(process.env.PAYMENT_EVENTS_PREFETCH ?? '10', 10),
}));

View File

@@ -14,8 +14,12 @@ export class NormalizeWeightLimitTradeDirectionBoth1749000000000
UPDATE freight.weight_limit_rules
SET trade_direction = 'BOTH'
WHERE trade_direction::text = 'ANY';
UPDATE freight.weight_limit_rules
SET trade_direction = 'IMPORT'
WHERE trade_direction IS NULL;
EXCEPTION WHEN undefined_table OR undefined_column THEN NULL;
END $$;
END $$;
`);
}

View File

@@ -0,0 +1,138 @@
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
export class CreateFacilitiesTable1750000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
schema: 'freight',
name: 'facilities',
columns: [
{
name: 'id',
type: 'uuid',
isPrimary: true,
generationStrategy: 'uuid',
default: 'gen_random_uuid()',
},
{
name: 'code',
type: 'varchar',
length: '40',
isUnique: true,
},
{
name: 'name',
type: 'varchar',
length: '160',
},
{
name: 'description',
type: 'text',
isNullable: true,
},
{
name: 'facility_type',
type: 'varchar',
length: '32',
},
{
name: 'facility_status',
type: 'varchar',
length: '32',
default: "'ACTIVE'",
},
{
name: 'location_name',
type: 'varchar',
length: '200',
isNullable: true,
},
{
name: 'country',
type: 'varchar',
length: '100',
isNullable: true,
},
{
name: 'city',
type: 'varchar',
length: '100',
isNullable: true,
},
{
name: 'address',
type: 'text',
isNullable: true,
},
{
name: 'latitude',
type: 'numeric',
precision: 10,
scale: 8,
isNullable: true,
},
{
name: 'longitude',
type: 'numeric',
precision: 11,
scale: 8,
isNullable: true,
},
{
name: 'capacity',
type: 'numeric',
precision: 14,
scale: 3,
isNullable: true,
},
{
name: 'is_active',
type: 'boolean',
default: true,
},
{
name: 'notes',
type: 'text',
isNullable: true,
},
{
name: 'created_at',
type: 'timestamp',
default: 'CURRENT_TIMESTAMP',
},
{
name: 'updated_at',
type: 'timestamp',
default: 'CURRENT_TIMESTAMP',
},
{
name: 'deleted_at',
type: 'timestamp',
isNullable: true,
},
],
}),
);
await queryRunner.createIndex(
'freight.facilities',
new TableIndex({
name: 'idx_facilities_code',
columnNames: ['code'],
isUnique: true,
}),
);
await queryRunner.createIndex(
'freight.facilities',
new TableIndex({
name: 'idx_facilities_status',
columnNames: ['facility_status'],
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('freight.facilities');
}
}

View File

@@ -0,0 +1,52 @@
import { MigrationInterface, QueryRunner, TableColumn, TableForeignKey } from 'typeorm';
export class AddFacilityIdToWarehouses1750000000001 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
const table = await queryRunner.getTable('freight.warehouses');
if (!table) {
// warehouses table doesn't exist yet, skip this migration
return;
}
const hasColumn = table.columns.some((col) => col.name === 'facility_id');
if (hasColumn) {
// Column already exists, skip
return;
}
await queryRunner.addColumn(
'freight.warehouses',
new TableColumn({
name: 'facility_id',
type: 'uuid',
isNullable: true,
}),
);
await queryRunner.createForeignKey(
'freight.warehouses',
new TableForeignKey({
columnNames: ['facility_id'],
referencedColumnNames: ['id'],
referencedTableName: 'facilities',
referencedSchema: 'freight',
onDelete: 'SET NULL',
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
const table = await queryRunner.getTable('freight.warehouses');
if (!table) {
return;
}
const foreignKey = table.foreignKeys.find((fk) => fk.columnNames.includes('facility_id'));
if (foreignKey) {
await queryRunner.dropForeignKey('freight.warehouses', foreignKey);
}
const hasColumn = table.columns.some((col) => col.name === 'facility_id');
if (hasColumn) {
await queryRunner.dropColumn('freight.warehouses', 'facility_id');
}
}
}

View File

@@ -0,0 +1,48 @@
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
/**
* Proof of Delivery (customer pickup) capture on cargoes:
* receiver name, delivered/picked-up timestamp, and delivery remarks.
*/
export class AddProofOfDeliveryToCargoes1750000000002 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
const table = await queryRunner.getTable('freight.cargoes');
if (!table) {
// cargoes table doesn't exist yet, skip this migration
return;
}
const columnsToAdd = [
{ name: 'receiver_name', type: 'varchar', isNullable: true },
{ name: 'delivered_at', type: 'timestamp', isNullable: true },
{ name: 'delivery_remarks', type: 'text', isNullable: true },
];
const columnsToCreate = columnsToAdd.filter(
(col) => !table.columns.some((c) => c.name === col.name),
);
if (columnsToCreate.length > 0) {
await queryRunner.addColumns(
'freight.cargoes',
columnsToCreate.map((col) => new TableColumn(col)),
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
const table = await queryRunner.getTable('freight.cargoes');
if (!table) {
return;
}
const columnNames = ['receiver_name', 'delivered_at', 'delivery_remarks'];
const columnsToRemove = columnNames.filter((name) =>
table.columns.some((c) => c.name === name),
);
if (columnsToRemove.length > 0) {
await queryRunner.dropColumns('freight.cargoes', columnsToRemove);
}
}
}

View File

@@ -0,0 +1,75 @@
import { MigrationInterface, QueryRunner, Table, TableColumn } from 'typeorm';
/**
* Batch 4.5 — warehouse inspection reports + inventory inspection status.
*/
export class AddWarehouseInspection1750000000003 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// inventory.inspection_status
const inventoryTable = await queryRunner.getTable('freight.warehouse_inventory');
if (inventoryTable) {
const hasColumn = inventoryTable.columns.some((col) => col.name === 'inspection_status');
if (!hasColumn) {
await queryRunner.addColumn(
'freight.warehouse_inventory',
new TableColumn({ name: 'inspection_status', type: 'varchar', length: '20', isNullable: true }),
);
}
}
// warehouse_inspection_reports table
const inspectionTable = await queryRunner.getTable('freight.warehouse_inspection_reports');
if (!inspectionTable) {
await queryRunner.createTable(
new Table({
schema: 'freight',
name: 'warehouse_inspection_reports',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, default: 'uuid_generate_v4()' },
{ name: 'inventory_id', type: 'uuid' },
{ name: 'booking_id', type: 'uuid', isNullable: true },
{ name: 'customer_id', type: 'uuid', isNullable: true },
{ name: 'report_type', type: 'varchar', length: '32', default: "'INSPECTION'" },
{ name: 'inspection_status', type: 'varchar', length: '20', default: "'NEEDS_REVIEW'" },
{ name: 'has_damage', type: 'boolean', default: false },
{ name: 'damage_description', type: 'text', isNullable: true },
{ name: 'has_weight_loss', type: 'boolean', default: false },
{ name: 'expected_weight', type: 'numeric', precision: 14, scale: 3, isNullable: true },
{ name: 'actual_weight', type: 'numeric', precision: 14, scale: 3, isNullable: true },
{ name: 'weight_loss', type: 'numeric', precision: 14, scale: 3, isNullable: true },
{ name: 'weight_loss_unit', type: 'varchar', length: '12', isNullable: true },
{ name: 'has_missing_items', type: 'boolean', default: false },
{ name: 'missing_items_description', type: 'text', isNullable: true },
{ name: 'remarks', type: 'text', isNullable: true },
{ name: 'inspected_by_id', type: 'uuid', isNullable: true },
{ name: 'inspected_at', type: 'timestamptz', isNullable: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
indices: [
{ name: 'idx_wir_inventory', columnNames: ['inventory_id'] },
{ name: 'idx_wir_booking', columnNames: ['booking_id'] },
{ name: 'idx_wir_status', columnNames: ['inspection_status'] },
],
}),
true,
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
const inspectionTable = await queryRunner.getTable('freight.warehouse_inspection_reports');
if (inspectionTable) {
await queryRunner.dropTable('freight.warehouse_inspection_reports', true);
}
const inventoryTable = await queryRunner.getTable('freight.warehouse_inventory');
if (inventoryTable) {
const hasColumn = inventoryTable.columns.some((col) => col.name === 'inspection_status');
if (hasColumn) {
await queryRunner.dropColumn('freight.warehouse_inventory', 'inspection_status');
}
}
}
}

View File

@@ -0,0 +1,39 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateVehiclesTable1770000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'vehicles' AND table_schema = 'freight') THEN
CREATE TABLE freight.vehicles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
plate_number VARCHAR NOT NULL UNIQUE,
registration_number VARCHAR NOT NULL UNIQUE,
vehicle_type VARCHAR NOT NULL,
manufacturer VARCHAR NOT NULL,
model VARCHAR NOT NULL,
year INTEGER NOT NULL,
fuel_type VARCHAR NOT NULL,
capacity NUMERIC NOT NULL,
status VARCHAR DEFAULT 'ACTIVE' NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
deleted_at TIMESTAMP NULL
);
CREATE INDEX idx_vehicles_plate_number ON freight.vehicles(plate_number);
CREATE INDEX idx_vehicles_registration_number ON freight.vehicles(registration_number);
CREATE INDEX idx_vehicles_status ON freight.vehicles(status);
CREATE INDEX idx_vehicles_vehicle_type ON freight.vehicles(vehicle_type);
CREATE INDEX idx_vehicles_manufacturer ON freight.vehicles(manufacturer);
END IF;
END $$;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.vehicles CASCADE;`);
}
}

View File

@@ -0,0 +1,42 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateDriversTable1775000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'drivers' AND table_schema = 'freight') THEN
CREATE TABLE freight.drivers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
license_number VARCHAR NOT NULL UNIQUE,
first_name VARCHAR NOT NULL,
last_name VARCHAR NOT NULL,
email VARCHAR NOT NULL UNIQUE,
phone_number VARCHAR NOT NULL UNIQUE,
date_of_birth DATE NOT NULL,
license_expiry_date DATE NOT NULL,
status VARCHAR DEFAULT 'ACTIVE' NOT NULL,
vehicle_types_authorized VARCHAR[],
address TEXT,
emergency_contact VARCHAR,
notes TEXT,
total_trips INTEGER DEFAULT 0,
rating NUMERIC(3, 2),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
deleted_at TIMESTAMP NULL
);
CREATE INDEX idx_drivers_license_number ON freight.drivers(license_number);
CREATE INDEX idx_drivers_email ON freight.drivers(email);
CREATE INDEX idx_drivers_phone_number ON freight.drivers(phone_number);
CREATE INDEX idx_drivers_status ON freight.drivers(status);
END IF;
END $$;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.drivers CASCADE;`);
}
}

View File

@@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddLocomotiveReadiness1781000000000 implements MigrationInterface {
name = 'AddLocomotiveReadiness1781000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.locomotives
ADD COLUMN IF NOT EXISTS readiness VARCHAR(20) NOT NULL DEFAULT 'IMPORT_READY'
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_locomotives_readiness
ON freight.locomotives (readiness)
WHERE deleted_at IS NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_locomotives_readiness`);
await queryRunner.query(`
ALTER TABLE freight.locomotives
DROP COLUMN IF EXISTS readiness
`);
}
}

View File

@@ -0,0 +1,35 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateTrainCheckpointEvents1781000000001 implements MigrationInterface {
name = 'CreateTrainCheckpointEvents1781000000001';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.train_checkpoint_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
train_schedule_id UUID NOT NULL REFERENCES freight.train_schedules(id) ON DELETE CASCADE,
yard_id UUID NOT NULL,
sequence_no INT NOT NULL,
kind VARCHAR(20) NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
note TEXT NULL,
recorded_by_user_id UUID NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ NULL
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_train_checkpoint_events_schedule
ON freight.train_checkpoint_events (train_schedule_id, sequence_no)
WHERE deleted_at IS NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX IF EXISTS freight.idx_train_checkpoint_events_schedule`,
);
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_checkpoint_events`);
}
}

View File

@@ -0,0 +1,45 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddBatchBookingFields1781000000002 implements MigrationInterface {
name = 'AddBatchBookingFields1781000000002';
public async up(queryRunner: QueryRunner): Promise<void> {
// Booking → target schedule (pool membership) + 1h pay-window deadline.
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS train_schedule_id UUID NULL,
ADD COLUMN IF NOT EXISTS payment_deadline TIMESTAMPTZ NULL
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_bookings_train_schedule_id
ON freight.bookings (train_schedule_id)
WHERE deleted_at IS NULL
`);
// TrainSchedule → booking-window status (OPEN/FULL/CLOSED).
await queryRunner.query(`
ALTER TABLE freight.train_schedules
ADD COLUMN IF NOT EXISTS booking_window_status VARCHAR(10) NOT NULL DEFAULT 'OPEN'
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_train_schedules_booking_window_status
ON freight.train_schedules (booking_window_status)
WHERE deleted_at IS NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX IF EXISTS freight.idx_train_schedules_booking_window_status`,
);
await queryRunner.query(
`ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS booking_window_status`,
);
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_bookings_train_schedule_id`);
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP COLUMN IF EXISTS train_schedule_id,
DROP COLUMN IF EXISTS payment_deadline
`);
}
}

View File

@@ -0,0 +1,36 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddSelectedForBatchStatus1781000000003 implements MigrationInterface {
name = 'AddSelectedForBatchStatus1781000000003';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS selected_for_batch_at TIMESTAMPTZ NULL
`);
await queryRunner.query(`
UPDATE freight.bookings
SET
status = 'SELECTED_FOR_BATCH',
selected_for_batch_at = COALESCE(
payment_deadline - INTERVAL '5 minutes',
updated_at
)
WHERE status = 'AWAITING_PAYMENT'
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE freight.bookings
SET status = 'AWAITING_PAYMENT'
WHERE status = 'SELECTED_FOR_BATCH'
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP COLUMN IF EXISTS selected_for_batch_at
`);
}
}

View File

@@ -0,0 +1,30 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Allow DOMESTIC trade direction on weight_limit_rules (domestic corridor bookings).
*/
export class AddDomesticWeightLimitTradeDirection1781000000004
implements MigrationInterface
{
name = 'AddDomesticWeightLimitTradeDirection1781000000004';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DO $$ BEGIN
ALTER TYPE freight.weight_limit_rules_trade_direction_enum ADD VALUE 'DOMESTIC';
EXCEPTION
WHEN duplicate_object THEN NULL;
WHEN undefined_object THEN
BEGIN
ALTER TYPE weight_limit_rules_trade_direction_enum ADD VALUE 'DOMESTIC';
EXCEPTION
WHEN duplicate_object THEN NULL;
END;
END $$;
`);
}
public async down(_queryRunner: QueryRunner): Promise<void> {
// PostgreSQL does not support removing enum values safely.
}
}

View File

@@ -0,0 +1,81 @@
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
export class CreateTrainCompositionRemovalLog1781000000005 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
schema: 'freight',
name: 'train_composition_removal_logs',
columns: [
{
name: 'id',
type: 'uuid',
isPrimary: true,
default: 'uuid_generate_v4()',
},
{
name: 'schedule_id',
type: 'uuid',
isNullable: false,
},
{
name: 'booking_id',
type: 'uuid',
isNullable: false,
},
{
name: 'booking_reference',
type: 'varchar',
length: '64',
isNullable: true,
},
{
name: 'removed_by_user_id',
type: 'uuid',
isNullable: true,
},
{
name: 'removed_at',
type: 'timestamptz',
default: 'NOW()',
isNullable: false,
},
{
name: 'notes',
type: 'text',
isNullable: true,
},
{
name: 'created_at',
type: 'timestamptz',
default: 'NOW()',
isNullable: false,
},
{
name: 'updated_at',
type: 'timestamptz',
default: 'NOW()',
isNullable: false,
},
{
name: 'deleted_at',
type: 'timestamptz',
isNullable: true,
},
],
}),
true,
);
await queryRunner.createIndex(
'freight.train_composition_removal_logs',
new TableIndex({
columnNames: ['schedule_id'],
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('freight.train_composition_removal_logs', true);
}
}

View File

@@ -0,0 +1,88 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class WagonLocomotiveYardLink1782000000000 implements MigrationInterface {
name = 'WagonLocomotiveYardLink1782000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.wagons
ADD COLUMN IF NOT EXISTS "current_yard_id" UUID NULL;
`);
await queryRunner.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'FK_wagon_current_yard'
) THEN
ALTER TABLE freight.wagons
ADD CONSTRAINT "FK_wagon_current_yard"
FOREIGN KEY ("current_yard_id") REFERENCES freight.yards(id) ON DELETE SET NULL;
END IF;
END $$;
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_wagon_current_yard_id"
ON freight.wagons ("current_yard_id");
`);
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_wagons_readiness`);
await queryRunner.query(`ALTER TABLE freight.wagons DROP COLUMN IF EXISTS readiness;`);
await queryRunner.query(`
ALTER TABLE freight.locomotives
ADD COLUMN IF NOT EXISTS "current_yard_id" UUID NULL;
`);
await queryRunner.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'FK_locomotive_current_yard'
) THEN
ALTER TABLE freight.locomotives
ADD CONSTRAINT "FK_locomotive_current_yard"
FOREIGN KEY ("current_yard_id") REFERENCES freight.yards(id) ON DELETE SET NULL;
END IF;
END $$;
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_locomotive_current_yard_id"
ON freight.locomotives ("current_yard_id");
`);
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_locomotives_readiness`);
await queryRunner.query(`ALTER TABLE freight.locomotives DROP COLUMN IF EXISTS readiness;`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.wagons
ADD COLUMN IF NOT EXISTS readiness VARCHAR(20) NOT NULL DEFAULT 'IMPORT_READY';
`);
await queryRunner.query(`
ALTER TABLE freight.locomotives
ADD COLUMN IF NOT EXISTS readiness VARCHAR(20) NOT NULL DEFAULT 'IMPORT_READY';
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_wagons_readiness
ON freight.wagons (readiness)
WHERE deleted_at IS NULL;
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_locomotives_readiness
ON freight.locomotives (readiness)
WHERE deleted_at IS NULL;
`);
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_wagon_current_yard_id"`);
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_locomotive_current_yard_id"`);
await queryRunner.query(`
ALTER TABLE freight.wagons DROP CONSTRAINT IF EXISTS "FK_wagon_current_yard";
`);
await queryRunner.query(`
ALTER TABLE freight.locomotives DROP CONSTRAINT IF EXISTS "FK_locomotive_current_yard";
`);
await queryRunner.query(`ALTER TABLE freight.wagons DROP COLUMN IF EXISTS "current_yard_id";`);
await queryRunner.query(`ALTER TABLE freight.locomotives DROP COLUMN IF EXISTS "current_yard_id";`);
}
}

View File

@@ -0,0 +1,62 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddPaymentWebhookEventAndRefund1782000000001 implements MigrationInterface {
name = "AddPaymentWebhookEventAndRefund1782000000001";
public async up(queryRunner: QueryRunner): Promise<void> {
// Enum for webhook provider — shares the same values as payments_method_enum
// but is a separate type so both tables remain independently evolvable.
await queryRunner.query(`
CREATE TYPE freight.payment_webhook_method_enum AS ENUM ('telebirr', 'cbe-birr', 'ebirr');
`);
await queryRunner.query(`
CREATE TABLE freight.payment_webhook_events (
id uuid NOT NULL DEFAULT uuid_generate_v4(),
provider freight.payment_webhook_method_enum NOT NULL,
external_event_id varchar(255) NOT NULL,
merchant_order_id varchar(255),
provider_txn_id varchar(255),
signature_valid boolean NOT NULL,
status varchar(100) NOT NULL,
payload jsonb NOT NULL,
received_at TIMESTAMP NOT NULL DEFAULT now(),
processed_at TIMESTAMP,
processing_error text,
CONSTRAINT PK_payment_webhook_events PRIMARY KEY (id),
CONSTRAINT UQ_payment_webhook_events_provider_event UNIQUE (provider, external_event_id)
);
`);
await queryRunner.query(`
CREATE INDEX IDX_payment_webhook_events_merchant_order_id
ON freight.payment_webhook_events (merchant_order_id);
`);
await queryRunner.query(`
CREATE TABLE freight.payment_refunds (
id uuid NOT NULL DEFAULT uuid_generate_v4(),
payment_id uuid NOT NULL,
amount_minor int NOT NULL,
reason varchar(255),
provider_refund_id varchar(255),
status varchar(50) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT PK_payment_refunds PRIMARY KEY (id),
CONSTRAINT FK_payment_refunds_payment
FOREIGN KEY (payment_id)
REFERENCES freight.payments (id)
ON DELETE RESTRICT
);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.payment_refunds;`);
await queryRunner.query(`DROP INDEX IF EXISTS freight.IDX_payment_webhook_events_merchant_order_id;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.payment_webhook_events;`);
await queryRunner.query(`DROP TYPE IF EXISTS freight.payment_webhook_method_enum;`);
}
}

View File

@@ -0,0 +1,16 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class ExtendPaymentMethodEnum1782000000002 implements MigrationInterface {
name = "ExtendPaymentMethodEnum1782000000002";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TYPE freight.payments_method_enum ADD VALUE IF NOT EXISTS 'waafi';`);
await queryRunner.query(`ALTER TYPE freight.payments_method_enum ADD VALUE IF NOT EXISTS 'card';`);
await queryRunner.query(`ALTER TYPE freight.payments_method_enum ADD VALUE IF NOT EXISTS 'dmoney';`);
}
public async down(_queryRunner: QueryRunner): Promise<void> {
// PostgreSQL does not support removing enum values directly.
// To roll back, recreate the type without the added values and update the column.
}
}

View File

@@ -0,0 +1,39 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class ReplacePriorityRulesWithPriorityConfigs1783000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE freight.priority_configs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
type VARCHAR(20) NOT NULL CHECK (type IN ('WAGON', 'CURRENCY')),
label VARCHAR(100) NOT NULL,
currency VARCHAR(5) NULL,
min_wagon_count INT NOT NULL,
max_wagon_count INT NOT NULL,
score_points INT NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT false,
display_order INT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL,
CONSTRAINT chk_wagon_range CHECK (min_wagon_count <= max_wagon_count),
CONSTRAINT chk_currency_for_type CHECK (
(type = 'WAGON' AND currency IS NULL) OR
(type = 'CURRENCY' AND currency IS NOT NULL)
)
);
`);
await queryRunner.query(`
CREATE INDEX idx_priority_configs_type_active ON freight.priority_configs (type, is_active);
`);
await queryRunner.query(`
CREATE INDEX idx_priority_configs_currency_type ON freight.priority_configs (currency, type);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.priority_configs;`);
}
}

View File

@@ -0,0 +1,22 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateSavedSignatures1784000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE freight.saved_signatures (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL,
signer_display_name VARCHAR(200) NOT NULL,
signature_file_id UUID NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL,
CONSTRAINT uq_saved_signatures_user_id UNIQUE (user_id)
);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.saved_signatures;`);
}
}

View File

@@ -0,0 +1,125 @@
import { MigrationInterface, QueryRunner, Table, TableColumn } from 'typeorm';
/**
* Batch 5 — warehouse allocation rules, storage/demurrage fee rules,
* and demurrage lifecycle timestamps on inventory.
*/
export class AddWarehouseAllocationAndFeeRules1790000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
schema: 'freight',
name: 'warehouse_allocation_rules',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, default: 'uuid_generate_v4()' },
{ name: 'name', type: 'varchar', length: '160' },
{ name: 'priority', type: 'int', default: 100 },
{ name: 'freight_type', type: 'varchar', length: '16', isNullable: true },
{ name: 'trade_direction', type: 'varchar', length: '16', isNullable: true },
{ name: 'cargo_type_code', type: 'varchar', length: '50', isNullable: true },
{ name: 'container_status', type: 'varchar', length: '24', isNullable: true },
{ name: 'requires_inspection', type: 'boolean', isNullable: true },
{ name: 'target_facility_code', type: 'varchar', length: '40', isNullable: true },
{ name: 'target_yard_code', type: 'varchar', length: '40' },
{ name: 'target_warehouse_code', type: 'varchar', length: '40', isNullable: true },
{ name: 'target_zone_code', type: 'varchar', length: '40', isNullable: true },
{ name: 'storage_type', type: 'varchar', length: '80', isNullable: true },
{ name: 'is_active', type: 'boolean', default: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
indices: [
{ name: 'idx_war_priority', columnNames: ['priority'] },
{ name: 'idx_war_active', columnNames: ['is_active'] },
],
}),
true,
);
await queryRunner.createTable(
new Table({
schema: 'freight',
name: 'warehouse_fee_rules',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, default: 'uuid_generate_v4()' },
{ name: 'name', type: 'varchar', length: '160' },
{ name: 'rule_type', type: 'varchar', length: '20' },
{ name: 'priority', type: 'int', default: 100 },
{ name: 'freight_type', type: 'varchar', length: '16', isNullable: true },
{ name: 'trade_direction', type: 'varchar', length: '16', isNullable: true },
{ name: 'cargo_type_code', type: 'varchar', length: '50', isNullable: true },
{ name: 'container_type', type: 'varchar', length: '40', isNullable: true },
{ name: 'facility_id', type: 'uuid', isNullable: true },
{ name: 'warehouse_id', type: 'uuid', isNullable: true },
{ name: 'yard_id', type: 'uuid', isNullable: true },
{ name: 'zone_id', type: 'uuid', isNullable: true },
{ name: 'free_days', type: 'int', default: 0 },
{ name: 'rate_per_day', type: 'numeric', precision: 14, scale: 2, default: 0 },
{ name: 'currency', type: 'varchar', length: '8', default: "'USD'" },
{ name: 'is_active', type: 'boolean', default: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
indices: [
{ name: 'idx_wfr_type', columnNames: ['rule_type'] },
{ name: 'idx_wfr_active', columnNames: ['is_active'] },
],
}),
true,
);
const inventoryTable = await queryRunner.getTable('freight.warehouse_inventory');
if (inventoryTable) {
const columnsToAdd = [
{ name: 'inspection_started_at', type: 'timestamptz', isNullable: true },
{ name: 'inspection_completed_at', type: 'timestamptz', isNullable: true },
{ name: 'ready_for_pickup_at', type: 'timestamptz', isNullable: true },
{ name: 'release_date', type: 'timestamptz', isNullable: true },
{ name: 'gate_cleared_at', type: 'timestamptz', isNullable: true },
];
const columnsToCreate = columnsToAdd.filter(
(col) => !inventoryTable.columns.some((c) => c.name === col.name),
);
if (columnsToCreate.length > 0) {
await queryRunner.addColumns(
'freight.warehouse_inventory',
columnsToCreate.map((col) => new TableColumn(col)),
);
}
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
const inventoryTable = await queryRunner.getTable('freight.warehouse_inventory');
if (inventoryTable) {
const columnNames = [
'inspection_started_at',
'inspection_completed_at',
'ready_for_pickup_at',
'release_date',
'gate_cleared_at',
];
const columnsToRemove = columnNames.filter((name) =>
inventoryTable.columns.some((c) => c.name === name),
);
if (columnsToRemove.length > 0) {
await queryRunner.dropColumns('freight.warehouse_inventory', columnsToRemove);
}
}
const feeRulesTable = await queryRunner.getTable('freight.warehouse_fee_rules');
if (feeRulesTable) {
await queryRunner.dropTable('freight.warehouse_fee_rules', true);
}
const allocationRulesTable = await queryRunner.getTable('freight.warehouse_allocation_rules');
if (allocationRulesTable) {
await queryRunner.dropTable('freight.warehouse_allocation_rules', true);
}
}
}

View File

@@ -0,0 +1,88 @@
import { MigrationInterface, QueryRunner, Table } from 'typeorm';
/** Batch 6 — warehouse fee invoices + invoice items. */
export class AddWarehouseFeeInvoices1790000000001 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
schema: 'freight',
name: 'warehouse_fee_invoices',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, default: 'uuid_generate_v4()' },
{ name: 'invoice_number', type: 'varchar', length: '40', isUnique: true },
{ name: 'booking_id', type: 'uuid', isNullable: true },
{ name: 'customer_id', type: 'uuid', isNullable: true },
{ name: 'inventory_id', type: 'uuid' },
{ name: 'facility_id', type: 'uuid', isNullable: true },
{ name: 'warehouse_id', type: 'uuid', isNullable: true },
{ name: 'yard_id', type: 'uuid', isNullable: true },
{ name: 'zone_id', type: 'uuid', isNullable: true },
{ name: 'invoice_type', type: 'varchar', length: '32', default: "'MIXED_WAREHOUSE_FEES'" },
{ name: 'status', type: 'varchar', length: '20', default: "'DRAFT'" },
{ name: 'subtotal_amount', type: 'numeric', precision: 14, scale: 2, default: 0 },
{ name: 'tax_amount', type: 'numeric', precision: 14, scale: 2, default: 0 },
{ name: 'total_amount', type: 'numeric', precision: 14, scale: 2, default: 0 },
{ name: 'paid_amount', type: 'numeric', precision: 14, scale: 2, default: 0 },
{ name: 'balance_amount', type: 'numeric', precision: 14, scale: 2, default: 0 },
{ name: 'currency', type: 'varchar', length: '8', default: "'USD'" },
{ name: 'period_start', type: 'timestamptz', isNullable: true },
{ name: 'period_end', type: 'timestamptz', isNullable: true },
{ name: 'issued_at', type: 'timestamptz', isNullable: true },
{ name: 'due_date', type: 'timestamptz', isNullable: true },
{ name: 'paid_at', type: 'timestamptz', isNullable: true },
{ name: 'cancelled_at', type: 'timestamptz', isNullable: true },
{ name: 'payments', type: 'jsonb', default: "'[]'" },
{ name: 'notes', type: 'text', isNullable: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
indices: [
{ name: 'idx_wfi_booking', columnNames: ['booking_id'] },
{ name: 'idx_wfi_inventory', columnNames: ['inventory_id'] },
{ name: 'idx_wfi_status', columnNames: ['status'] },
],
}),
true,
);
await queryRunner.createTable(
new Table({
schema: 'freight',
name: 'warehouse_fee_invoice_items',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, default: 'uuid_generate_v4()' },
{ name: 'invoice_id', type: 'uuid' },
{ name: 'fee_rule_id', type: 'uuid', isNullable: true },
{ name: 'fee_type', type: 'varchar', length: '32' },
{ name: 'description', type: 'varchar', length: '255' },
{ name: 'quantity', type: 'numeric', precision: 12, scale: 2, default: 1 },
{ name: 'unit_rate', type: 'numeric', precision: 14, scale: 2, default: 0 },
{ name: 'amount', type: 'numeric', precision: 14, scale: 2, default: 0 },
{ name: 'currency', type: 'varchar', length: '8', default: "'USD'" },
{ name: 'chargeable_days', type: 'int', isNullable: true },
{ name: 'free_days', type: 'int', isNullable: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
foreignKeys: [
{
columnNames: ['invoice_id'],
referencedSchema: 'freight',
referencedTableName: 'warehouse_fee_invoices',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
},
],
indices: [{ name: 'idx_wfii_invoice', columnNames: ['invoice_id'] }],
}),
true,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('freight.warehouse_fee_invoice_items', true);
await queryRunner.dropTable('freight.warehouse_fee_invoices', true);
}
}

View File

@@ -0,0 +1,123 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class WarehouseBatch21790000000001 implements MigrationInterface {
name = 'WarehouseBatch21790000000001';
public async up(queryRunner: QueryRunner): Promise<void> {
// ── Capacity columns (weight + volume) on warehouse / yard / zone ──────
for (const table of ['warehouses', 'warehouse_yards', 'warehouse_zones']) {
await queryRunner.query(`
ALTER TABLE freight.${table}
ADD COLUMN IF NOT EXISTS max_weight NUMERIC(14,3) NULL,
ADD COLUMN IF NOT EXISTS max_volume NUMERIC(14,3) NULL,
ADD COLUMN IF NOT EXISTS current_volume NUMERIC(14,3) NOT NULL DEFAULT 0;
`);
// Backfill max_weight from the Batch 1 capacity_weight column.
await queryRunner.query(`
UPDATE freight.${table} SET max_weight = capacity_weight WHERE max_weight IS NULL;
`);
}
// ── Inventory lifecycle: migrate Batch 1 statuses to Batch 2 set ───────
await queryRunner.query(`
ALTER TABLE freight.warehouse_inventory
ALTER COLUMN status SET DEFAULT 'RECEIVED';
`);
await queryRunner.query(`
UPDATE freight.warehouse_inventory SET status = 'RECEIVED' WHERE status = 'ARRIVED_AT_WAREHOUSE';
`);
await queryRunner.query(`
UPDATE freight.warehouse_inventory SET status = 'STORED' WHERE status = 'UNDER_INSPECTION';
`);
// ── New lifecycle timestamps ──────────────────────────────────────────
await queryRunner.query(`
ALTER TABLE freight.warehouse_inventory
ADD COLUMN IF NOT EXISTS stored_at TIMESTAMPTZ NULL,
ADD COLUMN IF NOT EXISTS reserved_at TIMESTAMPTZ NULL,
ADD COLUMN IF NOT EXISTS loaded_at TIMESTAMPTZ NULL,
ADD COLUMN IF NOT EXISTS dispatched_at TIMESTAMPTZ NULL;
`);
// booking_id becomes nullable (inventory can exist before booking linkage).
await queryRunner.query(`
ALTER TABLE freight.warehouse_inventory ALTER COLUMN booking_id DROP NOT NULL;
`);
// ── Movement history ──────────────────────────────────────────────────
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.warehouse_inventory_movement (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
inventory_id UUID NOT NULL REFERENCES freight.warehouse_inventory(id) ON DELETE CASCADE,
from_warehouse_id UUID NOT NULL,
from_yard_id UUID NOT NULL,
from_zone_id UUID NOT NULL,
to_warehouse_id UUID NOT NULL,
to_yard_id UUID NOT NULL,
to_zone_id UUID NOT NULL,
remarks TEXT NULL,
moved_by VARCHAR(120) NULL,
moved_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL
);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_movement_inventory_id
ON freight.warehouse_inventory_movement(inventory_id);
`);
// ── Activity log ──────────────────────────────────────────────────────
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.warehouse_activity_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
inventory_id UUID NULL,
warehouse_id UUID NULL,
activity_type VARCHAR(40) NOT NULL,
description TEXT NULL,
performed_by VARCHAR(120) NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL
);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_warehouse_activity_log_inventory_id
ON freight.warehouse_activity_log(inventory_id);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_warehouse_activity_log_warehouse_id
ON freight.warehouse_activity_log(warehouse_id);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_warehouse_activity_log_activity_type
ON freight.warehouse_activity_log(activity_type);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_activity_log;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_inventory_movement;`);
await queryRunner.query(`
ALTER TABLE freight.warehouse_inventory
DROP COLUMN IF EXISTS stored_at,
DROP COLUMN IF EXISTS reserved_at,
DROP COLUMN IF EXISTS loaded_at,
DROP COLUMN IF EXISTS dispatched_at;
`);
await queryRunner.query(`
ALTER TABLE freight.warehouse_inventory ALTER COLUMN status SET DEFAULT 'RECEIVED';
`);
for (const table of ['warehouses', 'warehouse_yards', 'warehouse_zones']) {
await queryRunner.query(`
ALTER TABLE freight.${table}
DROP COLUMN IF EXISTS max_weight,
DROP COLUMN IF EXISTS max_volume,
DROP COLUMN IF EXISTS current_volume;
`);
}
}
}

View File

@@ -0,0 +1,44 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Batch 3 — Warehouse → Loading → Train Departure visibility.
* Adds the warehouse_loadings record (inventory ↔ wagon). Does NOT touch any
* scheduling / wagon tables — the warehouse only reads from those.
*/
export class WarehouseBatch31790000000002 implements MigrationInterface {
name = 'WarehouseBatch31790000000002';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.warehouse_loadings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
warehouse_inventory_id UUID NOT NULL REFERENCES freight.warehouse_inventory(id) ON DELETE CASCADE,
booking_id UUID NULL,
wagon_id UUID NOT NULL,
loaded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
loaded_by VARCHAR(120) NULL,
loaded_weight NUMERIC(14,3) NULL,
notes TEXT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL
);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_warehouse_loadings_inventory_id
ON freight.warehouse_loadings(warehouse_inventory_id);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_warehouse_loadings_booking_id
ON freight.warehouse_loadings(booking_id);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_warehouse_loadings_wagon_id
ON freight.warehouse_loadings(wagon_id);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_loadings;`);
}
}

View File

@@ -10,12 +10,14 @@ import {
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { FreightAdmin } from "../../common/booking-guards";
import { BackofficeService } from "./backoffice.service";
import { CreateOrganizationUserDto } from "./dto/create-organization-user.dto";
import { UpdateEmployeeUserRolesDto } from "./dto/update-employee-user-roles.dto";
@ApiTags("backoffice")
@Controller("backoffice")
@FreightAdmin()
export class BackofficeController {
constructor(private readonly backofficeService: BackofficeService) {}

View File

@@ -1,10 +1,12 @@
import { Controller, Get, Param, ParseUUIDPipe } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { FreightAdmin } from "../../common/booking-guards";
import { BillingService } from "./billing.service";
@ApiTags("billing")
@Controller("billing")
@FreightAdmin()
export class BillingController {
constructor(private readonly billingService: BillingService) {}

View File

@@ -1,6 +1,9 @@
import {
BadRequestException,
forwardRef,
Inject,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { Readable } from 'stream';
@@ -19,9 +22,13 @@ import { assertBookingStatus } from './booking-status.util';
import { ContractViewDto } from './dto/contract-view.dto';
import { SignContractDto } from './dto/sign-contract.dto';
import { ContractSignerRole } from './entities/booking-contract-signature.entity';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { SignaturesService } from '../signatures/signatures.service';
@Injectable()
export class BookingContractService {
private readonly logger = new Logger(BookingContractService.name);
constructor(
private readonly bookingsRepository: BookingsRepository,
private readonly filesService: FilesService,
@@ -30,6 +37,9 @@ export class BookingContractService {
private readonly viewModelBuilder: ContractViewModelBuilder,
private readonly renderer: ContractRendererService,
private readonly pdfService: ContractPdfService,
@Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatchService: BookingBatchService,
private readonly signaturesService: SignaturesService,
) {}
buildContractSummary(booking: Booking): string {
@@ -67,10 +77,16 @@ export class BookingContractService {
return { summary };
}
async getContractView(bookingId: string): Promise<ContractViewDto> {
async getContractView(
bookingId: string,
viewerUserId?: string,
): Promise<ContractViewDto> {
const { view } = await this.viewModelBuilder.build(bookingId);
await this.inlineSignatureImages(view.signatures);
const html = this.renderer.render(view);
const savedSignature = viewerUserId
? ((await this.signaturesService.getForUser(viewerUserId)) ?? undefined)
: undefined;
return {
bookingId: view.bookingId,
reference: view.reference,
@@ -82,6 +98,7 @@ export class BookingContractService {
canSignStaff: view.canSignStaff,
hasContractDocument: view.hasContractDocument,
signatures: view.signatures,
savedSignature,
pricingSchedule: view.pricing as unknown as Record<string, unknown>,
};
}
@@ -92,7 +109,16 @@ export class BookingContractService {
const templateKey = this.templateResolver.resolve(booking);
const summary = this.buildContractSummary(booking);
await this.upsertContractPdf(bookingId, booking.reference, templateKey);
// PDF rendering (Puppeteer/Chromium) is best-effort and must NOT block the contract
// from becoming ready — the document is (re)rendered lazily on view/download.
try {
await this.upsertContractPdf(bookingId, booking.reference, templateKey);
} catch (err) {
this.logger.warn(
`Contract PDF deferred for ${booking.reference}: ${err}. It will render on view/download once Chromium is available.`,
);
}
const now = new Date();
const updated = await this.bookingsRepository.update(bookingId, {
@@ -177,6 +203,23 @@ export class BookingContractService {
ipAddress: options.ipAddress ?? null,
});
// Persist the just-used signature to the signer's reusable profile so they
// don't have to redraw it on the next contract. Best-effort: a failure here
// must never block contract execution.
if (options.signerUserId) {
try {
await this.signaturesService.upsertForUser({
userId: options.signerUserId,
signerDisplayName: dto.signerDisplayName,
signatureImageBase64: dto.signatureImageBase64,
});
} catch (err) {
this.logger.warn(
`Could not save reusable signature for user ${options.signerUserId}: ${err}`,
);
}
}
const updates: Record<string, unknown> = {};
if (role === 'CUSTOMER') {
@@ -191,11 +234,20 @@ export class BookingContractService {
}
const updated = await this.bookingsRepository.update(bookingId, updates as never);
await this.upsertContractPdf(
bookingId,
booking.reference,
booking.contractTemplateKey ?? this.templateResolver.resolve(booking),
);
if (role === 'STAFF' && updated?.trainScheduleId) {
this.bookingBatchService.enqueueScheduleProcessing(updated.trainScheduleId);
}
try {
await this.upsertContractPdf(
bookingId,
booking.reference,
booking.contractTemplateKey ?? this.templateResolver.resolve(booking),
);
} catch (err) {
this.logger.warn(
`Signed-contract PDF deferred for ${booking.reference}: ${err}. It will render on view/download.`,
);
}
return updated!;
}

View File

@@ -16,7 +16,7 @@ export const BOOKING_LIST_TABS: ReadonlyArray<{
statuses: readonly string[] | null;
}> = [
{ key: 'all', statuses: null },
{ key: 'intake', statuses: ['SUBMITTED'] },
{ key: 'intake', statuses: ['SUBMITTED', 'PENDING_CONSOLIDATION'] },
{
key: 'in_approval',
statuses: ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE'],
@@ -28,7 +28,7 @@ export const BOOKING_LIST_TABS: ReadonlyArray<{
{ key: 'payment', statuses: ['FULLY_EXECUTED'] },
{
key: 'operations',
statuses: ['IN_TRANSIT', 'PENDING_CONSOLIDATION', 'CONSOLIDATED','PAID'],
statuses: ['IN_TRANSIT', 'PAID'],
},
{ key: 'completed', statuses: ['COMPLETED'] },
{ key: 'closed', statuses: ['REJECTED', 'CANCELLED'] },

View File

@@ -5,6 +5,7 @@ import { assertBookingStatus } from './booking-status.util';
import { InAppPaymentReceiptDto } from './dto/pay-booking.dto';
import { PaymentService } from '../payment/payment.service';
import { PaymentStatus } from '../payment/entities/payment.entity';
import { PaymentMethodTypeEnum } from '../payment/payments.dto';
export interface InAppPaymentReceipt extends InAppPaymentReceiptDto { }
const NON_TERMINAL_STATUSES: PaymentStatus[] = [
@@ -22,7 +23,7 @@ export class BookingPaymentService {
async pay(bookingId: string): Promise<{ redirectUrl: string }> {
const booking = await this.requireBooking(bookingId);
assertBookingStatus(booking, ['FULLY_EXECUTED', '']);
assertBookingStatus(booking, ['FULLY_EXECUTED', 'SELECTED_FOR_BATCH', 'AWAITING_PAYMENT', '']);
const existing = await this.paymentService.findBookingById(bookingId);
if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) {
@@ -34,11 +35,15 @@ export class BookingPaymentService {
}
}
const resp = await this.paymentService.initBookingTelebirr(bookingId, "web");
const resp = await this.paymentService.initiatePayment({
bookingId,
method: PaymentMethodTypeEnum.TELEBIRR,
platform: "web",
});
const action = resp.clientAction as { type?: string; url?: string } | undefined;
return {
redirectUrl:
resp.redirectUrl ?? "",
redirectUrl: action?.type === "REDIRECT" ? (action.url ?? "") : "",
};
}

View File

@@ -0,0 +1,129 @@
import { BookingPricingService } from './booking-pricing.service';
import type { Booking } from './entities/booking.entity';
import type { Rate } from '../rule-engine/entities/rate.entity';
const MOCK_CBE_RATE = 130;
describe('BookingPricingService — domestic corridor', () => {
const intercityBulkUsd: Rate = {
id: 'rate-intercity-bulk-usd',
rateType: 'INTERCITY_BULK',
currency: 'USD',
rateValue: 35,
rateUnit: 'PER_TON',
status: 'LIVE',
containerTypeId: null,
} as Rate;
const intercityContainerUsd: Rate = {
id: 'rate-intercity-container-usd',
rateType: 'INTERCITY_CONTAINER',
currency: 'USD',
rateValue: 400,
rateUnit: 'PER_CONTAINER',
status: 'LIVE',
containerTypeId: null,
} as Rate;
let service: BookingPricingService;
let bookingsRepository: { calculateWagonCount: jest.Mock };
let ratesService: { findLiveRates: jest.Mock };
let cbeExchangeService: { getUsdToEtbRate: jest.Mock };
beforeEach(() => {
bookingsRepository = { calculateWagonCount: jest.fn().mockResolvedValue(2) };
ratesService = {
findLiveRates: jest.fn().mockResolvedValue([intercityBulkUsd, intercityContainerUsd]),
};
cbeExchangeService = {
getUsdToEtbRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE),
};
service = new BookingPricingService(
bookingsRepository as never,
{} as never,
{} as never,
ratesService as never,
{} as never,
cbeExchangeService as never,
);
});
it('prices domestic bulk in ETB using INTERCITY_BULK USD rate × CBE exchange rate', async () => {
const booking = {
id: 'b-1',
freightType: 'BULK',
tradeDirection: 'DOMESTIC',
paymentCurrency: 'ETB',
cargoTotalWeightVgm: 120,
bookingContainers: [],
} as unknown as Booking;
const result = await (
service as unknown as {
computeBaseRailLinesWithRates: (
b: Booking,
input: { containers: [] },
) => Promise<{ lineItems: Array<{ amount: number; code: string; currency: string }> }>;
}
).computeBaseRailLinesWithRates(booking, { containers: [] });
expect(result.lineItems).toHaveLength(1);
expect(result.lineItems[0].code).toBe('INTERCITY_BULK');
expect(result.lineItems[0].currency).toBe('ETB');
expect(result.lineItems[0].amount).toBe(Math.round(35 * 120 * MOCK_CBE_RATE));
});
it('prices domestic bulk in USD using INTERCITY_BULK USD rate directly', async () => {
const booking = {
id: 'b-1-usd',
freightType: 'BULK',
tradeDirection: 'DOMESTIC',
paymentCurrency: 'USD',
cargoTotalWeightVgm: 120,
bookingContainers: [],
} as unknown as Booking;
const result = await (
service as unknown as {
computeBaseRailLinesWithRates: (
b: Booking,
input: { containers: [] },
) => Promise<{ lineItems: Array<{ amount: number; code: string; currency: string }> }>;
}
).computeBaseRailLinesWithRates(booking, { containers: [] });
expect(result.lineItems).toHaveLength(1);
expect(result.lineItems[0].code).toBe('INTERCITY_BULK');
expect(result.lineItems[0].currency).toBe('USD');
expect(result.lineItems[0].amount).toBe(35 * 120);
});
it('prices domestic container in ETB using INTERCITY_CONTAINER USD fallback × CBE rate', async () => {
const booking = {
id: 'b-2',
freightType: 'CONTAINER',
tradeDirection: 'DOMESTIC',
paymentCurrency: 'ETB',
cargoTotalWeightVgm: 50,
bookingContainers: [],
} as unknown as Booking;
const result = await (
service as unknown as {
computeBaseRailLinesWithRates: (
b: Booking,
input: {
containers: Array<{ containerTypeId: string; quantity: number }>;
},
) => Promise<{ lineItems: Array<{ amount: number; code: string; currency: string }> }>;
}
).computeBaseRailLinesWithRates(booking, {
containers: [{ containerTypeId: 'ct-20', quantity: 3 }],
});
expect(result.lineItems.some((l) => l.code === 'INTERCITY_CONTAINER')).toBe(true);
const line = result.lineItems.find((l) => l.code === 'INTERCITY_CONTAINER')!;
expect(line.currency).toBe('ETB');
});
});

View File

@@ -4,6 +4,7 @@ import { ContainerTypesService } from '../rule-engine/services/container-types.s
import { RatesService } from '../rule-engine/services/rates.service';
import { ServiceTypesService } from '../rule-engine/services/service-types.service';
import { Rate } from '../rule-engine/entities/rate.entity';
import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service';
import {
AppliedCargoModifier,
BookingEvaluationInput,
@@ -40,6 +41,7 @@ export class BookingPricingService {
private readonly containerTypesService: ContainerTypesService,
private readonly ratesService: RatesService,
private readonly serviceTypesService: ServiceTypesService,
private readonly cbeExchangeService: CbeExchangeService,
) {}
async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> {
@@ -80,6 +82,10 @@ export class BookingPricingService {
const evalInput = await this.buildEvalInputForBooking(booking);
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
const paymentCurrency = booking.paymentCurrency;
const isEtbBooking = paymentCurrency === 'ETB';
const usdToEtb = isEtbBooking ? await this.cbeExchangeService.getUsdToEtbRate() : 1;
const lineItems: PriceLineItemDto[] = [];
let total = 0;
@@ -95,14 +101,16 @@ export class BookingPricingService {
const usedRatesMap = new Map(baseRates.map((r) => [r.id, r]));
for (const mod of ruleResult.appliedModifiers) {
const usdAmount = mod.calculatedAmount;
const convertedAmount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
const item: PriceLineItemDto = {
code: mod.surchargeTypeCode,
description: `Surcharge: ${mod.surchargeTypeCode}`,
amount: mod.calculatedAmount,
currency: mod.currency,
amount: convertedAmount,
currency: paymentCurrency,
};
lineItems.push(item);
total += mod.calculatedAmount;
total += convertedAmount;
const rate = rateById.get(mod.rateId);
if (rate) usedRatesMap.set(rate.id, rate);
@@ -174,6 +182,17 @@ export class BookingPricingService {
};
}),
);
// Wagon count is persisted per container line at booking creation; sum it.
const totalWagons =
booking.freightType === 'CONTAINER'
? Math.ceil(
(booking.bookingContainers ?? []).reduce(
(sum, bc) => sum + Number(bc.wagonsRequired ?? 0),
0,
),
)
: 0;
return {
freightType: booking.freightType as 'CONTAINER' | 'BULK',
cargoTypeId: booking.cargoTypeId ?? null,
@@ -184,6 +203,7 @@ export class BookingPricingService {
isGovernment: booking.isGovernment,
allowConsolidation: booking.allowConsolidation,
shippingLineId: booking.shippingLineId,
totalWagons,
containers,
};
}
@@ -263,7 +283,9 @@ export class BookingPricingService {
evalInput: BookingEvaluationInput,
): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> {
const liveRates = await this.ratesService.findLiveRates();
const currency = booking.paymentCurrency;
const paymentCurrency = booking.paymentCurrency;
const isEtbBooking = paymentCurrency === 'ETB';
const usdToEtb = isEtbBooking ? await this.cbeExchangeService.getUsdToEtbRate() : 1;
const isBulk = booking.freightType === 'BULK';
const rateType =
@@ -275,38 +297,45 @@ export class BookingPricingService {
? isBulk
? 'BULK_EXPORT'
: 'CONTAINER_EXPORT'
: 'INTERCITY_CONTAINER';
: isBulk
? 'INTERCITY_BULK'
: 'INTERCITY_CONTAINER';
const lines: PriceLineItemDto[] = [];
const usedRatesMap = new Map<string, Rate>();
const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id);
for (const container of evalInput.containers) {
const rate = this.pickRate(liveRates, rateType, container.containerTypeId, currency);
const rate = this.pickRate(liveRates, rateType, container.containerTypeId, 'USD');
if (!rate) continue;
usedRatesMap.set(rate.id, rate);
const amount = this.amountForRate(rate, container.quantity, wagonCount);
const usdAmount = this.amountForRate(rate, container.quantity, wagonCount);
const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
lines.push({
code: rateType,
description: `Base rail (${rateType})`,
amount,
currency: rate.currency,
currency: paymentCurrency,
});
}
if (lines.length === 0) {
const fallback = liveRates.find(
(r) => r.rateType === rateType && r.currency === currency && r.status === 'LIVE',
(r) => r.rateType === rateType && r.currency === 'USD' && r.status === 'LIVE',
);
if (fallback) {
usedRatesMap.set(fallback.id, fallback);
const amount = this.amountForRate(fallback, 1, wagonCount);
const bulkTons = Number(booking.cargoTotalWeightVgm ?? 0);
const quantity =
isBulk && fallback.rateUnit === 'PER_TON' ? Math.max(bulkTons, 0) : 1;
const usdAmount = this.amountForRate(fallback, quantity, wagonCount);
const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
lines.push({
code: rateType,
description: `Base rail (${rateType})`,
amount,
currency: fallback.currency,
currency: paymentCurrency,
});
}
}

View File

@@ -1,28 +1,28 @@
import { Inject, Injectable } from '@nestjs/common';
import { In, Not } from 'typeorm';
import { Inject, Injectable } from "@nestjs/common";
import { In, Not } from "typeorm";
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { CargoType } from "../rule-engine/entities/cargo-type.entity";
import { ContainerType } from "../rule-engine/entities/container-type.entity";
import {
CARGO_TYPES_REPOSITORY,
ICargoTypesRepository,
} from '../rule-engine/interfaces/cargo-types.repository.interface';
} from "../rule-engine/interfaces/cargo-types.repository.interface";
import {
CONTAINER_TYPES_REPOSITORY,
IContainerTypesRepository,
} from '../rule-engine/interfaces/container-types.repository.interface';
} from "../rule-engine/interfaces/container-types.repository.interface";
import {
IServiceTypesRepository,
SERVICE_TYPES_REPOSITORY,
} from '../rule-engine/interfaces/service-types.repository.interface';
} from "../rule-engine/interfaces/service-types.repository.interface";
import {
IShippingLinesRepository,
SHIPPING_LINES_REPOSITORY,
} from '../rule-engine/interfaces/shipping-lines.repository.interface';
} from "../rule-engine/interfaces/shipping-lines.repository.interface";
import {
IYardsRepository,
YARDS_REPOSITORY,
} from '../rule-engine/interfaces/yards.repository.interface';
} from "../rule-engine/interfaces/yards.repository.interface";
import {
BookingReferenceCargoTypeChildDto,
BookingReferenceCargoTypeGroupDto,
@@ -32,9 +32,9 @@ import {
BookingReferenceServiceDto,
BookingReferenceShippingLineDto,
BookingReferenceYardDto,
} from './dto/booking-reference-data.dto';
} from "./dto/booking-reference-data.dto";
const LEGACY_YARD_CODES = ['LEGACY_ORIGIN', 'LEGACY_DEST'] as const;
const LEGACY_YARD_CODES = ["LEGACY_ORIGIN", "LEGACY_DEST"] as const;
export function buildCargoTypeTree(
rows: CargoType[],
@@ -42,13 +42,16 @@ export function buildCargoTypeTree(
const active = rows.filter((r) => r.isActive);
const parents = active
.filter((r) => !r.parentGroupId)
.sort((a, b) => a.displayOrder - b.displayOrder || a.code.localeCompare(b.code));
.sort(
(a, b) => a.displayOrder - b.displayOrder || a.code.localeCompare(b.code),
);
return parents.map((parent) => {
const children = active
.filter((r) => r.parentGroupId === parent.id)
.sort(
(a, b) => a.displayOrder - b.displayOrder || a.code.localeCompare(b.code),
(a, b) =>
a.displayOrder - b.displayOrder || a.code.localeCompare(b.code),
)
.map(
(child): BookingReferenceCargoTypeChildDto => ({
@@ -79,14 +82,14 @@ export function groupContainersBySize(
for (const ct of active) {
const sizeKey =
ct.sizeFt != null && ct.sizeFt > 0 ? `${ct.sizeFt}ft` : 'other';
ct.sizeFt != null && ct.sizeFt > 0 ? `${ct.sizeFt}ft` : "other";
const list = bySize.get(sizeKey) ?? [];
list.push(ct);
bySize.set(sizeKey, list);
}
const sortSizeKey = (key: string): number => {
if (key === 'other') return Number.MAX_SAFE_INTEGER;
if (key === "other") return Number.MAX_SAFE_INTEGER;
const n = parseInt(key, 10);
return Number.isNaN(n) ? Number.MAX_SAFE_INTEGER - 1 : n;
};
@@ -126,7 +129,7 @@ export class BookingReferenceDataService {
private readonly shippingLinesRepository: IShippingLinesRepository,
@Inject(CARGO_TYPES_REPOSITORY)
private readonly cargoTypesRepository: ICargoTypesRepository,
) {}
) { }
async getReferenceData(): Promise<BookingReferenceDataDto> {
const [yards, containerTypes, serviceTypes, shippingLines, cargoTypes] =
@@ -136,23 +139,23 @@ export class BookingReferenceDataService {
isActive: true,
code: Not(In([...LEGACY_YARD_CODES])),
},
order: { displayOrder: 'ASC', code: 'ASC' },
order: { displayOrder: "ASC", code: "ASC" },
}),
this.containerTypesRepository.findAll({
where: { isActive: true },
order: { displayOrder: 'ASC', code: 'ASC' },
order: { displayOrder: "ASC", code: "ASC" },
}),
this.serviceTypesRepository.findAll({
where: { isActive: true },
order: { displayOrder: 'ASC', code: 'ASC' },
order: { displayOrder: "ASC", code: "ASC" },
}),
this.shippingLinesRepository.findAll({
where: { isActive: true },
order: { label: 'ASC', code: 'ASC' },
order: { label: "ASC", code: "ASC" },
}),
this.cargoTypesRepository.findAll({
where: { isActive: true },
order: { displayOrder: 'ASC', code: 'ASC' },
order: { displayOrder: "ASC", code: "ASC" },
}),
]);
@@ -168,9 +171,8 @@ export class BookingReferenceDataService {
containers: groupContainersBySize(containerTypes),
service: serviceTypes.map(
(s): BookingReferenceServiceDto => ({
id: s.id,
name: s.serviceName,
code: s.code,
...s,
}),
),
shipping_line: shippingLines.map(

View File

@@ -1,4 +1,10 @@
import { BadRequestException, forwardRef, Inject, Injectable } from '@nestjs/common';
import {
BadRequestException,
ConflictException,
forwardRef,
Inject,
Injectable,
} from '@nestjs/common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
@@ -184,6 +190,16 @@ export class BookingTransitionService {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['SUBMITTED']);
// Consolidation gate: a booking whose containers don't fill whole wagons
// cannot be accepted until it is paired with a complementary booking.
const gate = await this.bookingsService.resolveConsolidationGate(bookingId);
if (gate.blocked) {
throw new ConflictException(
gate.message ??
'Booking requires consolidation and cannot be accepted until a partner is found.',
);
}
await this.ruleEngineService.instantiateApprovalSteps(bookingId, {
freightType: booking.freightType as 'CONTAINER' | 'BULK',
cargoTypeId: booking.cargoTypeId,

View File

@@ -54,7 +54,7 @@ import {
type AuthUserPayload,
resolveAuthUserId,
} from '../../common/resolve-auth-user-id';
import { assertFreightPermission } from '../../common/freight-permission.util';
import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util';
@ApiTags('bookings')
@Controller('bookings')
@@ -73,7 +73,7 @@ export class BookingsController {
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Create a new freight booking (DRAFT)' })
@ApiBody({ type: CreateBookingDto })
create(
async create(
@Body() dto: CreateBookingDto,
@UploadedFiles() files: Express.Multer.File[],
@CurrentUser() user: TCurrentUser,
@@ -81,7 +81,22 @@ export class BookingsController {
if (dto.isGovernment) {
assertFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept);
}
return this.bookingsService.create(dto, files ?? [], user?.id);
const result = await this.bookingsService.create(dto, files ?? [], user?.id);
// Staff-created commercial bookings skip the draft stage: auto generate-price + submit.
const isStaff = hasFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept);
if (isStaff && !dto.isGovernment) {
try {
await this.pricingService.generatePrice(result.booking.id);
await this.transitionService.submit(result.booking.id);
const submitted = await this.bookingsService.findById(result.booking.id);
return { booking: submitted, warnings: result.warnings };
} catch {
// If auto-pricing/submit fails, fall back to the DRAFT so staff can finish manually.
return result;
}
}
return result;
}
@Patch(':id')
@@ -113,6 +128,20 @@ export class BookingsController {
return this.bookingsService.getListSummary(filter);
}
@Get('my')
@ApiOperation({
summary: "List the current customer's bookings ready for payment",
description:
'Bookings owned by the authenticated user\'s company that are payable ' +
'(FULLY_EXECUTED, SELECTED_FOR_BATCH, AWAITING_PAYMENT) and not yet PAID.',
})
findMyPayable(
@CurrentUser() user: AuthUserPayload,
@Query() filter: FilterBookingDto,
) {
return this.bookingsService.findMyPayable(resolveAuthUserId(user), filter);
}
@Get('queues/:queue')
@ApiOperation({
summary: 'List bookings for a dashboard queue',
@@ -313,8 +342,12 @@ export class BookingsController {
@Get(':id/contract/view')
@ApiOkResponse({ type: ContractViewDto })
@ApiOperation({ summary: 'Contract HTML view for portal and backoffice' })
getContractView(@Param('id', ParseUUIDPipe) id: string) {
return this.contractService.getContractView(id);
getContractView(
@Param('id', ParseUUIDPipe) id: string,
@Request() req: { user?: { id?: string; sub?: string } },
) {
const userId = req.user?.id ?? req.user?.sub;
return this.contractService.getContractView(id, userId);
}
@Get(':id/contract/document')

View File

@@ -1,4 +1,4 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
// import { CustomersModule } from '../customers/customers.module';
@@ -6,6 +6,7 @@ import { CompaniesModule } from '../companies/companies.module';
import { FilesModule } from '../files/files.module';
import { MinioModule } from '../minio/minio.module';
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
import { SignaturesModule } from '../signatures/signatures.module';
import { BookingContractService } from './booking-contract.service';
import { BookingPaymentService } from './booking-payment.service';
import { BookingPricingService } from './booking-pricing.service';
@@ -29,6 +30,8 @@ import { ContractRendererService } from '../../contracts/contract-renderer.servi
import { ContractTemplateResolver } from '../../contracts/contract-template.resolver';
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
import { PaymentModule } from '../payment/payment.module';
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service';
@Module({
imports: [
@@ -42,11 +45,13 @@ import { PaymentModule } from '../payment/payment.module';
BookingContractSignature,
]),
PaymentModule,
forwardRef(() => TrainSchedulingModule),
FilesModule,
MinioModule,
CompaniesModule,
// CustomersModule,
RuleEngineModule,
SignaturesModule,
],
controllers: [BookingsController, PayController],
providers: [
@@ -63,6 +68,7 @@ import { PaymentModule } from '../payment/payment.module';
ContractPricingScheduleBuilder,
ContractRendererService,
ContractPdfService,
CbeExchangeService,
],
exports: [BookingsService, BookingsRepository],
})

View File

@@ -31,6 +31,8 @@ export interface BookingListFilterOptions {
freightType?: string;
tradeDirection?: string;
paymentCurrency?: string;
paymentStatus?: string;
excludePaymentStatus?: string;
allowConsolidation?: boolean;
consolidationPaired?: string;
}
@@ -91,6 +93,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
.leftJoinAndSelect('booking.rateSnapshots', 'snapshots')
.leftJoinAndSelect('booking.cargoModifiers', 'modifiers')
.leftJoinAndSelect('booking.reviewNotes', 'reviewNotes')
.leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner')
.where('booking.id = :id', { id })
.leftJoinAndMapMany(
'booking.files',
@@ -175,7 +178,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
.andWhere('b.allowConsolidation = true')
.andWhere('b.consolidationPartnerId IS NULL')
.andWhere('b.status IN (:...statuses)', {
statuses: ['DRAFT', 'PENDING_CONSOLIDATION'],
statuses: ['DRAFT', 'SUBMITTED', 'PENDING_CONSOLIDATION'],
})
.andWhere('b.originYardId = :originYardId', {
originYardId: booking.originYardId,
@@ -212,15 +215,27 @@ export class BookingsRepository extends BaseRepository<Booking> {
return null;
}
/** Pair two bookings for consolidation. */
/**
* Pair two bookings for consolidation. Both return to SUBMITTED so staff can
* accept them into the approval chain; the link itself (consolidationPartnerId)
* marks them as consolidated in the UI.
*/
async pairConsolidation(bookingId: string, partnerId: string): Promise<void> {
await this.repository.update(bookingId, {
consolidationPartnerId: partnerId,
status: 'CONSOLIDATED',
status: 'SUBMITTED',
} as never);
await this.repository.update(partnerId, {
consolidationPartnerId: bookingId,
status: 'CONSOLIDATED',
status: 'SUBMITTED',
} as never);
}
/** Park a booking that needs consolidation but has no partner yet. */
async parkForConsolidation(bookingId: string): Promise<void> {
await this.repository.update(bookingId, {
consolidationPartnerId: null,
status: 'PENDING_CONSOLIDATION',
} as never);
}
@@ -427,6 +442,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
.leftJoinAndSelect('booking.serviceType', 'serviceType')
.leftJoinAndSelect('booking.approvalSteps', 'approvalSteps')
.leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner')
.where('booking.deleted_at IS NULL');
this.applyListFilters(qb, options);
@@ -570,6 +586,16 @@ export class BookingsRepository extends BaseRepository<Booking> {
paymentCurrency: options.paymentCurrency,
});
}
if (options.paymentStatus) {
qb.andWhere('booking.payment_status = :paymentStatus', {
paymentStatus: options.paymentStatus,
});
}
if (options.excludePaymentStatus) {
qb.andWhere('booking.payment_status != :excludePaymentStatus', {
excludePaymentStatus: options.excludePaymentStatus,
});
}
if (options.allowConsolidation !== undefined) {
qb.andWhere('booking.allow_consolidation = :allowConsolidation', {
allowConsolidation: options.allowConsolidation,
@@ -659,6 +685,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
originStationId?: string;
destinationStationId?: string;
schedulingStatus?: string;
trainScheduleId?: string;
}): Promise<Booking[]> {
const qb = this.repository
.createQueryBuilder('booking')
@@ -676,6 +703,14 @@ export class BookingsRepository extends BaseRepository<Booking> {
.where('booking.status = :paidStatus', { paidStatus: 'PAID' })
.andWhere('scheduleBooking.id IS NULL');
// Mirror the automatic batch pool: a schedule only ever considers bookings that
// targeted THAT schedule (same as findBatchPool's train_schedule_id filter).
if (options.trainScheduleId) {
qb.andWhere('booking.train_schedule_id = :trainScheduleId', {
trainScheduleId: options.trainScheduleId,
});
}
if (options.freightType) {
qb.andWhere('booking.freightType = :freightType', { freightType: options.freightType });
}
@@ -703,6 +738,90 @@ export class BookingsRepository extends BaseRepository<Booking> {
.getMany();
}
/**
* Ready, not-yet-allocated bookings targeting a schedule (the batch pool).
* Commercial = FULLY_EXECUTED; government = APPROVED or PAID (skips contract).
* Ordered government → priority → contract-sign time.
*/
findBatchPool(scheduleId: string): Promise<Booking[]> {
return this.repository
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
.andWhere('sb.id IS NULL')
.andWhere(
`((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`,
)
.orderBy('booking.is_government', 'DESC')
.addOrderBy('booking.priority_score', 'DESC')
.addOrderBy('booking.fully_executed_at', 'ASC')
.addOrderBy('booking.created_at', 'ASC')
.getMany();
}
/** Every booking that targeted a schedule (any status) — for the batch monitoring board. */
findAllBySchedule(scheduleId: string): Promise<Booking[]> {
return this.repository
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
.orderBy('booking.is_government', 'DESC')
.addOrderBy('booking.priority_score', 'DESC')
.addOrderBy('booking.created_at', 'ASC')
.getMany();
}
/** Bookings currently reserved (SELECTED_FOR_BATCH) against a schedule. */
findReservedForSchedule(scheduleId: string): Promise<Booking[]> {
return this.repository
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
.andWhere(`booking.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`)
.getMany();
}
/** PAID bookings targeting a schedule that have no train_schedule_bookings link yet. */
findPaidUnlinkedForSchedule(scheduleId: string): Promise<Booking[]> {
return this.repository
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.leftJoin(
TrainScheduleBooking,
'scheduleBooking',
'scheduleBooking.booking_id = booking.id',
)
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
.andWhere(`booking.status = 'PAID'`)
.andWhere('scheduleBooking.id IS NULL')
.orderBy('booking.priority_score', 'DESC')
.addOrderBy('booking.created_at', 'ASC')
.getMany();
}
/** Commercial bookings already allocated to a schedule, lowest-priority first (for government preempt). */
findAllocatedCommercialForSchedule(scheduleId: string): Promise<Booking[]> {
return this.repository
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.innerJoin(
TrainScheduleBooking,
'sb',
'sb.booking_id = booking.id AND sb.train_schedule_id = :scheduleId',
{ scheduleId },
)
.where('booking.is_government = false')
.orderBy('booking.priority_score', 'ASC')
.addOrderBy('booking.created_at', 'DESC')
.getMany();
}
findByIdsForScheduling(bookingIds: string[], manager?: EntityManager): Promise<Booking[]> {
if (!bookingIds.length) return Promise.resolve([]);
return this.bookingRepo(manager).find({

View File

@@ -14,6 +14,12 @@ import {
BookingEvaluationInput,
RuleEngineService,
} from '../rule-engine/rule-engine.service';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource, In } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { Yard } from '../rule-engine/entities/yard.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { BookingsRepository } from './bookings.repository';
import { ConsolidationService } from './consolidation.service';
import { assertFreightShape } from './booking-freight.util';
@@ -40,6 +46,7 @@ const NEEDS_ACTION_STATUSES = [
@Injectable()
export class BookingsService {
constructor(
@InjectDataSource() private readonly dataSource: DataSource,
private readonly bookingsRepository: BookingsRepository,
private readonly filesService: FilesService,
private readonly minioService: MinioService,
@@ -50,6 +57,36 @@ export class BookingsService {
private readonly consolidationService: ConsolidationService,
) {}
/** Resolve trade direction from yard countries; reject client mismatch. */
private async resolveTradeDirectionForBooking(
originYardId: string,
destinationYardId: string,
provided?: string,
): Promise<string> {
const yards = await this.dataSource.getRepository(Yard).find({
where: { id: In([originYardId, destinationYardId]) },
});
const origin = yards.find((y) => y.id === originYardId);
const destination = yards.find((y) => y.id === destinationYardId);
if (!origin) {
throw new BadRequestException(`Origin yard ${originYardId} not found`);
}
if (!destination) {
throw new BadRequestException(`Destination yard ${destinationYardId} not found`);
}
if (originYardId === destinationYardId) {
throw new BadRequestException('Origin and destination yards must differ');
}
const expected = deriveTradeDirection(origin, destination);
if (provided && provided !== expected) {
throw new BadRequestException(
`tradeDirection must be ${expected} for the selected yard pair (got ${provided})`,
);
}
return expected;
}
/** Generate a unique booking reference number. */
private async generateReference(): Promise<string> {
const year = new Date().getFullYear();
@@ -83,9 +120,13 @@ export class BookingsService {
vgmPerUnitTons: c.vgmPerUnitTons,
totalVgmTons,
isReefer: ct.isReefer,
wagonsRequired: c.quantity * (Number(ct.wagonsPerUnit) || 1),
};
}),
);
const totalWagons = Math.ceil(
containers.reduce((sum, c) => sum + c.wagonsRequired, 0),
);
return {
freightType: dto.freightType,
@@ -98,6 +139,7 @@ export class BookingsService {
allowConsolidation:
dto.freightType === 'CONTAINER' ? dto.allowConsolidation : false,
shippingLineId: dto.shippingLineId,
totalWagons,
containers,
};
}
@@ -162,6 +204,48 @@ export class BookingsService {
return { booking: pending, messages };
}
/**
* Consolidation gate used at staff-accept time. Returns the (possibly newly
* paired) booking plus whether it still needs a consolidation partner.
* When a booking needs consolidation and none is found, it is parked in
* PENDING_CONSOLIDATION and `blocked` is true so the caller refuses the accept.
*/
async resolveConsolidationGate(bookingId: string): Promise<{
booking: Booking;
blocked: boolean;
message?: string;
}> {
let booking = await this.findById(bookingId);
// Already paired — passes the gate.
if (booking.consolidationPartnerId) {
return { booking, blocked: false };
}
const needs =
await this.consolidationService.needsConsolidationFromBooking(booking);
if (!needs) {
return { booking, blocked: false };
}
// A partner may have appeared since submission — try to pair now.
const result = await this.tryAutoConsolidate(booking);
booking = result.booking;
if (booking.consolidationPartnerId) {
return { booking, blocked: false, message: result.messages.join(' ') };
}
// Still no partner — park it and block the accept.
await this.bookingsRepository.parkForConsolidation(booking.id);
booking = await this.findById(booking.id);
const slots = await this.consolidationService.slotsFromBooking(booking);
return {
booking,
blocked: true,
message: this.consolidationService.describePending(booking, slots),
};
}
/** Create a new freight booking. */
async create(
dto: CreateBookingDto,
@@ -199,6 +283,25 @@ export class BookingsService {
companyId = company.id;
}
// Schedule targeting: when provided, the schedule must be OPEN and on the same route.
if (dto.trainScheduleId) {
const schedule = await this.dataSource
.getRepository(TrainSchedule)
.findOne({ where: { id: dto.trainScheduleId } });
if (!schedule) {
throw new BadRequestException(`Train schedule ${dto.trainScheduleId} not found`);
}
if (schedule.bookingWindowStatus !== 'OPEN') {
throw new BadRequestException('Selected schedule is no longer accepting bookings');
}
if (
schedule.originStationId !== dto.originYardId ||
schedule.destinationStationId !== dto.destinationYardId
) {
throw new BadRequestException('Selected schedule is not on the booking route');
}
}
const reference = dto.reference || (await this.generateReference());
const containers = dto.containers ?? [];
assertFreightShape({
@@ -207,6 +310,12 @@ export class BookingsService {
containers,
});
const tradeDirection = await this.resolveTradeDirectionForBooking(
dto.originYardId,
dto.destinationYardId,
dto.tradeDirection,
);
const allowConsolidation =
dto.freightType === 'CONTAINER'
? await this.resolveConsolidation(containers, dto.allowConsolidation)
@@ -217,7 +326,7 @@ export class BookingsService {
cargoTypeId: dto.freightType === 'BULK' ? dto.cargoTypeId : null,
serviceTypeId: dto.serviceTypeId,
paymentCurrency: dto.paymentCurrency,
tradeDirection: dto.tradeDirection,
tradeDirection,
isHazardous: dto.isHazardous,
isGovernment,
allowConsolidation,
@@ -235,6 +344,7 @@ export class BookingsService {
isGovernment,
governmentInstitution: isGovernment ? dto.governmentInstitution!.trim() : null,
trainId: dto.trainId,
trainScheduleId: dto.trainScheduleId ?? null,
contractType: dto.contractType,
previousContractId: dto.previousContractId,
serviceTypeId: dto.serviceTypeId,
@@ -243,7 +353,7 @@ export class BookingsService {
equipmentReturn: dto.equipmentReturn,
originYardId: dto.originYardId,
destinationYardId: dto.destinationYardId,
tradeDirection: dto.tradeDirection,
tradeDirection,
freightType: dto.freightType,
cargoTypeId: dto.freightType === 'BULK' ? dto.cargoTypeId! : null,
cargoFreeText: dto.cargoFreeText,
@@ -338,6 +448,14 @@ export class BookingsService {
assertFreightShape({ freightType, cargoTypeId, containers });
const originYardId = dto.originYardId ?? existing.originYardId;
const destinationYardId = dto.destinationYardId ?? existing.destinationYardId;
const tradeDirection = await this.resolveTradeDirectionForBooking(
originYardId,
destinationYardId,
dto.tradeDirection,
);
const allowConsolidation =
freightType === 'CONTAINER'
? await this.resolveConsolidation(
@@ -351,7 +469,7 @@ export class BookingsService {
cargoTypeId,
serviceTypeId: dto.serviceTypeId ?? existing.serviceTypeId,
paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
tradeDirection: dto.tradeDirection ?? existing.tradeDirection,
tradeDirection,
isHazardous: dto.isHazardous ?? existing.isHazardous,
allowConsolidation,
shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined,
@@ -377,6 +495,7 @@ export class BookingsService {
cargoTypeId: freightType === 'BULK' ? cargoTypeId : null,
allowConsolidation,
priorityScore: ruleResult.priorityScore,
tradeDirection,
};
if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate);
if (dto.startDate) updates.startDate = new Date(dto.startDate);
@@ -475,6 +594,7 @@ export class BookingsService {
freightType: filter.freightType,
tradeDirection: filter.tradeDirection,
paymentCurrency: filter.paymentCurrency,
paymentStatus: filter.paymentStatus,
allowConsolidation: filter.allowConsolidation,
consolidationPaired: filter.consolidationPaired,
sortBy: filter.sortBy,
@@ -482,6 +602,35 @@ export class BookingsService {
});
}
/** Booking statuses at which a customer can pay (mirrors booking-payment.service). */
private static readonly PAYABLE_STATUSES = [
'FULLY_EXECUTED',
'SELECTED_FOR_BATCH',
'AWAITING_PAYMENT',
];
/**
* List the current customer's bookings that are ready for payment:
* payable status AND not yet PAID. Company scope is derived from the
* authenticated user and cannot be widened by the caller.
*/
async findMyPayable(
userId: string,
filter: FilterBookingDto,
): Promise<{ items: Booking[]; total: number }> {
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
return this.bookingsRepository.findAllPaginated({
page: filter.page ?? 1,
pageSize: filter.pageSize ?? 20,
statuses: BookingsService.PAYABLE_STATUSES,
excludePaymentStatus: 'PAID',
companyId: company.id,
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});
}
/** Aggregate metrics and tab counts for the backoffice booking list. */
async getListSummary(filter: FilterBookingDto): Promise<BookingListSummaryDto> {
const page = filter.page ?? 1;
@@ -496,6 +645,7 @@ export class BookingsService {
freightType: filter.freightType,
tradeDirection: filter.tradeDirection,
paymentCurrency: filter.paymentCurrency,
paymentStatus: filter.paymentStatus,
allowConsolidation: filter.allowConsolidation,
consolidationPaired: filter.consolidationPaired,
};

View File

@@ -14,6 +14,14 @@ export class ContractSignatureDto {
signatureImageUrl?: string | null;
}
export class SavedSignatureViewDto {
@ApiProperty()
signerDisplayName!: string;
@ApiPropertyOptional()
signatureImageUrl?: string | null;
}
export class ContractViewDto {
@ApiProperty()
bookingId!: string;
@@ -45,6 +53,9 @@ export class ContractViewDto {
@ApiProperty({ type: [ContractSignatureDto] })
signatures!: ContractSignatureDto[];
@ApiPropertyOptional({ type: SavedSignatureViewDto })
savedSignature?: SavedSignatureViewDto;
@ApiPropertyOptional()
pricingSchedule?: Record<string, unknown>;
}

View File

@@ -91,6 +91,12 @@ export class CreateBookingDto {
@IsUUID()
trainId?: string;
/** Target schedule this booking is created against (required by the backoffice create form). */
@ApiPropertyOptional({ format: 'uuid', description: 'Target train schedule (pool membership)' })
@IsOptional()
@IsUUID()
trainScheduleId?: string;
@ApiProperty({ example: '2026-06-15T00:00:00.000Z' })
@IsDateString()
scheduledDate!: string;

View File

@@ -7,6 +7,7 @@ import {
PAYMENT_CURRENCIES,
TRADE_DIRECTIONS,
} from './create-booking.dto';
import { PAYMENT_STATUSES } from '../entities/booking.entity';
export class FilterBookingDto {
@ApiPropertyOptional({ enum: BOOKING_STATUSES })
@@ -65,6 +66,11 @@ export class FilterBookingDto {
@IsIn([...PAYMENT_CURRENCIES])
paymentCurrency?: string;
@ApiPropertyOptional({ enum: PAYMENT_STATUSES })
@IsOptional()
@IsIn([...PAYMENT_STATUSES])
paymentStatus?: string;
@ApiPropertyOptional()
@IsOptional()
@Transform(({ value }) => value === 'true' || value === true)

View File

@@ -29,6 +29,8 @@ export const BOOKING_STATUSES = [
'CONTRACT_READY',
'SIGNED_CUSTOMER',
'FULLY_EXECUTED',
'SELECTED_FOR_BATCH',
'EXPIRED',
'PNR_GENERATED',
'PAYMENT_VERIFICATION_IN_PROGRESS',
'PAID',
@@ -273,10 +275,23 @@ export class Booking extends BaseEntity {
@Column({ name: 'hold_expires_at', type: 'timestamptz', nullable: true })
holdExpiresAt?: Date | null;
@Column({ name: 'scheduled_at', type: 'timestamptz', nullable: true })
scheduledAt?: Date | null;
/** The schedule this booking targets (pool membership), set at creation. FK to train_schedules. */
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
trainScheduleId?: string | null;
/** End of the pay window once the booking is SELECTED_FOR_BATCH. */
@Column({ name: 'payment_deadline', type: 'timestamptz', nullable: true })
paymentDeadline?: Date | null;
/** When the batch engine picked this booking and opened the pay window. */
@Column({ name: 'selected_for_batch_at', type: 'timestamptz', nullable: true })
selectedForBatchAt?: Date | null;
@OneToMany(() => BookingContainer, (bc) => bc.booking)
bookingContainers?: BookingContainer[];

View File

@@ -10,6 +10,7 @@ import {
Query,
} from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { FleetManage, FleetView } from '../../common/booking-guards';
import { CreateCargoDto } from './dto/create-cargo.dto';
import { UpdateCargoDto } from './dto/update-cargo.dto';
import { LoadCargoDto } from './dto/load-cargo.dto';
@@ -18,10 +19,12 @@ import { CargoesService } from './cargoes.service';
@ApiTags('cargoes')
@Controller('cargoes')
@FleetView()
export class CargoesController {
constructor(private readonly cargoesService: CargoesService) {}
@Post()
@FleetManage()
@ApiOperation({ summary: 'Create a new cargo' })
create(@Body() dto: CreateCargoDto) {
return this.cargoesService.create(dto);
@@ -40,30 +43,35 @@ export class CargoesController {
}
@Patch(':id')
@FleetManage()
@ApiOperation({ summary: 'Update a cargo' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateCargoDto) {
return this.cargoesService.update(id, dto);
}
@Delete(':id')
@FleetManage()
@ApiOperation({ summary: 'Delete a cargo' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.cargoesService.remove(id);
}
@Post(':id/load')
@FleetManage()
@ApiOperation({ summary: 'Load cargo into a container' })
load(@Param('id', ParseUUIDPipe) id: string, @Body() dto: LoadCargoDto) {
return this.cargoesService.loadCargo(id, dto);
}
@Post(':id/unload')
@FleetManage()
@ApiOperation({ summary: 'Unload cargo from container' })
unload(@Param('id', ParseUUIDPipe) id: string) {
return this.cargoesService.unloadCargo(id);
}
@Post(':id/deliver')
@FleetManage()
@ApiOperation({ summary: 'Mark cargo as delivered' })
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto?: DeliverCargoDto) {
return this.cargoesService.deliverCargo(id, dto);

View File

@@ -157,7 +157,9 @@ export class CargoesService {
}
cargo.status = 'DELIVERED';
if (dto?.deliveryRemarks) cargo.description = dto.deliveryRemarks;
cargo.deliveredAt = dto?.pickupDate ? new Date(dto.pickupDate) : new Date();
if (dto?.receiverName) cargo.receiverName = dto.receiverName;
if (dto?.deliveryRemarks) cargo.deliveryRemarks = dto.deliveryRemarks;
const remaining =
cargo.containerId != null

View File

@@ -1,6 +1,16 @@
import { IsOptional, IsString } from 'class-validator';
import { IsDateString, IsOptional, IsString } from 'class-validator';
export class DeliverCargoDto {
/** Name of the person who received / picked up the cargo (Proof of Delivery). */
@IsOptional()
@IsString()
receiverName?: string;
/** When the cargo was picked up / delivered. Defaults to now. */
@IsOptional()
@IsDateString()
pickupDate?: string;
@IsOptional()
@IsString()
deliveryRemarks?: string;

View File

@@ -40,6 +40,16 @@ export class Cargo extends BaseEntity {
@Column({ name: 'unloaded_at', type: 'timestamp', nullable: true })
unloadedAt!: Date | null;
// Proof of Delivery (customer pickup) capture.
@Column({ name: 'receiver_name', type: 'varchar', nullable: true })
receiverName!: string | null;
@Column({ name: 'delivered_at', type: 'timestamp', nullable: true })
deliveredAt!: Date | null;
@Column({ name: 'delivery_remarks', type: 'text', nullable: true })
deliveryRemarks!: string | null;
@Column({ name: 'wagon_booking_allocation_id', type: 'uuid', nullable: true })
wagonBookingAllocationId!: string | null;
@@ -57,6 +67,7 @@ export class Cargo extends BaseEntity {
@Column({ name: 'load_type', type: 'varchar', length: 20, nullable: true })
loadType!: string | null;
// Relationship to Container
@ManyToOne(() => Container, (container) => container.cargoes, { onDelete: 'RESTRICT', nullable: true })
@JoinColumn({ name: 'container_id' })
container!: Container | null;

View File

@@ -0,0 +1,106 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
const DEFAULT_SCRAPE_URL = 'https://ethio.forex/bank/CBET';
/** Matches USD buying/selling embedded in ethio.forex CBET page HTML (after entity unescape). */
const USD_RATE_REGEX =
/currency_code":\[0,"USD"\],"currency_name":\[0,"US DOLLAR"\],"buying":\[0,([\d.]+)\],"selling":\[0,([\d.]+)\]/;
@Injectable()
export class CbeExchangeService {
private readonly logger = new Logger(CbeExchangeService.name);
private cachedRate: number | null = null;
private cacheExpiresAt = 0;
constructor(private readonly configService: ConfigService) {}
/**
* Returns the current CBE USD→ETB **selling** rate scraped from ethio.forex.
* Cached for CBE_EXCHANGE_CACHE_TTL_MS; falls back to CBE_EXCHANGE_FALLBACK_RATE on failure.
*/
async getUsdToEtbRate(): Promise<number> {
const now = Date.now();
if (this.cachedRate !== null && now < this.cacheExpiresAt) {
return this.cachedRate;
}
const scrapeUrl = this.getScrapeUrl();
const fallbackRate =
this.configService.get<number>('app.cbeExchange.fallbackRate') ?? 130;
const cacheTtlMs =
this.configService.get<number>('app.cbeExchange.cacheTtlMs') ?? 3_600_000;
try {
const response = await fetch(scrapeUrl, {
signal: AbortSignal.timeout(8_000),
headers: { 'User-Agent': 'Mozilla/5.0' },
});
if (!response.ok) {
throw new Error(`CBE scrape responded with status ${response.status}`);
}
const html = await response.text();
const rates = this.parseScrapedRates(html);
if (!rates) {
throw new Error('USD rate not found in ethio.forex page HTML');
}
const rate = rates.selling;
if (!Number.isFinite(rate) || rate <= 0) {
throw new Error(`Invalid selling rate parsed: ${rate}`);
}
this.cachedRate = rate;
this.cacheExpiresAt = now + cacheTtlMs;
this.logger.log(
`CBE USD→ETB rate refreshed from ethio.forex — buying=${rates.buying} selling=${rate}`,
);
return rate;
} catch (err) {
this.logger.error(
`Failed to scrape CBE exchange rate — using fallback ${fallbackRate} ETB/USD. Error: ${(err as Error).message}`,
);
if (this.cachedRate !== null) {
this.logger.warn(`Using previously cached CBE rate: ${this.cachedRate}`);
return this.cachedRate;
}
return fallbackRate;
}
}
private getScrapeUrl(): string {
const configured =
this.configService.get<string>('app.cbeExchange.scrapeUrl') ??
this.configService.get<string>('app.cbeExchange.apiUrl');
return configured?.trim() || DEFAULT_SCRAPE_URL;
}
private parseScrapedRates(
html: string,
): { buying: number; selling: number } | null {
const decoded = this.unescapeHtml(html);
const match = USD_RATE_REGEX.exec(decoded);
if (!match) return null;
const buying = Number(match[1]);
const selling = Number(match[2]);
if (!Number.isFinite(buying) || !Number.isFinite(selling)) return null;
return { buying, selling };
}
private unescapeHtml(html: string): string {
return html
.replace(/&quot;/g, '"')
.replace(/&#34;/g, '"')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>');
}
}

View File

@@ -2,6 +2,7 @@ import { Controller, Get, Post, Patch, Delete, Body, Param, Query, ParseUUIDPipe
import { AnyFilesInterceptor } from '@nestjs/platform-express';
import { ApiOperation, ApiTags, ApiConsumes } from '@nestjs/swagger';
import { CurrentUser } from '@edr/api-common';
import { FreightAdmin } from '../../common/booking-guards';
import { FilesService } from '../files/files.service';
import { CompaniesService } from './companies.service';
import { CreateCompanyDto } from './dto/create-company.dto';
@@ -15,6 +16,7 @@ import { ResponseFFClientDto } from './dto/response-ff-client.dto';
import { CompanyInfoResponseDto } from './dto/company-info-response.dto';
import { UpdateProfileDto } from './dto/update-profile.dto';
import { ProfileResponseDto } from './dto/profile-response.dto';
import { DashboardSummaryResponseDto } from './dto/dashboard-summary-response.dto';
interface CurrentIamUser {
id: string;
@@ -45,6 +47,12 @@ export class CompaniesController {
return new ProfileResponseDto(profile, company);
}
@Get('dashboard')
@ApiOperation({ summary: 'Get portal dashboard KPIs (delivered, spend, freight volume) for the current user' })
async getDashboard(@CurrentUser() user: CurrentIamUser): Promise<DashboardSummaryResponseDto> {
return this.companiesService.getDashboardSummary(user.id);
}
@Patch('profile')
@ApiOperation({ summary: 'Update profile (flattened settings page)' })
async updateProfile(
@@ -75,6 +83,7 @@ export class CompaniesController {
}
@Post()
@FreightAdmin()
@ApiOperation({ summary: 'Create a new company (customer, forwarder, transporter, broker)' })
async create(@Body() dto: CreateCompanyDto): Promise<ResponseCompanyDto> {
const company = await this.companiesService.createCompany(dto);
@@ -112,6 +121,7 @@ export class CompaniesController {
}
@Patch(':id')
@FreightAdmin()
@ApiOperation({ summary: 'Update a company' })
async update(
@Param('id', ParseUUIDPipe) id: string,
@@ -122,6 +132,7 @@ export class CompaniesController {
}
@Delete(':id')
@FreightAdmin()
@ApiOperation({ summary: 'Soft-delete a company' })
@HttpCode(HttpStatus.NO_CONTENT)
async remove(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
@@ -140,6 +151,7 @@ export class CompaniesController {
}
@Post(':companyId/profiles')
@FreightAdmin()
@ApiOperation({ summary: 'Add a profile (employee) to a company' })
async createProfile(
@Param('companyId', ParseUUIDPipe) companyId: string,
@@ -168,6 +180,7 @@ export class CompaniesController {
}
@Post('ff-clients')
@FreightAdmin()
@ApiOperation({ summary: 'Link a forwarder to a client company' })
async createFFClient(@Body() dto: CreateFFClientDto): Promise<ResponseFFClientDto> {
const client = await this.companiesService.createFFClient(dto);
@@ -184,6 +197,7 @@ export class CompaniesController {
}
@Delete('ff-clients/:id')
@FreightAdmin()
@ApiOperation({ summary: 'Remove a forwarder-client relationship' })
@HttpCode(HttpStatus.NO_CONTENT)
async removeFFClient(@Param('id', ParseUUIDPipe) id: string): Promise<void> {

View File

@@ -6,14 +6,16 @@ import { CompaniesService } from './companies.service';
import { CompaniesRepository } from './companies.repository';
import { ExternalProfileRepository } from './external-profile.repository';
import { FFClientRepository } from './ff-client.repository';
import { CompanyDashboardRepository } from './company-dashboard.repository';
import { Company } from './entities/company.entity';
import { ExternalProfile } from './entities/external-profile.entity';
import { FFClient } from './entities/ff-client.entity';
import { Booking } from '../bookings/entities/booking.entity';
@Module({
imports: [TypeOrmModule.forFeature([Company, ExternalProfile, FFClient]), FilesModule],
imports: [TypeOrmModule.forFeature([Company, ExternalProfile, FFClient, Booking]), FilesModule],
controllers: [CompaniesController],
providers: [CompaniesService, CompaniesRepository, ExternalProfileRepository, FFClientRepository],
providers: [CompaniesService, CompaniesRepository, ExternalProfileRepository, FFClientRepository, CompanyDashboardRepository],
exports: [CompaniesService],
})
export class CompaniesModule {}

View File

@@ -2,6 +2,7 @@ import { Injectable, NotFoundException, ConflictException } from '@nestjs/common
import { CompaniesRepository } from './companies.repository';
import { ExternalProfileRepository } from './external-profile.repository';
import { FFClientRepository } from './ff-client.repository';
import { CompanyDashboardRepository } from './company-dashboard.repository';
import { CreateCompanyDto } from './dto/create-company.dto';
import { UpdateCompanyDto } from './dto/update-company.dto';
import { CreateExternalProfileDto } from './dto/create-external-profile.dto';
@@ -9,6 +10,7 @@ import { CreateFFClientDto } from './dto/create-ff-client.dto';
import { CreateCompanyWithProfileDto } from './dto/create-company-with-profile.dto';
import { UpdateProfileDto } from './dto/update-profile.dto';
import { ProfileResponseDto } from './dto/profile-response.dto';
import { DashboardSummaryResponseDto } from './dto/dashboard-summary-response.dto';
import { Company } from './entities/company.entity';
import { ExternalProfile } from './entities/external-profile.entity';
import { FFClient } from './entities/ff-client.entity';
@@ -27,6 +29,7 @@ export class CompaniesService {
private readonly companiesRepo: CompaniesRepository,
private readonly profilesRepo: ExternalProfileRepository,
private readonly ffClientsRepo: FFClientRepository,
private readonly dashboardRepo: CompanyDashboardRepository,
) {}
async createCompany(dto: CreateCompanyDto): Promise<Company> {
@@ -98,6 +101,122 @@ export class CompaniesService {
return { profile, company };
}
/**
* Dashboard KPIs for the portal home (MyPortalPage), aggregated from the
* current user's company bookings. All figures are scoped to that company.
*
* Note: delivered/spend/volume all derive from the bookings table — there is
* no separate data source for them. On-time delivery rate is replaced by
* completion rate (delivered ÷ committed): the schema has no ETA /
* promised-delivery date, so on-time cannot be computed.
*
* Period attribution uses booking.created_at: there is no delivery-date
* column, so "delivered YTD" counts bookings created this year that reached a
* delivered/completed status.
*/
async getDashboardSummary(userId: string): Promise<DashboardSummaryResponseDto> {
// A user without a company profile has no bookings — return an empty summary
// rather than 404, so the portal home still renders.
const profile = await this.profilesRepo.findByUserId(userId);
const companyId = profile?.company?.id ?? profile?.companyId ?? null;
if (!companyId) return this.emptyDashboardSummary();
const now = new Date();
const yearStart = new Date(now.getFullYear(), 0, 1);
const prevYearStart = new Date(now.getFullYear() - 1, 0, 1);
// Same point in the previous year, so YoY compares like-for-like windows.
const prevYearToDate = new Date(prevYearStart.getTime() + (now.getTime() - yearStart.getTime()));
const [
deliveredThis,
committedThis,
spendThisByCcy,
spendPrevByCcy,
tonnageThis,
tonnagePrev,
monthlyRows,
] = await Promise.all([
this.dashboardRepo.countDelivered(companyId, yearStart, now),
this.dashboardRepo.countCommitted(companyId, yearStart, now),
this.dashboardRepo.sumPaidSpendByCurrency(companyId, yearStart, now),
this.dashboardRepo.sumPaidSpendByCurrency(companyId, prevYearStart, prevYearToDate),
this.dashboardRepo.sumCommittedTonnage(companyId, yearStart, now),
this.dashboardRepo.sumCommittedTonnage(companyId, prevYearStart, prevYearToDate),
this.dashboardRepo.monthlyCommittedTonnage(companyId, this.monthsAgo(now, 5), now),
]);
// Spend can span currencies; report the dominant one (prefer ETB on ties).
const spend = this.pickCurrencyTotal(spendThisByCcy);
const spendPrev = spendPrevByCcy.find((c) => c.currency === spend.currency)?.total ?? 0;
return {
deliveredCount: deliveredThis,
// Share of committed bookings that reached delivered/completed.
completionRate: committedThis > 0 ? Math.round((deliveredThis / committedThis) * 100) : 0,
spendYtd: spend.total,
spendCurrency: spend.currency,
spendYtdChangePct: this.changePct(spend.total, spendPrev),
freightVolume: {
totalTonnes: Math.round(tonnageThis),
totalValue: spend.total,
currency: spend.currency,
ytdChangePct: this.changePct(tonnageThis, tonnagePrev),
monthly: this.buildMonthlySeries(now, monthlyRows),
},
};
}
private emptyDashboardSummary(): DashboardSummaryResponseDto {
const now = new Date();
return {
deliveredCount: 0,
completionRate: 0,
spendYtd: 0,
spendCurrency: 'ETB',
spendYtdChangePct: 0,
freightVolume: {
totalTonnes: 0,
totalValue: 0,
currency: 'ETB',
ytdChangePct: 0,
monthly: this.buildMonthlySeries(now, []),
},
};
}
/** First day of the month `n` months before `from`. */
private monthsAgo(from: Date, n: number): Date {
return new Date(from.getFullYear(), from.getMonth() - n, 1);
}
/** Pick the currency with the largest total, preferring ETB on ties / when empty. */
private pickCurrencyTotal(totals: { currency: string; total: number }[]): { currency: string; total: number } {
if (totals.length === 0) return { currency: 'ETB', total: 0 };
return totals.reduce((best, cur) => (cur.total > best.total ? cur : best));
}
/** Percentage change vs a prior value, rounded; 0 when there is no prior base. */
private changePct(current: number, previous: number): number {
if (previous <= 0) return 0;
return Math.round(((current - previous) / previous) * 100);
}
/** Build a fixed 6-month tonnage series ending on `now`, zero-filling gaps. */
private buildMonthlySeries(
now: Date,
rows: { year: number; month: number; tonnes: number }[],
): { month: string; tonnes: number }[] {
const labels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const byKey = new Map(rows.map((r) => [`${r.year}-${r.month}`, r.tonnes]));
const series: { month: string; tonnes: number }[] = [];
for (let i = 5; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
const key = `${d.getFullYear()}-${d.getMonth() + 1}`;
series.push({ month: labels[d.getMonth()], tonnes: Math.round(byKey.get(key) ?? 0) });
}
return series;
}
async updateCompany(id: string, dto: UpdateCompanyDto): Promise<Company> {
await this.findCompanyById(id);
const updated = await this.companiesRepo.update(id, dto);

View File

@@ -0,0 +1,126 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Booking } from '../bookings/entities/booking.entity';
/** Booking statuses that represent a delivered/finished shipment. */
const DELIVERED_STATUSES = ['DELIVERED', 'COMPLETED'] as const;
/**
* Statuses that represent real, committed freight (excludes drafts and dead
* bookings) — used for tonnage so cancelled/expired drafts don't inflate volume.
*/
const COMMITTED_STATUSES = [
'APPROVED',
'CONTRACT_READY',
'SIGNED_CUSTOMER',
'FULLY_EXECUTED',
'SELECTED_FOR_BATCH',
'PNR_GENERATED',
'PAYMENT_VERIFICATION_IN_PROGRESS',
'PAID',
'IN_TRANSIT',
'COMPLETED',
'DELIVERED',
'CONSOLIDATED',
] as const;
export interface CurrencyTotal {
currency: string;
total: number;
}
export interface MonthlyTonnage {
year: number;
month: number; // 1-12
tonnes: number;
}
/**
* Read-only aggregation queries against the bookings table, scoped to a
* company, that back the portal dashboard. Lives in the companies module so it
* can be exposed via `companies.controller` without a circular dependency on
* BookingsModule (which already imports CompaniesModule).
*/
@Injectable()
export class CompanyDashboardRepository {
constructor(
@InjectRepository(Booking)
private readonly bookings: Repository<Booking>,
) {}
/** Count of delivered/completed bookings for a company within [from, to). */
async countDelivered(companyId: string, from: Date, to: Date): Promise<number> {
return this.bookings
.createQueryBuilder('b')
.where('b.company_id = :companyId', { companyId })
.andWhere('b.deleted_at IS NULL')
.andWhere('b.status IN (:...statuses)', { statuses: [...DELIVERED_STATUSES] })
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
.getCount();
}
/** Count of committed (non-draft, non-dead) bookings for a company within [from, to). */
async countCommitted(companyId: string, from: Date, to: Date): Promise<number> {
return this.bookings
.createQueryBuilder('b')
.where('b.company_id = :companyId', { companyId })
.andWhere('b.deleted_at IS NULL')
.andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] })
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
.getCount();
}
/** Sum of paid booking totals, grouped by currency, within [from, to). */
async sumPaidSpendByCurrency(companyId: string, from: Date, to: Date): Promise<CurrencyTotal[]> {
const rows = await this.bookings
.createQueryBuilder('b')
.select('b.payment_currency', 'currency')
.addSelect('COALESCE(SUM(b.total_amount), 0)', 'total')
.where('b.company_id = :companyId', { companyId })
.andWhere('b.deleted_at IS NULL')
.andWhere("b.payment_status = 'PAID'")
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
.groupBy('b.payment_currency')
.getRawMany<{ currency: string; total: string }>();
return rows.map((r) => ({ currency: r.currency ?? 'ETB', total: Number(r.total) }));
}
/** Total committed tonnage (cargo VGM) for a company within [from, to). */
async sumCommittedTonnage(companyId: string, from: Date, to: Date): Promise<number> {
const row = await this.bookings
.createQueryBuilder('b')
.select('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'total')
.where('b.company_id = :companyId', { companyId })
.andWhere('b.deleted_at IS NULL')
.andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] })
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
.getRawOne<{ total: string }>();
return Number(row?.total ?? 0);
}
/** Committed tonnage grouped by calendar month within [from, to). */
async monthlyCommittedTonnage(companyId: string, from: Date, to: Date): Promise<MonthlyTonnage[]> {
const rows = await this.bookings
.createQueryBuilder('b')
.select('EXTRACT(YEAR FROM b.created_at)', 'year')
.addSelect('EXTRACT(MONTH FROM b.created_at)', 'month')
.addSelect('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'total')
.where('b.company_id = :companyId', { companyId })
.andWhere('b.deleted_at IS NULL')
.andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] })
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
.groupBy('year')
.addGroupBy('month')
.getRawMany<{ year: string; month: string; total: string }>();
return rows.map((r) => ({
year: Number(r.year),
month: Number(r.month),
tonnes: Number(r.total),
}));
}
}

View File

@@ -0,0 +1,59 @@
import { ApiProperty } from '@nestjs/swagger';
export class FreightVolumePointDto {
@ApiProperty({ example: 'May', description: 'Short month label' })
month!: string;
@ApiProperty({ example: 940, description: 'Tonnage shipped in the month' })
tonnes!: number;
}
export class FreightVolumeDto {
@ApiProperty({ example: 4180, description: 'Total tonnage shipped year-to-date' })
totalTonnes!: number;
@ApiProperty({ example: 1240000, description: 'Total committed freight value year-to-date' })
totalValue!: number;
@ApiProperty({ example: 'ETB' })
currency!: string;
@ApiProperty({ example: 16, description: 'Tonnage change vs same period last year, in percent' })
ytdChangePct!: number;
@ApiProperty({ type: [FreightVolumePointDto], description: 'Monthly tonnage series (oldest first, last 6 months)' })
monthly!: FreightVolumePointDto[];
}
/**
* KPIs for the portal dashboard (MyPortalPage), aggregated from the current
* user's company bookings. All figures are scoped to that company.
*
* Note: every metric here derives from the bookings table — there is no
* separate "non-booking" data source for delivered/spend/volume. On-time
* delivery rate is replaced by completion rate: no ETA / promised-delivery
* column exists in the schema, so on-time cannot be computed, whereas
* completion rate (delivered ÷ committed) can.
*/
export class DashboardSummaryResponseDto {
@ApiProperty({ example: 12, description: 'Bookings delivered/completed year-to-date' })
deliveredCount!: number;
@ApiProperty({
example: 92,
description: 'Share of committed bookings that have been delivered/completed (YTD), in percent',
})
completionRate!: number;
@ApiProperty({ example: 1240000, description: 'Total paid spend year-to-date' })
spendYtd!: number;
@ApiProperty({ example: 'ETB' })
spendCurrency!: string;
@ApiProperty({ example: 16, description: 'Spend change vs same period last year, in percent' })
spendYtdChangePct!: number;
@ApiProperty({ type: FreightVolumeDto })
freightVolume!: FreightVolumeDto;
}

View File

@@ -9,16 +9,19 @@ import {
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { FleetManage, FleetView } from "../../common/booking-guards";
import { ConsignmentsService } from "./consignments.service";
import { CreateConsignmentDto } from "./dto/create-consignment.dto";
import { FilterConsignmentDto } from "./dto/filter-consignment.dto";
@ApiTags("consignments")
@Controller("consignments")
@FleetView()
export class ConsignmentsController {
constructor(private readonly consignmentsService: ConsignmentsService) {}
@Post()
@FleetManage()
@ApiOperation({ summary: "Create a new consignment" })
create(@Body() dto: CreateConsignmentDto) {
return this.consignmentsService.create(dto);

View File

@@ -10,6 +10,7 @@ import {
Query,
} from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { FleetManage, FleetView } from '../../common/booking-guards';
import { CreateContainerDto } from './dto/create-container.dto';
import { UpdateContainerDto } from './dto/update-container.dto';
import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto';
@@ -17,10 +18,12 @@ import { ContainersService } from './containers.service';
@ApiTags('containers')
@Controller('containers')
@FleetView()
export class ContainersController {
constructor(private readonly containersService: ContainersService) {}
@Post()
@FleetManage()
@ApiOperation({ summary: 'Create a new container' })
create(@Body() dto: CreateContainerDto) {
return this.containersService.create(dto);
@@ -39,24 +42,28 @@ export class ContainersController {
}
@Patch(':id')
@FleetManage()
@ApiOperation({ summary: 'Update a container' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContainerDto) {
return this.containersService.update(id, dto);
}
@Delete(':id')
@FleetManage()
@ApiOperation({ summary: 'Delete a container' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.containersService.remove(id);
}
@Post(':id/assign-wagon')
@FleetManage()
@ApiOperation({ summary: 'Assign container to a wagon' })
assignToWagon(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignContainerToWagonDto) {
return this.containersService.assignToWagon(id, dto);
}
@Post(':id/unassign-wagon')
@FleetManage()
@ApiOperation({ summary: 'Unassign container from wagon' })
unassignFromWagon(@Param('id', ParseUUIDPipe) id: string) {
return this.containersService.unassignFromWagon(id);

View File

@@ -16,12 +16,14 @@ import {
import { ApiOperation } from "@nestjs/swagger";
import { FreightAdmin } from "../../common/booking-guards";
import { CustomersService } from "./customers.service";
import { CreateCustomerDto } from "./dto/create-customer.dto";
import { UpdateCustomerDto } from "./dto/update-customer.dto";
import { Customer } from "./entities/customer.entity";
@Controller("customers")
@FreightAdmin()
export class CustomersController {
constructor(private readonly customersService: CustomersService) {}

View File

@@ -0,0 +1,74 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Param,
Body,
Query,
ParseUUIDPipe,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { FleetManage, FleetView } from '../../common/booking-guards';
import { DriversService } from './drivers.service';
import { CreateDriverDto } from './dto/create-driver.dto';
import { UpdateDriverDto } from './dto/update-driver.dto';
@ApiTags('drivers')
@ApiBearerAuth()
@Controller('drivers')
@FleetView()
export class DriversController {
constructor(private readonly driversService: DriversService) {}
@Post()
@FleetManage()
@ApiOperation({ summary: 'Create a new driver' })
create(@Body() createDriverDto: CreateDriverDto) {
return this.driversService.create(createDriverDto);
}
@Get()
@ApiOperation({ summary: 'Get all drivers with filters' })
findAll(
@Query('search') search?: string,
@Query('status') status?: string,
@Query('page') page?: string,
@Query('limit') limit?: string,
@Query('sortBy') sortBy?: string,
@Query('sortOrder') sortOrder?: 'ASC' | 'DESC',
) {
return this.driversService.findAll({
search,
status: status as any,
page: page ? parseInt(page) : undefined,
limit: limit ? parseInt(limit) : undefined,
sortBy,
sortOrder,
});
}
@Get(':id')
@ApiOperation({ summary: 'Get driver by id' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.driversService.findById(id);
}
@Patch(':id')
@FleetManage()
@ApiOperation({ summary: 'Update a driver' })
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() updateDriverDto: UpdateDriverDto,
) {
return this.driversService.update(id, updateDriverDto);
}
@Delete(':id')
@FleetManage()
@ApiOperation({ summary: 'Delete a driver' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.driversService.remove(id);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Driver } from './entities/driver.entity';
import { DriversService } from './drivers.service';
import { DriversController } from './drivers.controller';
@Module({
imports: [TypeOrmModule.forFeature([Driver])],
providers: [DriversService],
controllers: [DriversController],
exports: [DriversService],
})
export class DriversModule {}

View File

@@ -0,0 +1,88 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BaseRepository } from '@edr/api-common';
import { Driver } from './entities/driver.entity';
@Injectable()
export class DriversRepository extends BaseRepository<Driver> {
constructor(
@InjectRepository(Driver)
repository: Repository<Driver>,
) {
super(repository);
}
async findByLicenseNumber(licenseNumber: string): Promise<Driver | null> {
return this.repository.findOne({ where: { licenseNumber } });
}
async findByEmail(email: string): Promise<Driver | null> {
return this.repository.findOne({ where: { email } });
}
async findByPhoneNumber(phoneNumber: string): Promise<Driver | null> {
return this.repository.findOne({ where: { phoneNumber } });
}
async findDriverById(id: string): Promise<Driver | null> {
return this.repository.findOne({ where: { id } });
}
async findAllWithFilters(query: {
page?: number;
pageSize?: number;
search?: string;
status?: string;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
}) {
const page = query.page || 1;
const pageSize = query.pageSize || 10;
const skip = (page - 1) * pageSize;
let queryBuilder = this.repository.createQueryBuilder('driver');
if (query.search) {
queryBuilder = queryBuilder.where(
'(driver.firstName ILIKE :search OR driver.lastName ILIKE :search OR driver.email ILIKE :search OR driver.phoneNumber ILIKE :search OR driver.licenseNumber ILIKE :search)',
{ search: `%${query.search}%` },
);
}
if (query.status) {
queryBuilder = queryBuilder.andWhere('driver.status = :status', {
status: query.status,
});
}
const sortBy = query.sortBy || 'createdAt';
const sortOrder = query.sortOrder || 'DESC';
queryBuilder = queryBuilder
.orderBy(`driver.${sortBy}`, sortOrder)
.skip(skip)
.take(pageSize);
const [data, total] = await queryBuilder.getManyAndCount();
return {
data,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
};
}
async createDriver(driverData: any): Promise<Driver> {
const driver = this.repository.create(driverData);
const result = await this.repository.save(driver);
return result?.[0] as Driver;
}
async updateDriver(driver: Driver): Promise<Driver> {
const result = await this.repository.save(driver);
return result as Driver;
}
}

View File

@@ -0,0 +1,119 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CreateDriverDto } from './dto/create-driver.dto';
import { UpdateDriverDto } from './dto/update-driver.dto';
import { Driver, DriverStatus } from './entities/driver.entity';
@Injectable()
export class DriversService {
constructor(
@InjectRepository(Driver)
private readonly driverRepo: Repository<Driver>,
) {}
async create(dto: CreateDriverDto): Promise<Driver> {
const existing = await this.driverRepo.findOne({
where: [
{ licenseNumber: dto.licenseNumber },
{ email: dto.email },
{ phoneNumber: dto.phoneNumber },
],
});
if (existing) {
if (existing.licenseNumber === dto.licenseNumber) {
throw new ConflictException(`Driver with license number ${dto.licenseNumber} already exists`);
}
if (existing.email === dto.email) {
throw new ConflictException(`Driver with email ${dto.email} already exists`);
}
if (existing.phoneNumber === dto.phoneNumber) {
throw new ConflictException(`Driver with phone number ${dto.phoneNumber} already exists`);
}
}
const driver = this.driverRepo.create(dto);
return this.driverRepo.save(driver);
}
async findAll(query: {
search?: string;
status?: DriverStatus | string;
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
} = {}): Promise<Driver[]> {
const qb = this.driverRepo.createQueryBuilder('d');
if (query.search) {
const searchTerm = `%${query.search}%`;
qb.where('d.firstName ILIKE :search', { search: searchTerm })
.orWhere('d.lastName ILIKE :search', { search: searchTerm })
.orWhere('d.email ILIKE :search', { search: searchTerm })
.orWhere('d.licenseNumber ILIKE :search', { search: searchTerm })
.orWhere('d.phoneNumber ILIKE :search', { search: searchTerm });
}
if (query.status) {
qb.andWhere('d.status = :status', { status: query.status });
}
const sortBy = query.sortBy && ['firstName', 'lastName', 'status', 'createdAt'].includes(query.sortBy)
? query.sortBy
: 'createdAt';
const sortOrder = (query.sortOrder ?? 'DESC').toUpperCase();
return qb
.orderBy(`d.${sortBy}`, sortOrder as 'ASC' | 'DESC')
.getMany();
}
async findById(id: string): Promise<Driver> {
const driver = await this.driverRepo.findOne({ where: { id } });
if (!driver) {
throw new NotFoundException(`Driver ${id} not found`);
}
return driver;
}
async update(id: string, dto: UpdateDriverDto): Promise<Driver> {
const driver = await this.findById(id);
if (dto.licenseNumber && dto.licenseNumber !== driver.licenseNumber) {
const existing = await this.driverRepo.findOne({
where: { licenseNumber: dto.licenseNumber },
});
if (existing) {
throw new ConflictException(`Driver with license number ${dto.licenseNumber} already exists`);
}
}
if (dto.email && dto.email !== driver.email) {
const existing = await this.driverRepo.findOne({
where: { email: dto.email },
});
if (existing) {
throw new ConflictException(`Driver with email ${dto.email} already exists`);
}
}
if (dto.phoneNumber && dto.phoneNumber !== driver.phoneNumber) {
const existing = await this.driverRepo.findOne({
where: { phoneNumber: dto.phoneNumber },
});
if (existing) {
throw new ConflictException(`Driver with phone number ${dto.phoneNumber} already exists`);
}
}
Object.assign(driver, dto);
return this.driverRepo.save(driver);
}
async remove(id: string): Promise<void> {
await this.findById(id);
await this.driverRepo.softDelete(id);
}
}

View File

@@ -0,0 +1,45 @@
import { IsString, IsEmail, IsDateString, IsEnum, IsOptional, IsArray } from 'class-validator';
import { DriverStatus } from '../entities/driver.entity';
export class CreateDriverDto {
@IsString()
licenseNumber!: string;
@IsString()
firstName!: string;
@IsString()
lastName!: string;
@IsEmail()
email!: string;
@IsString()
phoneNumber!: string;
@IsDateString()
dateOfBirth!: string;
@IsDateString()
licenseExpiryDate!: string;
@IsEnum(DriverStatus)
status!: DriverStatus;
@IsOptional()
@IsArray()
@IsString({ each: true })
vehicleTypesAuthorized?: string[];
@IsOptional()
@IsString()
address?: string;
@IsOptional()
@IsString()
emergencyContact?: string;
@IsOptional()
@IsString()
notes?: string;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateDriverDto } from './create-driver.dto';
export class UpdateDriverDto extends PartialType(CreateDriverDto) {}

View File

@@ -0,0 +1,54 @@
import { Entity, Column } from 'typeorm';
import { BaseEntity } from '@edr/api-common';
export enum DriverStatus {
ACTIVE = 'ACTIVE',
INACTIVE = 'INACTIVE',
SUSPENDED = 'SUSPENDED',
ON_LEAVE = 'ON_LEAVE',
}
@Entity({ name: 'drivers', schema: 'freight' })
export class Driver extends BaseEntity {
@Column({ name: 'license_number', unique: true, nullable: true })
licenseNumber?: string;
@Column({ name: 'first_name', nullable: true })
firstName?: string;
@Column({ name: 'last_name', nullable: true })
lastName?: string;
@Column({ unique: true, nullable: true })
email?: string;
@Column({ name: 'phone_number', unique: true, nullable: true })
phoneNumber?: string;
@Column({ name: 'date_of_birth', type: 'date', nullable: true })
dateOfBirth?: Date;
@Column({ name: 'license_expiry_date', type: 'date', nullable: true })
licenseExpiryDate?: Date;
@Column({ type: 'varchar', default: DriverStatus.ACTIVE, nullable: true })
status?: DriverStatus;
@Column({ name: 'vehicle_types_authorized', type: 'varchar', array: true, nullable: true })
vehicleTypesAuthorized?: string[];
@Column({ type: 'text', nullable: true })
address?: string | null;
@Column({ name: 'emergency_contact', type: 'varchar', nullable: true })
emergencyContact?: string | null;
@Column({ type: 'text', nullable: true })
notes?: string | null;
@Column({ name: 'total_trips', type: 'int', default: 0, nullable: true })
totalTrips?: number;
@Column({ type: 'numeric', precision: 3, scale: 2, nullable: true })
rating?: number | null;
}

View File

@@ -13,6 +13,7 @@ import {
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { FreightAdmin } from "../../common/booking-guards";
import { CreateDropdownOptionDto } from "./dto/create-dropdown-option.dto";
import { CreateDropdownSettingDto } from "./dto/create-dropdown-setting.dto";
import { UpdateDropdownOptionDto } from "./dto/update-dropdown-option.dto";
@@ -24,6 +25,9 @@ import { DropdownSettingsService } from "./dropdown-settings.service";
export class DropdownSettingsController {
constructor(private readonly service: DropdownSettingsService) {}
// Reads stay open: the customer portal fetches these to render dynamic
// dropdowns (by-code). Only writes are admin-guarded.
@Get()
@ApiOperation({ summary: "List all dropdown settings" })
list() {
@@ -43,12 +47,14 @@ export class DropdownSettingsController {
}
@Post()
@FreightAdmin()
@ApiOperation({ summary: "Create a new dropdown setting" })
create(@Body() dto: CreateDropdownSettingDto) {
return this.service.create(dto);
}
@Patch(":id")
@FreightAdmin()
@ApiOperation({ summary: "Update a dropdown setting's metadata" })
update(
@Param("id", ParseUUIDPipe) id: string,
@@ -58,6 +64,7 @@ export class DropdownSettingsController {
}
@Delete(":id")
@FreightAdmin()
@ApiOperation({ summary: "Soft-delete a dropdown setting" })
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param("id", ParseUUIDPipe) id: string) {
@@ -67,6 +74,7 @@ export class DropdownSettingsController {
/* ------------------------- option routes ------------------------- */
@Put(":id/options")
@FreightAdmin()
@ApiOperation({ summary: "Replace the full option list for a setting" })
replaceOptions(
@Param("id", ParseUUIDPipe) id: string,
@@ -76,6 +84,7 @@ export class DropdownSettingsController {
}
@Post(":id/options")
@FreightAdmin()
@ApiOperation({ summary: "Append a single option to a setting" })
addOption(
@Param("id", ParseUUIDPipe) id: string,
@@ -85,6 +94,7 @@ export class DropdownSettingsController {
}
@Patch("options/:optionId")
@FreightAdmin()
@ApiOperation({ summary: "Update a single option" })
updateOption(
@Param("optionId", ParseUUIDPipe) optionId: string,
@@ -94,6 +104,7 @@ export class DropdownSettingsController {
}
@Delete("options/:optionId")
@FreightAdmin()
@ApiOperation({ summary: "Soft-delete a single option" })
@HttpCode(HttpStatus.NO_CONTENT)
removeOption(@Param("optionId", ParseUUIDPipe) optionId: string) {

View File

@@ -0,0 +1,18 @@
import type { FacilityStatus, FacilityType } from '../entities/facility.entity';
export class CreateFacilityDto {
code!: string;
name!: string;
description?: string;
facilityType!: FacilityType;
facilityStatus?: FacilityStatus;
locationName?: string;
country?: string;
city?: string;
address?: string;
latitude?: number;
longitude?: number;
capacity?: number;
isActive?: boolean;
notes?: string;
}

View File

@@ -0,0 +1,18 @@
import type { FacilityStatus, FacilityType } from '../entities/facility.entity';
export class UpdateFacilityDto {
code?: string;
name?: string;
description?: string;
facilityType?: FacilityType;
facilityStatus?: FacilityStatus;
locationName?: string;
country?: string;
city?: string;
address?: string;
latitude?: number;
longitude?: number;
capacity?: number;
isActive?: boolean;
notes?: string;
}

View File

@@ -0,0 +1,60 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, OneToMany } from 'typeorm';
import { Warehouse } from '../../warehouses/entities/warehouse.entity';
export const FACILITY_TYPES = ['PORT', 'DRY_PORT', 'TERMINAL', 'RAIL_YARD', 'WAREHOUSE_COMPLEX'] as const;
export type FacilityType = (typeof FACILITY_TYPES)[number];
export const FACILITY_STATUSES = ['ACTIVE', 'INACTIVE', 'UNDER_MAINTENANCE'] as const;
export type FacilityStatus = (typeof FACILITY_STATUSES)[number];
@Entity({ schema: 'freight', name: 'facilities' })
@Index(['code'], { unique: true })
@Index(['facilityStatus'])
export class Facility extends BaseEntity {
@Column({ name: 'code', type: 'varchar', length: 40, unique: true })
code!: string;
@Column({ name: 'name', type: 'varchar', length: 160 })
name!: string;
@Column({ name: 'description', type: 'text', nullable: true })
description?: string | null;
@Column({ name: 'facility_type', type: 'varchar', length: 32 })
facilityType!: FacilityType;
@Column({ name: 'facility_status', type: 'varchar', length: 32, default: 'ACTIVE' })
facilityStatus!: FacilityStatus;
@Column({ name: 'location_name', type: 'varchar', length: 200, nullable: true })
locationName?: string | null;
@Column({ name: 'country', type: 'varchar', length: 100, nullable: true })
country?: string | null;
@Column({ name: 'city', type: 'varchar', length: 100, nullable: true })
city?: string | null;
@Column({ name: 'address', type: 'text', nullable: true })
address?: string | null;
@Column({ name: 'latitude', type: 'numeric', precision: 10, scale: 8, nullable: true })
latitude?: number | null;
@Column({ name: 'longitude', type: 'numeric', precision: 11, scale: 8, nullable: true })
longitude?: number | null;
@Column({ name: 'capacity', type: 'numeric', precision: 14, scale: 3, nullable: true })
capacity?: number | null;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
@Column({ name: 'notes', type: 'text', nullable: true })
notes?: string | null;
@OneToMany(() => Warehouse, (warehouse) => warehouse.facility)
warehouses?: Warehouse[];
}

View File

@@ -0,0 +1,44 @@
import { Body, Controller, Delete, Get, HttpCode, Param, Patch, Post } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateFacilityDto } from './dto/create-facility.dto';
import { UpdateFacilityDto } from './dto/update-facility.dto';
import { Facility } from './entities/facility.entity';
import { FacilitiesService } from './facilities.service';
@ApiTags('Facilities')
@Controller('facilities')
export class FacilitiesController {
constructor(private readonly facilitiesService: FacilitiesService) {}
@Post()
@ApiOperation({ summary: 'Create a new facility' })
async create(@Body() createFacilityDto: CreateFacilityDto): Promise<Facility> {
return this.facilitiesService.create(createFacilityDto);
}
@Get()
@ApiOperation({ summary: 'List all facilities' })
async findAll(): Promise<Facility[]> {
return this.facilitiesService.findAll();
}
@Get(':id')
@ApiOperation({ summary: 'Get a facility by ID' })
async findOne(@Param('id') id: string): Promise<Facility | null> {
return this.facilitiesService.findOne(id);
}
@Patch(':id')
@ApiOperation({ summary: 'Update a facility' })
async update(@Param('id') id: string, @Body() updateFacilityDto: UpdateFacilityDto): Promise<Facility | null> {
return this.facilitiesService.update(id, updateFacilityDto);
}
@Delete(':id')
@HttpCode(204)
@ApiOperation({ summary: 'Delete a facility (soft delete)' })
async remove(@Param('id') id: string): Promise<void> {
return this.facilitiesService.remove(id);
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Facility } from './entities/facility.entity';
import { FacilitiesController } from './facilities.controller';
import { FacilitiesRepository } from './facilities.repository';
import { FacilitiesService } from './facilities.service';
@Module({
imports: [TypeOrmModule.forFeature([Facility])],
controllers: [FacilitiesController],
providers: [FacilitiesService, FacilitiesRepository],
exports: [FacilitiesService, FacilitiesRepository],
})
export class FacilitiesModule {}

View File

@@ -0,0 +1,13 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Facility } from './entities/facility.entity';
@Injectable()
export class FacilitiesRepository extends BaseRepository<Facility> {
constructor(@InjectRepository(Facility) repository: Repository<Facility>) {
super(repository);
}
}

View File

@@ -0,0 +1,31 @@
import { Injectable } from '@nestjs/common';
import { CreateFacilityDto } from './dto/create-facility.dto';
import { UpdateFacilityDto } from './dto/update-facility.dto';
import { Facility } from './entities/facility.entity';
import { FacilitiesRepository } from './facilities.repository';
@Injectable()
export class FacilitiesService {
constructor(private readonly facilitiesRepository: FacilitiesRepository) {}
async create(createFacilityDto: CreateFacilityDto): Promise<Facility> {
return this.facilitiesRepository.create(createFacilityDto);
}
async findAll(): Promise<Facility[]> {
return this.facilitiesRepository.findAll({ relations: ['warehouses'] });
}
async findOne(id: string): Promise<Facility | null> {
return this.facilitiesRepository.findById(id);
}
async update(id: string, updateFacilityDto: UpdateFacilityDto): Promise<Facility | null> {
return this.facilitiesRepository.update(id, updateFacilityDto);
}
async remove(id: string): Promise<void> {
return this.facilitiesRepository.softDelete(id);
}
}

View File

@@ -13,6 +13,7 @@ import {
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { FreightAdmin } from "../../common/booking-guards";
import { CreateFileUploadFieldDto } from "./dto/create-file-upload-field.dto";
import { CreateFileUploadSettingDto } from "./dto/create-file-upload-setting.dto";
import { UpdateFileUploadFieldDto } from "./dto/update-file-upload-field.dto";
@@ -24,6 +25,9 @@ import { FileUploadSettingsService } from "./file-upload-settings.service";
export class FileUploadSettingsController {
constructor(private readonly service: FileUploadSettingsService) {}
// Reads stay open: the customer portal fetches these to render dynamic
// upload forms (by-code / by-entity). Only writes are admin-guarded.
@Get()
@ApiOperation({ summary: "List all file upload settings" })
list() {
@@ -49,12 +53,14 @@ export class FileUploadSettingsController {
}
@Post()
@FreightAdmin()
@ApiOperation({ summary: "Create a new file upload setting" })
create(@Body() dto: CreateFileUploadSettingDto) {
return this.service.create(dto);
}
@Patch(":id")
@FreightAdmin()
@ApiOperation({ summary: "Update a file upload setting's metadata" })
update(
@Param("id", ParseUUIDPipe) id: string,
@@ -64,6 +70,7 @@ export class FileUploadSettingsController {
}
@Delete(":id")
@FreightAdmin()
@ApiOperation({ summary: "Soft-delete a file upload setting" })
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param("id", ParseUUIDPipe) id: string) {
@@ -73,6 +80,7 @@ export class FileUploadSettingsController {
/* ------------------------- field routes ------------------------- */
@Put(":id/fields")
@FreightAdmin()
@ApiOperation({ summary: "Replace the full field list for a setting" })
replaceFields(
@Param("id", ParseUUIDPipe) id: string,
@@ -82,6 +90,7 @@ export class FileUploadSettingsController {
}
@Post(":id/fields")
@FreightAdmin()
@ApiOperation({ summary: "Append a single field to a setting" })
addField(
@Param("id", ParseUUIDPipe) id: string,
@@ -91,6 +100,7 @@ export class FileUploadSettingsController {
}
@Patch("fields/:fieldId")
@FreightAdmin()
@ApiOperation({ summary: "Update a single field" })
updateField(
@Param("fieldId", ParseUUIDPipe) fieldId: string,
@@ -100,6 +110,7 @@ export class FileUploadSettingsController {
}
@Delete("fields/:fieldId")
@FreightAdmin()
@ApiOperation({ summary: "Soft-delete a single field" })
@HttpCode(HttpStatus.NO_CONTENT)
removeField(@Param("fieldId", ParseUUIDPipe) fieldId: string) {

View File

@@ -1,8 +1,11 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsIn, IsNumber, IsOptional, IsString, MaxLength, Min } from 'class-validator';
import { IsIn, IsNumber, IsOptional, IsString, MaxLength, Min, IsUUID } from 'class-validator';
import { LOCOMOTIVE_STATUSES, LOCOMOTIVE_TYPES } from '../entities/locomotive.entity';
import {
LOCOMOTIVE_STATUSES,
LOCOMOTIVE_TYPES,
} from '../entities/locomotive.entity';
export class CreateLocomotiveDto {
@ApiProperty({ example: 'LOCO-001' })
@@ -24,6 +27,11 @@ export class CreateLocomotiveDto {
@IsIn([...LOCOMOTIVE_STATUSES])
status!: string;
@ApiPropertyOptional({ description: 'Current yard location' })
@IsOptional()
@IsUUID()
currentYardId?: string;
@ApiProperty({ example: 3500 })
@Transform(({ value }) => Number(value))
@IsNumber()

View File

@@ -1,7 +1,10 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional } from 'class-validator';
import { IsIn, IsOptional, IsUUID } from 'class-validator';
import { LOCOMOTIVE_STATUSES, LOCOMOTIVE_TYPES } from '../entities/locomotive.entity';
import {
LOCOMOTIVE_STATUSES,
LOCOMOTIVE_TYPES,
} from '../entities/locomotive.entity';
export class FilterLocomotivesDto {
@ApiPropertyOptional({ enum: LOCOMOTIVE_STATUSES })
@@ -13,4 +16,9 @@ export class FilterLocomotivesDto {
@IsOptional()
@IsIn([...LOCOMOTIVE_TYPES])
locomotiveType?: string;
@ApiPropertyOptional({ description: 'Filter by current yard' })
@IsOptional()
@IsUUID()
currentYardId?: string;
}

View File

@@ -1,7 +1,8 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, OneToMany } from 'typeorm';
import { Column, Entity, Index, OneToMany, ManyToOne, JoinColumn } from 'typeorm';
import { TrainSet } from '../../train-sets/entities/train-set.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
export const LOCOMOTIVE_STATUSES = [
'AVAILABLE',
@@ -21,6 +22,7 @@ export type LocomotiveType = (typeof LOCOMOTIVE_TYPES)[number];
@Entity({ schema: 'freight', name: 'locomotives' })
@Index(['code'])
@Index(['status'])
@Index(['currentYardId'])
export class Locomotive extends BaseEntity {
@Column({ name: 'code', type: 'varchar', length: 32, unique: true })
code!: string;
@@ -40,6 +42,13 @@ export class Locomotive extends BaseEntity {
@Column({ name: 'status', type: 'varchar', length: 20, default: 'AVAILABLE' })
status!: LocomotiveStatus;
@Column({ name: 'current_yard_id', type: 'uuid', nullable: true })
currentYardId!: string | null;
@ManyToOne(() => Yard, { nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'current_yard_id' })
currentYard?: Yard | null;
@Column({ name: 'power_kw', type: 'numeric', precision: 10, scale: 3, nullable: true })
powerKw?: number | null;

View File

@@ -1,6 +1,7 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { FleetManage, FleetView } from '../../common/booking-guards';
import { CreateLocomotiveDto } from './dto/create-locomotive.dto';
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
import { UpdateLocomotiveDto } from './dto/update-locomotive.dto';
@@ -9,6 +10,7 @@ import { LocomotivesService } from './locomotives.service';
@ApiTags('locomotives')
@ApiBearerAuth()
@Controller('locomotives')
@FleetView()
export class LocomotivesController {
constructor(private readonly locomotivesService: LocomotivesService) {}
@@ -25,18 +27,21 @@ export class LocomotivesController {
}
@Post()
@FleetManage()
@ApiOperation({ summary: 'Create a locomotive' })
create(@Body() dto: CreateLocomotiveDto) {
return this.locomotivesService.create(dto);
}
@Patch(':id')
@FleetManage()
@ApiOperation({ summary: 'Update a locomotive' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLocomotiveDto) {
return this.locomotivesService.update(id, dto);
}
@Post(':id/decommission')
@FleetManage()
@ApiOperation({ summary: 'Decommission a locomotive' })
decommission(@Param('id', ParseUUIDPipe) id: string) {
return this.locomotivesService.decommission(id);

View File

@@ -3,7 +3,12 @@ import { ConflictException, Injectable, NotFoundException } from '@nestjs/common
import { CreateLocomotiveDto } from './dto/create-locomotive.dto';
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
import { UpdateLocomotiveDto } from './dto/update-locomotive.dto';
import { Locomotive, type LocomotiveStatus, type LocomotiveType } from './entities/locomotive.entity';
import {
Locomotive,
type LocomotiveStatus,
type LocomotiveType,
} from './entities/locomotive.entity';
import { LocomotivesRepository } from './locomotives.repository';
@Injectable()
@@ -17,7 +22,9 @@ export class LocomotivesService {
...(filter.locomotiveType
? { locomotiveType: filter.locomotiveType as LocomotiveType }
: {}),
...(filter.currentYardId ? { currentYardId: filter.currentYardId } : {}),
},
relations: { currentYard: true },
order: { code: 'ASC' },
});
}
@@ -34,6 +41,7 @@ export class LocomotivesService {
name: dto.name?.trim() || null,
locomotiveType: dto.locomotiveType as LocomotiveType,
status: dto.status as LocomotiveStatus,
currentYardId: dto.currentYardId ?? null,
maxPullWeightTons: dto.maxPullWeightTons,
maxTrainLengthMeters: dto.maxTrainLengthMeters,
powerKw: dto.powerKw ?? null,
@@ -43,7 +51,9 @@ export class LocomotivesService {
}
async findById(id: string): Promise<Locomotive> {
const locomotive = await this.locomotivesRepository.findById(id);
const locomotive = await this.locomotivesRepository.findById(id, {
relations: { currentYard: true },
});
if (!locomotive) {
throw new NotFoundException(`Locomotive ${id} not found`);
@@ -67,6 +77,10 @@ export class LocomotivesService {
locomotiveType:
dto.locomotiveType === undefined ? locomotive.locomotiveType : dto.locomotiveType as LocomotiveType,
status: dto.status === undefined ? locomotive.status : dto.status as LocomotiveStatus,
currentYardId:
dto.currentYardId === undefined
? locomotive.currentYardId
: (dto.currentYardId ?? null),
name: dto.name === undefined ? locomotive.name : dto.name?.trim() || null,
powerKw: dto.powerKw === undefined ? locomotive.powerKw : dto.powerKw ?? null,
tractionForceKn:

View File

@@ -0,0 +1,37 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from "typeorm";
import { PaymentEntity } from "./payment.entity";
@Entity({ schema: "freight", name: "payment_refunds" })
export class PaymentRefundEntity {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "uuid", name: "payment_id" })
paymentId!: string;
@Column({ type: "int", name: "amount_minor" })
amountMinor!: number;
@Column({ type: "varchar", length: 255, nullable: true })
reason?: string;
@Column({ type: "varchar", length: 255, nullable: true, name: "provider_refund_id" })
providerRefundId?: string;
@Column({ type: "varchar", length: 50 })
status!: string;
@CreateDateColumn({ name: "created_at" })
createdAt!: Date;
@ManyToOne(() => PaymentEntity, (payment) => payment.refunds, { onDelete: "RESTRICT" })
@JoinColumn({ name: "payment_id" })
payment!: PaymentEntity;
}

View File

@@ -0,0 +1,48 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
Unique,
} from "typeorm";
export type WebhookPaymentMethod = "telebirr" | "cbe-birr" | "ebirr";
@Entity({ schema: "freight", name: "payment_webhook_events" })
@Unique(["provider", "externalEventId"])
@Index(["merchantOrderId"])
export class PaymentWebhookEventEntity {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr"] })
provider!: WebhookPaymentMethod;
@Column({ type: "varchar", length: 255, name: "external_event_id" })
externalEventId!: string;
@Column({ type: "varchar", length: 255, nullable: true, name: "merchant_order_id" })
merchantOrderId?: string;
@Column({ type: "varchar", length: 255, nullable: true, name: "provider_txn_id" })
providerTxnId?: string;
@Column({ type: "boolean", name: "signature_valid" })
signatureValid!: boolean;
@Column({ type: "varchar", length: 100 })
status!: string;
@Column({ type: "jsonb" })
payload!: Record<string, unknown>;
@CreateDateColumn({ name: "received_at" })
receivedAt!: Date;
@Column({ type: "timestamp", nullable: true, name: "processed_at" })
processedAt?: Date;
@Column({ type: "text", nullable: true, name: "processing_error" })
processingError?: string;
}

View File

@@ -1,8 +1,9 @@
import { BaseEntity, Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from "typeorm";
import { BaseEntity, Column, CreateDateColumn, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm";
import { PaymentRefundEntity } from "./payment-refund.entity";
type PaymentType = "booking"
type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr"
type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr" | "waafi" | "card" | "dmoney" | "cac-bank"
type Currency = "ETB" | "USD"
export type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded"
@@ -17,7 +18,7 @@ export class PaymentEntity extends BaseEntity {
@Column({ type: "enum", enum: ["booking"] })
type!: PaymentType;
@Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr"] })
@Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr", "waafi", "card", "dmoney", "cac-bank"] })
method!: PaymentMethod
@Column({ type: "enum", enum: ["ETB", "USD"] })
@@ -32,13 +33,13 @@ export class PaymentEntity extends BaseEntity {
@Column({ type: "jsonb", default: {}, name: "raw_initiation" })
rawInitiation?: Record<string, unknown>
@Column({ type: "jsonb", name: "client_action" })
@Column({ type: "jsonb", nullable: true, name: "client_action" })
clientAction?: Record<string, unknown>;
@Column({ type: "varchar", length: 255, unique: true, name: "merchant_order_id", })
merchantOrderId!: string
@Column({ type: "varchar", length: 255, unique: true, name: "transaction_id", })
@Column({ type: "varchar", length: 255, unique: true, nullable: true, name: "transaction_id", })
transactionId?: string
@Column({ type: "enum", enum: ["action-required", "processing", "success", "failed", "canceled", "refunded"], default: "action-required" })
@@ -62,4 +63,7 @@ export class PaymentEntity extends BaseEntity {
@CreateDateColumn({ name: "created_at" })
createdAt!: Date
@OneToMany(() => PaymentRefundEntity, (refund) => refund.payment)
refunds!: PaymentRefundEntity[];
}

View File

@@ -0,0 +1,35 @@
import {
Body,
Controller,
HttpCode,
HttpStatus,
Post,
UseGuards,
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payment.dto";
import { PaymentService } from "./payment.service";
/**
* Consumer side of the payment microservice's outbox relay.
* Only the payment service may call this (shared SERVICE_AUTH_TOKEN).
* Idempotent by design — the relay delivers at-least-once, so duplicates must be harmless.
* Becomes a queue consumer via PaymentEventsConsumer when RabbitMQ is available;
* this HTTP endpoint remains as a transport-agnostic fallback.
*/
@ApiTags("Internal Payments")
@UseGuards(ServiceAuthGuard)
@Controller("internal/payments")
export class InternalPaymentController {
constructor(private readonly paymentService: PaymentService) { }
@Post("mark-paid")
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: "Apply a payment.succeeded / payment.failed event from the payment service (idempotent)",
})
async markPaid(@Body() event: PaymentEventDto): Promise<MarkPaidResponseDto> {
return this.paymentService.handlePaymentEvent(event);
}
}

View File

@@ -0,0 +1,53 @@
import {
IsEnum,
IsIn,
IsInt,
IsISO8601,
IsOptional,
IsPositive,
IsString,
IsUUID,
} from "class-validator";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import {
PaymentEventType,
PaymentReferenceType,
PaymentService,
ProviderMethod,
} from "@edr/types";
/**
* Wire shape of the PaymentEvent envelope (@edr/types) delivered by the payment
* microservice's outbox relay. Delivery is at-least-once — the consumer is idempotent.
*/
export class PaymentEventDto {
@ApiProperty({ enum: [1] }) @IsIn([1]) version!: 1;
@ApiProperty() @IsUUID() eventId!: string;
@ApiProperty({ enum: ["payment.succeeded", "payment.failed"] })
@IsIn(["payment.succeeded", "payment.failed"])
eventType!: PaymentEventType;
@ApiProperty() @IsISO8601() occurredAt!: string;
@ApiProperty({ enum: PaymentService }) @IsEnum(PaymentService) service!: string;
@ApiProperty() @IsUUID() intentId!: string;
@ApiProperty({ enum: PaymentReferenceType })
@IsEnum(PaymentReferenceType)
referenceType!: string;
@ApiProperty() @IsString() referenceId!: string;
@ApiProperty() @IsString() merchantOrderId!: string;
@ApiProperty({ enum: ProviderMethod }) @IsEnum(ProviderMethod) provider!: string;
@ApiProperty() @IsInt() @IsPositive() amountMinor!: number;
@ApiProperty() @IsString() currency!: string;
@ApiPropertyOptional() @IsOptional() @IsString() providerTxnId?: string;
@ApiPropertyOptional() @IsOptional() @IsISO8601() paidAt?: string;
@ApiPropertyOptional() @IsOptional() @IsString() failureCode?: string;
@ApiPropertyOptional() @IsOptional() @IsString() failureMessage?: string;
}
export class MarkPaidResponseDto {
@ApiProperty() processed!: boolean;
@ApiPropertyOptional() alreadyFinalized?: boolean;
@ApiPropertyOptional() reason?: string;
}

View File

@@ -0,0 +1,80 @@
import { BadGatewayException, Injectable, Logger } from "@nestjs/common";
import { HttpService } from "@nestjs/axios";
import { AxiosError } from "axios";
import { firstValueFrom } from "rxjs";
import {
InitiatePaymentRequest,
PaymentIntentSnapshot,
PaymentReferenceType,
PaymentService,
} from "@edr/types";
/**
* Thin HTTP client for the payment microservice (apps/edr-payment-api).
* Domain validation stays in the freight API; provider calls, intents,
* and webhooks live in the payment service.
*/
@Injectable()
export class PaymentClientService {
private readonly logger = new Logger(PaymentClientService.name);
private readonly baseUrl = (
process.env.PAYMENT_API_URL ?? "https://paymentcallback.triaplc.com"
).replace(/\/$/, "");
private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? "";
constructor(private readonly http: HttpService) { }
/** POST /payments/initiate — idempotent per (service, referenceType, referenceId). */
async initiate(request: InitiatePaymentRequest): Promise<PaymentIntentSnapshot> {
return this.call("POST", "/payments/initiate", request);
}
/** GET /payments/intents?… — active intent by domain reference; null when none exists. */
async getIntentByReference(
referenceType: PaymentReferenceType,
referenceId: string,
): Promise<PaymentIntentSnapshot | null> {
const query = new URLSearchParams({
service: PaymentService.FREIGHT,
referenceType,
referenceId,
});
try {
return await this.call("GET", `/payments/intents?${query.toString()}`);
} catch (err) {
if (err instanceof AxiosError && err.response?.status === 404) return null;
throw err;
}
}
private async call<T>(method: "GET" | "POST", path: string, body?: unknown): Promise<T> {
const url = `${this.baseUrl}${path}`;
try {
const response = await firstValueFrom(
this.http.request<T>({
method,
url,
data: body,
headers: this.serviceToken
? { "x-service-token": this.serviceToken }
: {},
}),
);
return response.data;
} catch (err) {
if (err instanceof AxiosError && err.response) {
if (err.response.status === 404) throw err;
const detail =
(err.response.data as { message?: string | string[] })?.message ??
err.message;
this.logger.error(
`payment service ${method} ${path}${err.response.status}: ${detail}`,
);
throw new BadGatewayException(`Payment service error: ${detail}`);
}
const message = err instanceof Error && err.message ? err.message : String(err);
this.logger.error(`payment service unreachable (${method} ${path}): ${message}`);
throw new BadGatewayException("Payment service unreachable");
}
}
}

View File

@@ -0,0 +1,49 @@
import { Injectable, Logger } from "@nestjs/common";
import { Nack, RabbitSubscribe } from "@golevelup/nestjs-rabbitmq";
import { Public } from "@edr/api-common";
import {
PAYMENT_EVENTS_DLX,
PAYMENT_EVENTS_EXCHANGE,
PAYMENT_QUEUES,
PaymentEvent,
PaymentService,
paymentServiceBindingPattern,
} from "@edr/types";
import { PaymentEventDto } from "./internal-payment.dto";
import { PaymentService as PaymentSvc } from "./payment.service";
const FREIGHT_QUEUE = PAYMENT_QUEUES[PaymentService.FREIGHT];
@Injectable()
export class PaymentEventsConsumer {
private readonly logger = new Logger(PaymentEventsConsumer.name);
constructor(private readonly paymentService: PaymentSvc) { }
@Public()
@RabbitSubscribe({
exchange: PAYMENT_EVENTS_EXCHANGE,
routingKey: paymentServiceBindingPattern(PaymentService.FREIGHT),
queue: FREIGHT_QUEUE.main,
queueOptions: {
durable: true,
deadLetterExchange: PAYMENT_EVENTS_DLX,
},
})
async handle(event: PaymentEvent): Promise<Nack | void> {
try {
const result = await this.paymentService.handlePaymentEvent(
event as unknown as PaymentEventDto,
);
this.logger.log(
`processed ${event.eventType} (${event.eventId}) ref=${event.referenceId}: ${JSON.stringify(result)}`,
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(
`DEAD-LETTERING ${event.eventType} (${event.eventId}) ref=${event.referenceId}: ${message}`,
);
return new Nack(false);
}
}
}

View File

@@ -1,44 +1,219 @@
import { Controller, Get, NotFoundException, Param, Post, Res } from "@nestjs/common";
import { PaymentService } from "./payment.service";
import {
Body,
Controller,
Get,
HttpStatus,
Param,
Post,
Query,
Res,
} from "@nestjs/common";
import {
ApiTags,
ApiOperation,
ApiQuery,
ApiOkResponse,
ApiProduces,
} from "@nestjs/swagger";
import { Response } from "express";
import { Public } from "@edr/api-common";
import { Response } from "express"
import { BookingView, FreightAdmin } from "../../common/booking-guards";
import { PaymentService } from "./payment.service";
import {
InitiatePaymentDto,
InitiateResponseDto,
IntentStatusDto,
PaymentMethodTypeEnum,
PaymentPlatformDto,
RefundDto,
} from "./payments.dto";
@Public()
@ApiTags("Payment")
@Controller("payments")
export class PaymentController {
constructor(private readonly paymentService: PaymentService,) { }
constructor(private readonly paymentService: PaymentService) { }
@Post("/initiate")
initiate() {
return this.paymentService.initBookingTelebirr("123", "web")
}
@Post("/bookings/check-payment/:orderId")
checkPayment(@Param("orderId") orderId: string) {
return this.paymentService.checkStatusAndUpdate(orderId)
}
@Get("/bookings/telebirr/redirect/:orderId")
async pay(@Param("orderId") orderId: string, @Res() res: Response) {
const payment = await this.paymentService.getActivePaymentByOrderIdAndMethod(orderId, "telebirr")
if (!payment) {
throw new NotFoundException('payment not found')
@Get("summary")
@BookingView()
@ApiOperation({ summary: "Payment count/amount summary for dashboard cards" })
getSummary() {
return this.paymentService.getSummary();
}
return res.send(`
<!DOCTYPE html>
<html>
@Get("all")
@BookingView()
@ApiOperation({ summary: "Get all payments with filters (view-only, any staff)" })
@ApiQuery({ name: "search", required: false })
@ApiQuery({ name: "status", required: false })
@ApiQuery({ name: "method", required: false })
@ApiQuery({ name: "page", required: false })
@ApiQuery({ name: "pageSize", required: false })
async getAll(
@Query("search") search?: string,
@Query("status") status?: string,
@Query("method") method?: string,
@Query("page") page?: string,
@Query("pageSize") pageSize?: string,
) {
return this.paymentService.getAll({
search,
status,
method,
page: page ? parseInt(page) : 1,
pageSize: pageSize ? parseInt(pageSize) : 10,
});
}
@Post("initiate")
@ApiOperation({
summary: "Initiate payment for a freight booking",
description: `Initiates payment via the central payment microservice.\n\n**Supported methods:**\n- TELEBIRR — Ethiopian mobile money\n- CBE_BIRR — Commercial Bank of Ethiopia\n- EBIRR — Electronic payment gateway\n- WAAFI — Djibouti mobile money\n- CARD — Visa/Mastercard\n- DMONEY — Djibouti D-money\n- CAC_BANK — CAC Int Bank (OTP)`,
})
@ApiOkResponse({ type: InitiateResponseDto })
initiatePayment(@Body() dto: InitiatePaymentDto) {
return this.paymentService.initiatePayment(dto);
}
@Get("intents/:bookingId")
@ApiOperation({ summary: "Get payment intent status for a booking" })
@ApiOkResponse({ type: IntentStatusDto })
getIntent(@Param("bookingId") bookingId: string) {
return this.paymentService.getIntentByBookingId(bookingId);
}
@Post("refund")
@FreightAdmin()
@ApiOperation({ summary: "Refund a paid booking (staff/admin only)" })
refund(@Body() dto: RefundDto) {
return this.paymentService.refund(dto);
}
@Get("checkout")
@Public()
@ApiOperation({
summary: "Browser checkout redirect",
description:
"Initiates payment and returns an HTML page that auto-redirects to the provider checkout URL. Open directly in a browser tab.",
})
@ApiQuery({ name: "bookingId", required: true })
@ApiQuery({ name: "method", enum: PaymentMethodTypeEnum, required: true })
@ApiQuery({ name: "platform", enum: ["web", "mobile"], required: false })
@ApiProduces("text/html")
async checkout(
@Query("bookingId") bookingId: string,
@Query("method") method: PaymentMethodTypeEnum,
@Query("platform") platform: PaymentPlatformDto = "web",
@Res() res: Response,
) {
if (!bookingId) {
return res
.status(HttpStatus.BAD_REQUEST)
.type("html")
.send(this.buildErrorHtml("Missing required query parameter: bookingId"));
}
if (!method || !Object.values(PaymentMethodTypeEnum).includes(method)) {
return res
.status(HttpStatus.BAD_REQUEST)
.type("html")
.send(this.buildErrorHtml("Missing or invalid query parameter: method"));
}
try {
const result = await this.paymentService.initiatePayment({ bookingId, method, platform });
const url =
result.clientAction?.type === "REDIRECT" ? result.clientAction.url : undefined;
if (url) {
return res.status(HttpStatus.OK).type("html").send(this.buildRedirectHtml(url));
}
return res
.status(HttpStatus.OK)
.type("html")
.send(this.buildStatusHtml(result.status, result.intentId));
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "An unexpected error occurred";
return res.status(HttpStatus.OK).type("html").send(this.buildErrorHtml(message));
}
}
@Get("receipt/:orderId")
@Public()
@ApiOperation({ summary: "Generate a payment receipt HTML page" })
@ApiProduces("text/html")
async receipt(@Param("orderId") orderId: string, @Res() res: Response) {
const html = await this.paymentService.genReceiptHtml(orderId);
return res.status(HttpStatus.OK).type("html").send(html);
}
private buildRedirectHtml(url: string): string {
const escaped = url.replace(/\"/g, "&quot;");
return `<!DOCTYPE html>
<html lang="en">
<head>
<title>Redirecting...</title>
<meta charset="UTF-8">
<meta http-equiv="refresh" content="0;url=${escaped}">
<title>Redirecting to payment…</title>
<style>
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
.spinner { width: 40px; height: 40px; border: 4px solid #e0e0e0; border-top-color: #1a73e8; border-radius: 50%; animation: spin .8s linear infinite; margin: 0 auto 20px; }
@keyframes spin { to { transform: rotate(360deg); } }
p { color: #555; margin: 0 0 16px; }
a { color: #1a73e8; }
</style>
</head>
<body>
<p>Redirecting...</p>
<script>
window.location.href = "${payment.clientAction?.url}";
</script>
<div class="card">
<div class="spinner"></div>
<p>Redirecting to payment provider…</p>
<p><a href="${escaped}">Click here if you are not redirected</a></p>
</div>
<script>window.location.href = "${escaped}";</script>
</body>
</html>
`);
}
</html>`;
}
private buildStatusHtml(status: string, intentId: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Payment status</title>
<style>
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
.status { font-size: 1.1rem; font-weight: 600; color: #333; margin-bottom: 8px; }
small { color: #888; }
</style>
</head>
<body>
<div class="card">
<div class="status">${status}</div>
<small>Intent: ${intentId}</small>
</div>
</body>
</html>`;
}
private buildErrorHtml(message: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Payment error</title>
<style>
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
.error { color: #d32f2f; font-weight: 600; margin-bottom: 8px; }
p { color: #555; }
</style>
</head>
<body>
<div class="card">
<div class="error">Payment could not be initiated</div>
<p>${message}</p>
</div>
</body>
</html>`;
}
}

View File

@@ -1,17 +1,63 @@
import { Module } from "@nestjs/common";
import { PaymentService } from "./payment.service";
import { Module, forwardRef } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { HttpModule } from "@nestjs/axios";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { RabbitMQModule } from "@golevelup/nestjs-rabbitmq";
import {
PAYMENT_EVENTS_DLX,
PAYMENT_EVENTS_EXCHANGE,
PAYMENT_QUEUES,
PaymentService as PaymentServiceEnum,
paymentServiceBindingPattern,
} from "@edr/types";
import { PaymentService } from "./payment.service";
import { PaymentClientService } from "./payment-client.service";
import { PaymentController } from "./payment.controller";
import { ConfigModule } from "@nestjs/config";
import { PaymentRepository } from "./payment.repository";
import { WebhookController } from "./webhooks/webhook.controller";
import { TelebirrWebhookService } from "./webhooks/providers/telebirr.service";
import { TelebirrProvider } from "@edr/payment-providers";
import { PaymentEventsConsumer } from "./payment-events.consumer";
import { InternalPaymentController } from "./internal-payment.controller";
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module";
import { PaymentWebhookEventEntity } from "./entities/payment-webhook-event.entity";
import { PaymentRefundEntity } from "./entities/payment-refund.entity";
const FREIGHT_QUEUE = PAYMENT_QUEUES[PaymentServiceEnum.FREIGHT];
@Module({
imports: [HttpModule, ConfigModule],
providers: [PaymentRepository, PaymentService, TelebirrWebhookService, TelebirrProvider],
controllers: [PaymentController, WebhookController],
exports: [PaymentService]
imports: [
HttpModule.register({ timeout: 10_000 }),
ConfigModule,
forwardRef(() => TrainSchedulingModule),
TypeOrmModule.forFeature([PaymentWebhookEventEntity, PaymentRefundEntity]),
RabbitMQModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
uri: config.get<string>("rabbitmq.url") as string,
exchanges: [
{ name: PAYMENT_EVENTS_EXCHANGE, type: "topic", options: { durable: true } },
{ name: PAYMENT_EVENTS_DLX, type: "topic", options: { durable: true } },
],
queues: [
{
name: FREIGHT_QUEUE.dlq,
exchange: PAYMENT_EVENTS_DLX,
routingKey: paymentServiceBindingPattern(PaymentServiceEnum.FREIGHT),
options: { durable: true },
},
],
prefetchCount: config.get<number>("rabbitmq.prefetch") ?? 10,
connectionInitOptions: { wait: false },
}),
}),
],
providers: [
PaymentRepository,
PaymentService,
PaymentClientService,
PaymentEventsConsumer,
ServiceAuthGuard,
],
controllers: [PaymentController, InternalPaymentController],
exports: [PaymentService],
})
export class PaymentModule { }
export class PaymentModule { }

View File

@@ -57,6 +57,8 @@ export class PaymentRepository {
.getOne();
}
createQueryBuilder(alias: string) {
return this.paymentRepo.createQueryBuilder(alias);
}
}

View File

@@ -1,12 +1,16 @@
import {
BadRequestException,
forwardRef,
Inject,
Injectable,
InternalServerErrorException,
Logger,
NotFoundException,
} from "@nestjs/common";
import { DataSource } from "typeorm";
import { PaymentEntity } from "./entities/payment.entity";
import { PaymentRepository } from "./payment.repository";
import { PaymentClientService } from "./payment-client.service";
import * as fs from "fs";
import * as path from "path";
@@ -16,125 +20,344 @@ import { Booking } from "../bookings/entities/booking.entity";
import {
ClientAction,
createMerchantOrderId,
ProviderPaymentStatus,
TelebirrProvider,
} from "@edr/payment-providers";
import { ProviderInitiationInput } from "@edr/types"
import { InitiateResponseDto, PaymentPlatformDto } from "./payments.dto";
import {
PaymentService as PaymentServiceEnum,
PaymentReferenceType,
PaymentIntentSnapshot,
ProviderMethod,
} from "@edr/types";
import {
InitiatePaymentDto,
InitiateResponseDto,
IntentStatusDto,
RefundDto,
} from "./payments.dto";
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
const DEFAULT_CURRENCY = "ETB";
const STATUS_MAP: Record<string, ProviderPaymentStatus> = {
"action-required": ProviderPaymentStatus.REQUIRES_ACTION,
"processing": ProviderPaymentStatus.PROCESSING,
"success": ProviderPaymentStatus.SUCCEEDED,
"failed": ProviderPaymentStatus.FAILED,
"canceled": ProviderPaymentStatus.CANCELLED,
"refunded": ProviderPaymentStatus.CANCELLED,
};
@Injectable()
export class PaymentService {
private readonly logger = new Logger(PaymentService.name);
constructor(
private readonly configService: ConfigService,
private readonly datasource: DataSource,
private readonly paymentRepo: PaymentRepository,
private readonly telebirrProvider: TelebirrProvider,
private readonly paymentClient: PaymentClientService,
@Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatchService: BookingBatchService,
) { }
async initBookingTelebirr(
bookingId: string,
platform: PaymentPlatformDto,
): Promise<{ redirectUrl: string }> {
// const booking = await this.datasource.getRepository(Booking).findOneBy({ id: bookingId });
// if (!booking) throw new NotFoundException("Booking not found");
async getAll(filters: {
search?: string;
status?: string;
method?: string;
page?: number;
pageSize?: number;
}) {
const { search, status, method, page = 1, pageSize = 10 } = filters;
const skip = (page - 1) * pageSize;
// const booking = new Booking()
// booking.totalAmount = 20
// booking.id = randomUUID
const amount = 20
const merchantOrderId = createMerchantOrderId();
const redirectBase = this.configService.get<string>("TELEBIRR_SUCCESS_BOOKING_REDIRECT_BASE_URL");
const redirectUrl = `${redirectBase}/${merchantOrderId}`;
const amountMinor = Math.round(Number(amount) * 100);
const qb = this.paymentRepo.createQueryBuilder("payment");
const input: ProviderInitiationInput = {
merchantOrderId,
orderRef: bookingId,
if (search) {
qb.andWhere(
"(payment.merchantOrderId ILIKE :search OR payment.refId ILIKE :search OR payment.transactionId ILIKE :search)",
{ search: `%${search}%` },
);
}
if (status) {
qb.andWhere("payment.status = :status", { status });
}
if (method) {
qb.andWhere("payment.method = :method", { method });
}
const [items, total] = await qb
.orderBy("payment.createdAt", "DESC")
.skip(skip)
.take(pageSize)
.getManyAndCount();
return {
items: items.map((p) => ({
id: p.id,
bookingId: p.refId,
amount: p.amount,
currency: p.currency,
method: p.method,
status: p.status,
merchantOrderId: p.merchantOrderId,
paidAt: p.paidAt,
createdAt: p.createdAt,
})),
total,
page,
pageSize,
};
}
/** Aggregate counts across ALL payments for the dashboard summary cards. */
async getSummary() {
const rows = await this.paymentRepo
.createQueryBuilder("payment")
.select("payment.status", "status")
.addSelect("COUNT(*)::int", "count")
.groupBy("payment.status")
.getRawMany<{ status: string; count: number }>();
const byStatus: Record<string, number> = {};
let total = 0;
for (const row of rows) {
byStatus[row.status] = row.count;
total += row.count;
}
// Sum of successfully collected amounts.
const paidAgg = await this.paymentRepo
.createQueryBuilder("payment")
.select("COALESCE(SUM(payment.amount), 0)", "sum")
.where("payment.status = :status", { status: "success" })
.getRawOne<{ sum: string }>();
return {
total,
success: byStatus["success"] ?? 0,
processing:
(byStatus["processing"] ?? 0) + (byStatus["action-required"] ?? 0),
failed: (byStatus["failed"] ?? 0) + (byStatus["canceled"] ?? 0),
refunded: byStatus["refunded"] ?? 0,
paidAmount: Number(paidAgg?.sum ?? 0),
};
}
async initiatePayment(dto: InitiatePaymentDto): Promise<InitiateResponseDto> {
const booking = await this.datasource
.getRepository(Booking)
.findOneBy({ id: dto.bookingId });
if (!booking) throw new NotFoundException("Booking not found");
console.log("bookingbooking",booking)
const amountMinor = Math.round(Number(booking.totalAmount) * 100);
console.log("amountminor",amountMinor)
const snapshot = await this.paymentClient.initiate({
service: PaymentServiceEnum.FREIGHT,
referenceType: PaymentReferenceType.SHIPMENT,
referenceId: booking.id,
orderRef: booking.reference,
amountMinor,
currency: DEFAULT_CURRENCY,
platform: platform || "web",
redirectUrl,
currency: booking.paymentCurrency,
provider: dto.method as unknown as ProviderMethod,
platform: dto.platform,
payerAccount: dto.payerAccount,
returnUrl: dto.returnUrl ?? process.env.PAYMENT_RETURN_URL,
failureUrl: dto.failureUrl ?? process.env.PAYMENT_FAILURE_URL,
});
const intent = await this.syncIntentProjection(booking.id, booking, snapshot);
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
await this.finalizePaymentSuccess({
intentId: intent.id,
bookingId: booking.id,
providerTxnId: snapshot.providerTxnId,
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
});
}
return this.formatIntentResponse(intent);
}
private async syncIntentProjection(
bookingId: string,
booking: Booking,
snapshot: PaymentIntentSnapshot,
): Promise<PaymentEntity> {
const existing = await this.paymentRepo.findOneBy({ refId: bookingId, type: "booking" });
const PROVIDER_TO_METHOD: Record<string, PaymentEntity["method"]> = {
TELEBIRR: "telebirr",
CBE_BIRR: "cbe-birr",
EBIRR: "ebirr",
WAAFI: "waafi",
CARD: "card",
DMONEY: "dmoney",
CAC_BANK: "cac-bank",
};
const method: PaymentEntity["method"] =
PROVIDER_TO_METHOD[snapshot.provider ?? ""] ?? "telebirr";
const status = snapshot.status === ProviderPaymentStatus.SUCCEEDED
? "processing"
: this.toLocalStatus(snapshot.status);
const clientAction = (snapshot.clientAction ?? undefined) as Record<string, unknown> | undefined;
const data = {
status,
method,
merchantOrderId: snapshot.merchantOrderId ?? existing?.merchantOrderId ?? "",
transactionId: snapshot.providerTxnId ?? existing?.transactionId,
expiresAt: snapshot.expiresAt ? new Date(snapshot.expiresAt) : existing?.expiresAt,
failerCode: snapshot.failureCode ?? undefined,
failureMessage: snapshot.failureMessage ?? undefined,
};
const result = await this.telebirrProvider.initiate(input);
if (existing) {
await this.paymentRepo.update({ id: existing.id }, { ...data, clientAction } as any);
return { ...existing, ...data, clientAction } as PaymentEntity;
}
const payment = await this.paymentRepo.create({
amount: amount,
currency: DEFAULT_CURRENCY,
method: "telebirr",
return this.paymentRepo.create({
refId: bookingId,
type: "booking",
merchantOrderId,
rawInitiation: result.rawInitiation,
clientAction: result.clientAction as Record<string, unknown>,
expiresAt: result.expiresAt,
reason: `Payment for booking`,
});
return {
redirectUrl: `${this.configService.get<string>("TELEBIRR_REDIRECT_BASE_URL")}/${payment.merchantOrderId}`
}
amount: booking.totalAmount,
currency: booking.paymentCurrency,
reason: `Payment for booking ${booking.reference}`,
rawInitiation: snapshot as unknown as Record<string, unknown>,
clientAction: clientAction ?? {},
...data,
} as any);
}
async getIntentByBookingId(bookingId: string): Promise<IntentStatusDto> {
const local = await this.paymentRepo.findOneBy({ refId: bookingId, type: "booking" });
let snapshot: PaymentIntentSnapshot | null = null;
try {
snapshot = await this.paymentClient.getIntentByReference(
PaymentReferenceType.SHIPMENT,
bookingId,
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.warn(
`payment service lookup failed for booking ${bookingId}: ${message}; using local intent`,
);
}
if (!snapshot) {
if (!local) throw new NotFoundException("PaymentIntent not found");
return this.formatIntentStatus(local);
}
const booking = await this.datasource
.getRepository(Booking)
.findOneBy({ id: bookingId });
if (!booking) throw new NotFoundException("Booking not found");
const intent = await this.syncIntentProjection(bookingId, booking, snapshot);
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
await this.finalizePaymentSuccess({
intentId: intent.id,
bookingId: booking.id,
providerTxnId: snapshot.providerTxnId,
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
});
}
const refreshed = await this.paymentRepo.findOneBy({ id: intent.id });
return this.formatIntentStatus(refreshed ?? intent);
}
async refund(dto: RefundDto) {
const intent = await this.paymentRepo.findOneBy({ refId: dto.bookingId, type: "booking" });
if (!intent || intent.status !== "success") {
throw new BadRequestException("No successful payment to refund");
}
await this.datasource.transaction(async (mg) => {
await mg.update(PaymentEntity, { id: intent.id }, { status: "refunded", refundedAt: new Date() });
await mg.update(Booking, { id: dto.bookingId }, { paymentStatus: "FAILED", status: "CANCELLED" });
});
return { refunded: true, bookingId: dto.bookingId };
}
async finalizePaymentSuccess(input: {
intentId: string;
bookingId: string;
providerTxnId?: string;
paidAt?: Date;
}): Promise<{ alreadyFinalized: boolean }> {
const intent = await this.paymentRepo.findOneBy({ id: input.intentId });
if (!intent) throw new NotFoundException("PaymentIntent not found");
if (intent.status === "success") return { alreadyFinalized: true };
const paidAt = input.paidAt ?? new Date();
await this.datasource.transaction(async (mg) => {
await mg.update(
PaymentEntity,
{ id: intent.id },
{ status: "success", paidAt, transactionId: input.providerTxnId ?? intent.transactionId },
);
await mg.update(Booking, { id: input.bookingId }, { paymentStatus: "PAID" ,status:"PAID"});
});
try {
await this.bookingBatchService.ensurePaidBookingAllocated(input.bookingId);
} catch (err) {
this.logger.error(
`Error allocating booking after payment: ${err instanceof Error ? err.message : String(err)}`,
);
}
return { alreadyFinalized: false };
}
async markPaymentFailed(input: {
intentId: string;
failureCode?: string;
failureMessage?: string;
}): Promise<void> {
const intent = await this.paymentRepo.findOneBy({ id: input.intentId });
if (!intent) throw new NotFoundException("PaymentIntent not found");
if (intent.status === "success" || intent.status === "canceled") return;
await this.paymentRepo.update(
{ id: intent.id },
{ status: "failed", failerCode: input.failureCode, failureMessage: input.failureMessage },
);
}
async getActivePaymentByOrderIdAndMethod(orderId: string, method: PaymentEntity["method"]): Promise<PaymentEntity | null> {
return this.paymentRepo.getActivePaymentByOrderIdAndMethod(orderId, method)
return this.paymentRepo.getActivePaymentByOrderIdAndMethod(orderId, method);
}
async genReceiptHtml(orderId: string) {
const payment = await this.paymentRepo.findOneBy({
merchantOrderId: orderId,
status: "success"
})
if (!payment) {
throw new BadRequestException()
}
const payment = await this.paymentRepo.findOneBy({ merchantOrderId: orderId, status: "success" });
if (!payment) throw new BadRequestException("No successful payment found for this order");
const filePath = path.join(__dirname, "templates", "receipt.hbs");
if (!fs.existsSync(filePath)) {
throw new InternalServerErrorException()
}
if (!fs.existsSync(filePath)) throw new InternalServerErrorException();
const source = fs.readFileSync(filePath, "utf8");
const template = Handlebars.compile(source);
const html = template({
vendorName: "Ethio Djibouti Railway Ticket Booking",
return template({
vendorName: "Ethio Djibouti Railway Freight Booking",
vendorAddress: "Addis Ababa",
receiptDate: payment.paidAt,
paymentMethod: payment?.method,
subtotal: payment?.amount.toString(),
total: payment?.amount.toString(),
currency: payment?.currency,
reason: payment?.reason
paymentMethod: payment.method,
subtotal: payment.amount.toString(),
total: payment.amount.toString(),
currency: payment.currency,
reason: payment.reason,
});
return html;
}
async checkStatusAndUpdate(orderId: string) {
const resp = await this.paymentRepo.findOneBy({ merchantOrderId: orderId })
if (!resp) {
throw new NotFoundException("order id not found")
}
const result = await this.telebirrProvider.queryStatus(resp.merchantOrderId)
if (result.status === ProviderPaymentStatus.SUCCEEDED) {
await this.datasource.transaction(async (mg) => {
await mg.update(Booking, { id: resp.refId }, { status: "PAID" })
await mg.update(PaymentEntity, { id: resp.id }, { status: "success" })
})
}
return {
status: result.status
}
}
findBookingById(id: string) {
return this.paymentRepo.findOneBy({ refId: id, type: "booking" })
return this.paymentRepo.findOneBy({ refId: id, type: "booking" });
}
formatIntentResponse(intent: PaymentEntity): InitiateResponseDto {
@@ -142,19 +365,70 @@ export class PaymentService {
intent.clientAction && typeof intent.clientAction === "object"
? (intent.clientAction as unknown as ClientAction)
: undefined;
const statusMap: Record<string, ProviderPaymentStatus> = {
"action-required": ProviderPaymentStatus.REQUIRES_ACTION,
"processing": ProviderPaymentStatus.PROCESSING,
"success": ProviderPaymentStatus.SUCCEEDED,
"failed": ProviderPaymentStatus.FAILED,
"canceled": ProviderPaymentStatus.CANCELLED,
"refunded": ProviderPaymentStatus.CANCELLED,
};
return {
intentId: intent.id,
status: statusMap[intent.status] ?? ProviderPaymentStatus.PROCESSING,
status: STATUS_MAP[intent.status] ?? ProviderPaymentStatus.PROCESSING,
clientAction,
merchantOrderId: intent.merchantOrderId ?? undefined,
};
}
private formatIntentStatus(intent: PaymentEntity): IntentStatusDto {
return {
...this.formatIntentResponse(intent),
paidAt: intent.paidAt?.toISOString(),
failureCode: intent.failerCode ?? undefined,
failureMessage: intent.failureMessage ?? undefined,
};
}
async handlePaymentEvent(event: {
eventType: string;
eventId: string;
referenceId: string;
intentId: string;
providerTxnId?: string;
paidAt?: string;
failureCode?: string;
failureMessage?: string;
}): Promise<{ processed: boolean; alreadyFinalized?: boolean; reason?: string }> {
if (event.eventType === "payment.succeeded") {
const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" });
if (!intent) {
return { processed: false, reason: `No local intent for booking ${event.referenceId}` };
}
const { alreadyFinalized } = await this.finalizePaymentSuccess({
intentId: intent.id,
bookingId: event.referenceId,
providerTxnId: event.providerTxnId,
paidAt: event.paidAt ? new Date(event.paidAt) : undefined,
});
return { processed: true, alreadyFinalized };
}
if (event.eventType === "payment.failed") {
const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" });
if (!intent) {
return { processed: false, reason: `No local intent for booking ${event.referenceId}` };
}
await this.markPaymentFailed({
intentId: intent.id,
failureCode: event.failureCode,
failureMessage: event.failureMessage,
});
return { processed: true };
}
return { processed: false, reason: `Unknown event type: ${event.eventType}` };
}
private toLocalStatus(status: ProviderPaymentStatus): PaymentEntity["status"] {
switch (status) {
case ProviderPaymentStatus.SUCCEEDED: return "success";
case ProviderPaymentStatus.FAILED: return "failed";
case ProviderPaymentStatus.CANCELLED: return "canceled";
case ProviderPaymentStatus.PROCESSING: return "processing";
default: return "action-required";
}
}
}

View File

@@ -1,27 +1,67 @@
import { ProviderPaymentStatus } from "@edr/types";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsIn, IsOptional, IsString } from "class-validator";
import { IsEnum, IsIn, IsOptional, IsString } from "class-validator";
export type PaymentPlatformDto = "web" | "mobile";
export enum PaymentMethodTypeEnum {
TELEBIRR = "TELEBIRR",
CBE_BIRR = "CBE_BIRR",
EBIRR = "EBIRR",
WAAFI = "WAAFI",
CARD = "CARD",
DMONEY = "DMONEY",
CAC_BANK = "CAC_BANK",
}
export class InitiatePaymentDto {
@ApiProperty({ example: "booking-uuid" })
@IsString()
bookingId!: string;
@ApiProperty({ enum: ["TELEBIRR"], example: "TELEBIRR" })
@IsIn(["TELEBIRR"])
method!: "TELEBIRR";
@ApiProperty({
enum: PaymentMethodTypeEnum,
description: "Payment method: TELEBIRR/CBE_BIRR/EBIRR (Ethiopia), WAAFI (Djibouti), CARD (International), DMONEY",
example: "TELEBIRR",
})
@IsEnum(PaymentMethodTypeEnum)
method!: PaymentMethodTypeEnum;
@ApiPropertyOptional({ enum: ["web", "mobile"], default: "web" })
@IsOptional()
@IsIn(["web", "mobile"])
platform?: PaymentPlatformDto;
@ApiPropertyOptional({ description: "Payer account / mobile number (e.g. for Waafi MWALLET)" })
@IsOptional()
@IsString()
payerAccount?: string;
@ApiPropertyOptional({ description: "Browser return URL after successful payment" })
@IsOptional()
@IsString()
returnUrl?: string;
@ApiPropertyOptional({ description: "Browser return URL after failed/cancelled payment" })
@IsOptional()
@IsString()
failureUrl?: string;
}
export class RefundDto {
@ApiProperty({ example: "booking-uuid" })
@IsString()
bookingId!: string;
@ApiPropertyOptional({ description: "Optional reason for refund" })
@IsOptional()
@IsString()
reason?: string;
}
export class ClientActionDto {
@ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP"] })
type!: "REDIRECT" | "LAUNCH_APP";
@ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP"] })
type!: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP";
@ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" })
url?: string;
@@ -34,6 +74,12 @@ export class ClientActionDto {
@ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" })
shortCode?: string;
@ApiPropertyOptional({ description: "Set when type=COLLECT_OTP (e.g. CAC Bank)" })
providerOrderId?: string;
@ApiPropertyOptional({ description: "Set when type=COLLECT_OTP" })
message?: string;
}
export class InitiateResponseDto {

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