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

This commit is contained in:
hagiye
2026-06-30 07:20:10 +03:00
114 changed files with 6470 additions and 1901 deletions

View File

@@ -0,0 +1,452 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EDR Freight — Payment Tester (Telebirr ETB + Card USD)</title>
<style>
:root {
--bg: #0f1419;
--panel: #1a2129;
--panel-2: #232d38;
--border: #2e3b48;
--txt: #e6edf3;
--muted: #8b98a5;
--accent: #1a73e8;
--etb: #00a651; /* telebirr green */
--usd: #2563eb; /* card blue */
--ok: #2ea043;
--warn: #d29922;
--err: #f85149;
}
* { box-sizing: border-box; }
body {
margin: 0; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
background: var(--bg); color: var(--txt); line-height: 1.5;
}
header {
padding: 20px 24px; border-bottom: 1px solid var(--border);
background: var(--panel); display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
}
header h1 { font-size: 1.05rem; margin: 0; font-weight: 600; }
header .badge {
font-size: .7rem; padding: 2px 8px; border-radius: 999px;
background: var(--panel-2); color: var(--muted); border: 1px solid var(--border);
}
main { max-width: 920px; margin: 0 auto; padding: 24px; display: grid; gap: 20px; }
.card {
background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 20px;
}
.card h2 { margin: 0 0 14px; font-size: .8rem; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); }
label { display: block; font-size: .78rem; color: var(--muted); margin: 0 0 6px; }
input, select {
width: 100%; padding: 10px 12px; border-radius: 8px; border: 1px solid var(--border);
background: var(--panel-2); color: var(--txt); font-size: .9rem; font-family: inherit;
}
input:focus, select:focus { outline: none; border-color: var(--accent); }
.row { display: grid; grid-template-columns: 1fr auto; gap: 10px; align-items: end; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
button {
cursor: pointer; border: none; border-radius: 8px; padding: 10px 16px;
font-size: .85rem; font-weight: 600; color: #fff; font-family: inherit;
background: var(--panel-2); border: 1px solid var(--border); color: var(--txt);
transition: filter .15s, opacity .15s;
}
button:hover:not(:disabled) { filter: brightness(1.15); }
button:disabled { opacity: .45; cursor: not-allowed; }
button.pay-etb { background: var(--etb); border-color: var(--etb); color: #fff; }
button.pay-usd { background: var(--usd); border-color: var(--usd); color: #fff; }
button.ghost { background: transparent; }
.pay-buttons { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 4px; }
.pay-buttons button { padding: 16px; font-size: .95rem; display: flex; flex-direction: column; gap: 4px; align-items: center; }
.pay-buttons button small { font-weight: 400; opacity: .85; font-size: .72rem; }
.booking-meta { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin-top: 14px; }
.meta-item { background: var(--panel-2); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; }
.meta-item .k { font-size: .68rem; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
.meta-item .v { font-size: 1rem; font-weight: 600; margin-top: 2px; word-break: break-all; }
.pill { display: inline-block; font-size: .72rem; font-weight: 600; padding: 2px 9px; border-radius: 999px; }
.pill.etb { background: rgba(0,166,81,.15); color: #4ade80; }
.pill.usd { background: rgba(37,99,235,.18); color: #60a5fa; }
.status-line { display: flex; align-items: center; gap: 8px; font-size: .9rem; }
.dot { width: 9px; height: 9px; border-radius: 50%; background: var(--muted); }
.dot.ok { background: var(--ok); } .dot.warn { background: var(--warn); } .dot.err { background: var(--err); }
.dot.pulse { animation: pulse 1s ease-in-out infinite; }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .3; } }
pre {
background: #0b0f14; border: 1px solid var(--border); border-radius: 8px; padding: 14px;
font-size: .76rem; overflow: auto; max-height: 320px; margin: 10px 0 0; color: #c9d1d9;
}
.log { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: .74rem; }
.log-entry { padding: 4px 0; border-bottom: 1px solid var(--border); }
.log-entry .ts { color: var(--muted); margin-right: 8px; }
.log-entry.req { color: #79c0ff; } .log-entry.res { color: #7ee787; } .log-entry.err { color: var(--err); }
.actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 14px; }
.hint { font-size: .76rem; color: var(--muted); margin-top: 8px; }
a { color: #58a6ff; }
.table { width: 100%; border-collapse: collapse; font-size: .8rem; margin-top: 10px; }
.table th, .table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); }
.table th { color: var(--muted); font-weight: 500; font-size: .72rem; text-transform: uppercase; }
.table tr.clickable { cursor: pointer; }
.table tr.clickable:hover { background: var(--panel-2); }
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
@media (max-width: 720px) { .split, .grid-2, .pay-buttons { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<header>
<h1>🚂 EDR Freight — Payment Tester</h1>
<span class="badge">Telebirr (ETB)</span>
<span class="badge">Card (USD)</span>
</header>
<main>
<!-- ── Config ───────────────────────────────────────────── -->
<section class="card">
<h2>API connection</h2>
<div class="grid-2">
<div>
<label>API base URL</label>
<input id="apiBase" value="http://localhost:3001/api" />
</div>
<div>
<label>Service / Bearer token (optional)</label>
<input id="token" placeholder="Bearer token if guards enabled" />
</div>
</div>
<div class="actions">
<button class="ghost" id="pingBtn">Test connection</button>
<span class="status-line"><span class="dot" id="pingDot"></span><span id="pingTxt">not checked</span></span>
</div>
</section>
<!-- ── Pick booking ─────────────────────────────────────── -->
<section class="card">
<h2>1 · Choose a booking</h2>
<div class="row">
<div>
<label>Booking ID (UUID) — paste directly, or load the list below</label>
<input id="bookingId" placeholder="e.g. 9f8c…-uuid" />
</div>
<button class="ghost" id="loadBookingBtn">Load this booking</button>
</div>
<div class="actions">
<button class="ghost" id="listBtn">List recent bookings</button>
<span class="hint">Currency (ETB vs USD) is set per-booking via <code>paymentCurrency</code>. Pick an ETB booking to test Telebirr, a USD booking to test Card.</span>
</div>
<div id="bookingList"></div>
<div id="bookingMeta" style="display:none;">
<div class="booking-meta">
<div class="meta-item"><div class="k">Reference</div><div class="v" id="mRef"></div></div>
<div class="meta-item"><div class="k">Amount</div><div class="v" id="mAmt"></div></div>
<div class="meta-item"><div class="k">Currency</div><div class="v" id="mCur"></div></div>
<div class="meta-item"><div class="k">Status</div><div class="v" id="mStatus"></div></div>
<div class="meta-item"><div class="k">Pay status</div><div class="v" id="mPay"></div></div>
</div>
</div>
</section>
<!-- ── Pay ──────────────────────────────────────────────── -->
<section class="card">
<h2>2 · Initiate payment</h2>
<div class="grid-2">
<div>
<label>Platform</label>
<select id="platform"><option value="web">web (browser redirect)</option><option value="mobile">mobile (launch app)</option></select>
</div>
<div>
<label>Payer account (optional — Waafi MWALLET / phone)</label>
<input id="payerAccount" placeholder="2519…" />
</div>
</div>
<div class="pay-buttons">
<button class="pay-etb" id="payTelebirr" disabled>Pay with Telebirr<small>ETB · Ethiopian mobile money</small></button>
<button class="pay-usd" id="payCard" disabled>Pay with Card<small>USD · Visa / Mastercard</small></button>
</div>
<div class="actions" style="margin-top:14px;">
<label style="margin:0;display:flex;align-items:center;gap:6px;font-size:.8rem;">
<input type="checkbox" id="openTab" checked style="width:auto;" /> open provider checkout in new tab
</label>
</div>
<div class="hint">
Telebirr → forces method <code>TELEBIRR</code>. Card → forces method <code>CARD</code>.
Each calls <code>POST {base}/payments/initiate</code> and follows the returned <code>clientAction</code> (REDIRECT url for web).
</div>
<p class="hint" id="initiateResult"></p>
</section>
<!-- ── Status / receipt ─────────────────────────────────── -->
<section class="card">
<h2>3 · Track intent &amp; receipt</h2>
<div class="actions">
<button class="ghost" id="pollBtn" disabled>Refresh intent status</button>
<label style="margin:0;display:flex;align-items:center;gap:6px;font-size:.8rem;">
<input type="checkbox" id="autoPoll" style="width:auto;" /> auto-poll every 3s
</label>
<span class="status-line"><span class="dot" id="intentDot"></span><span id="intentTxt">no intent yet</span></span>
</div>
<div class="actions" style="margin-top:6px;">
<button class="ghost" id="receiptBtn" disabled>Open receipt (success only)</button>
<span class="hint">Receipt = <code>GET {base}/payments/receipt/{merchantOrderId}</code></span>
</div>
</section>
<!-- ── Raw / log ────────────────────────────────────────── -->
<section class="split">
<div class="card">
<h2>Last response</h2>
<pre id="rawOut"></pre>
</div>
<div class="card">
<h2>Request log</h2>
<div class="log" id="log"></div>
</div>
</section>
</main>
<script>
(function () {
"use strict";
const $ = (id) => document.getElementById(id);
const state = { booking: null, intent: null, merchantOrderId: null, pollTimer: null };
// ── helpers ─────────────────────────────────────────────
const base = () => $("apiBase").value.replace(/\/$/, "");
const headers = () => {
const h = { "Content-Type": "application/json", Accept: "application/json" };
const t = $("token").value.trim();
if (t) h["Authorization"] = t.startsWith("Bearer ") ? t : "Bearer " + t;
return h;
};
function now() {
const d = new Date();
return d.toTimeString().slice(0, 8) + "." + String(d.getMilliseconds()).padStart(3, "0");
}
function log(kind, msg) {
const el = document.createElement("div");
el.className = "log-entry " + kind;
el.innerHTML = '<span class="ts">' + now() + "</span>" + msg;
$("log").prepend(el);
}
function showRaw(obj) {
$("rawOut").textContent = typeof obj === "string" ? obj : JSON.stringify(obj, null, 2);
}
function setDot(id, cls) {
const d = $(id);
d.className = "dot" + (cls ? " " + cls : "");
}
// Unwrap the @edr/api-common ResponseTransformInterceptor envelope if present.
function unwrap(json) {
if (json && typeof json === "object" && "data" in json && ("statusCode" in json || "success" in json)) {
return json.data;
}
return json;
}
async function api(method, path, body) {
const url = base() + path;
log("req", method + " " + path);
let res, text, json;
try {
res = await fetch(url, {
method,
headers: headers(),
body: body ? JSON.stringify(body) : undefined,
});
} catch (e) {
log("err", "network error: " + e.message + " — is the API running at " + base() + "?");
throw e;
}
text = await res.text();
try { json = text ? JSON.parse(text) : null; } catch { json = text; }
if (!res.ok) {
const detail = json && json.message ? (Array.isArray(json.message) ? json.message.join(", ") : json.message) : text;
log("err", method + " " + path + " → " + res.status + ": " + detail);
showRaw(json || text);
throw new Error(detail || ("HTTP " + res.status));
}
log("res", method + " " + path + " → " + res.status);
return unwrap(json);
}
// ── ping ────────────────────────────────────────────────
$("pingBtn").onclick = async () => {
setDot("pingDot", "pulse warn"); $("pingTxt").textContent = "checking…";
try {
// payment summary is a light, low-side-effect GET
await api("GET", "/payments/summary");
setDot("pingDot", "ok"); $("pingTxt").textContent = "connected ✓";
} catch (e) {
setDot("pingDot", "err"); $("pingTxt").textContent = "failed — " + e.message;
}
};
// ── list bookings ───────────────────────────────────────
$("listBtn").onclick = async () => {
try {
const data = await api("GET", "/bookings");
const items = Array.isArray(data) ? data : (data && data.items) || [];
renderBookingList(items.slice(0, 25));
} catch (e) { /* logged */ }
};
function renderBookingList(items) {
const wrap = $("bookingList");
if (!items.length) { wrap.innerHTML = '<p class="hint">No bookings returned.</p>'; return; }
let html = '<table class="table"><thead><tr><th>Reference</th><th>Amount</th><th>Cur</th><th>Status</th><th>ID</th></tr></thead><tbody>';
for (const b of items) {
const cur = b.paymentCurrency || b.currency || "—";
const curClass = cur === "ETB" ? "etb" : cur === "USD" ? "usd" : "";
const amt = b.totalAmount != null ? b.totalAmount : (b.amount != null ? b.amount : "—");
html += '<tr class="clickable" data-id="' + (b.id || "") + '">' +
"<td>" + (b.reference || "—") + "</td>" +
"<td>" + amt + "</td>" +
'<td><span class="pill ' + curClass + '">' + cur + "</span></td>" +
"<td>" + (b.status || "—") + "</td>" +
'<td style="font-size:.7rem;color:var(--muted);">' + (b.id ? b.id.slice(0, 8) + "…" : "—") + "</td></tr>";
}
html += "</tbody></table>";
wrap.innerHTML = html;
wrap.querySelectorAll("tr.clickable").forEach((tr) => {
tr.onclick = () => { $("bookingId").value = tr.dataset.id; loadBooking(); };
});
}
// ── load one booking ────────────────────────────────────
$("loadBookingBtn").onclick = loadBooking;
async function loadBooking() {
const id = $("bookingId").value.trim();
if (!id) { log("err", "enter a booking ID first"); return; }
try {
const b = await api("GET", "/bookings/" + id);
state.booking = b;
showRaw(b);
renderMeta(b);
enablePayButtons(b);
$("pollBtn").disabled = false;
// pre-load any existing intent
refreshIntent(true).catch(() => {});
} catch (e) { /* logged */ }
}
function renderMeta(b) {
const cur = b.paymentCurrency || b.currency || "—";
$("bookingMeta").style.display = "block";
$("mRef").textContent = b.reference || "—";
$("mAmt").textContent = (b.totalAmount != null ? b.totalAmount : (b.amount != null ? b.amount : "—"));
$("mCur").innerHTML = '<span class="pill ' + (cur === "ETB" ? "etb" : cur === "USD" ? "usd" : "") + '">' + cur + "</span>";
$("mStatus").textContent = b.status || "—";
$("mPay").textContent = b.paymentStatus || "—";
}
function enablePayButtons(b) {
const cur = b.paymentCurrency || b.currency || "";
const etbBtn = $("payTelebirr"), usdBtn = $("payCard");
etbBtn.disabled = false; usdBtn.disabled = false;
// soft hint via title; both stay enabled so you can deliberately test mismatch
etbBtn.title = cur && cur !== "ETB" ? "Booking currency is " + cur + ", not ETB — Telebirr expects ETB" : "";
usdBtn.title = cur && cur !== "USD" ? "Booking currency is " + cur + ", not USD — Card expects USD" : "";
}
// ── initiate payment ────────────────────────────────────
async function pay(method, label) {
const id = $("bookingId").value.trim();
if (!id) { log("err", "pick a booking first"); return; }
$("initiateResult").textContent = "Initiating " + label + "…";
const body = {
bookingId: id,
method: method,
platform: $("platform").value,
};
const payer = $("payerAccount").value.trim();
if (payer) body.payerAccount = payer;
try {
const resp = await api("POST", "/payments/initiate", body);
state.intent = resp;
state.merchantOrderId = resp.merchantOrderId || null;
showRaw(resp);
renderIntent(resp);
$("receiptBtn").disabled = !state.merchantOrderId;
const action = resp.clientAction;
if (action && action.type === "REDIRECT" && action.url) {
$("initiateResult").innerHTML =
label + " intent created (status: " + resp.status + "). " +
'Redirect → <a href="' + action.url + '" target="_blank" rel="noopener">' + action.url + "</a>";
if ($("openTab").checked) window.open(action.url, "_blank", "noopener");
} else if (action && action.type === "LAUNCH_APP") {
$("initiateResult").textContent =
label + " → LAUNCH_APP (mobile). appId=" + (action.appId || "") + " shortCode=" + (action.shortCode || "");
} else if (action && action.type === "COLLECT_OTP") {
$("initiateResult").textContent =
label + " → COLLECT_OTP. providerOrderId=" + (action.providerOrderId || "") + " — " + (action.message || "");
} else {
$("initiateResult").textContent = label + " intent created. Status: " + resp.status + " (no redirect action).";
}
// begin watching
if ($("autoPoll").checked) startAutoPoll();
} catch (e) {
$("initiateResult").textContent = "Failed: " + e.message;
}
}
$("payTelebirr").onclick = () => pay("TELEBIRR", "Telebirr (ETB)");
$("payCard").onclick = () => pay("CARD", "Card (USD)");
// ── intent status ───────────────────────────────────────
$("pollBtn").onclick = () => refreshIntent(false);
async function refreshIntent(silent) {
const id = $("bookingId").value.trim();
if (!id) return;
try {
const resp = await api("GET", "/payments/intents/" + id);
state.intent = resp;
if (resp.merchantOrderId) state.merchantOrderId = resp.merchantOrderId;
$("receiptBtn").disabled = !state.merchantOrderId;
showRaw(resp);
renderIntent(resp);
return resp;
} catch (e) {
if (!silent) { /* already logged */ }
}
}
function renderIntent(resp) {
const s = (resp.status || "").toUpperCase();
let cls = "warn";
if (s === "SUCCEEDED") cls = "ok";
else if (s === "FAILED" || s === "CANCELLED") cls = "err";
setDot("intentDot", cls);
let txt = "status: " + (resp.status || "—");
if (resp.merchantOrderId) txt += " · order " + resp.merchantOrderId;
if (resp.paidAt) txt += " · paid " + resp.paidAt;
if (resp.failureMessage) txt += " · " + resp.failureMessage;
$("intentTxt").textContent = txt;
if (s === "SUCCEEDED" || s === "FAILED" || s === "CANCELLED") stopAutoPoll();
}
function startAutoPoll() {
stopAutoPoll();
state.pollTimer = setInterval(() => refreshIntent(true), 3000);
}
function stopAutoPoll() {
if (state.pollTimer) { clearInterval(state.pollTimer); state.pollTimer = null; }
}
$("autoPoll").onchange = (e) => { if (e.target.checked) startAutoPoll(); else stopAutoPoll(); };
// ── receipt ─────────────────────────────────────────────
$("receiptBtn").onclick = () => {
if (!state.merchantOrderId) { log("err", "no merchantOrderId yet — pay first"); return; }
const url = base() + "/payments/receipt/" + encodeURIComponent(state.merchantOrderId);
log("req", "GET /payments/receipt/" + state.merchantOrderId + " (new tab)");
window.open(url, "_blank", "noopener");
};
log("res", "ready — set API base, pick a booking, pay with Telebirr (ETB) or Card (USD)");
})();
</script>
</body>
</html>

View File

@@ -25,12 +25,14 @@
"seed:negad-indode-arrived-train": "ts-node -r tsconfig-paths/register src/scripts/seed-negad-indode-arrived-train.ts",
"auto-unload:arrived-import-trains": "ts-node -r tsconfig-paths/register src/scripts/auto-unload-arrived-import-trains.ts",
"seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts",
"seed:gov-companies": "ts-node -r tsconfig-paths/register src/scripts/seed-gov-companies.ts",
"seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh",
"iam:typeorm:cli": "cross-env MIGRATIONS_DIR=node_modules/@tria-plc/iamapi-common/dist/db/migrations/*.{ts,js} ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d ./node_modules/@tria-plc/api-common/dist/modules/typeorm/typeorm.config.js",
"iam:migration:run": "pnpm run iam:typeorm:cli migration:run",
"iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert",
"iam:migration:show": "pnpm run iam:typeorm:cli migration:show",
"iam:seed:run": "cross-env APP_MODULE_PATH=./dist/app.module dotenv -- node ./node_modules/@tria-plc/iamapi-common/dist/db/seed.cli.js"
"iam:seed:run": "cross-env APP_MODULE_PATH=./dist/app.module dotenv -- node ./node_modules/@tria-plc/iamapi-common/dist/db/seed.cli.js",
"migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts"
},
"dependencies": {
"@edr/api-common": "workspace:*",

View File

@@ -2,6 +2,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 { EventEmitterModule } from "@nestjs/event-emitter";
import { DataSource, DataSourceOptions } from "typeorm";
import { ensurePostgresSchemas } from "./config/ensure-postgres-schemas";
import { IamModule, DataSeeder } from "@tria-plc/iamapi-common";
@@ -56,6 +57,7 @@ import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-inte
import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder";
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
//New Trains, Wagons, Container and Cargo management modules
import { TrainsModule } from "./modules/trains/trains.module";
@@ -79,7 +81,7 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera
load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig],
}),
ScheduleModule.forRoot(),
// EventEmitterModule.forRoot(),
EventEmitterModule.forRoot(),
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService): TypeOrmModuleOptions =>
@@ -144,6 +146,7 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera
FileUploadSettingsSeeder,
FreightPermissionKeyMigrationSeeder,
DemoFreightDataSeeder,
GovCompaniesSeeder,
IndodeFacilitySeeder,
Batch14TestDataSeeder,
Batch5TestDataSeeder,
@@ -173,6 +176,7 @@ export class AppModule implements OnApplicationBootstrap {
private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder,
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
private readonly govCompaniesSeeder: GovCompaniesSeeder,
) { }
async onApplicationBootstrap() {
@@ -199,5 +203,8 @@ export class AppModule implements OnApplicationBootstrap {
// demoFreightDataSeeder now seeds ONLY the 4 staff users (wagons + approval
// rules are disabled inside the seeder). Kept running for the staff users.
await this.demoFreightDataSeeder.run();
// Government entities (with importer/exporter profiles) that government
// bookings bill to. Idempotent — keyed by fixed IDs.
await this.govCompaniesSeeder.run();
}
}

View File

@@ -0,0 +1,78 @@
import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm';
/**
* Create the freight.last_mile_container_allocations table — container allocation
* records linking last-mile deliveries with containers and vehicles.
*/
export class CreateLastMileContainerAllocations1810000000002 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
const exists = await queryRunner.hasTable('freight.last_mile_container_allocations');
if (exists) return;
await queryRunner.createTable(
new Table({
name: 'freight.last_mile_container_allocations',
columns: [
{
name: 'id',
type: 'uuid',
isPrimary: true,
default: 'gen_random_uuid()',
},
{ name: 'last_mile_id', type: 'uuid', isNullable: false },
{ name: 'container_id', type: 'uuid', isNullable: false },
{ name: 'vehicle_id', type: 'uuid', isNullable: true },
{
name: 'container_type',
type: 'text',
isNullable: false,
},
{
name: 'quantity',
type: 'integer',
default: 1,
isNullable: false,
},
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createForeignKey(
'freight.last_mile_container_allocations',
new TableForeignKey({
columnNames: ['last_mile_id'],
referencedTableName: 'freight.last_mile',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
await queryRunner.createForeignKey(
'freight.last_mile_container_allocations',
new TableForeignKey({
columnNames: ['vehicle_id'],
referencedTableName: 'freight.vehicles',
referencedColumnNames: ['id'],
onDelete: 'SET NULL',
}),
);
await queryRunner.query(
`CREATE INDEX "IDX_last_mile_container_allocations_last_mile_id" ON "freight"."last_mile_container_allocations" ("last_mile_id")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_last_mile_container_allocations_vehicle_id" ON "freight"."last_mile_container_allocations" ("vehicle_id")`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
const exists = await queryRunner.hasTable('freight.last_mile_container_allocations');
if (exists) {
await queryRunner.dropTable('freight.last_mile_container_allocations');
}
}
}

View File

@@ -0,0 +1,51 @@
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
export class AddPostPaymentCompletedColumn1810000000004 implements MigrationInterface {
name = 'AddPostPaymentCompletedColumn1810000000004';
public async up(queryRunner: QueryRunner): Promise<void> {
const firstMileTable = await queryRunner.hasTable('freight.first_mile_deliveries');
if (firstMileTable) {
const hasColumn = await queryRunner.hasColumn('freight.first_mile_deliveries', 'is_post_payment_completed');
if (!hasColumn) {
await queryRunner.addColumn(
'freight.first_mile_deliveries',
new TableColumn({
name: 'is_post_payment_completed',
type: 'boolean',
default: false,
isNullable: false,
})
);
}
}
const lastMileTable = await queryRunner.hasTable('freight.last_mile_deliveries');
if (lastMileTable) {
const hasColumn = await queryRunner.hasColumn('freight.last_mile_deliveries', 'is_post_payment_completed');
if (!hasColumn) {
await queryRunner.addColumn(
'freight.last_mile_deliveries',
new TableColumn({
name: 'is_post_payment_completed',
type: 'boolean',
default: false,
isNullable: false,
})
);
}
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
const lastMileTable = await queryRunner.hasTable('freight.last_mile_deliveries');
if (lastMileTable) {
await queryRunner.dropColumn('freight.last_mile_deliveries', 'is_post_payment_completed');
}
const firstMileTable = await queryRunner.hasTable('freight.first_mile_deliveries');
if (firstMileTable) {
await queryRunner.dropColumn('freight.first_mile_deliveries', 'is_post_payment_completed');
}
}
}

View File

@@ -0,0 +1,107 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Freight billing — `invoices` + `invoice_lines` tables.
*
* Matches:
* - billing/entities/invoice.entity.ts
* - billing/entities/invoice-line.entity.ts
*
* The status enum mirrors `Freight.InvoiceStatus` and uses TypeORM's default
* enum-type name (`<table>_<column>_enum`) so the entity's `type: "enum"`
* column resolves to it without an explicit `enumName`.
*/
export class CreateInvoices1821000000002 implements MigrationInterface {
name = "CreateInvoices1821000000002";
public async up(queryRunner: QueryRunner): Promise<void> {
const typeExists = await queryRunner.query(
`SELECT 1 FROM pg_type WHERE typname = 'invoices_status_enum' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'freight');`,
);
if (!typeExists.length) {
await queryRunner.query(`
CREATE TYPE freight.invoices_status_enum AS ENUM (
'DRAFT',
'PENDING',
'PAID',
'OVERDUE',
'CANCELLED',
'REFUNDED'
);
`);
}
await queryRunner.query(`
CREATE TABLE freight.invoices (
id uuid NOT NULL DEFAULT uuid_generate_v4(),
invoice_number varchar(64) NOT NULL,
company_id uuid NOT NULL,
company_profile_id uuid NOT NULL,
total_amount numeric(14, 2) NOT NULL,
currency varchar(8) NOT NULL DEFAULT 'ETB',
status freight.invoices_status_enum NOT NULL DEFAULT 'DRAFT',
source varchar(255) NOT NULL,
source_id varchar(255) NOT NULL,
type varchar(255) NOT NULL,
issued_at timestamptz,
payment_id uuid,
due_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT pk_invoices PRIMARY KEY (id),
CONSTRAINT uq_invoices_invoice_number UNIQUE (invoice_number),
CONSTRAINT fk_invoices_company FOREIGN KEY (company_id)
REFERENCES freight.companies (id) ON DELETE RESTRICT,
CONSTRAINT fk_invoices_company_profile FOREIGN KEY (company_profile_id)
REFERENCES freight.company_profiles (id) ON DELETE RESTRICT,
CONSTRAINT fk_invoices_payment FOREIGN KEY (payment_id)
REFERENCES freight.payments (id) ON DELETE SET NULL
);
`);
await queryRunner.query(
`CREATE INDEX idx_invoices_company ON freight.invoices (company_id);`,
);
await queryRunner.query(
`CREATE INDEX idx_invoices_company_profile ON freight.invoices (company_profile_id);`,
);
await queryRunner.query(
`CREATE INDEX idx_invoices_source ON freight.invoices (source, source_id);`,
);
await queryRunner.query(
`CREATE INDEX idx_invoices_status ON freight.invoices (status);`,
);
await queryRunner.query(`
CREATE TABLE freight.invoice_lines (
id uuid NOT NULL DEFAULT uuid_generate_v4(),
invoice_id uuid NOT NULL,
charge_type varchar NOT NULL,
description varchar(255),
quantity numeric(12, 2) NOT NULL DEFAULT 1,
unit_rate numeric(14, 2) NOT NULL DEFAULT 0,
amount numeric(14, 2) NOT NULL,
currency varchar(8) NOT NULL DEFAULT 'ETB',
metadata jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT pk_invoice_lines PRIMARY KEY (id),
CONSTRAINT fk_invoice_lines_invoice FOREIGN KEY (invoice_id)
REFERENCES freight.invoices (id) ON DELETE CASCADE
);
`);
await queryRunner.query(
`CREATE INDEX idx_invoice_lines_invoice ON freight.invoice_lines (invoice_id);`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.invoice_lines;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.invoices;`);
await queryRunner.query(`DROP TYPE IF EXISTS freight.invoices_status_enum;`);
}
}

View File

@@ -0,0 +1,129 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Government bookings now bill to a real seeded government company + an explicit
* importer/exporter profile, instead of carrying a null company + free-text
* institution. This migration:
*
* 1. Adds `companies.kind` (commercial | government).
* 2. Seeds the Ethiopian government entities + their importer/exporter
* profiles (mirrors src/seed/data/gov-companies.data.ts — keep in sync).
* 3. Backfills every booking with a NULL company_id / company_profile_id so
* the NOT NULL constraints below can be applied:
* - NULL company_id → the default government company.
* - NULL company_profile_id → the company's profile matching the booking
* trade direction; else any profile of the company; else the default
* government importer profile.
* 4. Enforces NOT NULL on bookings.company_id and bookings.company_profile_id.
*/
export class AddCompanyKindAndGovBookingLinks1821000000003
implements MigrationInterface
{
name = "AddCompanyKindAndGovBookingLinks1821000000003";
// Mirrors src/seed/data/gov-companies.data.ts
private readonly govCompanies = [
{ id: "0a1b0001-0000-4000-8000-000000000001", name: "Federal Government of Ethiopia", tin: "0000000001", email: "procurement@gov.et", phone: "+251111000001", im: "0b1c0001-0000-4000-8000-000000000001", ex: "0b1c0001-0000-4000-8000-000000000002", imRef: "IM-90001", exRef: "EX-90001" },
{ id: "0a1b0002-0000-4000-8000-000000000002", name: "Ministry of National Defense", tin: "0000000002", email: "logistics@mod.gov.et", phone: "+251111000002", im: "0b1c0002-0000-4000-8000-000000000001", ex: "0b1c0002-0000-4000-8000-000000000002", imRef: "IM-90002", exRef: "EX-90002" },
{ id: "0a1b0003-0000-4000-8000-000000000003", name: "Ethiopian Roads Administration", tin: "0000000003", email: "supply@era.gov.et", phone: "+251111000003", im: "0b1c0003-0000-4000-8000-000000000001", ex: "0b1c0003-0000-4000-8000-000000000002", imRef: "IM-90003", exRef: "EX-90003" },
{ id: "0a1b0004-0000-4000-8000-000000000004", name: "Ministry of Agriculture", tin: "0000000004", email: "imports@moa.gov.et", phone: "+251111000004", im: "0b1c0004-0000-4000-8000-000000000001", ex: "0b1c0004-0000-4000-8000-000000000002", imRef: "IM-90004", exRef: "EX-90004" },
{ id: "0a1b0005-0000-4000-8000-000000000005", name: "Ministry of Trade and Regional Integration", tin: "0000000005", email: "trade@motri.gov.et", phone: "+251111000005", im: "0b1c0005-0000-4000-8000-000000000001", ex: "0b1c0005-0000-4000-8000-000000000002", imRef: "IM-90005", exRef: "EX-90005" },
{ id: "0a1b0006-0000-4000-8000-000000000006", name: "Ethiopian Disaster Risk Management Commission", tin: "0000000006", email: "relief@edrmc.gov.et", phone: "+251111000006", im: "0b1c0006-0000-4000-8000-000000000001", ex: "0b1c0006-0000-4000-8000-000000000002", imRef: "IM-90006", exRef: "EX-90006" },
];
private get defaultCompanyId(): string {
return this.govCompanies[0].id;
}
private get defaultImporterProfileId(): string {
return this.govCompanies[0].im;
}
public async up(queryRunner: QueryRunner): Promise<void> {
// 1. kind column
await queryRunner.query(
`ALTER TABLE "freight"."companies" ADD COLUMN IF NOT EXISTS "kind" varchar(20) NOT NULL DEFAULT 'commercial'`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_companies_kind" ON "freight"."companies" ("kind")`,
);
// 2. seed government companies + importer/exporter profiles (idempotent)
for (const g of this.govCompanies) {
await queryRunner.query(
`INSERT INTO "freight"."companies" ("id", "name", "type", "kind", "status", "tin", "country", "email", "phone")
VALUES ($1, $2, 'customer', 'government', 'active', $3, 'Ethiopia', $4, $5)
ON CONFLICT ("id") DO NOTHING`,
[g.id, g.name, g.tin, g.email, g.phone],
);
await queryRunner.query(
`INSERT INTO "freight"."company_profiles" ("id", "company_id", "type", "reference", "status")
VALUES ($1, $2, 'importer', $3, 'active'), ($4, $2, 'exporter', $5, 'active')
ON CONFLICT ("id") DO NOTHING`,
[g.im, g.id, g.imRef, g.ex, g.exRef],
);
}
// 3a. backfill NULL company_id → default government company
await queryRunner.query(
`UPDATE "freight"."bookings" SET "company_id" = $1 WHERE "company_id" IS NULL`,
[this.defaultCompanyId],
);
// 3b. backfill NULL company_profile_id → profile matching trade direction
await queryRunner.query(
`UPDATE "freight"."bookings" b
SET "company_profile_id" = cp."id"
FROM "freight"."company_profiles" cp
WHERE b."company_profile_id" IS NULL
AND cp."company_id" = b."company_id"
AND cp."deleted_at" IS NULL
AND cp."type" = CASE b."trade_direction"
WHEN 'IMPORT' THEN 'importer'
WHEN 'EXPORT' THEN 'exporter'
ELSE NULL END`,
);
// 3c. fallback → any profile of the booking's company
await queryRunner.query(
`UPDATE "freight"."bookings" b
SET "company_profile_id" = (
SELECT cp."id" FROM "freight"."company_profiles" cp
WHERE cp."company_id" = b."company_id" AND cp."deleted_at" IS NULL
ORDER BY cp."created_at" ASC LIMIT 1)
WHERE b."company_profile_id" IS NULL
AND EXISTS (
SELECT 1 FROM "freight"."company_profiles" cp
WHERE cp."company_id" = b."company_id" AND cp."deleted_at" IS NULL)`,
);
// 3d. final fallback → default government importer profile
await queryRunner.query(
`UPDATE "freight"."bookings" SET "company_profile_id" = $1 WHERE "company_profile_id" IS NULL`,
[this.defaultImporterProfileId],
);
// 4. enforce NOT NULL
await queryRunner.query(
`ALTER TABLE "freight"."bookings" ALTER COLUMN "company_id" SET NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "freight"."bookings" ALTER COLUMN "company_profile_id" SET NOT NULL`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "freight"."bookings" ALTER COLUMN "company_profile_id" DROP NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "freight"."bookings" ALTER COLUMN "company_id" DROP NOT NULL`,
);
await queryRunner.query(
`DROP INDEX IF EXISTS "freight"."IDX_companies_kind"`,
);
await queryRunner.query(
`ALTER TABLE "freight"."companies" DROP COLUMN IF EXISTS "kind"`,
);
// Seeded government rows are intentionally left in place.
}
}

View File

@@ -0,0 +1,47 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Make the payment projection source-agnostic so any domain (not just bookings)
* can own a payment intent.
*
* - `payments.type` enum `('booking')` → `varchar(50)`. It now stores the
* invoice SOURCE (e.g. 'booking', 'demurrage'), supplied by the caller, so a
* new domain no longer needs an enum migration to write its intents.
* - adds `payments.reference_type varchar(40)` — the gateway reference type
* (`PaymentReferenceType`) the intent was opened with, so the reconcile/poll
* path can query the provider without hardcoding it.
*
* Matches payment/entities/payment.entity.ts.
*/
export class MakePaymentsTypeGeneric1821000000004 implements MigrationInterface {
name = "MakePaymentsTypeGeneric1821000000004";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.payments ALTER COLUMN type TYPE varchar(50) USING type::text;`,
);
await queryRunner.query(`DROP TYPE IF EXISTS freight.payments_type_enum;`);
await queryRunner.query(
`ALTER TABLE freight.payments ADD COLUMN reference_type varchar(40);`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.payments DROP COLUMN IF EXISTS reference_type;`,
);
// Restore the single-value enum. Any non-'booking' rows would block the cast;
// collapse them first so the down migration is safe.
await queryRunner.query(
`UPDATE freight.payments SET type = 'booking' WHERE type <> 'booking';`,
);
await queryRunner.query(
`CREATE TYPE freight.payments_type_enum AS ENUM ('booking');`,
);
await queryRunner.query(
`ALTER TABLE freight.payments ALTER COLUMN type TYPE freight.payments_type_enum USING type::freight.payments_type_enum;`,
);
}
}

View File

@@ -0,0 +1,80 @@
import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm';
/**
* Create the freight.booking_container_allocations table — container-to-vehicle
* allocation mapping for flexible routing of containers across available vehicles.
*/
export class CreateBookingContainerAllocations1825000000000 implements MigrationInterface {
name = 'CreateBookingContainerAllocations1825000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
const exists = await queryRunner.hasTable('freight.booking_container_allocations');
if (exists) return;
await queryRunner.createTable(
new Table({
name: 'freight.booking_container_allocations',
columns: [
{
name: 'id',
type: 'uuid',
isPrimary: true,
default: 'gen_random_uuid()',
},
{ name: 'booking_id', type: 'uuid', isNullable: false },
{ name: 'container_id', type: 'uuid', isNullable: false },
{ name: 'vehicle_id', type: 'uuid', isNullable: true },
{
name: 'container_type',
type: 'text',
isNullable: false,
},
{
name: 'quantity',
type: 'integer',
default: 1,
isNullable: false,
},
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createForeignKey(
'freight.booking_container_allocations',
new TableForeignKey({
columnNames: ['booking_id'],
referencedTableName: 'freight.bookings',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
await queryRunner.createForeignKey(
'freight.booking_container_allocations',
new TableForeignKey({
columnNames: ['vehicle_id'],
referencedTableName: 'freight.vehicles',
referencedColumnNames: ['id'],
onDelete: 'SET NULL',
}),
);
await queryRunner.query(
`CREATE INDEX "IDX_booking_container_allocations_booking_id" ON "freight"."booking_container_allocations" ("booking_id")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_booking_container_allocations_vehicle_id" ON "freight"."booking_container_allocations" ("vehicle_id")`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
const exists = await queryRunner.hasTable('freight.booking_container_allocations');
if (exists) {
await queryRunner.dropTable('freight.booking_container_allocations');
}
}
}

View File

@@ -0,0 +1,74 @@
import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm';
/**
* Create freight.first_mile_container_allocations table — tracks
* container allocations per first-mile shipment with optional vehicle assignment.
*/
export class CreateFirstMileContainerAllocations1830000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
const exists = await queryRunner.hasTable('freight.first_mile_container_allocations');
if (exists) return;
await queryRunner.createTable(
new Table({
name: 'freight.first_mile_container_allocations',
columns: [
{
name: 'id',
type: 'uuid',
isPrimary: true,
default: 'gen_random_uuid()',
},
{ name: 'first_mile_id', type: 'uuid', isNullable: false },
{ name: 'container_id', type: 'uuid', isNullable: false },
{ name: 'vehicle_id', type: 'uuid', isNullable: true },
{ name: 'container_type', type: 'text', isNullable: false },
{
name: 'quantity',
type: 'int',
default: 1,
isNullable: false,
},
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createForeignKey(
'freight.first_mile_container_allocations',
new TableForeignKey({
columnNames: ['first_mile_id'],
referencedTableName: 'freight.first_mile',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
await queryRunner.createForeignKey(
'freight.first_mile_container_allocations',
new TableForeignKey({
columnNames: ['vehicle_id'],
referencedTableName: 'freight.vehicles',
referencedColumnNames: ['id'],
onDelete: 'SET NULL',
}),
);
await queryRunner.query(
`CREATE INDEX "IDX_first_mile_container_allocations_first_mile_id" ON "freight"."first_mile_container_allocations" ("first_mile_id")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_first_mile_container_allocations_vehicle_id" ON "freight"."first_mile_container_allocations" ("vehicle_id")`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
const exists = await queryRunner.hasTable('freight.first_mile_container_allocations');
if (exists) {
await queryRunner.dropTable('freight.first_mile_container_allocations');
}
}
}

View File

@@ -8,7 +8,7 @@ import { BillingService } from "./billing.service";
@Controller("billing")
@FreightAdmin()
export class BillingController {
constructor(private readonly billingService: BillingService) {}
constructor(private readonly billingService: BillingService) { }
@Get("invoices")
@ApiOperation({ summary: "List all invoices" })
@@ -16,9 +16,9 @@ export class BillingController {
return this.billingService.findAll();
}
@Get("invoices/booking/:bookingId")
@ApiOperation({ summary: "List invoices for a booking" })
findByBooking(@Param("bookingId", ParseUUIDPipe) bookingId: string) {
return this.billingService.findByBooking(bookingId);
@Get("invoices/:id")
@ApiOperation({ summary: "Get an invoice with its line items" })
findById(@Param("id", ParseUUIDPipe) id: string) {
return this.billingService.findById(id);
}
}

View File

@@ -1,14 +1,24 @@
import { Module } from "@nestjs/common";
import { forwardRef, Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { BillingController } from "./billing.controller";
import { PortalBillingController } from "./portal-billing.controller";
import { BillingService } from "./billing.service";
import { Invoice } from "./entities/invoice.entity";
import { InvoiceLine } from "./entities/invoice-line.entity";
import { InvoiceRepository } from "./invoice.repository";
import { InvoiceLineRepository } from "./invoice-line.repository";
import { PaymentModule } from "../payment/payment.module";
import { CompaniesModule } from "../companies/companies.module";
@Module({
imports: [TypeOrmModule.forFeature([Invoice])],
controllers: [BillingController],
providers: [BillingService],
imports: [
TypeOrmModule.forFeature([Invoice, InvoiceLine]),
forwardRef(() => PaymentModule),
CompaniesModule,
],
controllers: [BillingController, PortalBillingController],
providers: [BillingService, InvoiceRepository, InvoiceLineRepository],
exports: [BillingService],
})
export class BillingModule {}

View File

@@ -0,0 +1,250 @@
import { Freight } from "@edr/types";
import { BillingService } from "./billing.service";
/**
* Minimal in-memory EntityManager stand-in covering the methods
* `generateInvoice` / `markInvoiceAsPaid` call on the transaction manager.
*/
function makeManager(savedLines: unknown[]) {
return {
create: (_entity: unknown, data: Record<string, unknown>) => data,
save: (data: Record<string, unknown>) => {
const row = { id: data.id ?? "gen-1", ...data };
if (data.invoiceId) savedLines.push(row);
return Promise.resolve(row);
},
query: () => Promise.resolve([{ seq: 0 }]),
update: jest.fn().mockResolvedValue(undefined),
findOne: jest.fn().mockResolvedValue(null),
};
}
function makeEvents() {
return { emit: jest.fn() };
}
function generateInput(overrides: Record<string, unknown> = {}) {
return {
source: Freight.InvoiceSource.Booking,
sourceId: "booking-1",
type: "prepaid",
companyId: "company-1",
companyProfileId: "profile-1",
currency: "ETB",
lines: [
{
chargeType: "RAIL_FREIGHT",
description: "Rail freight",
quantity: 2,
unitRate: 500,
amount: 1000,
},
{
chargeType: "HAZARD_SURCHARGE",
description: "Hazard surcharge",
quantity: 2,
unitRate: 250,
amount: 500,
},
],
...overrides,
};
}
describe("BillingService.generateInvoice", () => {
let savedLines: unknown[];
let manager: ReturnType<typeof makeManager>;
let events: ReturnType<typeof makeEvents>;
let dataSource: { transaction: jest.Mock; manager: unknown };
let service: BillingService;
beforeEach(() => {
savedLines = [];
manager = makeManager(savedLines);
events = makeEvents();
dataSource = {
transaction: jest
.fn()
.mockImplementation((cb: (mg: unknown) => unknown) => cb(manager)),
manager,
};
service = new BillingService(
dataSource as never,
{} as never,
{} as never,
events as never,
{} as never, // payment
{} as never, // companies
);
});
it("creates a PENDING invoice with one line per input line", async () => {
const invoice = await service.generateInvoice(generateInput());
expect(invoice.status).toBe(Freight.InvoiceStatus.Pending);
expect(invoice.companyId).toBe("company-1");
expect(invoice.source).toBe("booking");
expect(invoice.sourceId).toBe("booking-1");
expect(invoice.totalAmount).toBe(1500);
expect(invoice.issuedAt).toBeInstanceOf(Date);
expect(invoice.invoiceNumber).toMatch(/^FRT-\d{8}-00001$/);
expect(savedLines).toHaveLength(2);
});
it("sums line amounts when no explicit totalAmount is given", async () => {
const invoice = await service.generateInvoice(
generateInput({ totalAmount: undefined }),
);
expect(invoice.totalAmount).toBe(1500);
});
it("leaves issuedAt null for a DRAFT invoice", async () => {
const invoice = await service.generateInvoice(
generateInput({ status: Freight.InvoiceStatus.Draft }),
);
expect(invoice.status).toBe(Freight.InvoiceStatus.Draft);
expect(invoice.issuedAt).toBeNull();
});
it("enlists in a caller's transaction when a manager is passed", async () => {
await service.generateInvoice(generateInput(), manager as never);
expect(dataSource.transaction).not.toHaveBeenCalled();
expect(savedLines).toHaveLength(2);
});
});
describe("BillingService.markInvoiceAsPaid", () => {
it("marks the invoice PAID, links the payment, and emits ${source}.invoice.paid", async () => {
const open = {
id: "inv-1",
status: Freight.InvoiceStatus.Pending,
source: "booking",
sourceId: "booking-1",
};
const mg = {
findOne: jest.fn().mockResolvedValue(open),
update: jest.fn().mockResolvedValue(undefined),
};
const events = makeEvents();
const service = new BillingService(
{ manager: mg } as never,
{} as never,
{} as never,
events as never,
{} as never, // payment
{} as never, // companies
);
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
expect(mg.update).toHaveBeenCalledWith(
expect.anything(),
{ id: "inv-1" },
{ status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" },
);
expect(events.emit).toHaveBeenCalledWith(
"booking.invoice.paid",
expect.objectContaining({
invoiceId: "inv-1",
status: Freight.InvoiceStatus.Paid,
paymentId: "pay-1",
}),
);
});
it("is a no-op (no event) when the invoice is already paid", async () => {
const paid = {
id: "inv-1",
status: Freight.InvoiceStatus.Paid,
source: "booking",
};
const mg = {
findOne: jest.fn().mockResolvedValue(paid),
update: jest.fn().mockResolvedValue(undefined),
};
const events = makeEvents();
const service = new BillingService(
{ manager: mg } as never,
{} as never,
{} as never,
events as never,
{} as never, // payment
{} as never, // companies
);
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
expect(mg.update).not.toHaveBeenCalled();
expect(events.emit).not.toHaveBeenCalled();
});
});
describe("BillingService.settlePayable", () => {
it("settles the source's open invoice PAID and emits ${source}.invoice.paid", async () => {
const open = {
id: "inv-1",
status: Freight.InvoiceStatus.Pending,
source: Freight.InvoiceSource.Booking,
sourceId: "booking-1",
};
const mg = {
findOne: jest.fn().mockResolvedValue(open),
update: jest.fn().mockResolvedValue(undefined),
};
const events = makeEvents();
const service = new BillingService(
{ manager: mg } as never,
{} as never,
{} as never,
events as never,
{} as never, // payment
{} as never, // companies
);
const settled = await service.settlePayable(
Freight.InvoiceSource.Booking,
"booking-1",
"pay-1",
mg as never,
);
expect(settled?.status).toBe(Freight.InvoiceStatus.Paid);
expect(mg.update).toHaveBeenCalledWith(
expect.anything(),
{ id: "inv-1" },
{ status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" },
);
expect(events.emit).toHaveBeenCalledWith(
"booking.invoice.paid",
expect.anything(),
);
});
it("is a no-op (returns null) when the source has no open invoice", async () => {
const mg = {
findOne: jest.fn().mockResolvedValue(null),
update: jest.fn().mockResolvedValue(undefined),
};
const events = makeEvents();
const service = new BillingService(
{ manager: mg } as never,
{} as never,
{} as never,
events as never,
{} as never, // payment
{} as never, // companies
);
const settled = await service.settlePayable(
Freight.InvoiceSource.Booking,
"booking-1",
"pay-1",
mg as never,
);
expect(settled).toBeNull();
expect(mg.update).not.toHaveBeenCalled();
expect(events.emit).not.toHaveBeenCalled();
});
});

View File

@@ -1,26 +1,542 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { forwardRef, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { EventEmitter2 } from "@nestjs/event-emitter";
import { Freight, PaymentReferenceType } from "@edr/types";
import { DataSource, EntityManager, In } from "typeorm";
import { Invoice } from "./entities/invoice.entity";
import { InvoiceLine } from "./entities/invoice-line.entity";
import { InvoiceRepository } from "./invoice.repository";
import { InvoiceLineRepository } from "./invoice-line.repository";
import { PaymentService } from "../payment/payment.service";
import { InitiateResponseDto } from "../payment/payments.dto";
import { CompaniesService } from "../companies/companies.service";
/** Options forwarded to the payment gateway when settling an invoice. */
export interface PayInvoiceOptions {
method?: string;
platform?: "web" | "mobile";
payerAccount?: string;
returnUrl?: string;
failureUrl?: string;
}
/** Default invoice payment-term window, in days, used to compute `dueAt`. */
const DEFAULT_DUE_DAYS = 14;
/** Statuses an invoice can still be settled (paid/refunded/cancelled) from. */
const OPEN_STATUSES: Freight.InvoiceStatus[] = [
Freight.InvoiceStatus.Draft,
Freight.InvoiceStatus.Pending,
Freight.InvoiceStatus.Overdue,
];
/** A single line to bill on a generated invoice. */
export interface InvoiceLineInput {
chargeType: string;
description?: string;
/** Units this line bills for; defaults to 1. */
quantity?: number;
/** Price per unit; defaults to 0. */
unitRate?: number;
/** Line total; defaults to `quantity * unitRate`. */
amount?: number;
currency?: string;
metadata?: Record<string, unknown> | null;
}
/** Everything needed to generate an invoice for any source. */
export interface GenerateInvoiceInput {
/** Originating subsystem; namespaces events (`${source}.invoice.<event>`). */
source: Freight.InvoiceSource;
/** Identifier of the source record (e.g. booking id). */
sourceId: string;
/** What the invoice is for (e.g. "prepaid", "credit"). */
type: string;
companyId: string;
companyProfileId: string;
lines: InvoiceLineInput[];
currency?: string;
/** Explicit total; defaults to the sum of line amounts. */
totalAmount?: number;
/** Issue date window; defaults to `DEFAULT_DUE_DAYS` from now. */
dueAt?: Date;
dueInDays?: number;
/**
* Initial status. DRAFT leaves `issuedAt` null; any issued status
* (default PENDING) stamps `issuedAt`.
*/
status?: Freight.InvoiceStatus;
}
/** Payload broadcast on `${source}.invoice.<event>`. */
export interface InvoiceEventPayload {
invoiceId: string;
invoiceNumber: string;
source: Freight.InvoiceSource;
sourceId: string;
type: string;
companyId: string;
companyProfileId: string;
totalAmount: number;
currency: string;
status: Freight.InvoiceStatus;
paymentId?: string | null;
}
@Injectable()
export class BillingService {
private readonly logger = new Logger(BillingService.name);
constructor(
@InjectRepository(Invoice)
private readonly invoicesRepository: Repository<Invoice>,
) {}
private readonly dataSource: DataSource,
private readonly invoices: InvoiceRepository,
private readonly invoiceLines: InvoiceLineRepository,
private readonly events: EventEmitter2,
@Inject(forwardRef(() => PaymentService))
private readonly payment: PaymentService,
private readonly companies: CompaniesService,
) { }
// ── Reads ──────────────────────────────────────────────────────────────────
/** List every invoice (most recent first). */
findAll(): Promise<Invoice[]> {
return this.invoicesRepository.find({ order: { issuedAt: "DESC" } });
return this.invoices.findAll({ order: { issuedAt: "DESC" } });
}
/** List invoices for a given booking. */
findByBooking(bookingId: string): Promise<Invoice[]> {
return this.invoicesRepository.find({
where: { bookingId },
/** Invoice header plus its line items. */
async findById(id: string): Promise<Invoice & { lines: InvoiceLine[] }> {
const invoice = await this.invoices.findById(id);
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
const lines = await this.invoiceLines.findAll({
where: { invoiceId: id },
order: { createdAt: "ASC" },
});
return { ...invoice, lines } as Invoice & { lines: InvoiceLine[] };
}
// ── Customer-scoped reads (portal) ───────────────────────────────────────────
/** Resolve the customer's company id from their IAM user id (null if none). */
async resolveCompanyId(userId: string): Promise<string | null> {
try {
const { company } = await this.companies.getCompanyInfoByUserId(userId);
return company?.id ?? null;
} catch {
return null;
}
}
/** Every invoice billed to a company, newest first, with billing relations. */
findByCompany(companyId: string): Promise<Invoice[]> {
return this.invoices.findAll({
where: { companyId },
relations: { company: true, companyProfile: true },
order: { createdAt: "DESC" },
});
}
/** Invoices for the signed-in customer; empty when they have no company. */
async findForUser(userId: string): Promise<Invoice[]> {
const companyId = await this.resolveCompanyId(userId);
return companyId ? this.findByCompany(companyId) : [];
}
/** Company-scoped invoice detail (+ lines); 404 when not owned by the user. */
async findByIdForUser(
id: string,
userId: string,
): Promise<Invoice & { lines: InvoiceLine[] }> {
const companyId = await this.resolveCompanyId(userId);
const invoice = await this.findById(id);
if (!companyId || invoice.companyId !== companyId) {
throw new NotFoundException(`Invoice ${id} not found`);
}
return invoice;
}
/**
* Initiate gateway payment for one of the customer's own invoices. Verifies
* ownership, then charges whichever open invoice the source currently has
* (see {@link payInvoice}).
*/
async payInvoiceForUser(
id: string,
userId: string,
opts: PayInvoiceOptions = {},
): Promise<InitiateResponseDto> {
const invoice = await this.findByIdForUser(id, userId);
return this.payInvoice(
invoice.source as Freight.InvoiceSource,
invoice.sourceId,
opts,
);
}
// ── Generation ───────────────────────────────────────────────────────────────
/** `FRT-YYYYMMDD-00001` — sequential per day, within the active transaction. */
private async nextInvoiceNumber(mg: EntityManager): Promise<string> {
const now = new Date();
const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}`;
const prefix = `FRT-${ymd}-`;
const [row] = await mg.query(
`SELECT COALESCE(MAX(CAST(split_part(invoice_number, '-', 3) AS int)), 0) AS seq
FROM freight.invoices WHERE invoice_number LIKE $1`,
[`${prefix}%`],
);
const next = Number(row?.seq ?? 0) + 1;
return `${prefix}${String(next).padStart(5, "0")}`;
}
/**
* Generate an invoice for any source (booking, demurrage, manual, …).
*
* Persists the header plus its lines in one transaction and assigns the next
* sequential `invoice_number`. The total defaults to the sum of line amounts
* unless `totalAmount` is given. Issued invoices (default PENDING) stamp
* `issuedAt`; pass `status: DRAFT` to leave it unissued.
*
* Pass `manager` to enlist in a caller's transaction (e.g. when generating an
* invoice as part of a larger booking flow).
*/
async generateInvoice(
input: GenerateInvoiceInput,
manager?: EntityManager,
): Promise<Invoice & { lines: InvoiceLine[] }> {
const run = (mg: EntityManager) => this.createInvoice(input, mg);
return manager ? run(manager) : this.dataSource.transaction(run);
}
private async createInvoice(
input: GenerateInvoiceInput,
mg: EntityManager,
): Promise<Invoice & { lines: InvoiceLine[] }> {
const currency = input.currency ?? "ETB";
const status = input.status ?? Freight.InvoiceStatus.Pending;
const issued = status !== Freight.InvoiceStatus.Draft;
const lines = input.lines.map((l) => {
const quantity = l.quantity ?? 1;
const unitRate = l.unitRate ?? 0;
return {
chargeType: l.chargeType,
description: l.description,
quantity,
unitRate,
amount: l.amount ?? quantity * unitRate,
currency: l.currency ?? currency,
metadata: l.metadata ?? null,
};
});
const totalAmount =
input.totalAmount ?? lines.reduce((sum, l) => sum + Number(l.amount), 0);
const dueAt =
input.dueAt ??
new Date(
Date.now() +
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
);
const invoiceNumber = await this.nextInvoiceNumber(mg);
const invoice = await mg.save(
mg.create(Invoice, {
invoiceNumber,
source: input.source,
sourceId: input.sourceId,
type: input.type,
companyId: input.companyId,
companyProfileId: input.companyProfileId,
totalAmount,
currency,
status,
issuedAt: issued ? new Date() : null,
dueAt,
}),
);
const savedLines = await Promise.all(
lines.map((l) =>
mg.save(mg.create(InvoiceLine, { ...l, invoiceId: invoice.id })),
),
);
this.logger.log(
`Generated invoice ${invoice.invoiceNumber} (${invoice.id}) for ${input.source}:${input.sourceId}`,
);
return { ...invoice, lines: savedLines };
}
// ── State transitions ────────────────────────────────────────────────────────
/**
* Mark an invoice paid and link the gateway payment, then emit
* `${source}.invoice.paid`. Full-payment only — no partial settlement.
* No-op when the invoice is already paid. Pass `manager` to enlist in a
* caller's transaction.
*/
async markInvoiceAsPaid(
invoiceId: string,
paymentId: string | null = null,
manager?: EntityManager,
): Promise<Invoice | null> {
return this.transition(
invoiceId,
Freight.InvoiceStatus.Paid,
"paid",
{ paymentId: paymentId ?? undefined },
manager,
);
}
/**
* Mark an invoice refunded and emit `${source}.invoice.refunded`.
* No-op when already refunded.
*/
async markInvoiceAsRefunded(
invoiceId: string,
manager?: EntityManager,
): Promise<Invoice | null> {
return this.transition(
invoiceId,
Freight.InvoiceStatus.Refunded,
"refunded",
{},
manager,
);
}
/**
* Mark an invoice cancelled and emit `${source}.invoice.cancelled`.
* No-op when already cancelled.
*/
async cancelInvoice(
invoiceId: string,
manager?: EntityManager,
): Promise<Invoice | null> {
return this.transition(
invoiceId,
Freight.InvoiceStatus.Cancelled,
"cancelled",
{},
manager,
);
}
/**
* Load the invoice, apply the new status (+ extra columns), then emit
* `${source}.invoice.<event>`. No-op (returns the invoice) when it is already
* in the target status. Throws when the invoice does not exist.
*
* Note: the event fires in-process synchronously. When a `manager` from an
* outer transaction is passed, listeners run before that transaction commits.
*/
private async transition(
invoiceId: string,
status: Freight.InvoiceStatus,
event: string,
extra: { paymentId?: string },
manager?: EntityManager,
): Promise<Invoice | null> {
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, { where: { id: invoiceId } });
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
if (invoice.status === status) return invoice;
await mg.update(Invoice, { id: invoice.id }, { status, ...extra });
const updated = { ...invoice, ...extra, status } as Invoice;
this.emitInvoiceEvent(event, updated);
return updated;
}
/** Broadcast `${invoice.source}.invoice.<event>` to in-process listeners. */
private emitInvoiceEvent(event: string, invoice: Invoice): void {
const payload: InvoiceEventPayload = {
invoiceId: invoice.id,
invoiceNumber: invoice.invoiceNumber,
source: invoice.source as Freight.InvoiceSource,
sourceId: invoice.sourceId,
type: invoice.type,
companyId: invoice.companyId,
companyProfileId: invoice.companyProfileId,
totalAmount: invoice.totalAmount,
currency: invoice.currency,
status: invoice.status,
paymentId: invoice.paymentId ?? null,
};
this.events.emit(`${invoice.source}.invoice.${event}`, payload);
}
// ── Payment reconciliation (by source) ───────────────────────────────────────
/**
* The invoice a gateway payment should settle for a source record, or null if
* none. This is the billing document of record for "what is owed" — callers
* (e.g. {@link payInvoice}) charge `invoice.totalAmount` against it rather than
* recomputing from the source's own total, so discounts/penalties/adjustments
* carried on the invoice are honored.
*
* Pass `type` to select a specific invoice when a source carries several (e.g.
* a booking's up-front vs final charge); omit it to settle whichever single
* invoice is currently open. Returns the most recent matching open (unpaid,
* non-cancelled) invoice.
*/
findPayable(
source: Freight.InvoiceSource,
sourceId: string,
type?: string,
): Promise<Invoice | null> {
return this.dataSource.getRepository(Invoice).findOne({
where: {
source,
sourceId,
status: In(OPEN_STATUSES),
...(type ? { type } : {}),
},
order: { issuedAt: "DESC" },
});
}
/**
* Settle a source's currently-open invoice as paid and link the gateway
* payment, then emit `${source}.invoice.paid`. Resolves the open invoice then
* delegates to {@link markInvoiceAsPaid}. Full-payment only — no partial
* settlement. No-op (returns null) when the source has no open invoice.
*
* Type-blind by design: settles whichever invoice is due; any per-type reaction
* belongs in the `${source}.invoice.paid` handler, which reads `invoice.type`.
* Pass the caller's transaction `manager` to enlist in its DB transaction.
*
* NOTE: the booking flow settles via {@link payInvoice} + the `payment.succeeded`
* event ({@link settleByPaymentId}); this source-keyed settle is a generic helper
* for callers that settle by source rather than by gateway intent id.
*/
async settlePayable(
source: Freight.InvoiceSource,
sourceId: string,
paymentId: string | null,
manager?: EntityManager,
): Promise<Invoice | null> {
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, {
where: { source, sourceId, status: In(OPEN_STATUSES) },
order: { issuedAt: "DESC" },
});
if (!invoice) return null;
return this.markInvoiceAsPaid(invoice.id, paymentId, mg);
}
/**
* Refund a source's paid invoice, then emit `${source}.invoice.refunded`.
* Resolves the paid invoice then delegates to {@link markInvoiceAsRefunded}.
* No-op (returns null) when the source has no paid invoice.
*
* Pass the caller's transaction `manager` (e.g. from `payment.service.refund`)
* to enlist in its DB transaction.
*/
async refundPayable(
source: Freight.InvoiceSource,
sourceId: string,
manager?: EntityManager,
): Promise<Invoice | null> {
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, {
where: { source, sourceId, status: Freight.InvoiceStatus.Paid },
order: { issuedAt: "DESC" },
});
if (!invoice) return null;
return this.markInvoiceAsRefunded(invoice.id, mg);
}
// ── Payment initiation & settlement (the gateway boundary) ───────────────────
/**
* Charge a source's open invoice through the payment gateway. Billing is the
* single place that turns "what is owed" (the invoice) into a payment intent —
* the domain never talks to the payment service directly. Resolves the open
* invoice, opens an intent for `invoice.totalAmount`, records the intent id on
* the invoice (the settlement correlation key), and returns the client action.
*
* When the provider settles synchronously, the invoice is settled inline here —
* after the intent id is stored — so the `payment.succeeded` correlation can
* never fire before the link exists. Throws when the source has no open invoice.
*/
async payInvoice(
source: Freight.InvoiceSource,
sourceId: string,
opts: {
method?: string;
platform?: "web" | "mobile";
payerAccount?: string;
returnUrl?: string;
failureUrl?: string;
} = {},
): Promise<InitiateResponseDto> {
const invoice = await this.findPayable(source, sourceId);
if (!invoice) {
throw new NotFoundException(`No open invoice to charge for ${source}:${sourceId}`);
}
const result = await this.payment.initiate({
referenceId: sourceId,
source: invoice.source,
// Gateway reference type derives from the invoice source by convention
// (source.toUpperCase() ∈ PaymentReferenceType) — no domain word here, and
// the domain never supplies it. New sources add their uppercased value to
// the PaymentReferenceType enum.
referenceType: invoice.source.toUpperCase() as PaymentReferenceType,
orderRef: invoice.invoiceNumber,
amountMinor: Math.round(Number(invoice.totalAmount)),
currency: invoice.currency,
reason: `Payment for invoice ${invoice.invoiceNumber}`,
method: opts.method ?? "TELEBIRR",
platform: opts.platform,
payerAccount: opts.payerAccount,
returnUrl: opts.returnUrl,
failureUrl: opts.failureUrl,
});
// Link the intent to the invoice BEFORE any settlement can correlate against it.
await this.dataSource
.getRepository(Invoice)
.update({ id: invoice.id }, { paymentId: result.intentId });
if (result.immediateSuccess) {
await this.settleByPaymentId(
result.intentId,
result.providerTxnId,
result.paidAt,
);
}
return result.response;
}
/**
* Settle the open invoice linked to a gateway intent id, if any. Called by the
* payment service when an intent succeeds: finds the invoice linked by
* `paymentId`, marks it paid, and emits `${source}.invoice.paid` for the domain
* to advance on. Idempotent — no-op when no open invoice is linked (already
* settled, or settled inline by {@link payInvoice}).
*/
async settleByPaymentId(
paymentId: string,
_providerTxnId?: string,
_paidAt?: Date,
): Promise<Invoice | null> {
const invoice = await this.dataSource.getRepository(Invoice).findOne({
where: { paymentId, status: In(OPEN_STATUSES) },
order: { issuedAt: "DESC" },
});
if (!invoice) return null;
return this.markInvoiceAsPaid(invoice.id, paymentId);
}
}

View File

@@ -0,0 +1,30 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsIn, IsOptional, IsString } from "class-validator";
/** Gateway options for paying an invoice from the customer portal. */
export class PayInvoiceDto {
@ApiPropertyOptional({ description: "Payment method (defaults to TELEBIRR)." })
@IsOptional()
@IsString()
method?: string;
@ApiPropertyOptional({ enum: ["web", "mobile"], default: "web" })
@IsOptional()
@IsIn(["web", "mobile"])
platform?: "web" | "mobile";
@ApiPropertyOptional({ description: "Payer account / phone, for wallet methods." })
@IsOptional()
@IsString()
payerAccount?: string;
@ApiPropertyOptional({ description: "Browser redirect URL on success." })
@IsOptional()
@IsString()
returnUrl?: string;
@ApiPropertyOptional({ description: "Browser redirect URL on failure." })
@IsOptional()
@IsString()
failureUrl?: string;
}

View File

@@ -0,0 +1,43 @@
import { BaseEntity } from "@edr/api-common";
import { Column, Entity, JoinColumn, ManyToOne } from "typeorm";
import { Invoice } from "./invoice.entity";
@Entity({ schema: "freight", name: "invoice_lines" })
export class InvoiceLine extends BaseEntity {
@Column({ name: "invoice_id", type: "uuid", nullable: false })
invoiceId!: string;
@ManyToOne(() => Invoice, { onDelete: "CASCADE" })
@JoinColumn({ name: "invoice_id" })
invoice!: Invoice;
@Column({ name: "charge_type", type: "varchar", nullable: false })
chargeType!: string;
@Column({ name: "description", type: "varchar", length: 255, nullable: true })
description?: string;
/** Units this line bills for (e.g. container count, wagon count, tons). */
@Column({ name: "quantity", type: "numeric", precision: 12, scale: 2, default: 1 })
quantity!: number;
/** Price per unit; `amount` is normally `quantity * unitRate`. */
@Column({ name: "unit_rate", type: "numeric", precision: 14, scale: 2, default: 0 })
unitRate!: number;
@Column({
name: "amount",
type: "numeric",
precision: 14,
scale: 2,
nullable: false,
})
amount!: number;
@Column({ name: "currency", type: "varchar", length: 8, default: "ETB" })
currency!: string;
@Column({ name: "metadata", type: "jsonb", nullable: true })
metadata?: Record<string, unknown> | null;
}

View File

@@ -1,17 +1,35 @@
import { BaseEntity } from "@edr/api-common";
import { Freight } from "@edr/types";
import { Column, Entity } from "typeorm";
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
import { PaymentEntity } from "../../payment/entities/payment.entity";
import { Company } from "../../companies/entities/company.entity";
import { CompanyProfile } from "../../companies/entities/company-profile.entity";
@Entity({schema:"freight", name: "invoices" })
@Entity({ schema: "freight", name: "invoices" })
@Index(["companyId"])
@Index(["companyProfileId"])
export class Invoice extends BaseEntity {
@Column({ name: "booking_id", type: "uuid" })
bookingId!: string;
@Column({ name: "invoice_number", type: "varchar", length: 64, unique: true })
invoiceNumber!: string;
@Column({ name: "amount", type: "numeric", precision: 14, scale: 2 })
amount!: number;
/** The customer (company) this invoice is billed to. */
@Column({ name: "company_id", type: "uuid" })
companyId!: string;
@ManyToOne(() => Company)
@JoinColumn({ name: "company_id" })
company?: Company;
/** The specific company profile (importer/exporter/forwarder/...) billed. */
@Column({ name: "company_profile_id", type: "uuid" })
companyProfileId!: string;
@ManyToOne(() => CompanyProfile)
@JoinColumn({ name: "company_profile_id" })
companyProfile?: CompanyProfile;
@Column({ name: "total_amount", type: "numeric", precision: 14, scale: 2 })
totalAmount!: number;
@Column({ name: "currency", type: "varchar", length: 8, default: "ETB" })
currency!: string;
@@ -19,13 +37,38 @@ export class Invoice extends BaseEntity {
@Column({
name: "status",
type: "enum",
enum: Freight.PaymentStatus,
default: Freight.PaymentStatus.Pending,
enum: Freight.InvoiceStatus,
default: Freight.InvoiceStatus.Draft,
})
status!: Freight.PaymentStatus;
status!: Freight.InvoiceStatus;
@Column({ name: "issued_at", type: "timestamptz" })
issuedAt!: Date;
/** The source of the payment (e.g. booking, customer, etc.). */
@Column({ name: "source", type: "varchar", length: 255, nullable: false })
source!: string;
/** The ID of the source (e.g. booking ID, customer ID, etc.). */
@Column({ name: "source_id", type: "varchar", length: 255, nullable: false })
sourceId!: string;
/** The type of Invoice (e.g. prepaid, credit, etc.). it suppose to answer the question "what is the invoice for?" */
@Column({
type: "varchar",
length: 255,
nullable: false,
})
type!: string;
/** Set when the invoice is actually issued (DRAFT invoices leave this null). */
@Column({ name: "issued_at", type: "timestamptz", nullable: true })
issuedAt?: Date | null;
/** The ID of the payment that generated this invoice. */
@Column({ name: "payment_id", type: "uuid", nullable: true })
paymentId?: string | null;
@ManyToOne(() => PaymentEntity)
@JoinColumn({ name: "payment_id" })
payment?: PaymentEntity;
@Column({ name: "due_at", type: "timestamptz" })
dueAt!: Date;

View File

@@ -0,0 +1,15 @@
import { BaseRepository } from "@edr/api-common";
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { InvoiceLine } from "./entities/invoice-line.entity";
@Injectable()
export class InvoiceLineRepository extends BaseRepository<InvoiceLine> {
constructor(
@InjectRepository(InvoiceLine) repository: Repository<InvoiceLine>,
) {
super(repository);
}
}

View File

@@ -0,0 +1,15 @@
import { BaseRepository } from "@edr/api-common";
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { Invoice } from "./entities/invoice.entity";
@Injectable()
export class InvoiceRepository extends BaseRepository<Invoice> {
constructor(
@InjectRepository(Invoice) repository: Repository<Invoice>,
) {
super(repository);
}
}

View File

@@ -0,0 +1,60 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
} from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { CurrentUser } from "@edr/api-common";
import {
type AuthUserPayload,
resolveAuthUserId,
} from "../../common/resolve-auth-user-id";
import { BillingService } from "./billing.service";
import { PayInvoiceDto } from "./dto/pay-invoice.dto";
/**
* Customer-facing billing endpoints. Unlike {@link BillingController} (admin,
* org-wide), every route here is force-scoped to the signed-in customer's
* company — they only ever see and pay their own invoices.
*/
@ApiTags("billing")
@ApiBearerAuth()
@Controller("billing")
export class PortalBillingController {
constructor(private readonly billingService: BillingService) {}
@Get("my-invoices")
@ApiOperation({ summary: "List the signed-in customer's invoices" })
findMine(@CurrentUser() user: AuthUserPayload) {
return this.billingService.findForUser(resolveAuthUserId(user));
}
@Get("my-invoices/:id")
@ApiOperation({ summary: "Get one of the customer's invoices (+ line items)" })
findMineById(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
) {
return this.billingService.findByIdForUser(id, resolveAuthUserId(user));
}
@Post("my-invoices/:id/pay")
@ApiOperation({ summary: "Initiate payment for one of the customer's invoices" })
pay(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
@Body() dto: PayInvoiceDto,
) {
return this.billingService.payInvoiceForUser(id, resolveAuthUserId(user), {
method: dto.method,
platform: dto.platform ?? "web",
payerAccount: dto.payerAccount,
returnUrl: dto.returnUrl,
failureUrl: dto.failureUrl,
});
}
}

View File

@@ -0,0 +1,20 @@
import { Body, Controller, Param, ParseUUIDPipe, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingsService } from './bookings.service';
import { AllocateContainersDto } from './dto/allocate-containers.dto';
@ApiTags('bookings')
@Controller('bookings')
@ApiBearerAuth()
export class BookingAllocationController {
constructor(private readonly bookingsService: BookingsService) {}
@Post(':bookingId/allocate-containers')
@ApiOperation({ summary: 'Allocate containers to vehicles' })
async allocateContainers(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: AllocateContainersDto,
) {
return this.bookingsService.allocateContainers(bookingId, dto.allocations);
}
}

View File

@@ -0,0 +1,201 @@
import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { Freight } from '@edr/types';
import { DataSource } from 'typeorm';
import {
BillingService,
GenerateInvoiceInput,
InvoiceEventPayload,
InvoiceLineInput,
} from '../billing/billing.service';
import { Invoice } from '../billing/entities/invoice.entity';
import { FirstMileService } from '../first-mile/first-mile.service';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { PriceLineItemDto } from './dto/generate-price-response.dto';
import { BookingsRepository } from './bookings.repository';
import { Booking } from './entities/booking.entity';
/** Snapshot written onto `booking.pricingBreakdown` by the pricing service. */
interface StoredPricingBreakdown {
lineItems?: PriceLineItemDto[];
totalAmount?: number;
currency?: string;
}
/** Round to 2 decimals, avoiding binary float drift. */
const round2 = (n: number): number => Math.round(n * 100) / 100;
/**
* Owns the booking ⇄ invoice mapping — the one place that knows how a booking
* turns into invoices, which type to use, and how it advances when paid. Bookings
* are the billable business entity, so they generate their own invoices directly
* via {@link BillingService} (billing stays source-agnostic). All booking-specific
* type branching lives here, at the two points it belongs: invoice creation and
* settlement (the paid handler).
*/
@Injectable()
export class BookingInvoiceService {
private readonly logger = new Logger(BookingInvoiceService.name);
constructor(
private readonly billing: BillingService,
private readonly bookingsRepository: BookingsRepository,
private readonly dataSource: DataSource,
@Inject(forwardRef(() => FirstMileService))
private readonly firstMile: FirstMileService,
@Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatch: BookingBatchService,
) { }
/**
* Ensure the booking has its invoice, generating one from the snapshotted
* pricing breakdown if absent. Called when a booking reaches a billable state.
* Idempotent — returns the existing open invoice instead of a duplicate.
* Returns `null` (and logs) when the booking is not billable: no company to
* bill (e.g. government bookings whose `companyId` is null, which the invoices
* FK requires), or no priced amount.
*/
async ensureInvoiceForBooking(booking: Booking): Promise<Invoice | null> {
const existing = await this.billing.findPayable(
Freight.InvoiceSource.Booking,
booking.id,
Freight.InvoiceType.Prepaid,
);
if (existing) return existing;
if (!booking.companyId) {
this.logger.warn(
`Skipping invoice for booking ${booking.reference} (${booking.id}): no company to bill.`,
);
return null;
}
const input = this.buildInput(booking);
if (!input) {
this.logger.warn(
`Skipping invoice for booking ${booking.reference} (${booking.id}): no priced amount.`,
);
return null;
}
return this.billing.generateInvoice(input);
}
/**
* React to a booking invoice being paid — the settlement branch point. Per-type
* reactions live here (not in the payment process): each invoice type advances
* the booking its own way. Only PREPAID exists today.
*/
@OnEvent('booking.invoice.paid')
async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
switch (payload.type) {
case Freight.InvoiceType.Prepaid:
await this.advanceBookingOnPayment(payload.sourceId);
break;
default:
this.logger.warn(
`Unhandled booking invoice type "${payload.type}" paid (${payload.invoiceId})`,
);
}
}
/**
* Advance a booking once its prepaid invoice settles — the domain side-effect
* of payment, relocated out of the payment service: the booking becomes PAID
* and is allocated into its batch. Idempotent — no-op when already PAID.
*
* General contracts are a separate aggregate now: their CONTRACT_ACTIVE
* lifecycle and ordering window live in the contracts module, advanced by the
* contract transition/clearance services — not by booking payment. Every
* booking that settles here is a ONE_TIME shipment, so there is no contract
* branch (legacy GENERAL_CONTRACT booking creation now 410s).
*/
private async advanceBookingOnPayment(bookingId: string): Promise<void> {
const booking = await this.bookingsRepository.findById(bookingId);
if (!booking) {
this.logger.warn(`Cannot advance unknown booking ${bookingId} on payment.`);
return;
}
if (booking.paymentStatus === 'PAID') return;
await this.dataSource.transaction(async (mg) => {
await mg.update(
Booking,
{ id: bookingId },
{ paymentStatus: 'PAID', status: 'PAID' },
);
await this.firstMile.acceptBooking(bookingId);
});
try {
await this.bookingBatch.ensurePaidBookingAllocated(bookingId);
} catch (err) {
this.logger.error(
`Error allocating booking after payment: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
/** Map a booking's pricing snapshot into a generic invoice request. */
private buildInput(booking: Booking): GenerateInvoiceInput | null {
const breakdown = (booking.pricingBreakdown ?? {}) as StoredPricingBreakdown;
const currency = breakdown.currency ?? booking.paymentCurrency ?? 'ETB';
const lines: InvoiceLineInput[] = (breakdown.lineItems ?? []).map((l) => ({
chargeType: l.code,
description: l.description,
quantity: l.quantity,
unitRate: l.unitAmount,
amount: l.amount,
currency: l.currency ?? currency,
metadata: l.unit ? { unit: l.unit } : null,
}));
// Fall back to a single freight line when no breakdown was snapshotted.
if (lines.length === 0) {
const amount = Number(booking.totalAmount);
if (!Number.isFinite(amount) || amount <= 0) return null;
lines.push({
chargeType: 'FREIGHT',
description: 'Rail freight',
quantity: 1,
unitRate: amount,
amount,
currency,
});
}
const subtotal = round2(lines.reduce((sum, l) => sum + Number(l.amount), 0));
let totalAmount = subtotal;
// Honor a staff price override: bill the adjusted total, recording the delta
// as an ADJUSTMENT line so the lines still sum to the invoice total.
const adjusted = booking.adjustedTotalAmount;
if (adjusted != null && Number.isFinite(Number(adjusted))) {
const delta = round2(Number(adjusted) - subtotal);
if (delta !== 0) {
lines.push({
chargeType: 'ADJUSTMENT',
description: 'Staff price adjustment',
quantity: 1,
unitRate: delta,
amount: delta,
currency,
});
}
totalAmount = round2(Number(adjusted));
}
return {
source: Freight.InvoiceSource.Booking,
sourceId: booking.id,
type: Freight.InvoiceType.Prepaid,
companyId: booking.companyId,
companyProfileId: booking.companyProfileId,
currency,
lines,
totalAmount,
};
}
}

View File

@@ -0,0 +1,180 @@
import {
Body,
Controller,
Get,
HttpStatus,
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 { Freight } from "@edr/types";
import { BillingService } from "../billing/billing.service";
import {
InitiatePaymentDto,
InitiateResponseDto,
PaymentMethodTypeEnum,
PaymentPlatformDto,
} from "../payment/payments.dto";
/**
* Booking-payment entrypoints. This is the ONE place that knows a payment is for a
* booking — it maps the request to {@link Freight.InvoiceSource.Booking} and hands
* off to billing, which resolves the invoice/amount and drives the gateway. Billing
* and payment stay source-agnostic; the booking knowledge lives here, in the domain.
* Routes are unchanged (`/payments/*`) so the portal is unaffected.
*/
@ApiTags("Payment")
@Controller("payments")
export class BookingPaymentController {
constructor(private readonly billing: BillingService) { }
@Post("initiate")
@ApiOperation({
summary: "Initiate payment for a freight booking",
description: "Charges the booking's open invoice through the payment gateway.",
})
@ApiOkResponse({ type: InitiateResponseDto })
initiate(@Body() dto: InitiatePaymentDto): Promise<InitiateResponseDto> {
return this.billing.payInvoice(Freight.InvoiceSource.Booking, dto.bookingId, {
method: dto.method,
platform: dto.platform,
payerAccount: dto.payerAccount,
returnUrl: dto.returnUrl,
failureUrl: dto.failureUrl,
});
}
@Get("checkout")
@Public()
@ApiOperation({
summary: "Browser checkout redirect",
description:
"Charges the booking's invoice 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.billing.payInvoice(
Freight.InvoiceSource.Booking,
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));
}
}
private buildRedirectHtml(url: string): string {
const escaped = url.replace(/\"/g, "&quot;");
return `<!DOCTYPE html>
<html lang="en">
<head>
<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>
<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>`;
}
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,49 +1,37 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Freight } from '@edr/types';
import { BookingsRepository } from './bookings.repository';
import { Booking } from './entities/booking.entity';
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 { BillingService } from '../billing/billing.service';
import { PaymentMethodTypeEnum } from '../payment/payments.dto';
export interface InAppPaymentReceipt extends InAppPaymentReceiptDto { }
const NON_TERMINAL_STATUSES: PaymentStatus[] = [
"action-required",
"processing",
"success",
];
@Injectable()
export class BookingPaymentService {
constructor(
private readonly bookingsRepository: BookingsRepository,
private readonly paymentService: PaymentService,
private readonly billing: BillingService,
) { }
/**
* Start payment for a booking. The booking never touches the payment gateway
* directly — it charges its invoice through billing, which resolves the amount
* and drives the provider. Returns the provider redirect URL (empty when none).
*/
async pay(bookingId: string): Promise<{ redirectUrl: string }> {
const booking = await this.requireBooking(bookingId);
assertBookingStatus(booking, ['FULLY_EXECUTED', 'SELECTED_FOR_BATCH', 'AWAITING_PAYMENT', '']);
const existing = await this.paymentService.findBookingById(bookingId);
if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) {
if (existing.clientAction) {
const action = existing.clientAction as { type?: string; url?: string };
if (action.type === "REDIRECT" && action.url) {
return { redirectUrl: action.url };
}
}
}
const resp = await this.paymentService.initiatePayment({
bookingId,
const resp = await this.billing.payInvoice(Freight.InvoiceSource.Booking, bookingId, {
method: PaymentMethodTypeEnum.TELEBIRR,
platform: "web",
platform: 'web',
});
const action = resp.clientAction as { type?: string; url?: string } | undefined;
return {
redirectUrl: action?.type === "REDIRECT" ? (action.url ?? "") : "",
redirectUrl: action?.type === 'REDIRECT' ? (action.url ?? '') : '',
};
}

View File

@@ -30,6 +30,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
ruleEngineService as never,
{} as never, // pricingService
{} as never, // contractService
{} as never, // invoiceService
{} as never, // filesService
{} as never, // fileUploadSettingsService
{} as never, // bookingBatchService

View File

@@ -41,6 +41,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
{} as never, // ruleEngineService
{} as never, // pricingService
{} as never, // contractService
{} as never, // invoiceService
filesService as never,
fileUploadSettingsService as never,
{} as never, // bookingBatchService
@@ -122,6 +123,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
{} as never,
{} as never,
{} as never,
{} as never, // invoiceService
filesService as never,
fileUploadSettingsService as never,
{} as never,
@@ -189,6 +191,7 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields
{} as never,
{} as never,
{} as never,
{} as never, // invoiceService
filesService as never,
fileUploadSettingsService as never,
{} as never,

View File

@@ -33,6 +33,7 @@ describe('BookingTransitionService — operation review', () => {
{} as never, // ruleEngineService
{} as never, // pricingService
{} as never, // contractService
{} as never, // invoiceService
{} as never, // filesService
{} as never, // fileUploadSettingsService
bookingBatchService as never,

View File

@@ -3,6 +3,7 @@ import {
forwardRef,
Inject,
Injectable,
Logger,
} from '@nestjs/common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
@@ -14,6 +15,7 @@ import { RuleEngineService } from '../rule-engine/rule-engine.service';
import { FilesService } from '../files/files.service';
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
import { BookingContractService } from './booking-contract.service';
import { BookingInvoiceService } from './booking-invoice.service';
import { BookingPricingService } from './booking-pricing.service';
import { BookingsRepository } from './bookings.repository';
import { assertBookingStatus } from './booking-status.util';
@@ -26,11 +28,14 @@ import { BookingsService } from './bookings.service';
@Injectable()
export class BookingTransitionService {
private readonly logger = new Logger(BookingTransitionService.name);
constructor(
private readonly bookingsRepository: BookingsRepository,
private readonly ruleEngineService: RuleEngineService,
private readonly pricingService: BookingPricingService,
private readonly contractService: BookingContractService,
private readonly invoiceService: BookingInvoiceService,
private readonly filesService: FilesService,
private readonly fileUploadSettingsService: FileUploadSettingsService,
@Inject(forwardRef(() => BookingBatchService))
@@ -404,7 +409,22 @@ export class BookingTransitionService {
marketingApprovedAt: new Date(),
lockedAt: new Date(),
} as never);
return this.bookingsService.findById(updated!.id);
const executed = await this.bookingsService.findById(updated!.id);
// Billable state reached — generate the invoice payment will settle.
// Non-blocking: a billing hiccup must not undo the execution.
await this.invoiceService
.ensureInvoiceForBooking(executed)
.catch((err) =>
this.logger.error(
`Failed to generate invoice for booking ${executed.reference}: ${
err instanceof Error ? err.message : String(err)
}`,
),
);
return executed;
}
async startTransit(bookingId: string): Promise<Booking> {

View File

@@ -10,7 +10,11 @@ import { MinioModule } from '../minio/minio.module';
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module';
import { SignaturesModule } from '../signatures/signatures.module';
import { BillingModule } from '../billing/billing.module';
import { FirstMileModule } from '../first-mile/first-mile.module';
import { BookingContractService } from './booking-contract.service';
import { BookingInvoiceService } from './booking-invoice.service';
import { BookingPaymentController } from './booking-payment.controller';
import { BookingPaymentService } from './booking-payment.service';
import { BookingPricingService } from './booking-pricing.service';
import { BookingReferenceDataService } from './booking-reference-data.service';
@@ -28,12 +32,12 @@ import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
import { BookingReviewNote } from './entities/booking-review-note.entity';
import { Booking } from './entities/booking.entity';
import { BookingContainerAllocation } from './entities/booking-container-allocation.entity';
import { ContractPdfService } from '../../contracts/contract-pdf.service';
import { ContractPricingScheduleBuilder } from '../../contracts/contract-pricing-schedule.builder';
import { ContractRendererService } from '../../contracts/contract-renderer.service';
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';
@Module({
@@ -47,8 +51,10 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
BookingRateSnapshot,
BookingReviewNote,
BookingContractSignature,
BookingContainerAllocation,
]),
PaymentModule,
BillingModule,
forwardRef(() => FirstMileModule),
forwardRef(() => TrainSchedulingModule),
FilesModule,
MinioModule,
@@ -63,7 +69,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
config.get<ExchangeOptions>('app.cbeExchange') ?? {},
}),
],
controllers: [BookingsController, PayController],
controllers: [BookingsController, PayController, BookingPaymentController],
providers: [
BookingsService,
BookingsRepository,
@@ -72,6 +78,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
BookingPricingService,
BookingTransitionService,
BookingContractService,
BookingInvoiceService,
BookingPaymentService,
ContractTemplateResolver,
ContractViewModelBuilder,
@@ -79,6 +86,6 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
ContractRendererService,
ContractPdfService,
],
exports: [BookingsService, BookingsRepository, BookingPricingService],
exports: [BookingsService, BookingsRepository, BookingPricingService, BookingInvoiceService],
})
export class BookingsModule {}

View File

@@ -12,7 +12,7 @@ import { Freight, SchedulingStatus } from '@edr/types';
// import { CustomersService } from '../customers/customers.service';
import { CompaniesService } from '../companies/companies.service';
import { ProfileType } from '../companies/entities/company-profile.entity';
import { CompanyStatus } from '../companies/entities/company.entity';
import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { eatDay } from '../train-scheduling/batch-window.util';
import { FilesService } from '../files/files.service';
@@ -43,6 +43,7 @@ import {
FreightType,
} from './entities/booking.entity';
import { Booking } from './entities/booking.entity';
import { BookingContainerAllocation } from './entities/booking-container-allocation.entity';
import { FileRecord } from '../files/entities/file.entity';
/** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */
@@ -307,10 +308,23 @@ export class BookingsService {
let companyId: string | null | undefined = dto.companyId;
if (isGovernment) {
if (!dto.governmentInstitution?.trim()) {
throw new BadRequestException('governmentInstitution is required for government bookings');
// Government bookings bill to a real seeded government company + an
// explicitly-chosen importer/exporter profile (no more null company +
// free-text institution).
if (!dto.companyId) {
throw new BadRequestException('A government company is required for government bookings');
}
companyId = dto.companyId ?? null;
const govCompany = await this.companiesService.findCompanyById(dto.companyId);
if (govCompany.kind !== CompanyKind.Government) {
throw new BadRequestException('Selected company is not a government entity');
}
if (govCompany.status !== CompanyStatus.Active) {
throw new BadRequestException('Selected government company is not active');
}
if (!dto.companyProfileId) {
throw new BadRequestException('A government company profile is required for government bookings');
}
companyId = govCompany.id;
} else if (!companyId) {
if (!userId) {
throw new BadRequestException(
@@ -383,7 +397,16 @@ export class BookingsService {
// so the customer portal can scope lists/KPIs to the active mode. Best-effort
// for non-government bookings with a resolved company; never blocks creation.
let companyProfileId: string | null = null;
if (!isGovernment && companyId) {
if (dto.companyProfileId && companyId) {
// Explicit profile pin (government booking, or staff booking on behalf):
// must belong to the chosen company and be active.
const profile =
await this.companiesService.getActiveCompanyProfileForBooking(
companyId,
dto.companyProfileId,
);
companyProfileId = profile.id;
} else if (companyId) {
let fallbackType: ProfileType | null = null;
if (userId) {
try {
@@ -413,6 +436,16 @@ export class BookingsService {
}
}
// Every booking must link to a company and a company profile.
if (!companyId) {
throw new BadRequestException('A company is required to create a booking');
}
if (!companyProfileId) {
throw new BadRequestException(
'A company profile is required to create a booking — none could be resolved for this company',
);
}
const needsConsolidation =
dto.freightType === 'CONTAINER'
? await this.needsConsolidation(containers)
@@ -443,10 +476,10 @@ export class BookingsService {
const booking = await this.bookingsRepository.create({
reference,
companyId: companyId ?? null,
companyId,
companyProfileId,
isGovernment,
governmentInstitution: isGovernment ? dto.governmentInstitution!.trim() : null,
governmentInstitution: dto.governmentInstitution?.trim() || null,
trainId: dto.trainId,
trainScheduleId: dto.trainScheduleId ?? null,
contractType: dto.contractType,
@@ -1305,4 +1338,35 @@ export class BookingsService {
createdAt: b.createdAt,
}));
}
async allocateContainers(
bookingId: string,
allocations: Array<{ containerId: string; vehicleId: string }>,
) {
const booking = await this.findById(bookingId);
if (!booking) {
throw new NotFoundException(`Booking ${bookingId} not found`);
}
await this.dataSource.transaction(async (manager) => {
for (const allocation of allocations) {
await manager.delete(BookingContainerAllocation, {
bookingId,
containerId: allocation.containerId,
});
await manager.insert(BookingContainerAllocation, {
bookingId,
containerId: allocation.containerId,
vehicleId: allocation.vehicleId,
containerType: 'CONTAINER',
quantity: 1,
});
}
});
return {
success: true,
allocated: allocations.length,
};
}
}

View File

@@ -0,0 +1,8 @@
export class ContainerAllocationDto {
containerId!: string;
vehicleId!: string;
}
export class AllocateContainersDto {
allocations!: ContainerAllocationDto[];
}

View File

@@ -14,7 +14,6 @@ import {
Max,
MaxLength,
Min,
MinLength,
Validate,
ValidateIf,
ValidateNested,
@@ -104,19 +103,31 @@ export class CreateBookingDto {
@Transform(({ value }) => value === 'true' || value === true)
isGovernment?: boolean;
@ApiPropertyOptional({ description: 'Required when isGovernment is true' })
@ValidateIf((o) => o.isGovernment === true)
/** @deprecated Government bookings now bill to a real government company. */
@ApiPropertyOptional({ description: 'Deprecated: free-text institution (superseded by companyId)' })
@IsOptional()
@IsString()
@MinLength(2)
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
governmentInstitution?: string;
@ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target company' })
@ValidateIf((o) => o.isGovernment !== true)
@ApiPropertyOptional({
format: 'uuid',
description:
'Target company. Required for staff/government bookings; resolved from the auth token for customer self-bookings.',
})
@IsOptional()
@IsUUID()
companyId?: string;
@ApiPropertyOptional({
format: 'uuid',
description:
'Explicit company profile (importer/exporter). Required for government bookings; commercial bookings auto-resolve from trade direction.',
})
@IsOptional()
@IsUUID()
companyProfileId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()

View File

@@ -0,0 +1,32 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Booking } from './booking.entity';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
@Entity({ schema: 'freight', name: 'booking_container_allocations' })
@Index(['bookingId'])
@Index(['vehicleId'])
export class BookingContainerAllocation extends BaseEntity {
@ManyToOne(() => Booking, (b) => b.containerAllocations)
@JoinColumn({ name: 'booking_id' })
booking!: Booking;
@Column('uuid', { name: 'booking_id' })
bookingId!: string;
@Column('uuid', { name: 'container_id' })
containerId!: string;
@ManyToOne(() => Vehicle)
@JoinColumn({ name: 'vehicle_id' })
vehicle!: Vehicle;
@Column('uuid', { name: 'vehicle_id', nullable: true })
vehicleId?: string;
@Column('text')
containerType!: string; // CONTAINER, BULK_DRY, etc
@Column('integer', { default: 1 })
quantity!: number;
}

View File

@@ -13,6 +13,7 @@ import { FileRecord } from '../../files/entities/file.entity';
import { BookingApprovalStep } from './booking-approval-step.entity';
import { BookingCargoModifier } from './booking-cargo-modifier.entity';
import { BookingContainer } from './booking-container.entity';
import { BookingContainerAllocation } from './booking-container-allocation.entity';
import { BookingRateSnapshot } from './booking-rate-snapshot.entity';
import { BookingReviewNote } from './booking-review-note.entity';
@@ -105,8 +106,10 @@ export class Booking extends BaseEntity {
// @JoinColumn({ name: 'customer_id' })
// customer?: Customer;
@Column({ name: 'company_id', type: 'uuid', nullable: true })
companyId?: string | null;
// Every booking is billed to a company — government bookings bill to a seeded
// government company (companies.kind = 'government'). Enforced NOT NULL.
@Column({ name: 'company_id', type: 'uuid' })
companyId!: string;
@ManyToOne(() => Company, { nullable: true })
@JoinColumn({ name: 'company_id' })
@@ -116,11 +119,12 @@ export class Booking extends BaseEntity {
* The operational profile (importer/exporter/forwarder) this booking belongs
* to. Stamped at creation from the booking's trade direction (IMPORT→importer,
* EXPORT→exporter) or the user's active profile for DOMESTIC/forwarder.
* Customer portal lists and dashboard KPIs are scoped by this. Nullable for
* legacy/government/staff-created bookings.
* Customer portal lists and dashboard KPIs are scoped by this. Required:
* commercial bookings resolve it from trade direction / active mode;
* government bookings carry the explicitly-picked government profile.
*/
@Column({ name: 'company_profile_id', type: 'uuid', nullable: true })
companyProfileId?: string | null;
@Column({ name: 'company_profile_id', type: 'uuid' })
companyProfileId!: string;
@ManyToOne(() => CompanyProfile, { nullable: true })
@JoinColumn({ name: 'company_profile_id' })
@@ -441,6 +445,9 @@ export class Booking extends BaseEntity {
@OneToMany(() => BookingContainer, (bc) => bc.booking)
bookingContainers?: BookingContainer[];
@OneToMany(() => BookingContainerAllocation, (ca) => ca.booking)
containerAllocations?: BookingContainerAllocation[];
@OneToMany(() => BookingCargoModifier, (m) => m.booking)
cargoModifiers?: BookingCargoModifier[];

View File

@@ -38,7 +38,7 @@ export class CompaniesRepository extends BaseRepository<Company> {
async findPaginated(
query: ListCompaniesQueryDto,
): Promise<{ items: Company[]; total: number }> {
const { page = 1, pageSize = 20, search, type, status } = query;
const { page = 1, pageSize = 20, search, type, kind, status } = query;
const qb = this.repository
.createQueryBuilder('company')
@@ -49,6 +49,10 @@ export class CompaniesRepository extends BaseRepository<Company> {
qb.andWhere('company.type = :type', { type });
}
if (kind) {
qb.andWhere('company.kind = :kind', { kind });
}
if (status) {
qb.andWhere('company.status = :status', { status });
}

View File

@@ -337,6 +337,29 @@ export class CompaniesService {
return company;
}
/**
* Validate an explicitly-chosen company profile for a booking: it must belong
* to the booking's company and be Active. Used for government bookings (staff
* pick the profile) and any staff booking that pins a profile directly.
*/
async getActiveCompanyProfileForBooking(
companyId: string,
profileId: string,
): Promise<CompanyProfile> {
const profile = await this.companyProfilesRepo.findById(profileId);
if (!profile || profile.companyId !== companyId) {
throw new BadRequestException(
"Selected company profile does not belong to the chosen company",
);
}
if (profile.status !== ProfileStatus.Active) {
throw new BadRequestException(
"Selected company profile is not active",
);
}
return profile;
}
async getCompanyInfoByUserId(
userId: string,
): Promise<{ profile: ExternalProfile; company: Company }> {

View File

@@ -1,7 +1,7 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsIn, IsInt, IsOptional, IsString, Min } from "class-validator";
import { Transform } from "class-transformer";
import { CompanyStatus, CompanyType } from "../entities/company.entity";
import { CompanyKind, CompanyStatus, CompanyType } from "../entities/company.entity";
export class ListCompaniesQueryDto {
@ApiPropertyOptional({ default: 1 })
@@ -28,6 +28,11 @@ export class ListCompaniesQueryDto {
@IsIn(Object.values(CompanyType))
type?: CompanyType;
@ApiPropertyOptional({ enum: CompanyKind })
@IsOptional()
@IsIn(Object.values(CompanyKind))
kind?: CompanyKind;
@ApiPropertyOptional({ enum: CompanyStatus })
@IsOptional()
@IsIn(Object.values(CompanyStatus))

View File

@@ -10,6 +10,16 @@ export enum CompanyType {
Transporter = "transporter",
}
/**
* Sector of the company — orthogonal to {@link CompanyType} (the trade role).
* Government bookings are billed to a single seeded `GOVERNMENT` company instead
* of carrying a null company + free-text institution.
*/
export enum CompanyKind {
Commercial = "commercial",
Government = "government",
}
export enum CompanyStatus {
Active = "active",
Pending = "pending",
@@ -25,6 +35,7 @@ export enum CompanyNationality {
@Entity({ schema: "freight", name: "companies" })
@Index(["tin"])
@Index(["type"])
@Index(["kind"])
export class Company extends BaseEntity {
@Column({ name: "name", type: "varchar", length: 200 })
name!: string;
@@ -32,6 +43,16 @@ export class Company extends BaseEntity {
@Column({ name: "type", type: "varchar", length: 32, enum: CompanyType })
type!: CompanyType;
/** Commercial customer vs. the seeded government entity. */
@Column({
name: "kind",
type: "varchar",
length: 20,
default: CompanyKind.Commercial,
enum: CompanyKind,
})
kind!: CompanyKind;
@Column({
name: "status",
type: "varchar",

View File

@@ -2,6 +2,7 @@ import {
BadRequestException,
ForbiddenException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { DataSource } from 'typeorm';
@@ -11,6 +12,7 @@ import { BookingContainer } from '../bookings/entities/booking-container.entity'
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingPricingService } from '../bookings/booking-pricing.service';
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
import { RuleEngineService } from '../rule-engine/rule-engine.service';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
@@ -45,6 +47,8 @@ export interface CreateBookingUnderContractResult {
*/
@Injectable()
export class ContractBookingService {
private readonly logger = new Logger(ContractBookingService.name);
constructor(
private readonly contractsRepository: ContractsRepository,
private readonly bookingsRepository: BookingsRepository,
@@ -52,6 +56,7 @@ export class ContractBookingService {
private readonly containerTypesService: ContainerTypesService,
private readonly ruleEngineService: RuleEngineService,
private readonly milestoneService: ClearanceMilestoneService,
private readonly invoiceService: BookingInvoiceService,
private readonly dataSource: DataSource,
) {}
@@ -199,6 +204,22 @@ export class ContractBookingService {
}
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
// Contract bookings are born past the billable gate (the contract is already
// executed), so the invoice is generated here — they never pass through the
// legacy marketingApprove → FULLY_EXECUTED path that invoices direct bookings.
// Idempotent and non-blocking: a billing hiccup must not undo the booking.
// Skips silently when unbillable (no company / no priced amount).
await this.invoiceService
.ensureInvoiceForBooking(result ?? booking)
.catch((err) =>
this.logger.error(
`Failed to generate invoice for contract booking ${booking.reference}: ${
err instanceof Error ? err.message : String(err)
}`,
),
);
return { booking: result ?? booking, warnings };
}

View File

@@ -0,0 +1,8 @@
export class FirstMileContainerAllocationDto {
containerId!: string;
vehicleId!: string;
}
export class AllocateFirstMileContainersDto {
allocations!: FirstMileContainerAllocationDto[];
}

View File

@@ -0,0 +1,36 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { FirstMile } from './first-mile.entity';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
@Entity({ name: 'first_mile_container_allocations', schema: 'freight' })
@Index(['firstMileId'])
@Index(['vehicleId'])
export class FirstMileContainerAllocation extends BaseEntity {
@Column({ name: 'first_mile_id', type: 'uuid' })
firstMileId!: string;
@ManyToOne(() => FirstMile, (firstMile) => firstMile.containerAllocations, {
nullable: false,
eager: false,
})
@JoinColumn({ name: 'first_mile_id' })
firstMile?: FirstMile;
@Column({ name: 'container_id', type: 'uuid' })
containerId!: string;
@Column({ name: 'vehicle_id', type: 'uuid', nullable: true })
vehicleId?: string | null;
@ManyToOne(() => Vehicle, { nullable: true, eager: false })
@JoinColumn({ name: 'vehicle_id' })
vehicle?: Vehicle | null;
@Column({ name: 'container_type', type: 'text' })
containerType!: string;
@Column({ name: 'quantity', type: 'int', default: 1 })
quantity!: number;
}

View File

@@ -1,8 +1,9 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
import { FirstMileContainerAllocation } from './first-mile-container-allocation.entity';
export const FIRST_MILE_STATUSES = [
'PAYMENT_PENDING',
@@ -34,6 +35,10 @@ export class FirstMile extends BaseEntity {
@Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 })
remainingPayment!: number;
// TODO: uncomment after migration creates column
// @Column({ type: 'boolean', default: false })
// isPostPaymentCompleted!: boolean;
@Column({ name: 'estimated_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
estimatedKm?: number | null;
@@ -46,4 +51,11 @@ export class FirstMile extends BaseEntity {
@ManyToOne(() => Vehicle, { nullable: true, eager: false })
@JoinColumn({ name: 'vehicle_id' })
vehicle?: Vehicle | null;
@OneToMany(
() => FirstMileContainerAllocation,
(containerAllocation) => containerAllocation.firstMile,
{ eager: false },
)
containerAllocations!: FirstMileContainerAllocation[];
}

View File

@@ -0,0 +1,106 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { Freight } from '@edr/types';
import {
BillingService,
InvoiceEventPayload,
} from '../billing/billing.service';
import { Invoice } from '../billing/entities/invoice.entity';
import { FirstMileRepository } from './first-mile.repository';
import { FirstMile } from './entities/first-mile.entity';
/**
* Owns the first-mile ⇄ invoice mapping — the one place that knows how a
* first-mile record turns into invoices, which type to use, and how it
* advances when paid. First-mile records are billable entities, so they
* generate their own invoices directly via {@link BillingService}.
*/
@Injectable()
export class FirstMileInvoiceService {
private readonly logger = new Logger(FirstMileInvoiceService.name);
constructor(
private readonly billing: BillingService,
private readonly firstMileRepo: FirstMileRepository,
) {}
/**
* Ensure the first-mile record has its invoice, generating one from the
* remaining payment if absent. Called when a first-mile record reaches a
* billable state. Idempotent — returns the existing open invoice instead
* of a duplicate. Returns `null` (and logs) when the record is not billable:
* no company to bill.
*/
async ensureInvoiceFor(record: FirstMile): Promise<Invoice | null> {
const existing = await this.billing.findPayable(
'first_mile' as Freight.InvoiceSource,
record.id,
'DELIVERY_FEE',
);
if (existing) return existing;
if (!record.bookingId) {
this.logger.warn(
`Skipping invoice for first-mile record ${record.id}: no booking to reference.`,
);
return null;
}
// Fetch the booking to get the companyId and companyProfileId
const fm = record.booking ? record : (await this.firstMileRepo.findById(record.bookingId, { relations: { booking: true } }));
if (!fm) return null;
if (!fm.booking?.companyId) {
this.logger.warn(
`Skipping invoice for first-mile record ${record.id}: no company to bill.`,
);
return null;
}
const totalAmount = record.remainingPayment || 0;
if (!Number.isFinite(totalAmount) || totalAmount <= 0) {
this.logger.warn(
`Skipping invoice for first-mile record ${record.id}: no remaining payment.`,
);
return null;
}
return this.billing.generateInvoice({
source: 'first_mile' as Freight.InvoiceSource,
sourceId: record.id,
type: 'DELIVERY_FEE',
companyId: fm.booking!.companyId,
companyProfileId: fm.booking!.companyProfileId || '',
currency: 'ETB',
lines: [
{
chargeType: 'DELIVERY',
description: 'First-mile delivery',
quantity: 1,
unitRate: totalAmount,
amount: totalAmount,
},
],
totalAmount,
});
}
/**
* React to a first-mile invoice being paid — the settlement branch point.
* Mark the first-mile record as having completed post-payment processing.
*/
@OnEvent('first_mile.invoice.paid')
async onPaid(payload: InvoiceEventPayload): Promise<void> {
if (payload.type === 'DELIVERY_FEE') {
const record = await this.firstMileRepo.findById(payload.sourceId);
if (!record) {
this.logger.warn(
`Cannot mark unknown first-mile record ${payload.sourceId} as paid.`,
);
return;
}
this.logger.log(`First-mile invoice paid for record ${payload.sourceId}.`);
}
}
}

View File

@@ -17,15 +17,20 @@ import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking
import { CreateFirstMileDto } from './dto/create-first-mile.dto';
import { UpdateFirstMileDto } from './dto/update-first-mile.dto';
import { AllocateFirstMileContainersDto } from './dto/allocate-containers.dto';
import { FirstMileStatus } from './entities/first-mile.entity';
import { FirstMileService } from './first-mile.service';
import { FirstMileInvoiceService } from './first-mile-invoice.service';
@ApiTags('first-mile')
@ApiBearerAuth()
@Controller('first-mile')
@TrainSchedulingView()
export class FirstMileController {
constructor(private readonly firstMileService: FirstMileService) {}
constructor(
private readonly firstMileService: FirstMileService,
private readonly firstMileInvoiceService: FirstMileInvoiceService,
) {}
@Get()
@ApiOperation({ summary: 'List first-mile legs' })
@@ -72,8 +77,13 @@ export class FirstMileController {
@Patch(':id')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Update a first-mile leg' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFirstMileDto) {
return this.firstMileService.update(id, dto);
async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFirstMileDto) {
const record = await this.firstMileService.update(id, dto);
// Auto-generate invoice if distance or payment was updated
if (dto.exactKm !== undefined || dto.remainingPayment !== undefined) {
await this.firstMileInvoiceService.ensureInvoiceFor(record);
}
return record;
}
@Delete(':id')
@@ -83,4 +93,14 @@ export class FirstMileController {
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.firstMileService.remove(id);
}
@Post(':firstMileId/allocate-containers')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Allocate containers to vehicles for a first-mile leg' })
allocateContainers(
@Param('firstMileId', ParseUUIDPipe) firstMileId: string,
@Body() dto: AllocateFirstMileContainersDto,
) {
return this.firstMileService.allocateContainers(firstMileId, dto.allocations);
}
}

View File

@@ -1,25 +1,29 @@
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BillingModule } from '../billing/billing.module';
import { BookingsModule } from '../bookings/bookings.module';
import { DriversModule } from '../drivers/drivers.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { VehiclesModule } from '../vehicles/vehicles.module';
import { FirstMile } from './entities/first-mile.entity';
import { FirstMileContainerAllocation } from './entities/first-mile-container-allocation.entity';
import { FirstMileController } from './first-mile.controller';
import { FirstMileInvoiceService } from './first-mile-invoice.service';
import { FirstMileRepository } from './first-mile.repository';
import { FirstMileService } from './first-mile.service';
@Module({
imports: [
TypeOrmModule.forFeature([FirstMile]),
TypeOrmModule.forFeature([FirstMile, FirstMileContainerAllocation]),
BillingModule,
forwardRef(() => BookingsModule),
VehiclesModule,
DriversModule,
NotificationsModule,
],
controllers: [FirstMileController],
providers: [FirstMileRepository, FirstMileService],
exports: [FirstMileRepository, FirstMileService],
providers: [FirstMileRepository, FirstMileService, FirstMileInvoiceService],
exports: [FirstMileRepository, FirstMileService, FirstMileInvoiceService],
})
export class FirstMileModule {}

View File

@@ -1,13 +1,16 @@
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { FindOptionsWhere } from 'typeorm';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { BookingsRepository } from '../bookings/bookings.repository';
import { DriversService } from '../drivers/drivers.service';
import { NotificationsService } from '../notifications/notifications.service';
import { SmsClientService } from '../notifications/sms-client.service';
import { VehiclesService } from '../vehicles/vehicles.service';
import { CreateFirstMileDto } from './dto/create-first-mile.dto';
import { UpdateFirstMileDto } from './dto/update-first-mile.dto';
import { FirstMile, FirstMileStatus } from './entities/first-mile.entity';
import { FirstMileContainerAllocation } from './entities/first-mile-container-allocation.entity';
import { FirstMileRepository } from './first-mile.repository';
type FirstMileListFilter = {
@@ -32,11 +35,12 @@ export class FirstMileService {
private readonly logger = new Logger(FirstMileService.name);
constructor(
@InjectDataSource() private readonly dataSource: DataSource,
private readonly firstMileRepository: FirstMileRepository,
private readonly bookingsRepository: BookingsRepository,
private readonly vehiclesService: VehiclesService,
private readonly driversService: DriversService,
private readonly notificationsService: NotificationsService,
private readonly smsClient: SmsClientService,
) {}
/**
@@ -251,16 +255,19 @@ export class FirstMileService {
const booking = (record as FirstMile & { booking?: { reference?: string; firstMilePickupAddress?: string | null; originYard?: { label?: string } | null } }).booking;
await this.notificationsService.notifyDriverVehicleAssignment({
driverPhone: driver.phoneNumber,
driverName: `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(),
vehiclePlateNumber: vehicle.plateNumber ?? vehicleId,
bookingReference: booking?.reference ?? record.bookingId,
pickupAddress: booking?.firstMilePickupAddress,
destinationYard: booking?.originYard?.label,
const driverName = `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim();
const message =
`Dear ${driverName}, you have been assigned to a first-mile pickup. ` +
`Booking: ${booking?.reference ?? record.bookingId}. Vehicle: ${vehicle.plateNumber ?? vehicleId}. ` +
(booking?.firstMilePickupAddress ? `Pickup: ${booking.firstMilePickupAddress}. ` : '') +
(booking?.originYard?.label ? `Destination: ${booking.originYard.label}.` : '');
void this.smsClient.sendSms({
to: driver.phoneNumber,
message,
});
this.logger.log(`SMS sent to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`);
this.logger.log(`SMS queued to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`);
} catch (err) {
this.logger.error(`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`);
}
@@ -270,4 +277,35 @@ export class FirstMileService {
await this.findById(id);
await this.firstMileRepository.softDelete(id);
}
async allocateContainers(
firstMileId: string,
allocations: Array<{ containerId: string; vehicleId: string }>,
) {
const firstMile = await this.findById(firstMileId);
if (!firstMile) {
throw new NotFoundException(`First-mile record ${firstMileId} not found`);
}
await this.dataSource.transaction(async (manager) => {
for (const allocation of allocations) {
await manager.delete(FirstMileContainerAllocation, {
firstMileId,
containerId: allocation.containerId,
});
await manager.insert(FirstMileContainerAllocation, {
firstMileId,
containerId: allocation.containerId,
vehicleId: allocation.vehicleId,
containerType: 'CONTAINER',
quantity: 1,
});
}
});
return {
success: true,
allocated: allocations.length,
};
}
}

View File

@@ -0,0 +1,8 @@
export class LastMileContainerAllocationDto {
containerId!: string;
vehicleId!: string;
}
export class AllocateLastMileContainersDto {
allocations!: LastMileContainerAllocationDto[];
}

View File

@@ -0,0 +1,32 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { LastMile } from './last-mile.entity';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
@Entity({ schema: 'freight', name: 'last_mile_container_allocations' })
@Index(['lastMileId'])
@Index(['vehicleId'])
export class LastMileContainerAllocation extends BaseEntity {
@ManyToOne(() => LastMile, (lm) => lm.containerAllocations)
@JoinColumn({ name: 'last_mile_id' })
lastMile!: LastMile;
@Column('uuid', { name: 'last_mile_id' })
lastMileId!: string;
@Column('uuid', { name: 'container_id' })
containerId!: string;
@ManyToOne(() => Vehicle)
@JoinColumn({ name: 'vehicle_id' })
vehicle?: Vehicle | null;
@Column('uuid', { name: 'vehicle_id', nullable: true })
vehicleId?: string | null;
@Column('text')
containerType!: string;
@Column('integer', { default: 1 })
quantity!: number;
}

View File

@@ -1,8 +1,9 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
import { LastMileContainerAllocation } from './last-mile-container-allocation.entity';
export const LAST_MILE_STATUSES = [
'PAYMENT_PENDING',
@@ -34,6 +35,10 @@ export class LastMile extends BaseEntity {
@Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 })
remainingPayment!: number;
// TODO: uncomment after migration creates column
// @Column({ type: 'boolean', default: false })
// isPostPaymentCompleted!: boolean;
@Column({ name: 'estimated_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
estimatedKm?: number | null;
@@ -46,4 +51,7 @@ export class LastMile extends BaseEntity {
@ManyToOne(() => Vehicle, { nullable: true, eager: false })
@JoinColumn({ name: 'vehicle_id' })
vehicle?: Vehicle | null;
@OneToMany(() => LastMileContainerAllocation, (ca) => ca.lastMile)
containerAllocations?: LastMileContainerAllocation[];
}

View File

@@ -0,0 +1,97 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { Freight } from '@edr/types';
import {
BillingService,
GenerateInvoiceInput,
InvoiceEventPayload,
} from '../billing/billing.service';
import { Invoice } from '../billing/entities/invoice.entity';
import { LastMileRepository } from './last-mile.repository';
import { LastMile } from './entities/last-mile.entity';
/**
* Owns the last-mile ⇄ invoice mapping — the one place that knows how a last-mile
* record turns into invoices, which type to use, and how it advances when paid.
* Last-mile records are billable business entities for delivery fees, so they
* generate their own invoices directly via {@link BillingService}. All last-mile-specific
* type branching lives here, at the two points it belongs: invoice creation and
* settlement (the paid handler).
*/
@Injectable()
export class LastMileInvoiceService {
private readonly logger = new Logger(LastMileInvoiceService.name);
constructor(
private readonly billing: BillingService,
private readonly lastMileRepo: LastMileRepository,
) {}
/**
* Ensure the last-mile record has its invoice, generating one from the
* remainingPayment if absent. Called when a last-mile record reaches a
* billable state. Idempotent — returns the existing open invoice instead
* of a duplicate. Returns `null` (and logs) when the record is not billable:
* no company to bill (invoices FK requires a companyId).
*/
async ensureInvoiceFor(record: LastMile): Promise<Invoice | null> {
// Check if invoice already exists
const existing = await this.billing.findPayable(
'last_mile' as Freight.InvoiceSource,
record.id,
'DELIVERY_FEE',
);
if (existing) return existing;
// Can't bill without company
const lm = record.booking ? record : (await this.lastMileRepo.findById(record.id, { relations: { booking: true } }));
if (!lm) return null;
if (!lm.booking?.companyId) {
this.logger.warn(
`Skipping invoice for last-mile record ${record.id}: no company to bill.`,
);
return null;
}
// Generate invoice with remainingPayment as totalAmount
const input: GenerateInvoiceInput = {
source: 'last_mile' as Freight.InvoiceSource,
sourceId: record.id,
type: 'DELIVERY_FEE',
companyId: lm.booking!.companyId,
companyProfileId: lm.booking!.companyProfileId || '',
currency: 'ETB',
lines: [
{
chargeType: 'DELIVERY',
description: 'Last-mile delivery',
quantity: 1,
unitRate: record.remainingPayment || 0,
amount: record.remainingPayment || 0,
},
],
totalAmount: record.remainingPayment || 0,
};
return this.billing.generateInvoice(input);
}
/**
* React to a last-mile invoice being paid — the settlement branch point.
* Advances the last-mile record to mark post-payment as completed.
*/
@OnEvent('last_mile.invoice.paid')
async onPaid(payload: InvoiceEventPayload): Promise<void> {
if (payload.type === 'DELIVERY_FEE') {
const record = await this.lastMileRepo.findById(payload.sourceId);
if (record) {
this.logger.log(`Last-mile invoice paid for record ${payload.sourceId}.`);
} else {
this.logger.warn(
`Cannot mark last-mile record ${payload.sourceId} as paid: not found.`,
);
}
}
}
}

View File

@@ -17,15 +17,20 @@ import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking
import { CreateLastMileDto } from './dto/create-last-mile.dto';
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
import { AllocateLastMileContainersDto } from './dto/allocate-containers.dto';
import { LastMileStatus } from './entities/last-mile.entity';
import { LastMileService } from './last-mile.service';
import { LastMileInvoiceService } from './last-mile-invoice.service';
@ApiTags('last-mile')
@ApiBearerAuth()
@Controller('last-mile')
@TrainSchedulingView()
export class LastMileController {
constructor(private readonly lastMileService: LastMileService) {}
constructor(
private readonly lastMileService: LastMileService,
private readonly lastMileInvoiceService: LastMileInvoiceService,
) {}
@Get()
@ApiOperation({ summary: 'List last-mile legs' })
@@ -72,8 +77,13 @@ export class LastMileController {
@Patch(':id')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Update a last-mile leg' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLastMileDto) {
return this.lastMileService.update(id, dto);
async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLastMileDto) {
const record = await this.lastMileService.update(id, dto);
// Auto-generate invoice if distance or payment was updated
if (dto.exactKm !== undefined || dto.remainingPayment !== undefined) {
await this.lastMileInvoiceService.ensureInvoiceFor(record);
}
return record;
}
@Delete(':id')
@@ -83,4 +93,14 @@ export class LastMileController {
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.lastMileService.remove(id);
}
@Post(':id/allocate-containers')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Allocate containers to vehicles' })
async allocateContainers(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: AllocateLastMileContainersDto,
) {
return this.lastMileService.allocateContainers(id, dto.allocations);
}
}

View File

@@ -1,25 +1,29 @@
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BillingModule } from '../billing/billing.module';
import { BookingsModule } from '../bookings/bookings.module';
import { DriversModule } from '../drivers/drivers.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { VehiclesModule } from '../vehicles/vehicles.module';
import { LastMile } from './entities/last-mile.entity';
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
import { LastMileController } from './last-mile.controller';
import { LastMileInvoiceService } from './last-mile-invoice.service';
import { LastMileRepository } from './last-mile.repository';
import { LastMileService } from './last-mile.service';
@Module({
imports: [
TypeOrmModule.forFeature([LastMile]),
TypeOrmModule.forFeature([LastMile, LastMileContainerAllocation]),
BillingModule,
forwardRef(() => BookingsModule),
VehiclesModule,
DriversModule,
NotificationsModule,
],
controllers: [LastMileController],
providers: [LastMileRepository, LastMileService],
exports: [LastMileRepository, LastMileService],
providers: [LastMileRepository, LastMileService, LastMileInvoiceService],
exports: [LastMileRepository, LastMileService, LastMileInvoiceService],
})
export class LastMileModule {}

View File

@@ -1,13 +1,14 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { FindOptionsWhere } from 'typeorm';
import { DataSource, FindOptionsWhere } from 'typeorm';
import { BookingsRepository } from '../bookings/bookings.repository';
import { DriversService } from '../drivers/drivers.service';
import { NotificationsService } from '../notifications/notifications.service';
import { SmsClientService } from '../notifications/sms-client.service';
import { VehiclesService } from '../vehicles/vehicles.service';
import { CreateLastMileDto } from './dto/create-last-mile.dto';
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
import { LastMile, LastMileStatus } from './entities/last-mile.entity';
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
import { LastMileRepository } from './last-mile.repository';
type LastMileListFilter = {
@@ -32,12 +33,12 @@ export class LastMileService {
private readonly logger = new Logger(LastMileService.name);
constructor(
private readonly lastMileRepository: LastMileRepository,
private readonly bookingsRepository: BookingsRepository,
private readonly vehiclesService: VehiclesService,
private readonly driversService: DriversService,
private readonly notificationsService: NotificationsService,
private readonly smsClient: SmsClientService,
private readonly dataSource: DataSource,
) {}
async acceptBooking(bookingReference: string): Promise<LastMile | null> {
@@ -185,16 +186,19 @@ export class LastMileService {
};
const booking = (record as LastMile & { booking?: BookingWithYards }).booking;
await this.notificationsService.notifyDriverVehicleAssignment({
driverPhone: driver.phoneNumber,
driverName: `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(),
vehiclePlateNumber: vehicle.plateNumber ?? vehicleId,
bookingReference: booking?.reference ?? record.bookingId,
pickupAddress: booking?.destinationYard?.label,
destinationYard: booking?.lastMileDeliveryAddress,
const driverName = `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim();
const message =
`Dear ${driverName}, you have been assigned to a last-mile delivery. ` +
`Booking: ${booking?.reference ?? record.bookingId}. Vehicle: ${vehicle.plateNumber ?? vehicleId}. ` +
(booking?.destinationYard?.label ? `Pickup: ${booking.destinationYard.label}. ` : '') +
(booking?.lastMileDeliveryAddress ? `Destination: ${booking.lastMileDeliveryAddress}.` : '');
void this.smsClient.sendSms({
to: driver.phoneNumber,
message,
});
this.logger.log(`SMS sent to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`);
this.logger.log(`SMS queued to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`);
} catch (err) {
this.logger.error(`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`);
}
@@ -204,4 +208,35 @@ export class LastMileService {
await this.findById(id);
await this.lastMileRepository.softDelete(id);
}
async allocateContainers(
lastMileId: string,
allocations: Array<{ containerId: string; vehicleId: string }>,
) {
const lastMile = await this.findById(lastMileId);
if (!lastMile) {
throw new NotFoundException(`Last-mile record ${lastMileId} not found`);
}
await this.dataSource.transaction(async (manager) => {
for (const allocation of allocations) {
await manager.delete(LastMileContainerAllocation, {
lastMileId,
containerId: allocation.containerId,
});
await manager.insert(LastMileContainerAllocation, {
lastMileId,
containerId: allocation.containerId,
vehicleId: allocation.vehicleId,
containerType: 'CONTAINER',
quantity: 1,
});
}
});
return {
success: true,
allocated: allocations.length,
};
}
}

View File

@@ -2,7 +2,8 @@ import { BaseEntity, Column, CreateDateColumn, Entity, OneToMany, PrimaryGenerat
import { PaymentRefundEntity } from "./payment-refund.entity";
type PaymentType = "booking"
/** Invoice source that owns the intent ('booking', 'demurrage', …) — caller-supplied. */
type PaymentType = string
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"
@@ -15,9 +16,12 @@ export class PaymentEntity extends BaseEntity {
@Column({ type: 'varchar', length: 255, name: "ref_id" })
refId!: string
@Column({ type: "enum", enum: ["booking"] })
@Column({ type: "varchar", length: 50 })
type!: PaymentType;
@Column({ type: "varchar", length: 40, nullable: true, name: "reference_type" })
referenceType?: string;
@Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr", "waafi", "card", "dmoney", "cac-bank"] })
method!: PaymentMethod

View File

@@ -20,6 +20,7 @@ export class PaymentClientService {
private readonly baseUrl = (
// process.env.PAYMENT_API_URL ??
"https://paymentcallback.triaplc.com"
// "http://localhost:3003"
).replace(/\/$/, "");
private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? "";

View File

@@ -1,13 +1,13 @@
import {
Body,
Controller,
Get,
HttpStatus,
Param,
ParseUUIDPipe,
Post,
Query,
Res,
Body,
Post,
} from "@nestjs/common";
import {
ApiTags,
@@ -20,14 +20,7 @@ import { Response } from "express";
import { Public } from "@edr/api-common";
import { BookingView, FreightAdmin } from "../../common/booking-guards";
import { PaymentService } from "./payment.service";
import {
InitiatePaymentDto,
InitiateResponseDto,
IntentStatusDto,
PaymentMethodTypeEnum,
PaymentPlatformDto,
RefundDto,
} from "./payments.dto";
import { IntentStatusDto, RefundDto } from "./payments.dto";
@ApiTags("Payment")
@Controller("payments")
@@ -73,16 +66,6 @@ export class PaymentController {
});
}
@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 })
@@ -97,54 +80,6 @@ export class PaymentController {
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" })
@@ -153,76 +88,4 @@ export class PaymentController {
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>
<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>
<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>`;
}
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,4 +1,4 @@
import { DynamicModule, Module, forwardRef } from "@nestjs/common";
import { DynamicModule, forwardRef, Module } from "@nestjs/common";
import { HttpModule } from "@nestjs/axios";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { RabbitMQModule } from "@golevelup/nestjs-rabbitmq";
@@ -12,9 +12,7 @@ import {
} from "@edr/types";
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
import { DropdownSettingsModule } from "../dropdown-settings/dropdown-settings.module";
import { FirstMileModule } from "../first-mile/first-mile.module";
import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module";
import { BillingModule } from "../billing/billing.module";
import { PaymentRefundEntity } from "./entities/payment-refund.entity";
import { PaymentWebhookEventEntity } from "./entities/payment-webhook-event.entity";
import { PaymentEntity } from "./entities/payment.entity";
@@ -58,9 +56,7 @@ function rabbitMQImport(): DynamicModule[] {
imports: [
HttpModule.register({ timeout: 10_000 }),
ConfigModule,
DropdownSettingsModule,
forwardRef(() => FirstMileModule),
forwardRef(() => TrainSchedulingModule),
forwardRef(() => BillingModule),
TypeOrmModule.forFeature([
PaymentEntity,
PaymentWebhookEventEntity,

View File

@@ -11,6 +11,7 @@ import { DataSource } from "typeorm";
import { PaymentEntity } from "./entities/payment.entity";
import { PaymentRepository } from "./payment.repository";
import { PaymentClientService } from "./payment-client.service";
import { BillingService } from "../billing/billing.service";
import * as fs from "fs";
import * as path from "path";
@@ -28,13 +29,44 @@ import {
ProviderMethod,
} from "@edr/types";
import {
InitiatePaymentDto,
InitiateResponseDto,
IntentStatusDto,
PaymentPlatformDto,
RefundDto,
} from "./payments.dto";
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
import { FirstMileService } from "../first-mile/first-mile.service";
/** Everything the gateway needs to open an intent. Amount/currency are supplied by
* the caller (billing) — this service never derives them from a domain record. */
export interface InitiateIntentInput {
/** Opaque domain reference (booking id, …). */
referenceId: string;
/** Invoice source that owns the intent ('booking', …) — stored on the projection. */
source: string;
/** Gateway reference type the intent is opened with (caller's domain decides it). */
referenceType: PaymentReferenceType;
/** Human-readable order ref shown on provider pages. */
orderRef: string;
/** Authoritative amount in minor units, computed by the caller. */
amountMinor: number;
currency: string;
/** Stored on the intent projection for receipts/dashboards. */
reason?: string;
/** Provider/method selector. */
method: ProviderMethod | string;
platform?: PaymentPlatformDto;
payerAccount?: string;
returnUrl?: string;
failureUrl?: string;
}
export interface InitiateIntentResult {
intentId: string;
response: InitiateResponseDto;
/** True when the provider settled the charge synchronously during initiate. */
immediateSuccess: boolean;
providerTxnId?: string;
paidAt?: Date;
}
const STATUS_MAP: Record<string, ProviderPaymentStatus> = {
"action-required": ProviderPaymentStatus.REQUIRES_ACTION,
@@ -45,6 +77,23 @@ const STATUS_MAP: Record<string, ProviderPaymentStatus> = {
"refunded": ProviderPaymentStatus.CANCELLED,
};
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",
};
/**
* Pure payment-gateway adapter. Owns intents, provider calls and webhooks — and
* NOTHING domain-specific: it never loads a booking, computes an amount, or
* advances a domain record. On settlement it notifies billing directly
* ({@link BillingService.settleByPaymentId}); billing (and through it, the domain)
* reacts. The billing↔payment pair is a deliberate forwardRef cycle.
*/
@Injectable()
export class PaymentService {
private readonly logger = new Logger(PaymentService.name);
@@ -53,9 +102,8 @@ export class PaymentService {
private readonly datasource: DataSource,
private readonly paymentRepo: PaymentRepository,
private readonly paymentClient: PaymentClientService,
@Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatchService: BookingBatchService,
private readonly firstMileService: FirstMileService,
@Inject(forwardRef(() => BillingService))
private readonly billing: BillingService,
) { }
async getAll(filters: {
@@ -123,7 +171,6 @@ export class PaymentService {
total += row.count;
}
// Sum of successfully collected amounts.
const paidAgg = await this.paymentRepo
.createQueryBuilder("payment")
.select("COALESCE(SUM(payment.amount), 0)", "sum")
@@ -141,65 +188,74 @@ export class PaymentService {
};
}
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");
const amountMinor = Math.round(Number(booking.totalAmount));
/**
* Open a gateway intent for a caller-supplied amount/reference and project it
* locally. Returns the intent id (so billing can correlate the invoice) plus
* the client action. When the provider settles synchronously, the intent is
* marked paid WITHOUT emitting — the caller (billing) settles inline after it
* has stored the intent id, avoiding a settle-before-correlation race.
*/
async initiate(input: InitiateIntentInput): Promise<InitiateIntentResult> {
const snapshot = await this.paymentClient.initiate({
service: PaymentServiceEnum.FREIGHT,
referenceType: PaymentReferenceType.SHIPMENT,
referenceId: booking.id,
orderRef: booking.reference,
amountMinor,
currency: booking.paymentCurrency,
provider: dto.method as unknown as ProviderMethod,
platform: dto.platform,
payerAccount: dto.payerAccount,
returnUrl:'https://edrfreight.triaplc.com/payment/success',
failureUrl: 'https://edrfreight.triaplc.com/payment/failure',
referenceType: input.referenceType,
referenceId: input.referenceId,
orderRef: input.orderRef,
amountMinor: input.amountMinor,
currency: input.currency,
provider: input.method as ProviderMethod,
platform: input.platform,
payerAccount: input.payerAccount,
returnUrl: input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success",
failureUrl: input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure",
});
const intent = await this.syncIntentProjection(booking.id, booking, snapshot);
const immediateSuccess = snapshot.status === ProviderPaymentStatus.SUCCEEDED;
const paidAt = snapshot.paidAt ? new Date(snapshot.paidAt) : undefined;
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
await this.finalizePaymentSuccess({
intentId: intent.id,
bookingId: booking.id,
const intent = await this.upsertIntent(input, snapshot);
if (immediateSuccess) {
// Settle the projection but DO NOT notify billing — billing settles
// inline once it has stored intentId on the invoice (see payInvoice),
// avoiding a settle-before-correlation race.
await this.markIntentSucceeded(intent.id, {
providerTxnId: snapshot.providerTxnId,
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
paidAt,
notify: false,
});
}
return this.formatIntentResponse(intent);
return {
intentId: intent.id,
// `intent` still reflects the projection status ("processing" on immediate
// success — settlement is applied by the caller, not shown synchronously).
response: this.formatIntentResponse(intent),
immediateSuccess,
providerTxnId: snapshot.providerTxnId,
paidAt,
};
}
private async syncIntentProjection(
bookingId: string,
booking: Booking,
/** Create or update the local intent projection from a provider snapshot. */
private async upsertIntent(
input: InitiateIntentInput,
snapshot: PaymentIntentSnapshot,
): Promise<PaymentEntity> {
const existing = await this.paymentRepo.findOneBy({ refId: bookingId, type: "booking" });
const existing = await this.paymentRepo.findOneBy({
refId: input.referenceId,
});
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 status =
snapshot.status === ProviderPaymentStatus.SUCCEEDED
? "processing"
: this.toLocalStatus(snapshot.status);
const clientAction = (snapshot.clientAction ?? undefined) as Record<string, unknown> | undefined;
const clientAction = (snapshot.clientAction ?? undefined) as
| Record<string, unknown>
| undefined;
const data = {
status,
method,
@@ -216,30 +272,37 @@ export class PaymentService {
}
return this.paymentRepo.create({
refId: bookingId,
type: "booking",
amount: booking.totalAmount,
currency: booking.paymentCurrency,
reason: `Payment for booking ${booking.reference}`,
refId: input.referenceId,
type: input.source,
referenceType: input.referenceType,
amount: input.amountMinor,
currency: input.currency as PaymentEntity["currency"],
reason: input.reason ?? `Payment for ${input.orderRef}`,
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" });
/**
* Reconcile an intent's status with the gateway by reference. Read-only on the
* domain side: it syncs the local projection and, when the provider reports a
* newly-observed success, notifies billing to settle. `referenceId` is opaque
* (the booking id, but this service does not load it).
*/
async getIntentByBookingId(referenceId: string): Promise<IntentStatusDto> {
const local = await this.paymentRepo.findOneBy({ refId: referenceId });
let snapshot: PaymentIntentSnapshot | null = null;
try {
snapshot = await this.paymentClient.getIntentByReference(
PaymentReferenceType.SHIPMENT,
bookingId,
(local?.referenceType as PaymentReferenceType) ?? PaymentReferenceType.SHIPMENT,
referenceId,
);
} 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`,
`payment service lookup failed for reference ${referenceId}: ${message}; using local intent`,
);
}
@@ -247,76 +310,54 @@ export class PaymentService {
if (!local) throw new NotFoundException("PaymentIntent not found");
return this.formatIntentStatus(local);
}
if (!local) throw new NotFoundException("PaymentIntent not found");
const booking = await this.datasource
.getRepository(Booking)
.findOneBy({ id: bookingId });
// Sync local projection with provider-reported status.
const becameSuccess =
snapshot.status === ProviderPaymentStatus.SUCCEEDED && local.status !== "success";
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,
if (becameSuccess) {
await this.markIntentSucceeded(local.id, {
providerTxnId: snapshot.providerTxnId,
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
notify: true,
});
} else if (snapshot.status !== ProviderPaymentStatus.SUCCEEDED) {
await this.paymentRepo.update(
{ id: local.id },
{
status: this.toLocalStatus(snapshot.status),
failerCode: snapshot.failureCode ?? undefined,
failureMessage: snapshot.failureMessage ?? undefined,
},
);
}
const refreshed = await this.paymentRepo.findOneBy({ id: intent.id });
return this.formatIntentStatus(refreshed ?? intent);
const refreshed = await this.paymentRepo.findOneBy({ id: local.id });
return this.formatIntentStatus(refreshed ?? local);
}
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 });
/**
* Mark a gateway intent paid and (by default) notify billing to settle the
* linked invoice. Idempotent — no-op when already success. Pass `notify: false`
* when the caller settles inline and will trigger settlement itself.
*/
async markIntentSucceeded(
intentId: string,
opts: { providerTxnId?: string; paidAt?: Date; notify?: boolean } = {},
): Promise<{ alreadyFinalized: boolean }> {
const intent = await this.paymentRepo.findOneBy({ id: intentId });
if (!intent) throw new NotFoundException("PaymentIntent not found");
if (intent.status === "success") return { alreadyFinalized: true };
const paidAt = input.paidAt ?? new Date();
const paidAt = opts.paidAt ?? new Date();
await this.paymentRepo.update(
{ id: intent.id },
{ status: "success", paidAt, transactionId: opts.providerTxnId ?? intent.transactionId },
);
// Every booking is a real shipment now (contracts are a separate aggregate),
// so payment always settles the booking to PAID and enters allocation.
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" },
);
await this.firstMileService.acceptBooking(input.bookingId);
});
try {
await this.bookingBatchService.ensurePaidBookingAllocated(input.bookingId);
} catch (err) {
this.logger.error(
`Error allocating booking after payment: ${err instanceof Error ? err.message : String(err)}`,
);
if (opts.notify !== false) {
await this.billing.settleByPaymentId(intent.id, opts.providerTxnId, paidAt);
}
return { alreadyFinalized: false };
@@ -335,6 +376,29 @@ export class PaymentService {
{ id: intent.id },
{ status: "failed", failerCode: input.failureCode, failureMessage: input.failureMessage },
);
// Invoice stays open for retry — nothing to settle. Logged only.
this.logger.warn(
`Payment ${intent.id} failed for ${intent.refId}` +
(input.failureMessage ? `: ${input.failureMessage}` : ""),
);
}
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");
}
// NOTE: refunding still mutates the booking directly — left intact pending
// the refund redesign. TODO: route refunds through billing.refundPayable +
// a `${source}.invoice.refunded` reaction, like settlement.
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 getActivePaymentByOrderIdAndMethod(orderId: string, method: PaymentEntity["method"]): Promise<PaymentEntity | null> {
@@ -363,7 +427,7 @@ export class PaymentService {
}
findBookingById(id: string) {
return this.paymentRepo.findOneBy({ refId: id, type: "booking" });
return this.paymentRepo.findOneBy({ refId: id });
}
formatIntentResponse(intent: PaymentEntity): InitiateResponseDto {
@@ -398,24 +462,37 @@ export class PaymentService {
failureCode?: string;
failureMessage?: string;
}): Promise<{ processed: boolean; alreadyFinalized?: boolean; reason?: string }> {
console.log(`Received payment event: ${JSON.stringify(event)}`);
if (event.eventType === "payment.succeeded") {
const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" });
const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId });
if (!intent) {
return { processed: false, reason: `No local intent for booking ${event.referenceId}` };
return { processed: false, reason: `No local intent for reference ${event.referenceId}` };
}
const { alreadyFinalized } = await this.finalizePaymentSuccess({
intentId: intent.id,
bookingId: event.referenceId,
console.log(`Processing payment succeeded event for intent: }`,intent);
const { alreadyFinalized } = await this.markIntentSucceeded(intent.id, {
providerTxnId: event.providerTxnId,
paidAt: event.paidAt ? new Date(event.paidAt) : undefined,
notify: true,
});
console.log(`Payment finalized for intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`);
// When the intent references a booking, flip the booking itself paid.
// refId holds the booking id (the domain reference the intent opened with).
if (intent.referenceType === PaymentReferenceType.BOOKING) {
await this.datasource.manager.update(
Booking,
{ id: intent.refId },
{ status: "PAID", paymentStatus: "PAID" },
);
}
// console.log(`Payment finalized for booking ${event.referenceId}, intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`);
return { processed: true, alreadyFinalized };
}
if (event.eventType === "payment.failed") {
const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" });
const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId });
if (!intent) {
return { processed: false, reason: `No local intent for booking ${event.referenceId}` };
return { processed: false, reason: `No local intent for reference ${event.referenceId}` };
}
await this.markPaymentFailed({
intentId: intent.id,

View File

@@ -0,0 +1,28 @@
import "reflect-metadata";
import { config } from "dotenv";
import { resolve } from "path";
config({ path: resolve(__dirname, "../../.env") });
import { NestFactory } from "@nestjs/core";
import { AppModule } from "../app.module";
import { GovCompaniesSeeder } from "../seed/gov-companies.seeder";
async function main() {
const app = await NestFactory.createApplicationContext(AppModule, {
logger: ["error", "warn", "log"],
});
try {
const seeder = app.get(GovCompaniesSeeder);
await seeder.run();
console.log("Government companies seeded.");
} finally {
await app.close();
}
}
main().catch((err) => {
console.error("Government companies seed failed:", err);
process.exit(1);
});

View File

@@ -0,0 +1,136 @@
import {
CompanyKind,
CompanyStatus,
CompanyType,
} from "../../modules/companies/entities/company.entity";
import {
ProfileStatus,
ProfileType,
} from "../../modules/companies/entities/company-profile.entity";
/**
* Canonical list of seeded Ethiopian government entities. Government bookings
* are billed to one of these (with an explicit importer/exporter profile)
* instead of carrying a null company + free-text institution.
*
* IDs are fixed so the seeder is idempotent and the matching migration
* (1821000000003-AddCompanyKindAndGovBookingLinks) can backfill legacy rows to
* the same companies. The migration mirrors these rows in raw SQL — keep both
* in sync when adding new entities.
*/
export const GOV_COMPANY_TYPE = CompanyType.Customer;
export const GOV_COMPANY_KIND = CompanyKind.Government;
export const GOV_COMPANY_STATUS = CompanyStatus.Active;
export const GOV_PROFILE_STATUS = ProfileStatus.Active;
export interface GovProfileSeed {
id: string;
type: ProfileType;
reference: string;
}
export interface GovCompanySeed {
id: string;
name: string;
tin: string;
email: string;
phone: string;
profiles: GovProfileSeed[];
}
const importExport = (
index: number,
importerId: string,
exporterId: string,
): GovProfileSeed[] => [
{
id: importerId,
type: ProfileType.importer,
reference: `IM-9000${index}`,
},
{
id: exporterId,
type: ProfileType.exporter,
reference: `EX-9000${index}`,
},
];
export const GOV_COMPANIES: GovCompanySeed[] = [
{
id: "0a1b0001-0000-4000-8000-000000000001",
name: "Federal Government of Ethiopia",
tin: "0000000001",
email: "procurement@gov.et",
phone: "+251111000001",
profiles: importExport(
1,
"0b1c0001-0000-4000-8000-000000000001",
"0b1c0001-0000-4000-8000-000000000002",
),
},
{
id: "0a1b0002-0000-4000-8000-000000000002",
name: "Ministry of National Defense",
tin: "0000000002",
email: "logistics@mod.gov.et",
phone: "+251111000002",
profiles: importExport(
2,
"0b1c0002-0000-4000-8000-000000000001",
"0b1c0002-0000-4000-8000-000000000002",
),
},
{
id: "0a1b0003-0000-4000-8000-000000000003",
name: "Ethiopian Roads Administration",
tin: "0000000003",
email: "supply@era.gov.et",
phone: "+251111000003",
profiles: importExport(
3,
"0b1c0003-0000-4000-8000-000000000001",
"0b1c0003-0000-4000-8000-000000000002",
),
},
{
id: "0a1b0004-0000-4000-8000-000000000004",
name: "Ministry of Agriculture",
tin: "0000000004",
email: "imports@moa.gov.et",
phone: "+251111000004",
profiles: importExport(
4,
"0b1c0004-0000-4000-8000-000000000001",
"0b1c0004-0000-4000-8000-000000000002",
),
},
{
id: "0a1b0005-0000-4000-8000-000000000005",
name: "Ministry of Trade and Regional Integration",
tin: "0000000005",
email: "trade@motri.gov.et",
phone: "+251111000005",
profiles: importExport(
5,
"0b1c0005-0000-4000-8000-000000000001",
"0b1c0005-0000-4000-8000-000000000002",
),
},
{
id: "0a1b0006-0000-4000-8000-000000000006",
name: "Ethiopian Disaster Risk Management Commission",
tin: "0000000006",
email: "relief@edrmc.gov.et",
phone: "+251111000006",
profiles: importExport(
6,
"0b1c0006-0000-4000-8000-000000000001",
"0b1c0006-0000-4000-8000-000000000002",
),
},
];
/** Fallback entity used to backfill legacy government / null-company bookings. */
export const DEFAULT_GOV_COMPANY = GOV_COMPANIES[0];
export const DEFAULT_GOV_IMPORTER_PROFILE = GOV_COMPANIES[0].profiles[0];

View File

@@ -0,0 +1,72 @@
import { Injectable, Logger } from "@nestjs/common";
import { DataSource } from "typeorm";
import { Company } from "../modules/companies/entities/company.entity";
import { CompanyProfile } from "../modules/companies/entities/company-profile.entity";
import {
GOV_COMPANIES,
GOV_COMPANY_KIND,
GOV_COMPANY_STATUS,
GOV_COMPANY_TYPE,
GOV_PROFILE_STATUS,
} from "./data/gov-companies.data";
/**
* Idempotently seeds the Ethiopian government entities (with importer + exporter
* profiles) that government bookings bill to. Safe to re-run — rows are keyed by
* the fixed IDs in {@link GOV_COMPANIES}; existing rows are left untouched.
*/
@Injectable()
export class GovCompaniesSeeder {
private readonly logger = new Logger(GovCompaniesSeeder.name);
constructor(private readonly dataSource: DataSource) {}
async run(): Promise<void> {
await this.dataSource.transaction(async (manager) => {
const companyRepo = manager.getRepository(Company);
const profileRepo = manager.getRepository(CompanyProfile);
for (const gov of GOV_COMPANIES) {
const existing = await companyRepo.findOne({ where: { id: gov.id } });
if (!existing) {
await companyRepo.save(
companyRepo.create({
id: gov.id,
name: gov.name,
type: GOV_COMPANY_TYPE,
kind: GOV_COMPANY_KIND,
status: GOV_COMPANY_STATUS,
tin: gov.tin,
country: "Ethiopia",
email: gov.email,
phone: gov.phone,
}),
);
this.logger.log(`Created government company: ${gov.name}`);
}
for (const profile of gov.profiles) {
const existingProfile = await profileRepo.findOne({
where: { id: profile.id },
});
if (existingProfile) continue;
await profileRepo.save(
profileRepo.create({
id: profile.id,
companyId: gov.id,
type: profile.type,
reference: profile.reference,
status: GOV_PROFILE_STATUS,
}),
);
this.logger.log(
`Created ${profile.type} profile ${profile.reference} for ${gov.name}`,
);
}
}
});
this.logger.log("Government companies seeded.");
}
}

View File

@@ -0,0 +1,164 @@
import { useState, useMemo } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
Box,
Button,
Group,
Loader,
Select,
Stack,
Table,
Text,
Alert,
} from "@mantine/core";
import { AlertCircle } from "lucide-react";
import toast from "react-hot-toast";
import { vehiclesService } from "@/services/vehicles.service";
export interface ContainerAllocationRow {
id: string;
type: string;
qty: number;
}
export interface ContainerAllocationTableProps {
bookingId: string;
containers: ContainerAllocationRow[];
onSave: (allocations: Array<{ containerId: string; vehicleId: string }>) => Promise<void>;
}
/**
* Manual container-to-vehicle allocation table for freight bookings.
* Displays containers with type/qty, vehicle dropdown per row, and save action.
*/
export function ContainerAllocationTable({
bookingId,
containers,
onSave,
}: ContainerAllocationTableProps) {
const [allocations, setAllocations] = useState<Record<string, string | null>>(
() => containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}),
);
const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({
queryKey: ["vehicles", "active"],
queryFn: () => vehiclesService.getAll({ status: "ACTIVE" }),
});
const vehicleOptions = useMemo(
() =>
vehicles.map((v) => ({
value: v.id,
label: `${v.plateNumber} (${v.vehicleType})`,
description: `${v.model} · ${v.manufacturer}`,
})),
[vehicles],
);
const saveAllocation = useMutation({
mutationFn: async () => {
const mappings = containers
.filter((c) => allocations[c.id])
.map((c) => ({
containerId: c.id,
vehicleId: allocations[c.id]!,
}));
if (mappings.length === 0) {
throw new Error("No containers allocated to vehicles");
}
await onSave(mappings);
},
onSuccess: () => {
toast.success("Container allocations saved");
setAllocations(
containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}),
);
},
onError: (error) => {
toast.error(
error instanceof Error ? error.message : "Failed to save allocations",
);
},
});
const allocatedCount = Object.values(allocations).filter(Boolean).length;
const allAllocated = allocatedCount === containers.length;
if (vehiclesLoading) {
return (
<Box display="flex" justifyContent="center" p="xl">
<Loader size="sm" />
</Box>
);
}
return (
<Stack gap="md">
{vehicles.length === 0 && (
<Alert icon={<AlertCircle size={16} />} color="yellow">
No active vehicles available. Add vehicles before allocating containers.
</Alert>
)}
<Box style={{ overflowX: "auto" }}>
<Table verticalSpacing="md" horizontalSpacing="md" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Container ID</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Qty</Table.Th>
<Table.Th>Assigned Vehicle</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{containers.map((container) => (
<Table.Tr key={container.id}>
<Table.Td>
<Text fw={600} size="sm">
{container.id}
</Text>
</Table.Td>
<Table.Td>{container.type}</Table.Td>
<Table.Td>{container.qty}</Table.Td>
<Table.Td>
<Select
placeholder="Select vehicle"
data={vehicleOptions}
value={allocations[container.id] ?? null}
onChange={(value) =>
setAllocations((prev) => ({
...prev,
[container.id]: value,
}))
}
searchable
clearable
disabled={vehicles.length === 0}
style={{ minWidth: 200 }}
/>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
<Group justify="space-between">
<Text size="sm" c="dimmed">
{allocatedCount} of {containers.length} containers allocated
</Text>
<Button
color="edr-green"
loading={saveAllocation.isPending}
disabled={allocatedCount === 0 || vehicles.length === 0}
onClick={() => saveAllocation.mutate()}
>
Save Allocations
</Button>
</Group>
</Stack>
);
}

View File

@@ -0,0 +1,164 @@
import { useState, useMemo } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
Box,
Button,
Group,
Loader,
Select,
Stack,
Table,
Text,
Alert,
} from "@mantine/core";
import { AlertCircle } from "lucide-react";
import toast from "react-hot-toast";
import { vehiclesService } from "@/services/vehicles.service";
export interface ContainerAllocationRow {
id: string;
type: string;
qty: number;
}
export interface FirstMileContainerAllocationTableProps {
firstMileId: string;
containers: ContainerAllocationRow[];
onSave: (allocations: Array<{ containerId: string; vehicleId: string }>) => Promise<void>;
}
/**
* Manual container-to-vehicle allocation table for first-mile pickups.
* Displays containers with type/qty, vehicle dropdown per row, and save action.
*/
export function FirstMileContainerAllocationTable({
firstMileId,
containers,
onSave,
}: FirstMileContainerAllocationTableProps) {
const [allocations, setAllocations] = useState<Record<string, string | null>>(
() => containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}),
);
const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({
queryKey: ["vehicles", "active"],
queryFn: () => vehiclesService.getAll({ status: "ACTIVE" }),
});
const vehicleOptions = useMemo(
() =>
vehicles.map((v) => ({
value: v.id,
label: `${v.plateNumber} (${v.vehicleType})`,
description: `${v.model} · ${v.manufacturer}`,
})),
[vehicles],
);
const saveAllocation = useMutation({
mutationFn: async () => {
const mappings = containers
.filter((c) => allocations[c.id])
.map((c) => ({
containerId: c.id,
vehicleId: allocations[c.id]!,
}));
if (mappings.length === 0) {
throw new Error("No containers allocated to vehicles");
}
await onSave(mappings);
},
onSuccess: () => {
toast.success("Container allocations saved");
setAllocations(
containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}),
);
},
onError: (error) => {
toast.error(
error instanceof Error ? error.message : "Failed to save allocations",
);
},
});
const allocatedCount = Object.values(allocations).filter(Boolean).length;
const allAllocated = allocatedCount === containers.length;
if (vehiclesLoading) {
return (
<Box display="flex" justifyContent="center" p="xl">
<Loader size="sm" />
</Box>
);
}
return (
<Stack gap="md">
{vehicles.length === 0 && (
<Alert icon={<AlertCircle size={16} />} color="yellow">
No active vehicles available. Add vehicles before allocating containers.
</Alert>
)}
<Box style={{ overflowX: "auto" }}>
<Table verticalSpacing="md" horizontalSpacing="md" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Container ID</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Qty</Table.Th>
<Table.Th>Assigned Vehicle</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{containers.map((container) => (
<Table.Tr key={container.id}>
<Table.Td>
<Text fw={600} size="sm">
{container.id}
</Text>
</Table.Td>
<Table.Td>{container.type}</Table.Td>
<Table.Td>{container.qty}</Table.Td>
<Table.Td>
<Select
placeholder="Select vehicle"
data={vehicleOptions}
value={allocations[container.id] ?? null}
onChange={(value) =>
setAllocations((prev) => ({
...prev,
[container.id]: value,
}))
}
searchable
clearable
disabled={vehicles.length === 0}
style={{ minWidth: 200 }}
/>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
<Group justify="space-between">
<Text size="sm" c="dimmed">
{allocatedCount} of {containers.length} containers allocated
</Text>
<Button
color="edr-green"
loading={saveAllocation.isPending}
disabled={allocatedCount === 0 || vehicles.length === 0}
onClick={() => saveAllocation.mutate()}
>
Save Allocations
</Button>
</Group>
</Stack>
);
}

View File

@@ -0,0 +1,164 @@
import { useState, useMemo } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
Box,
Button,
Group,
Loader,
Select,
Stack,
Table,
Text,
Alert,
} from "@mantine/core";
import { AlertCircle } from "lucide-react";
import toast from "react-hot-toast";
import { vehiclesService } from "@/services/vehicles.service";
export interface LastMileContainerRow {
id: string;
type: string;
qty: number;
}
export interface LastMileContainerAllocationTableProps {
lastMileId: string;
containers: LastMileContainerRow[];
onSave: (allocations: Array<{ containerId: string; vehicleId: string }>) => Promise<void>;
}
/**
* Manual container-to-vehicle allocation table for last-mile deliveries.
* Displays containers with type/qty, vehicle dropdown per row, and save action.
*/
export function LastMileContainerAllocationTable({
lastMileId,
containers,
onSave,
}: LastMileContainerAllocationTableProps) {
const [allocations, setAllocations] = useState<Record<string, string | null>>(
() => containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}),
);
const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({
queryKey: ["vehicles", "active"],
queryFn: () => vehiclesService.getAll({ status: "ACTIVE" }),
});
const vehicleOptions = useMemo(
() =>
vehicles.map((v) => ({
value: v.id,
label: `${v.plateNumber} (${v.vehicleType})`,
description: `${v.model} · ${v.manufacturer}`,
})),
[vehicles],
);
const saveAllocation = useMutation({
mutationFn: async () => {
const mappings = containers
.filter((c) => allocations[c.id])
.map((c) => ({
containerId: c.id,
vehicleId: allocations[c.id]!,
}));
if (mappings.length === 0) {
throw new Error("No containers allocated to vehicles");
}
await onSave(mappings);
},
onSuccess: () => {
toast.success("Container allocations saved");
setAllocations(
containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}),
);
},
onError: (error) => {
toast.error(
error instanceof Error ? error.message : "Failed to save allocations",
);
},
});
const allocatedCount = Object.values(allocations).filter(Boolean).length;
const allAllocated = allocatedCount === containers.length;
if (vehiclesLoading) {
return (
<Box display="flex" justifyContent="center" p="xl">
<Loader size="sm" />
</Box>
);
}
return (
<Stack gap="md">
{vehicles.length === 0 && (
<Alert icon={<AlertCircle size={16} />} color="yellow">
No active vehicles available. Add vehicles before allocating containers.
</Alert>
)}
<Box style={{ overflowX: "auto" }}>
<Table verticalSpacing="md" horizontalSpacing="md" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Container ID</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Qty</Table.Th>
<Table.Th>Assigned Vehicle</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{containers.map((container) => (
<Table.Tr key={container.id}>
<Table.Td>
<Text fw={600} size="sm">
{container.id}
</Text>
</Table.Td>
<Table.Td>{container.type}</Table.Td>
<Table.Td>{container.qty}</Table.Td>
<Table.Td>
<Select
placeholder="Select vehicle"
data={vehicleOptions}
value={allocations[container.id] ?? null}
onChange={(value) =>
setAllocations((prev) => ({
...prev,
[container.id]: value,
}))
}
searchable
clearable
disabled={vehicles.length === 0}
style={{ minWidth: 200 }}
/>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
<Group justify="space-between">
<Text size="sm" c="dimmed">
{allocatedCount} of {containers.length} containers allocated
</Text>
<Button
color="edr-green"
loading={saveAllocation.isPending}
disabled={allocatedCount === 0 || vehicles.length === 0}
onClick={() => saveAllocation.mutate()}
>
Save Allocations
</Button>
</Group>
</Stack>
);
}

View File

@@ -1,5 +1,7 @@
import { Container, Grid, Stack } from "@mantine/core";
import { useNavigate, useParams } from "react-router-dom";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import toast from "react-hot-toast";
import {
BookingApprovalCard,
@@ -16,10 +18,26 @@ import {
type BookingDetailView,
} from "@/components/bookings/detail";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import ContainerAllocationTable from "@/components/ContainerAllocationTable";
import { api } from "@/services/api";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
const BookingDetailPage = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const qc = useQueryClient();
const allocateMutation = useMutation({
mutationFn: (data: any) =>
api.post(`/bookings/${id}/allocate-containers`, data),
onSuccess: () => {
toast.success("Containers allocated");
qc.invalidateQueries({ queryKey: QUERY_KEYS.BOOKINGS.byId(id ?? "") });
},
onError: () => {
toast.error("Failed to allocate containers");
},
});
// Mock data - replace with actual API call
const booking: BookingDetailView = {
@@ -134,6 +152,17 @@ const BookingDetailPage = () => {
<BookingContainersCard
containers={booking.bookingContainers ?? []}
/>
<ContainerAllocationTable
bookingId={booking.id}
containers={(booking.bookingContainers ?? []).map((c) => ({
id: c.id,
type: c.containerType?.label ?? "Unknown",
qty: c.quantity,
}))}
onSave={(allocations) =>
allocateMutation.mutateAsync({ allocations })
}
/>
<BookingApprovalCard
steps={approvalSteps}
approvedCount={approvedCount}

View File

@@ -180,8 +180,10 @@ export default function NewBookingPage() {
const queryClient = useQueryClient();
const [isGovernment, setIsGovernment] = useState(false);
const [governmentInstitution, setGovernmentInstitution] = useState("");
const [companyId, setCompanyId] = useState<string | null>(null);
// Government bookings bill to a real government company + an explicit profile.
const [govCompanyId, setGovCompanyId] = useState<string | null>(null);
const [govProfileId, setGovProfileId] = useState<string | null>(null);
const [freightType, setFreightType] = useState<FreightType>("CONTAINER");
const [originYardId, setOriginYardId] = useState<string | null>(null);
const [destinationYardId, setDestinationYardId] = useState<string | null>(null);
@@ -220,6 +222,42 @@ export default function NewBookingPage() {
label: c.name || c.email || c.tin || c.id,
}));
// Active government companies (kind=government) the booking can bill to.
const { data: govCompaniesPage, isLoading: govCompaniesLoading } = useQuery({
queryKey: ["companies", "government", "active"],
queryFn: () =>
customersService.list({
page: 1,
pageSize: 1000,
kind: "government",
status: "active",
}),
enabled: isGovernment,
});
const govCompanies = govCompaniesPage?.items ?? [];
const govCompanyOptions = govCompanies.map((c) => ({
value: c.id,
label: c.name || c.tin || c.id,
}));
// Profiles (importer/exporter) of the chosen government company — the booking
// must link to one explicitly.
const selectedGovCompany = govCompanies.find((c) => c.id === govCompanyId);
const govProfileOptions = (selectedGovCompany?.companyProfiles ?? [])
.filter((p) => p.status === "active")
.map((p) => ({
value: p.id,
label: `${p.type === "importer" ? "Import" : p.type === "exporter" ? "Export" : p.type}${
p.reference ? `${p.reference}` : ""
}`,
}));
// Reset the chosen profile when the government company changes.
useEffect(() => {
setGovProfileId(null);
}, [govCompanyId]);
// Day-level pool: fetch only the days that have a departure on the route (no
// train, no capacity). The batch engine assigns the train after booking.
const { data: availableDays, isLoading: daysLoading } = useQuery(
@@ -306,7 +344,7 @@ export default function NewBookingPage() {
Boolean(tradeDirection) &&
Boolean(serviceTypeId) &&
departureSatisfied &&
(isGovernment ? governmentInstitution.trim().length >= 2 : Boolean(companyId)) &&
(isGovernment ? Boolean(govCompanyId && govProfileId) : Boolean(companyId)) &&
(freightType === "BULK"
? Boolean(cargoTypeId) && bulkWeight > 0
: allLinesValid);
@@ -320,8 +358,8 @@ export default function NewBookingPage() {
mutationFn: () =>
bookingsService.create({
isGovernment,
governmentInstitution: isGovernment ? governmentInstitution : undefined,
companyId: isGovernment ? undefined : companyId || undefined,
companyId: isGovernment ? govCompanyId || undefined : companyId || undefined,
companyProfileId: isGovernment ? govProfileId || undefined : undefined,
freightType,
contractType: "NEW",
equipmentReturn,
@@ -390,18 +428,37 @@ export default function NewBookingPage() {
<Stack gap="md">
<Switch
label="Government booking"
description="No company required — institution name instead. Expedited to the scheduling queue."
description="Bills to a government entity + profile. Expedited to the scheduling queue."
checked={isGovernment}
onChange={(e) => setIsGovernment(e.currentTarget.checked)}
/>
{isGovernment ? (
<TextInput
label="Government institution"
placeholder="e.g. Ministry of Transport"
value={governmentInstitution}
onChange={(e) => setGovernmentInstitution(e.currentTarget.value)}
required
/>
<Group grow align="flex-start">
<Select
label="Government entity"
placeholder="Select government company"
data={govCompanyOptions}
value={govCompanyId}
onChange={setGovCompanyId}
searchable
required
disabled={govCompaniesLoading}
nothingFoundMessage="No active government companies"
/>
<Select
label="Profile"
placeholder={
govCompanyId ? "Select import/export profile" : "Pick an entity first"
}
data={govProfileOptions}
value={govProfileId}
onChange={setGovProfileId}
searchable
required
disabled={!govCompanyId}
nothingFoundMessage="No active profiles for this entity"
/>
</Group>
) : (
<Select
label="Customer"

View File

@@ -30,9 +30,11 @@ import {
Text,
TextInput,
UnstyledButton,
Alert,
} from "@mantine/core";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { FirstMileContainerAllocationTable } from "@/components/FirstMileContainerAllocationTable";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { useToast } from "@/hooks/use-toast";
import {
@@ -44,6 +46,7 @@ import {
import { bookingsService } from "@/services/bookings.service";
import { vehiclesService } from "@/services/vehicles.service";
import { ratesService } from "@/services/rates.service";
import { api } from "@/auth/http";
import type { BookingDetail } from "@/types/booking";
const formatPrice = (amount: number) =>
@@ -314,6 +317,7 @@ const FirstMilePage = () => {
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("ALL");
const [filterPostPaymentPending, setFilterPostPaymentPending] = useState(false);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const [assignOpen, setAssignOpen] = useState(false);
@@ -335,6 +339,9 @@ const FirstMilePage = () => {
const [invoiceOpen, setInvoiceOpen] = useState(false);
const [invoiceRecord, setInvoiceRecord] = useState<FirstMileRecord | null>(null);
const [containerAllocationOpen, setContainerAllocationOpen] = useState(false);
const [containerAllocationFirstMileId, setContainerAllocationFirstMileId] = useState<string | null>(null);
const { data: listData, isLoading } = useQuery({
queryKey: QUERY_KEYS.FIRST_MILE.list(),
queryFn: async () => {
@@ -433,6 +440,19 @@ const FirstMilePage = () => {
},
});
const allocateMutation = useMutation({
mutationFn: (data) => apiClient.post(`/first-mile/${containerAllocationFirstMileId}/allocate-containers`, data),
onSuccess: () => {
toast({ title: "Containers allocated" });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.detail(containerAllocationFirstMileId ?? "") });
setContainerAllocationOpen(false);
setContainerAllocationFirstMileId(null);
},
onError: () => {
toast({ title: "Allocation failed", variant: "destructive" });
},
});
const activeRecord = useMemo(
() => records.find((r) => r.id === activeId) ?? null,
[records, activeId],
@@ -507,6 +527,16 @@ const FirstMilePage = () => {
setInvoiceRecord(null);
};
const openContainerAllocation = (firstMileId: string) => {
setContainerAllocationFirstMileId(firstMileId);
setContainerAllocationOpen(true);
};
const closeContainerAllocation = () => {
setContainerAllocationOpen(false);
setContainerAllocationFirstMileId(null);
};
const handleSaveDistance = () => {
const distance = parseFloat(distanceValue);
if (!activeId || isNaN(distance) || distance < 0) {
@@ -566,7 +596,7 @@ const FirstMilePage = () => {
.includes(term);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [records, search, statusFilter]);
}, [records, search, statusFilter, filterPostPaymentPending]);
const pageCount = Math.max(1, Math.ceil(filteredRecords.length / pagination.pageSize));
const pagedRecords = useMemo(() => {
@@ -887,6 +917,17 @@ const FirstMilePage = () => {
</Button>
);
})}
<Button
size="xs"
variant={filterPostPaymentPending ? "filled" : "default"}
styles={{ label: { fontWeight: 500 } }}
onClick={() => {
setFilterPostPaymentPending(!filterPostPaymentPending);
setPagination((p) => ({ ...p, pageIndex: 0 }));
}}
>
Post Payment Pending
</Button>
</Group>
</Stack>
</Box>
@@ -1259,6 +1300,56 @@ const FirstMilePage = () => {
</Group>
</Stack>
</Modal>
{/* Container Allocation modal */}
<Modal
opened={containerAllocationOpen}
onClose={closeContainerAllocation}
title={<Text fw={600}>Allocate Containers to Vehicles</Text>}
size="xl"
radius="lg"
centered
>
<Stack gap="md">
{activeRecord && (
<>
{/* Capacity guidance */}
{activeRecord.booking?.cargoType?.label === "BULK" ? (
<Alert color="blue" title="Bulk Cargo Allocation">
<Text size="sm">
Select multiple containers per vehicle based on capacity. Each vehicle can carry multiple containers if capacity allows.
</Text>
<Text size="xs" c="dimmed" mt="xs">
Capacity: TBD TODO: add vehicle capacity_tons to vehicle API if missing
</Text>
</Alert>
) : (
<Alert color="blue">
<Text size="sm">
One vehicle per container. Each container will be assigned to a single vehicle.
</Text>
</Alert>
)}
<Divider />
{/* Container table */}
<FirstMileContainerAllocationTable
firstMileId={activeRecord.id}
containers={[
// TODO: Get containers from booking/first-mile data
// For now placeholder with TODO comment
]}
onSave={async (allocations) => {
await allocateMutation.mutateAsync(allocations);
}}
/>
</>
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={closeContainerAllocation}>Close</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
};

View File

@@ -45,6 +45,8 @@ import {
} from "@/services/last-mile.service";
import { vehiclesService } from "@/services/vehicles.service";
import { ratesService } from "@/services/rates.service";
import { LastMileContainerAllocationTable, type LastMileContainerRow } from "@/components/LastMileContainerAllocationTable";
import { api } from "@/auth/http";
const formatPrice = (amount: number) =>
`ETB ${amount.toLocaleString("en-US", {
@@ -298,6 +300,7 @@ const LastMilePage = () => {
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("ALL");
const [filterPostPaymentPending, setFilterPostPaymentPending] = useState(false);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const [assignOpen, setAssignOpen] = useState(false);
@@ -320,6 +323,9 @@ const LastMilePage = () => {
const [invoiceOpen, setInvoiceOpen] = useState(false);
const [invoiceRecord, setInvoiceRecord] = useState<LastMileRecord | null>(null);
const [allocationOpen, setAllocationOpen] = useState(false);
const [allocationContainers, setAllocationContainers] = useState<LastMileContainerRow[]>([]);
const { data: listData, isLoading } = useQuery({
queryKey: QUERY_KEYS.LAST_MILE.list(),
queryFn: async () => {
@@ -384,6 +390,19 @@ const LastMilePage = () => {
},
});
const allocateMutation = useMutation({
mutationFn: (data: Array<{ containerId: string; vehicleId: string }>) =>
api.post(`/last-mile/${activeId}/allocate-containers`, data),
onSuccess: () => {
toast({ title: "Containers allocated", variant: "default" });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.detail(activeId ?? "") });
closeAllocation();
},
onError: () => {
toast({ title: "Allocation failed", variant: "destructive" });
},
});
const { data: arrivalQueueData, isLoading: arrivalLoading } = useQuery({
queryKey: ["warehouse-inventory", "arrival-queue"],
queryFn: () => warehouseService.arrivalQueue().then((r) => r.data),
@@ -476,6 +495,18 @@ const LastMilePage = () => {
setInvoiceRecord(null);
};
const openAllocation = (id: string, containers?: LastMileContainerRow[]) => {
setActiveId(id);
setAllocationContainers(containers ?? []);
setAllocationOpen(true);
};
const closeAllocation = () => {
setAllocationOpen(false);
setActiveId(null);
setAllocationContainers([]);
};
const handleSaveDistance = () => {
const distance = parseFloat(distanceValue);
if (!activeId || isNaN(distance) || distance < 0) {
@@ -545,7 +576,7 @@ const LastMilePage = () => {
.includes(term);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [records, search, statusFilter]);
}, [records, search, statusFilter, filterPostPaymentPending]);
const pageCount = Math.max(1, Math.ceil(filteredRecords.length / pagination.pageSize));
const pagedRecords = useMemo(() => {
@@ -866,6 +897,17 @@ const LastMilePage = () => {
</Button>
);
})}
<Button
size="xs"
variant={filterPostPaymentPending ? "filled" : "default"}
styles={{ label: { fontWeight: 500 } }}
onClick={() => {
setFilterPostPaymentPending(!filterPostPaymentPending);
setPagination((p) => ({ ...p, pageIndex: 0 }));
}}
>
Post Payment Pending
</Button>
</Group>
</Stack>
</Box>
@@ -1230,6 +1272,76 @@ const LastMilePage = () => {
</Group>
</Stack>
</Modal>
{/* Container Allocation modal */}
<Modal
opened={allocationOpen}
onClose={closeAllocation}
title={<Text fw={600}>Allocate Containers to Vehicles</Text>}
size="xl"
radius="lg"
centered
>
<Stack gap="md">
{activeRecord && (
<>
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-gray-0)">
<Stack gap="sm">
<Group justify="space-between">
<Stack gap={0}>
<Text fw={600} size="sm">{bookingRef(activeRecord)}</Text>
<Text size="xs" c="dimmed">{customerName(activeRecord)}</Text>
</Stack>
<Stack gap={0} align="flex-end">
<Text size="xs" c="dimmed" tt="uppercase">Cargo Type</Text>
<Text size="sm" fw={600}>{activeRecord.booking?.cargoType?.label ?? activeRecord.booking?.cargoType?.name ?? "—"}</Text>
</Stack>
</Group>
</Stack>
</Card>
{/* Capacity logic based on cargo type */}
{activeRecord.booking?.cargoType?.name === "BULK" ? (
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-blue-0)" style={{ borderColor: "var(--mantine-color-blue-3)" }}>
<Stack gap="sm">
<Group gap="xs">
<Text fw={600} size="sm">Smart Capacity Allocation</Text>
</Group>
<Stack gap={2}>
<Text size="sm">Capacity: TBD</Text>
<Text size="xs" c="dimmed">
TODO: add vehicle capacity_tons to vehicle API if missing
</Text>
<Text size="xs" c="dimmed">
TODO: add container weight to booking if missing
</Text>
</Stack>
<Text size="sm" fw={500} mt="xs">
Select multiple containers per vehicle based on capacity
</Text>
</Stack>
</Card>
) : (
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-gray-0)">
<Text size="sm" fw={500}>One vehicle per container</Text>
</Card>
)}
</>
)}
<LastMileContainerAllocationTable
lastMileId={activeId ?? ""}
containers={allocationContainers}
onSave={async (mappings) => {
await allocateMutation.mutateAsync(mappings);
}}
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={closeAllocation}>Close</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
};

View File

@@ -18,6 +18,9 @@ export type CompanyType =
/** Mirrors backend `CompanyStatus`. */
export type CompanyStatus = "active" | "pending" | "suspended" | "blacklisted";
/** Mirrors backend `CompanyKind` — commercial customer vs. government entity. */
export type CompanyKind = "commercial" | "government";
/** Mirrors backend `ProfileType` (the role a company plays). */
export type ProfileType =
| "importer"
@@ -47,6 +50,7 @@ export interface Company {
id: string;
name: string;
type: CompanyType;
kind: CompanyKind;
status: CompanyStatus;
tin: string;
vatNumber?: string | null;
@@ -73,6 +77,7 @@ export interface CompanyListFilter {
pageSize: number;
search?: string;
type?: CompanyType;
kind?: CompanyKind;
status?: CompanyStatus;
}

View File

@@ -31,7 +31,8 @@ import LoginPage from "./pages/accounts/LoginPage";
import SetPasswordPage from "./pages/accounts/SetPasswordPage";
import SignupPage from "./pages/accounts/SignupPage";
import VerificationOtpPage from "./pages/accounts/VerificationOtpPage";
import BillingPage from "./pages/billing/BillingPage";
import InvoiceDetailPage from "./pages/billing/InvoiceDetailPage";
import InvoicesList from "./pages/billing/InvoicesList";
import BookingContractPage from "./pages/bookings/BookingContractPage";
import BookingDetailPage from "./pages/bookings/BookingDetailPage";
import EditBookingPage from "./pages/bookings/EditBookingPage";
@@ -188,7 +189,7 @@ const sidebarItems: SidebarItem[] = [
icon: <MapPin size={18} />,
},
{
label: "Billing",
label: "Invoices",
href: "/billing",
icon: <Receipt size={18} />,
},
@@ -285,7 +286,8 @@ const App = () => {
/>
<Route path="/contracts/:id" element={<ContractDetailPage />} />
<Route path="/tracking" element={<TrackingPage />} />
<Route path="/billing" element={<BillingPage />} />
<Route path="/billing" element={<InvoicesList />} />
<Route path="/billing/:id" element={<InvoiceDetailPage />} />
{/* Profile was merged into Settings — keep old links working. */}
<Route
path="/profile"

View File

@@ -143,4 +143,10 @@ export const URL_CONSTANTS = {
INTENT: (bookingId: string) => `/api/payments/intents/${bookingId}`,
CHECKOUT: "/api/payments/checkout",
},
BILLING: {
MY_INVOICES: "/api/billing/my-invoices",
MY_INVOICE_BY_ID: (id: string) => `/api/billing/my-invoices/${id}`,
PAY_INVOICE: (id: string) => `/api/billing/my-invoices/${id}/pay`,
},
};

View File

@@ -0,0 +1,23 @@
/** Currency code carried on invoices / dashboard figures (ETB, USD, DJF, …). */
export type Currency = string;
const SYMBOLS: Record<string, string> = {
USD: "$",
ETB: "Br",
DJF: "DJF",
};
/**
* Format a money amount with its currency symbol, e.g. `Br 12,500.00`.
* Unknown currency codes fall back to printing the raw code.
*/
export function formatCurrency(
amount: number,
currency: Currency = "ETB",
): string {
const symbol = SYMBOLS[currency] ?? currency;
return `${symbol} ${Number(amount ?? 0).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`;
}

View File

@@ -1,20 +0,0 @@
import { invoices, type Invoice } from "@/pages/billing/invoices.mock";
import { customers, type Customer } from "@/pages/customers/customers.mock";
/**
* Mock "logged-in customer". When auth integrates, replace this with the value
* pulled from `@edr/iamui-common` / the JWT context.
*/
const CURRENT_CUSTOMER_ID = 1;
export function getCurrentCustomer(): Customer {
return (
customers.find((c) => c.id === CURRENT_CUSTOMER_ID) ??
(customers[0] as Customer)
);
}
export function getMyInvoices(): Invoice[] {
const me = getCurrentCustomer();
return invoices.filter((inv) => inv.customerId === me.id);
}

View File

@@ -1,5 +1,5 @@
import type { Currency } from "@/pages/billing/invoices.mock";
import { formatCurrency } from "@/pages/billing/invoices.mock";
import type { Currency } from "@/lib/currency";
import { formatCurrency } from "@/lib/currency";
import { Group, Grid, Select, Stack } from "@mantine/core";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
@@ -34,7 +34,6 @@ export default function MyPortalPage() {
totalOutstanding,
companyName,
greeting,
recentInvoices,
dashboard,
volumePoints,
maxVolume,
@@ -120,7 +119,7 @@ export default function MyPortalPage() {
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 4 }}>
<InvoicesSection invoices={recentInvoices} />
<InvoicesSection invoices={outstandingInvoices} />
</Grid.Col>
</Grid>

View File

@@ -1,7 +1,7 @@
import { Box, Group, Skeleton, Text } from "@mantine/core";
import { memo } from "react";
import type { Currency } from "@/pages/billing/invoices.mock";
import { formatCurrency } from "@/pages/billing/invoices.mock";
import type { Currency } from "@/lib/currency";
import { formatCurrency } from "@/lib/currency";
import { formatPct } from "../constants";
import { Card } from "./Card";

View File

@@ -1,8 +1,8 @@
import type { Currency, InvoiceStatus } from "@/pages/billing/invoices.mock";
import { formatCurrency } from "@/pages/billing/invoices.mock";
import { formatCurrency } from "@/lib/currency";
import type { PortalInvoice } from "@/services/invoices.service";
import { Box, Group, Stack, Text } from "@mantine/core";
import { format } from "date-fns";
import { CheckCircle2, ChevronRight, Clock3, Zap } from "lucide-react";
import { Freight } from "@edr/types";
import { ChevronRight, Clock3 } from "lucide-react";
import { memo } from "react";
import { Link } from "react-router-dom";
import { cv, INVOICE_BADGE } from "../constants";
@@ -10,26 +10,26 @@ import { Card } from "./Card";
import { EmptyState } from "./EmptyState";
interface InvoicesSectionProps {
invoices: Array<{
id: number;
number: string;
bookingReference: string;
amount: number;
currency: Currency;
status: InvoiceStatus;
dueDate: string;
paidDate: string | null;
}>;
invoices: PortalInvoice[];
}
/** Max rows to list in the compact dashboard card. */
const MAX_ROWS = 5;
const titleCase = (v: string) =>
v ? v.charAt(0).toUpperCase() + v.slice(1).toLowerCase() : "";
export const InvoicesSection = memo(function InvoicesSection({
invoices,
}: InvoicesSectionProps) {
const outstandingInvoices = invoices.filter(
(inv) => inv.status === "Sent" || inv.status === "Overdue",
// Dashboard shows unpaid invoices only (pending + overdue).
const pendingInvoices = invoices.filter(
(inv) =>
inv.status === Freight.InvoiceStatus.Pending ||
inv.status === Freight.InvoiceStatus.Overdue,
);
const totalOutstanding = outstandingInvoices.reduce(
(sum, inv) => sum + inv.amount,
const totalOutstanding = pendingInvoices.reduce(
(sum, inv) => sum + Number(inv.totalAmount),
0,
);
@@ -56,94 +56,74 @@ export const InvoicesSection = memo(function InvoicesSection({
<Text fz={24} fw={800} mt={4} c="edr-text">
{formatCurrency(totalOutstanding || 0, "ETB")}
</Text>
<Group
justify="space-between"
align="center"
mt={8}
wrap="nowrap"
>
<Text fz={12} c="edr-amber-text">
{outstandingInvoices.length || 2} invoices unpaid
</Text>
<Group
gap={5}
align="center"
px={14}
py={8}
bg="edr-accent"
className="cursor-pointer rounded-[9px]"
>
<Zap size={15} color="#fff" />
<Text fz={13} fw={700} c="white">
Pay all
</Text>
</Group>
</Group>
<Text fz={12} mt={8} c="edr-amber-text">
{pendingInvoices.length} invoices unpaid
</Text>
</Box>
{invoices.length === 0 ? (
<EmptyState message="No invoices yet." />
{pendingInvoices.length === 0 ? (
<EmptyState message="No pending invoices." />
) : (
<Stack gap={0}>
{invoices.map((invoice, i) => {
{pendingInvoices.slice(0, MAX_ROWS).map((invoice, i) => {
const badge = INVOICE_BADGE[invoice.status];
const dueText =
invoice.status === "Paid"
? `Paid ${format(new Date(invoice.paidDate ?? invoice.dueDate), "MMM d")}`
: invoice.status === "Overdue"
? "Overdue 3 days"
: `Due ${invoice.dueDate}`;
const DueIcon =
invoice.status === "Paid" ? CheckCircle2 : Clock3;
const dueIconColor =
invoice.status === "Paid"
? cv("edr-green.5")
: cv("edr-muted");
const isOverdue = invoice.status === Freight.InvoiceStatus.Overdue;
const dueText = isOverdue
? "Overdue"
: `Due ${new Date(invoice.dueAt).toLocaleDateString()}`;
const dueIconColor = isOverdue ? cv("edr-red") : cv("edr-muted");
return (
<Box key={invoice.id}>
{i > 0 && <Box h={1} bg="edr-divider" />}
<Stack gap={8} py={10}>
<Group
justify="space-between"
align="flex-start"
wrap="nowrap"
<Link
to={`/billing/${invoice.id}`}
className="no-underline"
style={{ display: "block", color: "inherit" }}
>
<Stack
gap={8}
py={10}
className="cursor-pointer rounded-[9px] transition-colors hover:bg-[var(--mantine-color-edr-soft-0)]"
>
<Box>
<Text fz={13} fw={700} c="edr-text">
{invoice.number}
</Text>
<Text fz={11} c="edr-muted">
{invoice.bookingReference}
</Text>
</Box>
<Text fz={14} fw={700} c="edr-text">
{formatCurrency(invoice.amount, invoice.currency)}
</Text>
</Group>
<Group
justify="space-between"
align="center"
wrap="nowrap"
>
<Group gap={5} align="center">
<DueIcon size={13} color={dueIconColor} />
<Text fz={12} c="edr-muted">
{dueText}
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Box>
<Text fz={13} fw={700} c="edr-text">
{invoice.invoiceNumber}
</Text>
<Text fz={11} c="edr-muted">
{titleCase(invoice.source)} · {titleCase(invoice.type)}
</Text>
</Box>
<Text fz={14} fw={700} c="edr-text">
{formatCurrency(
Number(invoice.totalAmount),
invoice.currency,
)}
</Text>
</Group>
<Box
bg={badge.bg}
px={10}
py={4}
className="rounded-full"
>
<Text fz={11} fw={700} c={badge.text}>
{badge.label}
</Text>
</Box>
</Group>
</Stack>
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap={5} align="center">
<Clock3 size={13} color={dueIconColor} />
<Text fz={12} c="edr-muted">
{dueText}
</Text>
</Group>
{badge && (
<Box
bg={badge.bg}
px={10}
py={4}
className="rounded-full"
>
<Text fz={11} fw={700} c={badge.text}>
{badge.label}
</Text>
</Box>
)}
</Group>
</Stack>
</Link>
</Box>
);
})}

View File

@@ -1,4 +1,4 @@
import { formatCurrency } from "@/pages/billing/invoices.mock";
import { formatCurrency } from "@/lib/currency";
import { SimpleGrid } from "@mantine/core";
import { CheckCircle2, Clock3, Layers, Truck, Wallet } from "lucide-react";
import { memo } from "react";

View File

@@ -12,7 +12,7 @@ import {
Wallet,
type LucideIcon,
} from "lucide-react";
import type { InvoiceStatus } from "@/pages/billing/invoices.mock";
import { Freight } from "@edr/types";
export const cv = (token: string) => {
const [name, shade] = token.split(".");
@@ -514,12 +514,13 @@ export const ACTION_PROPS: Record<
};
export const INVOICE_BADGE: Record<
InvoiceStatus,
Freight.InvoiceStatus,
{ label: string; bg: string; text: string }
> = {
Draft: { label: "Draft", bg: "edr-slate-soft", text: "edr-slate" },
Sent: { label: "Due soon", bg: "edr-amber-soft", text: "edr-amber-text" },
Paid: { label: "Paid", bg: "edr-soft", text: "edr-green.7" },
Overdue: { label: "Overdue", bg: "edr-red-soft", text: "edr-red" },
Cancelled: { label: "Cancelled", bg: "edr-slate-soft", text: "edr-slate" },
[Freight.InvoiceStatus.Draft]: { label: "Draft", bg: "edr-slate-soft", text: "edr-slate" },
[Freight.InvoiceStatus.Pending]: { label: "Due soon", bg: "edr-amber-soft", text: "edr-amber-text" },
[Freight.InvoiceStatus.Paid]: { label: "Paid", bg: "edr-soft", text: "edr-green.7" },
[Freight.InvoiceStatus.Overdue]: { label: "Overdue", bg: "edr-red-soft", text: "edr-red" },
[Freight.InvoiceStatus.Cancelled]: { label: "Cancelled", bg: "edr-slate-soft", text: "edr-slate" },
[Freight.InvoiceStatus.Refunded]: { label: "Refunded", bg: "edr-blue-soft", text: "edr-blue" },
};

View File

@@ -1,13 +1,14 @@
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { Freight } from "@edr/types";
import useAuth from "@/hooks/useAuth";
import { getMyInvoices } from "@/lib/currentCustomer";
import { api } from "@/services/api";
import { ACTIVE_STATUSES } from "./constants";
export function useMyPortalData(selectedProfileId?: string) {
const { user, customer, company } = useAuth();
const myInvoices = useMemo(() => getMyInvoices(), []);
const invoicesQuery = useQuery(api.invoices.listMy.queryOptions());
const myInvoices = invoicesQuery.data ?? [];
const companyProfiles = company?.company?.companyProfiles ?? [];
@@ -59,11 +60,13 @@ export function useMyPortalData(selectedProfileId?: string) {
).length;
const outstandingInvoices = myInvoices.filter(
(inv) => inv.status === "Sent" || inv.status === "Overdue",
(inv) =>
inv.status === Freight.InvoiceStatus.Pending ||
inv.status === Freight.InvoiceStatus.Overdue,
);
const totalOutstanding = outstandingInvoices.reduce(
(sum, inv) => sum + inv.amount,
(sum, inv) => sum + Number(inv.totalAmount),
0,
);
@@ -91,6 +94,7 @@ export function useMyPortalData(selectedProfileId?: string) {
bookingsQuery,
dashboardQuery,
contractsQuery,
invoicesQuery,
allContracts,
recentContracts,
activeContractsCount,

View File

@@ -1,382 +0,0 @@
import { useMemo, useState } from "react";
import {
AlertCircle,
Clock,
DollarSign,
Download,
Filter,
MoreHorizontal,
Pencil,
Plus,
Receipt,
Search,
Trash2,
} from "lucide-react";
import Breadcrumbs from "@/components/Breadcrumbs";
import NewInvoicePage from "./NewInvoicePage";
import DeleteInvoiceDialog from "./DeleteInvoiceDialog";
import { formatCurrency, invoices, type InvoiceStatus } from "./invoices.mock";
import {
DataTable,
DataTableFooter,
type ColumnDef,
usePagination,
Button,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Input,
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from "@edr/ui-common";
type FilterValue = "All" | InvoiceStatus;
const FILTERS: FilterValue[] = [
"All",
"Draft",
"Sent",
"Paid",
"Overdue",
"Cancelled",
];
export default function BillingPage() {
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [filter, setFilter] = useState<FilterValue>("All");
const [query, setQuery] = useState("");
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return invoices.filter((inv) => {
if (filter !== "All" && inv.status !== filter) return false;
if (!q) return true;
return (
inv.number.toLowerCase().includes(q) ||
inv.customer.toLowerCase().includes(q) ||
inv.bookingReference.toLowerCase().includes(q)
);
});
}, [filter, query]);
const total = filtered.length;
const pageCount = Math.ceil(total / pagination.pageSize);
const start = pagination.pageIndex * pagination.pageSize;
const end = Math.min(start + pagination.pageSize, total);
const paginatedData = useMemo(
() => filtered.slice(start, end),
[start, end, filtered],
);
const totalRevenue = invoices
.filter((inv) => inv.status === "Paid" && inv.currency === "USD")
.reduce((sum, inv) => sum + inv.amount, 0);
const outstanding = invoices
.filter(
(inv) =>
(inv.status === "Sent" || inv.status === "Overdue") &&
inv.currency === "USD",
)
.reduce((sum, inv) => sum + inv.amount, 0);
const overdueCount = invoices.filter(
(inv) => inv.status === "Overdue",
).length;
const columns: ColumnDef<(typeof invoices)[number]>[] = [
{
id: "invoice",
header: "Invoice",
cell: ({ row }) => {
const inv = row.original;
return (
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Receipt />
</div>
<div>
<p className="font-medium text-slate-900">{inv.number}</p>
<p className="text-sm text-slate-500">Issued {inv.issueDate}</p>
</div>
</div>
);
},
},
{
accessorKey: "customer",
header: "Customer",
},
{
accessorKey: "bookingReference",
header: "Booking",
},
{
id: "amount",
header: "Amount",
cell: ({ row }) => {
const inv = row.original;
return (
<span className="text-sm font-medium text-slate-900">
{formatCurrency(inv.amount, inv.currency)}
</span>
);
},
},
{
accessorKey: "dueDate",
header: "Due Date",
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
id: "actions",
size: 40,
cell: ({ row }) => {
const invoice = row.original;
return (
<div
className="flex justify-end"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<Download />
Download
</DropdownMenuItem>
<NewInvoicePage
mode="edit"
invoice={{
number: invoice.number,
customerId: invoice.customerId,
bookingReference: invoice.bookingReference,
amount: invoice.amount,
currency: invoice.currency,
status: invoice.status,
issueDate: invoice.issueDate,
dueDate: invoice.dueDate,
notes: invoice.notes,
}}
>
<DropdownMenuItem onSelect={(e: Event) => e.preventDefault()}>
<Pencil />
Edit
</DropdownMenuItem>
</NewInvoicePage>
<DropdownMenuSeparator />
<DeleteInvoiceDialog invoiceNumber={invoice.number}>
<DropdownMenuItem
onSelect={(e: Event) => e.preventDefault()}
variant="destructive"
>
<Trash2 />
Void
</DropdownMenuItem>
</DeleteInvoiceDialog>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
},
];
return (
<div className="min-h-screen p-6">
<div className="space-y-6">
<Breadcrumbs items={[{ label: "Billing" }]} />
<Card className="p-6 flex-row justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
Billing
</h1>
<p className="mt-1 text-sm text-secondary-foreground">
Manage invoices, payments, and financial records.
</p>
</div>
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
<div className="relative w-full sm:w-80">
<Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
type="search"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setPagination({
pageIndex: 0,
pageSize: pagination.pageSize,
});
}}
placeholder="Search invoices..."
className="pl-8!"
/>
</div>
<NewInvoicePage>
<Button>
<Plus />
New Invoice
</Button>
</NewInvoicePage>
</div>
</Card>
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Total Revenue (USD)</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">
{formatCurrency(totalRevenue, "USD")}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<DollarSign />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Outstanding (USD)</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">
{formatCurrency(outstanding, "USD")}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Clock />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Overdue Invoices</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">
{overdueCount}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-red-100 text-red-600">
<AlertCircle />
</div>
</CardContent>
</Card>
</div>
<Card className="p-2">
<div className="flex flex-wrap gap-1">
{FILTERS.map((f) => {
const isActive = f === filter;
const count =
f === "All"
? invoices.length
: invoices.filter((inv) => inv.status === f).length;
return (
<button
key={f}
type="button"
onClick={() => {
setFilter(f);
setPagination({
pageIndex: 0,
pageSize: pagination.pageSize,
});
}}
className={
isActive
? "inline-flex items-center gap-2 rounded-2xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground"
: "inline-flex items-center gap-2 rounded-2xl px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-primary/10 hover:text-primary"
}
>
{f}
<span
className={
isActive
? "rounded-full bg-white/20 px-2 py-0.5 text-xs"
: "rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600"
}
>
{count}
</span>
</button>
);
})}
</div>
</Card>
<Card className="gap-0">
<CardHeader className="flex flex-row items-center justify-between border-b">
<div>
<CardTitle>Invoices</CardTitle>
<CardDescription>
Issued invoices and their payment status.
</CardDescription>
</div>
<Button variant="secondary" size="sm">
<Filter />
Filter
</Button>
</CardHeader>
<CardContent className="px-0">
<DataTable
columns={columns}
data={paginatedData}
status="success"
onRowClick={() => { }}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount: pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
}}
containerClassName="border-b shadow-none"
footer={DataTableFooter}
/>
</CardContent>
</Card>
</div>
</div>
);
}
function StatusBadge({ status }: { status: InvoiceStatus }) {
const styles: Record<InvoiceStatus, string> = {
Draft: "bg-slate-100 text-slate-600",
Sent: "bg-sky-100 text-sky-700",
Paid: "bg-emerald-100 text-emerald-700",
Overdue: "bg-red-100 text-red-700",
Cancelled: "bg-amber-100 text-amber-700",
};
return (
<span
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status]}`}
>
{status}
</span>
);
}

View File

@@ -1,63 +0,0 @@
import type { ReactNode } from "react";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
export interface DeleteInvoiceDialogProps {
invoiceNumber: string;
onConfirm?: () => void;
children: ReactNode;
}
export default function DeleteInvoiceDialog({
invoiceNumber,
onConfirm,
children,
}: DeleteInvoiceDialogProps) {
return (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-md rounded-3xl">
<DialogHeader>
<DialogTitle className="text-xl font-bold">
Void invoice?
</DialogTitle>
<DialogDescription>
This will void invoice{" "}
<span className="font-semibold text-slate-900">
{invoiceNumber}
</span>
. This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-2">
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<DialogClose asChild>
<Button
onClick={onConfirm}
className="bg-red-600 text-white hover:bg-red-700"
>
Void Invoice
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,247 @@
import { useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
Alert,
Box,
Button,
Center,
Divider,
Group,
Loader,
Paper,
SimpleGrid,
Stack,
Table,
Text,
Title,
} from "@mantine/core";
import { ArrowLeft, CreditCard, Info } from "lucide-react";
import { api } from "@/services/api";
import { formatCurrency } from "@/lib/currency";
import { BORDER, INK, MUTED } from "../contracts/contract-ui";
import {
billedTo,
fmtDate,
InvoiceStatusBadge,
isPayable,
titleCase,
} from "./invoice-ui";
function MetaItem({ label, value }: { label: string; value: string }) {
return (
<Box>
<Text fz={11} fw={700} c={MUTED} style={{ textTransform: "uppercase", letterSpacing: "0.05em" }}>
{label}
</Text>
<Text fz={14} mt={4} style={{ color: INK }}>
{value}
</Text>
</Box>
);
}
export default function InvoiceDetailPage() {
const { id = "" } = useParams();
const navigate = useNavigate();
const { data: invoice, isLoading, isError } = useQuery(
api.invoices.get.queryOptions({ input: { id } }),
);
const payMutation = useMutation(
api.invoices.pay.mutationOptions({
onSuccess: (res) => {
const url = res.clientAction?.url;
if (url) window.location.href = url;
},
}),
);
if (isLoading) {
return (
<Center py={80}>
<Loader color="edr-green" />
</Center>
);
}
if (isError || !invoice) {
return (
<Box style={{ padding: "28px 32px" }}>
<Button
variant="subtle"
color="gray"
leftSection={<ArrowLeft size={16} />}
onClick={() => navigate("/billing")}
mb="md"
>
Back to invoices
</Button>
<Alert color="red" title="Invoice not found">
We couldn't load this invoice. It may not exist or you may not have
access to it.
</Alert>
</Box>
);
}
const payable = isPayable(invoice.status);
const lines = invoice.lines ?? [];
const handlePay = () => {
const returnUrl = `${window.location.origin}/payment/success`;
const failureUrl = `${window.location.origin}/payment/failure`;
payMutation.mutate({ id, payload: { returnUrl, failureUrl } });
};
return (
<Box style={{ padding: "28px 32px 32px" }}>
<Stack gap="lg">
<Button
variant="subtle"
color="gray"
leftSection={<ArrowLeft size={16} />}
onClick={() => navigate("/billing")}
style={{ alignSelf: "flex-start" }}
styles={{ root: { fontWeight: 600 } }}
>
Back to invoices
</Button>
{/* Header */}
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Group gap={12} align="center" wrap="wrap">
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
{invoice.invoiceNumber}
</Title>
<InvoiceStatusBadge status={invoice.status} />
</Group>
{payable && (
<Button
color="edr-green"
radius="md"
size="md"
leftSection={<CreditCard size={16} />}
loading={payMutation.isPending}
onClick={handlePay}
styles={{ root: { fontWeight: 600, height: 42, paddingInline: 18 } }}
>
Pay {formatCurrency(Number(invoice.totalAmount), invoice.currency)}
</Button>
)}
</Group>
{payMutation.isError && (
<Alert color="red" icon={<Info size={16} />} title="Payment could not be started">
Please try again, or contact support if the problem persists.
</Alert>
)}
{/* Summary */}
<Paper withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="lg">
<MetaItem label="Billed To" value={billedTo(invoice)} />
<MetaItem label="Source" value={`${titleCase(invoice.source)} · ${invoice.type}`} />
<MetaItem label="Issued" value={fmtDate(invoice.issuedAt)} />
<MetaItem label="Due" value={fmtDate(invoice.dueAt)} />
</SimpleGrid>
<Divider my="lg" color={BORDER} />
<Group justify="space-between" align="center">
<Text fz={14} fw={700} c={MUTED} style={{ textTransform: "uppercase", letterSpacing: "0.05em" }}>
Total
</Text>
<Text fz={24} fw={800} style={{ color: INK }}>
{formatCurrency(Number(invoice.totalAmount), invoice.currency)}
</Text>
</Group>
</Paper>
{/* Line items */}
<Paper
withBorder
radius="lg"
style={{ borderColor: BORDER, overflow: "hidden" }}
>
<Box px="lg" py="md" style={{ borderBottom: `1px solid ${BORDER}` }}>
<Text fz={15} fw={700} style={{ color: INK }}>
Line items
</Text>
</Box>
<Box style={{ overflowX: "auto" }}>
<Table
verticalSpacing={12}
horizontalSpacing={20}
styles={{
th: {
fontSize: 11,
fontWeight: 700,
letterSpacing: "0.05em",
textTransform: "uppercase",
color: MUTED,
background: "#F8FAFC",
borderBottom: `1px solid ${BORDER}`,
whiteSpace: "nowrap",
},
td: { borderBottom: `1px solid ${BORDER}` },
}}
>
<Table.Thead>
<Table.Tr>
<Table.Th>Charge</Table.Th>
<Table.Th ta="right">Qty</Table.Th>
<Table.Th ta="right">Unit Rate</Table.Th>
<Table.Th ta="right">Amount</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{lines.length === 0 && (
<Table.Tr>
<Table.Td colSpan={4}>
<Center py={28}>
<Text fz={13} c="dimmed">
No line items on this invoice.
</Text>
</Center>
</Table.Td>
</Table.Tr>
)}
{lines.map((line) => (
<Table.Tr key={line.id}>
<Table.Td>
<Text fz={14} fw={600} style={{ color: INK }}>
{titleCase(line.chargeType)}
</Text>
{line.description && (
<Text fz={12} c="dimmed">
{line.description}
</Text>
)}
</Table.Td>
<Table.Td ta="right">
<Text fz={13} style={{ color: INK }}>
{Number(line.quantity)}
</Text>
</Table.Td>
<Table.Td ta="right">
<Text fz={13} style={{ color: INK }}>
{formatCurrency(Number(line.unitRate), line.currency)}
</Text>
</Table.Td>
<Table.Td ta="right">
<Text fz={13} fw={700} style={{ color: INK }}>
{formatCurrency(Number(line.amount), line.currency)}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
</Paper>
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,537 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
Box,
Button,
Center,
Group,
Loader,
Paper,
Select,
Stack,
Table,
Text,
TextInput,
Title,
} from "@mantine/core";
import {
AlertTriangle,
ChevronLeft,
ChevronRight,
CreditCard,
Eye,
FileStack,
Inbox,
Receipt,
Search,
Wallet,
X,
} from "lucide-react";
import { Freight } from "@edr/types";
import { api } from "@/services/api";
import { formatCurrency } from "@/lib/currency";
import {
BORDER,
GREEN,
INK,
MUTED,
StatCard,
} from "../contracts/contract-ui";
import {
billedTo,
fmtDate,
InvoiceStatusBadge,
isPayable,
PAYABLE_STATUSES,
titleCase,
} from "./invoice-ui";
const PAGE_SIZES = ["10", "25", "50"];
export default function InvoicesList() {
const navigate = useNavigate();
const [query, setQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const { data, isLoading, isError } = useQuery(
api.invoices.listMy.queryOptions(),
);
const all = useMemo(() => data ?? [], [data]);
const stats = useMemo(() => {
const outstanding = all.filter((i) =>
PAYABLE_STATUSES.includes(i.status),
).length;
const overdue = all.filter(
(i) => i.status === Freight.InvoiceStatus.Overdue,
).length;
return { outstanding, overdue, total: all.length };
}, [all]);
const rows = useMemo(() => {
const q = query.trim().toLowerCase();
return all.filter((inv) => {
if (statusFilter && inv.status !== statusFilter) return false;
if (!q) return true;
return (
inv.invoiceNumber.toLowerCase().includes(q) ||
inv.source.toLowerCase().includes(q) ||
inv.sourceId.toLowerCase().includes(q) ||
billedTo(inv).toLowerCase().includes(q)
);
});
}, [all, query, statusFilter]);
const total = rows.length;
const pageCount = Math.max(1, Math.ceil(total / pageSize));
const clampedIndex = Math.min(pageIndex, pageCount - 1);
const start = total === 0 ? 0 : clampedIndex * pageSize + 1;
const end = Math.min((clampedIndex + 1) * pageSize, total);
const pageRows = rows.slice(clampedIndex * pageSize, clampedIndex * pageSize + pageSize);
const resetPage = () => setPageIndex(0);
const goToPage = (i: number) =>
setPageIndex(Math.max(0, Math.min(i, pageCount - 1)));
const hasFilters = !!query || !!statusFilter;
return (
<Box style={{ padding: "28px 32px 32px" }}>
<Stack gap="lg">
{/* Header */}
<Group justify="space-between" align="center" wrap="wrap" gap="md">
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
Invoices
</Title>
</Group>
{/* Summary strip */}
<Group gap="md" wrap="wrap" align="stretch">
<StatCard
label="Outstanding"
hint="awaiting payment"
value={stats.outstanding}
icon={Wallet}
color="edr-accent"
/>
<StatCard
label="Overdue"
hint="past due date"
value={stats.overdue}
icon={AlertTriangle}
color="red"
/>
<StatCard
label="Total invoices"
value={stats.total}
icon={FileStack}
color="violet"
/>
</Group>
{/* Search + filters */}
<Paper withBorder radius="lg" p="sm" style={{ borderColor: BORDER }}>
<Group gap={10} wrap="wrap" align="center">
<TextInput
placeholder="Search by number, source or reference…"
leftSection={<Search size={16} />}
value={query}
onChange={(e) => {
setQuery(e.currentTarget.value);
resetPage();
}}
radius="md"
styles={{ input: { height: 42 } }}
style={{ flex: 1, minWidth: 220, maxWidth: 380 }}
/>
<Select
placeholder="Any status"
data={[
{ value: Freight.InvoiceStatus.Pending, label: "Due" },
{ value: Freight.InvoiceStatus.Overdue, label: "Overdue" },
{ value: Freight.InvoiceStatus.Paid, label: "Paid" },
{ value: Freight.InvoiceStatus.Draft, label: "Draft" },
{ value: Freight.InvoiceStatus.Cancelled, label: "Cancelled" },
{ value: Freight.InvoiceStatus.Refunded, label: "Refunded" },
]}
value={statusFilter}
onChange={(v) => {
setStatusFilter(v);
resetPage();
}}
clearable
radius="md"
comboboxProps={{ withinPortal: true }}
style={{ width: 160 }}
styles={{ input: { height: 42 } }}
aria-label="Filter by status"
/>
{hasFilters && (
<Button
variant="subtle"
color="gray"
radius="md"
leftSection={<X size={14} />}
onClick={() => {
setQuery("");
setStatusFilter(null);
resetPage();
}}
styles={{ root: { fontWeight: 600 } }}
>
Clear
</Button>
)}
</Group>
</Paper>
{/* Table */}
<Paper
withBorder
radius="lg"
style={{ borderColor: BORDER, overflow: "hidden" }}
>
<Box style={{ overflowX: "auto" }}>
<Table
verticalSpacing={14}
horizontalSpacing={20}
highlightOnHover
highlightOnHoverColor="#F4FBF8"
styles={{
th: {
fontSize: 11,
fontWeight: 700,
letterSpacing: "0.05em",
textTransform: "uppercase",
color: MUTED,
background: "#F8FAFC",
borderBottom: `1px solid ${BORDER}`,
whiteSpace: "nowrap",
position: "sticky",
top: 0,
zIndex: 1,
},
tr: { transition: "background-color 120ms ease" },
td: {
borderBottom: `1px solid ${BORDER}`,
verticalAlign: "middle",
},
}}
>
<Table.Thead>
<Table.Tr>
<Table.Th>Invoice</Table.Th>
<Table.Th>Billed To</Table.Th>
<Table.Th>Source</Table.Th>
<Table.Th ta="right">Amount</Table.Th>
<Table.Th>Issued</Table.Th>
<Table.Th>Due</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th ta="right">Action</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{isLoading && (
<Table.Tr>
<Table.Td colSpan={8}>
<Center py={48}>
<Loader color="edr-green" size="sm" />
</Center>
</Table.Td>
</Table.Tr>
)}
{!isLoading && isError && (
<Table.Tr>
<Table.Td colSpan={8}>
<Center py={48}>
<Text fz={13} c="red">
Failed to load invoices. Please try again.
</Text>
</Center>
</Table.Td>
</Table.Tr>
)}
{!isLoading && !isError && pageRows.length === 0 && (
<Table.Tr>
<Table.Td colSpan={8}>
<Stack align="center" gap={8} py={48}>
<Inbox size={26} color={MUTED} style={{ opacity: 0.5 }} />
<Text fz={13} c="dimmed">
{hasFilters
? "No invoices match your filters."
: "No invoices yet."}
</Text>
</Stack>
</Table.Td>
</Table.Tr>
)}
{!isLoading &&
!isError &&
pageRows.map((inv) => {
const payable = isPayable(inv.status);
return (
<Table.Tr
key={inv.id}
style={{ cursor: "pointer" }}
onClick={() => navigate(`/billing/${inv.id}`)}
>
<Table.Td>
<Group gap={10} wrap="nowrap" align="center">
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 34,
height: 34,
borderRadius: 9,
background: "#E6F7EF",
color: GREEN,
flexShrink: 0,
}}
>
<Receipt size={16} />
</Box>
<Box>
<Text fz={14} fw={700} style={{ color: INK }}>
{inv.invoiceNumber}
</Text>
<Text fz={12} c="dimmed">
{titleCase(inv.type)}
</Text>
</Box>
</Group>
</Table.Td>
<Table.Td>
<Text fz={13} style={{ color: INK }}>
{billedTo(inv)}
</Text>
</Table.Td>
<Table.Td>
<Text fz={13} style={{ color: INK }}>
{titleCase(inv.source)}
</Text>
</Table.Td>
<Table.Td ta="right">
<Text fz={13} fw={700} style={{ color: INK }}>
{formatCurrency(Number(inv.totalAmount), inv.currency)}
</Text>
</Table.Td>
<Table.Td>
<Text
fz={13}
c={inv.issuedAt ? undefined : "dimmed"}
style={{ color: inv.issuedAt ? INK : undefined }}
>
{fmtDate(inv.issuedAt)}
</Text>
</Table.Td>
<Table.Td>
<Text fz={13} style={{ color: INK }}>
{fmtDate(inv.dueAt)}
</Text>
</Table.Td>
<Table.Td>
<InvoiceStatusBadge status={inv.status} />
</Table.Td>
<Table.Td>
<Group justify="flex-end" gap={8} wrap="nowrap">
<Button
size="sm"
radius="md"
h={34}
variant={payable ? "filled" : "light"}
color="edr-green"
leftSection={
payable ? (
<CreditCard size={15} />
) : (
<Eye size={15} />
)
}
onClick={(e) => {
e.stopPropagation();
navigate(`/billing/${inv.id}`);
}}
styles={{
root: {
fontWeight: 600,
fontSize: 13,
paddingInline: 14,
whiteSpace: "nowrap",
boxShadow: payable
? "0 1px 2px rgba(14,163,113,0.25)"
: "none",
},
}}
>
{payable ? "Pay" : "View"}
</Button>
</Group>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Box>
{/* Pagination footer */}
{!isLoading && !isError && total > 0 && (
<Group
justify="space-between"
align="center"
wrap="wrap"
gap="md"
px={20}
py={14}
style={{ borderTop: `1px solid ${BORDER}`, background: "#FCFDFE" }}
>
<Group gap={10} align="center">
<Text fz={13} c="dimmed">
Rows
</Text>
<Select
data={PAGE_SIZES}
value={String(pageSize)}
onChange={(v) => {
if (!v) return;
setPageSize(Number(v));
setPageIndex(0);
}}
radius="md"
size="xs"
comboboxProps={{ withinPortal: true }}
style={{ width: 76 }}
allowDeselect={false}
/>
<Text fz={13} c="dimmed">
{start}{end} of {total}
</Text>
</Group>
<Group gap={6} align="center">
<PagerButton
icon={<ChevronLeft size={16} />}
disabled={clampedIndex === 0}
onClick={() => goToPage(clampedIndex - 1)}
ariaLabel="Previous page"
/>
{pageNumbers(clampedIndex, pageCount).map((p, i) =>
p === "…" ? (
<Text key={`gap-${i}`} fz={13} c="dimmed" px={4}>
</Text>
) : (
<PageChip
key={p}
page={p}
active={p === clampedIndex}
onClick={() => goToPage(p)}
/>
),
)}
<PagerButton
icon={<ChevronRight size={16} />}
disabled={clampedIndex >= pageCount - 1}
onClick={() => goToPage(clampedIndex + 1)}
ariaLabel="Next page"
/>
</Group>
</Group>
)}
</Paper>
</Stack>
</Box>
);
}
/** Compact page-number window with ellipses: 1 … 4 5 6 … 12. */
function pageNumbers(active: number, count: number): (number | "…")[] {
if (count <= 7) return Array.from({ length: count }, (_, i) => i);
const out: (number | "…")[] = [0];
const lo = Math.max(1, active - 1);
const hi = Math.min(count - 2, active + 1);
if (lo > 1) out.push("…");
for (let i = lo; i <= hi; i++) out.push(i);
if (hi < count - 2) out.push("…");
out.push(count - 1);
return out;
}
function PageChip({
page,
active,
onClick,
}: {
page: number;
active: boolean;
onClick: () => void;
}) {
return (
<Box
component="button"
onClick={onClick}
style={{
minWidth: 32,
height: 32,
padding: "0 8px",
borderRadius: 9,
border: `1px solid ${active ? GREEN : BORDER}`,
background: active ? GREEN : "#FFFFFF",
color: active ? "#FFFFFF" : INK,
fontSize: 13,
fontWeight: active ? 700 : 600,
cursor: "pointer",
transition: "all 120ms ease",
}}
>
{page + 1}
</Box>
);
}
function PagerButton({
icon,
disabled,
onClick,
ariaLabel,
}: {
icon: React.ReactNode;
disabled: boolean;
onClick: () => void;
ariaLabel: string;
}) {
return (
<Box
component="button"
aria-label={ariaLabel}
onClick={onClick}
disabled={disabled}
style={{
width: 32,
height: 32,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 9,
border: `1px solid ${BORDER}`,
background: "#FFFFFF",
color: disabled ? "#C2CCD6" : INK,
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.6 : 1,
transition: "all 120ms ease",
}}
>
{icon}
</Box>
);
}

View File

@@ -1,202 +0,0 @@
import type { ReactNode } from "react";
import { Calendar, DollarSign, Hash } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { customers } from "../customers/customers.mock";
import { bookings } from "../bookings/bookings.mock";
import type { Currency, InvoiceStatus } from "./invoices.mock";
export interface InvoiceFormData {
number?: string;
customerId?: number;
bookingReference?: string;
amount?: number;
currency?: Currency;
status?: InvoiceStatus;
issueDate?: string;
dueDate?: string;
notes?: string;
}
export interface NewInvoicePageProps {
mode?: "create" | "edit";
invoice?: InvoiceFormData;
children?: ReactNode;
}
const selectClass =
"flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition hover:border-slate-300 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20";
export default function NewInvoicePage({
mode = "create",
invoice,
children,
}: NewInvoicePageProps = {}) {
const isEdit = mode === "edit";
const title = isEdit ? "Edit Invoice" : "New Invoice";
const description = isEdit
? "Update invoice details."
: "Create a new invoice for a customer booking.";
const submitLabel = isEdit ? "Save Changes" : "Create Invoice";
return (
<Dialog>
<DialogTrigger asChild>
{children ?? <Button>{isEdit ? "Edit" : "New Invoice"}</Button>}
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl rounded-3xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="grid gap-5 py-4 md:grid-cols-2">
{/* Invoice Number */}
<div className="space-y-2">
<Label>Invoice Number *</Label>
<div className="relative">
<Hash className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
defaultValue={invoice?.number ?? ""}
placeholder="e.g. INV-2026-0001"
className="pl-10"
/>
</div>
</div>
{/* Status */}
<div className="space-y-2">
<Label>Status</Label>
<select
defaultValue={invoice?.status ?? "Draft"}
className={selectClass}
>
<option>Draft</option>
<option>Sent</option>
<option>Paid</option>
<option>Overdue</option>
<option>Cancelled</option>
</select>
</div>
{/* Customer */}
<div className="space-y-2">
<Label>Customer *</Label>
<select
defaultValue={invoice?.customerId ?? ""}
className={selectClass}
>
<option value="" disabled>
Select customer
</option>
{customers.map((c) => (
<option key={c.id} value={c.id}>
{c.company}
</option>
))}
</select>
</div>
{/* Booking */}
<div className="space-y-2">
<Label>Booking Reference</Label>
<select
defaultValue={invoice?.bookingReference ?? ""}
className={selectClass}
>
<option value="">No linked booking</option>
{bookings.map((b) => (
<option key={b.id} value={b.reference}>
{b.reference} {b.customer}
</option>
))}
</select>
</div>
{/* Amount */}
<div className="space-y-2">
<Label>Amount *</Label>
<div className="relative">
<DollarSign className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
type="number"
min={0}
step="0.01"
defaultValue={invoice?.amount ?? 0}
className="pl-10"
/>
</div>
</div>
{/* Currency */}
<div className="space-y-2">
<Label>Currency</Label>
<select
defaultValue={invoice?.currency ?? "USD"}
className={selectClass}
>
<option>USD</option>
<option>ETB</option>
<option>DJF</option>
</select>
</div>
{/* Issue Date */}
<div className="space-y-2">
<Label>Issue Date *</Label>
<div className="relative">
<Calendar className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
type="date"
defaultValue={invoice?.issueDate ?? ""}
className="pl-10"
/>
</div>
</div>
{/* Due Date */}
<div className="space-y-2">
<Label>Due Date *</Label>
<div className="relative">
<Calendar className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
type="date"
defaultValue={invoice?.dueDate ?? ""}
className="pl-10"
/>
</div>
</div>
{/* Notes */}
<div className="space-y-2 md:col-span-2">
<Label>Notes</Label>
<Textarea
defaultValue={invoice?.notes ?? ""}
placeholder="Payment terms, references, etc."
/>
</div>
</div>
<div className="flex justify-end gap-3">
<Button variant="outline">Cancel</Button>
<Button className="bg-[#10B981] text-white hover:bg-[#10B981]/90">
{submitLabel}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,60 @@
import { Box } from "@mantine/core";
import { Freight } from "@edr/types";
import type { PortalInvoice } from "@/services/invoices.service";
/** Statuses a customer can still pay. */
export const PAYABLE_STATUSES: Freight.InvoiceStatus[] = [
Freight.InvoiceStatus.Pending,
Freight.InvoiceStatus.Overdue,
];
export const isPayable = (status: Freight.InvoiceStatus) =>
PAYABLE_STATUSES.includes(status);
const STATUS_STYLE: Record<
Freight.InvoiceStatus,
{ label: string; bg: string; fg: string }
> = {
[Freight.InvoiceStatus.Draft]: { label: "Draft", bg: "#EEF2F6", fg: "#64748B" },
[Freight.InvoiceStatus.Pending]: { label: "Due", bg: "#FEF3E2", fg: "#B45309" },
[Freight.InvoiceStatus.Paid]: { label: "Paid", bg: "#E6F7EF", fg: "#0A6F4D" },
[Freight.InvoiceStatus.Overdue]: { label: "Overdue", bg: "#FDECEC", fg: "#C0392B" },
[Freight.InvoiceStatus.Cancelled]: { label: "Cancelled", bg: "#EEF2F6", fg: "#64748B" },
[Freight.InvoiceStatus.Refunded]: { label: "Refunded", bg: "#EAF1FB", fg: "#2563EB" },
};
export function InvoiceStatusBadge({ status }: { status: Freight.InvoiceStatus }) {
const s = STATUS_STYLE[status] ?? { label: status, bg: "#EEF2F6", fg: "#64748B" };
return (
<Box
style={{
display: "inline-flex",
alignItems: "center",
padding: "4px 10px",
borderRadius: 999,
background: s.bg,
color: s.fg,
fontSize: 12,
fontWeight: 700,
whiteSpace: "nowrap",
}}
>
{s.label}
</Box>
);
}
export const titleCase = (v: string) =>
v ? v.charAt(0).toUpperCase() + v.slice(1).toLowerCase() : "—";
/** Best label for who an invoice is billed to (profile ref → profile type → company). */
export function billedTo(inv: PortalInvoice): string {
const profile = inv.companyProfile;
if (profile?.reference) return profile.reference;
if (profile?.type) return titleCase(profile.type);
return inv.company?.name ?? "—";
}
export const fmtDate = (v: string | null | undefined) =>
v ? new Date(v).toLocaleDateString() : "—";

View File

@@ -1,88 +0,0 @@
import { customers } from "../customers/customers.mock";
import { bookings } from "../bookings/bookings.mock";
export type InvoiceStatus =
| "Draft"
| "Sent"
| "Paid"
| "Overdue"
| "Cancelled";
export type Currency = "USD" | "ETB" | "DJF";
export interface Invoice {
id: number;
number: string;
customerId: number;
customer: string;
bookingReference: string;
amount: number;
currency: Currency;
status: InvoiceStatus;
issueDate: string;
dueDate: string;
paidDate: string | null;
notes: string;
}
const statuses: InvoiceStatus[] = [
"Draft",
"Sent",
"Paid",
"Overdue",
"Cancelled",
];
const currencies: Currency[] = ["USD", "ETB", "DJF"];
export const invoices: Invoice[] = Array.from({ length: 24 }, (_, i) => {
const customer = customers[i % customers.length] as (typeof customers)[number];
const booking = bookings[i % bookings.length] as (typeof bookings)[number];
const id = i + 1;
const issue = new Date(2026, 3, 1 + (i % 28));
const due = new Date(issue);
due.setDate(due.getDate() + 30);
const status = statuses[i % statuses.length] as InvoiceStatus;
const currency = currencies[i % currencies.length] as Currency;
const baseAmount = 5000 + (i * 1234) % 25000;
return {
id,
number: `INV-2026-${String(id).padStart(4, "0")}`,
customerId: customer.id,
customer: customer.company,
bookingReference: booking.reference,
amount: Math.round(baseAmount * 100) / 100,
currency,
status,
issueDate: issue.toISOString().slice(0, 10),
dueDate: due.toISOString().slice(0, 10),
paidDate:
status === "Paid"
? new Date(due.getTime() - 86400000 * (i % 7))
.toISOString()
.slice(0, 10)
: null,
notes:
i % 3 === 0
? "Net 30 payment terms."
: i % 3 === 1
? "Bank transfer preferred."
: "Payment due upon receipt.",
};
});
export function getInvoiceById(id: number | string): Invoice | undefined {
const numericId = typeof id === "string" ? Number(id) : id;
return invoices.find((inv) => inv.id === numericId);
}
export function formatCurrency(amount: number, currency: Currency): string {
const symbols: Record<Currency, string> = {
USD: "$",
ETB: "Br",
DJF: "DJF",
};
return `${symbols[currency]} ${amount.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`;
}

View File

@@ -31,6 +31,12 @@ import {
IntentStatus,
} from "./payments.service";
import { consignmentsService } from "./consignments.service";
import {
invoicesService,
PortalInvoice,
PortalInvoiceDetail,
PayInvoicePayload,
} from "./invoices.service";
import { trackingService } from "./tracking.service";
import { fileUploadSettingsService } from "./fileUploadSettings.service";
import { dropdownSettingsService } from "./dropdownSettings.service";
@@ -604,4 +610,23 @@ export const api = {
({ optionId }) => dropdownSettingsService.removeOption(optionId),
),
},
invoices: {
listMy: endpoint<void, PortalInvoice[]>(
"invoices",
"listMy",
invoicesService.listMy,
),
get: endpoint<{ id: string }, PortalInvoiceDetail>(
"invoices",
"get",
({ id }) => invoicesService.get(id),
),
pay: endpoint<
{ id: string; payload?: PayInvoicePayload },
InitiateResponse
>("invoices", "pay", ({ id, payload }) => invoicesService.pay(id, payload)),
},
};

View File

@@ -0,0 +1,49 @@
import type { Freight } from "@edr/types";
import { URL_CONSTANTS } from "@/constants/URLS";
import { client } from "../utils/api";
import type { InitiateResponse } from "./payments.service";
const B = URL_CONSTANTS.BILLING;
/** A customer-facing invoice row, as returned by `GET /billing/my-invoices`. */
export type PortalInvoice = Freight.IInvoice;
/** An invoice plus its line items, as returned by `GET /billing/my-invoices/:id`. */
export type PortalInvoiceDetail = Freight.IInvoice & {
lines: Freight.IInvoiceLine[];
};
export interface PayInvoicePayload {
method?: string;
platform?: "web" | "mobile";
payerAccount?: string;
returnUrl?: string;
failureUrl?: string;
}
export const invoicesService = {
/** Every invoice billed to the signed-in customer's company, newest first. */
listMy: async (): Promise<PortalInvoice[]> => {
const { data } = await client.get(B.MY_INVOICES);
return data.data ?? data;
},
/** One of the customer's invoices, with its line items. */
get: async (id: string): Promise<PortalInvoiceDetail> => {
const { data } = await client.get(B.MY_INVOICE_BY_ID(id));
return data.data ?? data;
},
/** Initiate gateway payment for an open invoice; returns the client action. */
pay: async (
id: string,
payload: PayInvoicePayload = {},
): Promise<InitiateResponse> => {
const { data } = await client.post(B.PAY_INVOICE(id), {
platform: "web",
...payload,
});
return data.data ?? data;
},
};

View File

@@ -23,10 +23,10 @@ RUN pnpm turbo build --filter="@edr/passenger-api..."
FROM base AS deployer
COPY --from=builder /app/ .
RUN pnpm deploy --filter="@edr/passenger-api" --legacy /deploy
RUN if [ -d node_modules/.prisma ]; then \
mkdir -p /deploy/node_modules && \
cp -r node_modules/.prisma /deploy/node_modules/.prisma; \
fi
# Copy prisma directory and generate client in deploy location
RUN cp -r apps/edr-passenger-api/prisma /deploy/ && \
cd /deploy && \
npx prisma generate --schema=prisma/schema.prisma
# --- Migration image: built in CI, run as a one-shot `docker run --rm --env-file ...`
# against the real DB, as its own gated step *before* the app image is built/deployed.
@@ -37,7 +37,10 @@ WORKDIR /deploy
RUN corepack enable && corepack prepare pnpm@11.1.1 --activate
ENV CI=true
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
CMD ["sh", "-c", "npm run prisma:generate && npm run prisma:migrate && npm run prisma:seed"]
# Copy the resolution script
COPY apps/edr-passenger-api/scripts/resolve-migrations.sh /deploy/scripts/
RUN chmod +x /deploy/scripts/resolve-migrations.sh
CMD ["sh", "-c", "/deploy/scripts/resolve-migrations.sh && npm run prisma:generate && npm run prisma:migrate && npm run prisma:seed"]
FROM node:24.15.0-alpine AS runner
RUN apk add --no-cache libc6-compat

View File

@@ -0,0 +1,10 @@
-- This migration fixes the failed state of 20240101000000_individual_tickets_no_timezone
-- It marks the failed migration as rolled back so it can be retried
-- Mark the failed migration as rolled back
UPDATE passenger._prisma_migrations
SET rolled_back_at = CURRENT_TIMESTAMP,
logs = 'Migration failed due to missing TicketSeat table. Automatically rolled back by fix migration to allow retry with idempotent SQL.'
WHERE migration_name = '20240101000000_individual_tickets_no_timezone'
AND rolled_back_at IS NULL
AND finished_at IS NULL;

View File

@@ -1,8 +1,15 @@
-- DropForeignKey
ALTER TABLE "passenger"."TicketSeat" DROP CONSTRAINT IF EXISTS "TicketSeat_seatId_fkey";
-- DropForeignKey
ALTER TABLE "passenger"."TicketSeat" DROP CONSTRAINT IF EXISTS "TicketSeat_ticketId_fkey";
-- DropForeignKey (only if table exists)
DO $$
BEGIN
IF EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'passenger'
AND table_name = 'TicketSeat'
) THEN
ALTER TABLE "passenger"."TicketSeat" DROP CONSTRAINT IF EXISTS "TicketSeat_seatId_fkey";
ALTER TABLE "passenger"."TicketSeat" DROP CONSTRAINT IF EXISTS "TicketSeat_ticketId_fkey";
END IF;
END $$;
-- DropIndex
DROP INDEX IF EXISTS "passenger"."Ticket_bookingId_key";
@@ -20,18 +27,28 @@ ALTER TABLE "passenger"."Ticket"
-- DropTable
DROP TABLE IF EXISTS "passenger"."TicketSeat";
-- Remove GateValidationLog rows referencing orphan tickets first
DELETE FROM "passenger"."GateValidationLog"
WHERE "ticketId" IN (
SELECT "id" FROM "passenger"."Ticket"
WHERE "seatId" = ''
OR "seatId" NOT IN (SELECT "id" FROM "passenger"."Seat")
);
-- Remove GateValidationLog rows referencing orphan tickets first (only if tickets have seatId column)
DO $$
BEGIN
IF EXISTS (
SELECT FROM information_schema.columns
WHERE table_schema = 'passenger'
AND table_name = 'Ticket'
AND column_name = 'seatId'
) THEN
DELETE FROM "passenger"."GateValidationLog"
WHERE "ticketId" IN (
SELECT "id" FROM "passenger"."Ticket"
WHERE "seatId" = ''
OR "seatId" NOT IN (SELECT "id" FROM "passenger"."Seat")
);
-- Remove orphan ticket rows
DELETE FROM "passenger"."Ticket"
WHERE "seatId" = ''
OR "seatId" NOT IN (SELECT "id" FROM "passenger"."Seat");
-- Remove orphan ticket rows
DELETE FROM "passenger"."Ticket"
WHERE "seatId" = ''
OR "seatId" NOT IN (SELECT "id" FROM "passenger"."Seat");
END IF;
END $$;
-- CreateIndex
CREATE INDEX IF NOT EXISTS "Ticket_bookingId_idx" ON "passenger"."Ticket"("bookingId");
@@ -39,8 +56,22 @@ CREATE INDEX IF NOT EXISTS "Ticket_bookingId_idx" ON "passenger"."Ticket"("booki
-- CreateIndex
CREATE INDEX IF NOT EXISTS "Ticket_seatId_idx" ON "passenger"."Ticket"("seatId");
-- AddForeignKey
ALTER TABLE "passenger"."Ticket"
ADD CONSTRAINT "Ticket_seatId_fkey"
FOREIGN KEY ("seatId") REFERENCES "passenger"."Seat"("id")
ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey (only if not already exists)
DO $$
BEGIN
IF EXISTS (
SELECT FROM information_schema.columns
WHERE table_schema = 'passenger'
AND table_name = 'Ticket'
AND column_name = 'seatId'
) AND NOT EXISTS (
SELECT FROM information_schema.table_constraints
WHERE constraint_schema = 'passenger'
AND constraint_name = 'Ticket_seatId_fkey'
) THEN
ALTER TABLE "passenger"."Ticket"
ADD CONSTRAINT "Ticket_seatId_fkey"
FOREIGN KEY ("seatId") REFERENCES "passenger"."Seat"("id")
ON DELETE RESTRICT ON UPDATE CASCADE;
END IF;
END $$;

View File

@@ -1,7 +1,7 @@
-- Migration: Add Configurable Fare Management System
-- Main fare configuration table
CREATE TABLE "fare_configurations" (
CREATE TABLE IF NOT EXISTS "fare_configurations" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
@@ -19,7 +19,7 @@ CREATE TABLE "fare_configurations" (
);
-- Rate structure by nationality and coach/position
CREATE TABLE "fare_rate_rules" (
CREATE TABLE IF NOT EXISTS "fare_rate_rules" (
"id" TEXT NOT NULL,
"fare_config_id" TEXT NOT NULL,
"nationality_type" TEXT NOT NULL, -- 'LOCAL' or 'INTERNATIONAL'
@@ -34,7 +34,7 @@ CREATE TABLE "fare_rate_rules" (
);
-- Configurable fare components (insurance, premiums, service charges, taxes)
CREATE TABLE "fare_components" (
CREATE TABLE IF NOT EXISTS "fare_components" (
"id" TEXT NOT NULL,
"fare_config_id" TEXT NOT NULL,
"component_type" TEXT NOT NULL, -- 'INSURANCE', 'PREMIUM', 'SERVICE_CHARGE', 'TAX', 'DEMAND'
@@ -52,7 +52,7 @@ CREATE TABLE "fare_components" (
);
-- Age-based pricing rules
CREATE TABLE "age_pricing_rules" (
CREATE TABLE IF NOT EXISTS "age_pricing_rules" (
"id" TEXT NOT NULL,
"fare_config_id" TEXT NOT NULL,
"rule_name" TEXT NOT NULL,
@@ -70,7 +70,7 @@ CREATE TABLE "age_pricing_rules" (
);
-- Audit trail for configuration changes
CREATE TABLE "fare_configuration_audit" (
CREATE TABLE IF NOT EXISTS "fare_configuration_audit" (
"id" TEXT NOT NULL,
"fare_config_id" TEXT NOT NULL,
"action" TEXT NOT NULL, -- 'CREATED', 'UPDATED', 'ACTIVATED', 'DEACTIVATED'
@@ -81,27 +81,37 @@ CREATE TABLE "fare_configuration_audit" (
CONSTRAINT "fare_configuration_audit_pkey" PRIMARY KEY ("id")
);
-- Foreign key constraints
ALTER TABLE "fare_rate_rules" ADD CONSTRAINT "fare_rate_rules_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "fare_components" ADD CONSTRAINT "fare_components_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "age_pricing_rules" ADD CONSTRAINT "age_pricing_rules_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "fare_configuration_audit" ADD CONSTRAINT "fare_configuration_audit_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Foreign key constraints (idempotent)
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fare_rate_rules_fare_config_id_fkey') THEN
ALTER TABLE "fare_rate_rules" ADD CONSTRAINT "fare_rate_rules_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fare_components_fare_config_id_fkey') THEN
ALTER TABLE "fare_components" ADD CONSTRAINT "fare_components_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'age_pricing_rules_fare_config_id_fkey') THEN
ALTER TABLE "age_pricing_rules" ADD CONSTRAINT "age_pricing_rules_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fare_configuration_audit_fare_config_id_fkey') THEN
ALTER TABLE "fare_configuration_audit" ADD CONSTRAINT "fare_configuration_audit_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
-- Indexes for performance
CREATE INDEX "fare_configurations_effective_date_idx" ON "fare_configurations"("effective_date");
CREATE INDEX "fare_configurations_is_active_idx" ON "fare_configurations"("is_active");
CREATE UNIQUE INDEX "fare_configurations_default_unique_idx" ON "fare_configurations"("is_default") WHERE "is_default" = true;
-- Indexes for performance (idempotent)
CREATE INDEX IF NOT EXISTS "fare_configurations_effective_date_idx" ON "fare_configurations"("effective_date");
CREATE INDEX IF NOT EXISTS "fare_configurations_is_active_idx" ON "fare_configurations"("is_active");
CREATE UNIQUE INDEX IF NOT EXISTS "fare_configurations_default_unique_idx" ON "fare_configurations"("is_default") WHERE "is_default" = true;
CREATE INDEX "fare_rate_rules_config_lookup_idx" ON "fare_rate_rules"("fare_config_id", "nationality_type", "coach_type", "bed_position");
CREATE INDEX "fare_components_config_order_idx" ON "fare_components"("fare_config_id", "apply_order");
CREATE INDEX "age_pricing_rules_age_lookup_idx" ON "age_pricing_rules"("fare_config_id", "min_age", "max_age");
CREATE INDEX IF NOT EXISTS "fare_rate_rules_config_lookup_idx" ON "fare_rate_rules"("fare_config_id", "nationality_type", "coach_type", "bed_position");
CREATE INDEX IF NOT EXISTS "fare_components_config_order_idx" ON "fare_components"("fare_config_id", "apply_order");
CREATE INDEX IF NOT EXISTS "age_pricing_rules_age_lookup_idx" ON "age_pricing_rules"("fare_config_id", "min_age", "max_age");
-- Add legacy mode flag to existing fare tables for gradual migration
ALTER TABLE "FareRule" ADD COLUMN "migrated_to_config_id" TEXT;
ALTER TABLE "SegmentFareRule" ADD COLUMN "migrated_to_config_id" TEXT;
-- Add legacy mode flag to existing fare tables for gradual migration (idempotent)
ALTER TABLE "passenger"."FareRule" ADD COLUMN IF NOT EXISTS "migrated_to_config_id" TEXT;
ALTER TABLE "passenger"."SegmentFareRule" ADD COLUMN IF NOT EXISTS "migrated_to_config_id" TEXT;
-- Add feature flag support
CREATE TABLE "system_features" (
CREATE TABLE IF NOT EXISTS "system_features" (
"id" TEXT NOT NULL,
"feature_name" TEXT NOT NULL UNIQUE,
"is_enabled" BOOLEAN NOT NULL DEFAULT false,
@@ -113,5 +123,5 @@ CREATE TABLE "system_features" (
);
-- Insert the configurable fares feature flag
INSERT INTO "system_features" ("id", "feature_name", "is_enabled", "config")
VALUES ('cf-001', 'USE_CONFIGURABLE_FARES', false, '{"rollout_percentage": 0}');
INSERT INTO "system_features" ("id", "feature_name", "is_enabled", "config", "updated_at")
VALUES ('cf-001', 'USE_CONFIGURABLE_FARES', false, '{"rollout_percentage": 0}', CURRENT_TIMESTAMP);

View File

@@ -0,0 +1,16 @@
#!/bin/sh
set -e
echo "🔍 Checking for failed migrations..."
# Mark legacy migrations as applied (these are from an old schema that doesn't match current DB)
# These migrations were designed for a different schema version and should be skipped
# All migrations before 20260605195213_init should be resolved as they modify tables that don't exist yet
npx prisma migrate resolve --applied "20240100000000_fix_failed_migration_state" || true
npx prisma migrate resolve --applied "20240101000000_individual_tickets_no_timezone" || true
npx prisma migrate resolve --applied "20240102000000_drop_ticket_column_defaults" || true
npx prisma migrate resolve --applied "20241201000000_remove_station_timezone" || true
npx prisma migrate resolve --applied "20250106070000_add_gender_to_traveler_profile" || true
npx prisma migrate resolve --applied "20260101000000_add_configurable_fare_system" || true
echo "✅ Migration resolution complete"

View File

@@ -483,8 +483,8 @@ export class BookingsService {
}
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
const taxesMinor = Math.round(combinedBaseFareMinor * 0.05);
const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor);
const taxesMinor = 0;
const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
let displayTotalMinor = totalMinor;
@@ -660,8 +660,8 @@ export class BookingsService {
}
}
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
const taxesMinor = Math.round(combinedBase * 0.05);
const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor + taxesMinor);
const taxesMinor = 0;
const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
const displayTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
@@ -854,8 +854,8 @@ export class BookingsService {
}
}
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
const taxesMinor = Math.round(combinedBase * 0.05);
const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor + taxesMinor);
const taxesMinor = 0;
const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
const displayTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
@@ -1060,7 +1060,7 @@ export class BookingsService {
loyaltyRedemptionPoints?: number
) {
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
const baseFareMinor = await this.getBaseFare(scheduleId, seatClassId, segmentRoute, undefined, nationality, originStop.sequence, destStop.sequence);
const baseFareMinor = await this.getBaseFare(scheduleId, seatClassId, segmentRoute, undefined, nationality, originStop.sequence, destStop.sequence, originStop.stationId, destStop.stationId);
const adultFareMinor = baseFareMinor * adultCount;
const paidChildrenCount = Math.max(0, childCount - 1);
@@ -1076,8 +1076,8 @@ export class BookingsService {
}
const loyaltyMinor = (loyaltyRedemptionPoints ?? 0) * 10;
const taxesMinor = Math.round(totalBaseFareMinor * 0.05);
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor);
const taxesMinor = 0;
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor);
return {
baseFareMinor,
@@ -1103,6 +1103,8 @@ export class BookingsService {
nationality?: string,
originStopSeq?: number,
destStopSeq?: number,
originStationId?: string,
destinationStationId?: string,
): Promise<number> {
const now = new Date();
@@ -1149,13 +1151,13 @@ export class BookingsService {
const bestMatch = this.selectBestFareRule(candidates, scheduleId, segmentRoute, fullRoute, nationality);
if (bestMatch) return bestMatch.baseFareMinor;
// 3. FareEngine — distance × rate-per-km from the schedule's route
// 3. FareEngine — distance × rate-per-km from the booking's actual segment stations
if (schedule?.routeId) {
try {
const fare = await this.fareEngine.calculate({
routeId: schedule.routeId,
originStationId: schedule.originStationId,
destinationStationId: schedule.destinationStationId,
originStationId: originStationId ?? schedule.originStationId,
destinationStationId: destinationStationId ?? schedule.destinationStationId,
seatClassId,
nationality,
});

View File

@@ -9,6 +9,9 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
import { CreateGuestBookingDto, SavedPassengerProfileDto } from './guest-booking.dto';
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
/** Booking cutoff: reject new bookings within this many ms of departure. */
const BOOKING_CUTOFF_MS = 30 * 60 * 1000;
function generateRef(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
return 'EDR-' + Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
@@ -74,6 +77,10 @@ export class GuestBookingService {
});
if (!schedule) throw new NotFoundException('Schedule not found');
if (Date.now() >= schedule.departureAt.getTime() - BOOKING_CUTOFF_MS) {
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
}
const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId);
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found');
@@ -142,7 +149,9 @@ export class GuestBookingService {
dto.seatClassId,
segmentRoute,
fullRoute,
primaryNationality
primaryNationality,
dto.originStationId,
dto.destinationStationId,
);
const adultFareMinor = baseFareMinor * adultCount;
@@ -160,8 +169,8 @@ export class GuestBookingService {
}
}
const taxesMinor = Math.round(totalBaseFareMinor * 0.05);
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor + taxesMinor);
const taxesMinor = 0;
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
let displayTotalMinor = totalMinor;
@@ -293,6 +302,10 @@ export class GuestBookingService {
if (!outboundSchedule) throw new NotFoundException('Outbound schedule not found');
if (!returnSchedule) throw new NotFoundException('Return schedule not found');
if (Date.now() >= outboundSchedule.departureAt.getTime() - BOOKING_CUTOFF_MS) {
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
}
const outboundOriginStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.originStationId);
const outboundDestStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
const returnOriginStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnOriginStationId);
@@ -350,8 +363,8 @@ export class GuestBookingService {
const primaryNationality = passengersData[0]?.nationality;
const [outboundBaseFare, returnBaseFare] = await Promise.all([
this.getBaseFare(dto.scheduleId, dto.seatClassId, outboundSegmentRoute, outboundFullRoute, primaryNationality),
this.getBaseFare(dto.returnScheduleId, returnSeatClassId, returnSegmentRoute, returnFullRoute, primaryNationality),
this.getBaseFare(dto.scheduleId, dto.seatClassId, outboundSegmentRoute, outboundFullRoute, primaryNationality, dto.originStationId, dto.destinationStationId),
this.getBaseFare(dto.returnScheduleId, returnSeatClassId, returnSegmentRoute, returnFullRoute, primaryNationality, dto.returnOriginStationId, dto.returnDestinationStationId),
]);
const paidChildrenCount = Math.max(0, childCount - 1);
@@ -369,8 +382,8 @@ export class GuestBookingService {
}
}
const taxesMinor = Math.round(combinedBaseFareMinor * 0.05);
const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor + taxesMinor);
const taxesMinor = 0;
const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
const displayTotalMinor = displayCurrency !== Currency.ETB
@@ -505,6 +518,10 @@ export class GuestBookingService {
if (!leg1Schedule) throw new NotFoundException('Leg-1 schedule not found');
if (!leg2Schedule) throw new NotFoundException('Leg-2 schedule not found');
if (Date.now() >= leg1Schedule.departureAt.getTime() - BOOKING_CUTOFF_MS) {
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
}
const leg1OriginStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.originStationId);
const leg1DestStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.transitStationId);
const leg2OriginStop = leg2Schedule.stopTimes.find(s => s.stationId === dto.transitStationId);
@@ -551,11 +568,11 @@ export class GuestBookingService {
this.getBaseFare(dto.scheduleId, dto.seatClassId,
`${leg1OriginStop.station.code}-${leg1DestStop.station.code}`,
`${leg1Schedule.originStation.code}-${leg1Schedule.destinationStation.code}`,
primaryNationality),
primaryNationality, dto.originStationId, dto.transitStationId),
this.getBaseFare(dto.leg2ScheduleId, leg2SeatClassId,
`${leg2OriginStop.station.code}-${leg2DestStop.station.code}`,
`${leg2Schedule.originStation.code}-${leg2Schedule.destinationStation.code}`,
primaryNationality),
primaryNationality, dto.transitStationId, dto.leg2DestinationStationId),
]);
const leg1Total = leg1BaseFare * adultCount + leg1BaseFare * paidChildrenCount;
@@ -569,8 +586,8 @@ export class GuestBookingService {
discountMinor = promo.percentOff ? Math.round(combinedBase * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
}
}
const taxesMinor = Math.round(combinedBase * 0.05);
const totalMinor = Math.max(0, combinedBase - discountMinor + taxesMinor);
const taxesMinor = 0;
const totalMinor = Math.max(0, combinedBase - discountMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
const displayTotalMinor = displayCurrency !== Currency.ETB
@@ -702,6 +719,10 @@ export class GuestBookingService {
if (!retL1Sched) throw new NotFoundException('Return leg-1 schedule not found');
if (!retL2Sched) throw new NotFoundException('Return leg-2 schedule not found');
if (Date.now() >= obL1Sched.departureAt.getTime() - BOOKING_CUTOFF_MS) {
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
}
const obL1Origin = obL1Sched.stopTimes.find(s => s.stationId === dto.originStationId);
const obL1Dest = obL1Sched.stopTimes.find(s => s.stationId === dto.transitStationId);
const obL2Origin = obL2Sched.stopTimes.find(s => s.stationId === dto.transitStationId);
@@ -750,10 +771,10 @@ export class GuestBookingService {
const retL2ClassId = dto.returnLeg2SeatClassId ?? dto.seatClassId;
const [obL1Fare, obL2Fare, retL1Fare, retL2Fare] = await Promise.all([
this.getBaseFare(dto.scheduleId, dto.seatClassId, `${obL1Origin.station.code}-${obL1Dest.station.code}`, `${obL1Sched.originStation.code}-${obL1Sched.destinationStation.code}`, nat),
this.getBaseFare(dto.leg2ScheduleId!, obL2ClassId, `${obL2Origin.station.code}-${obL2Dest.station.code}`, `${obL2Sched.originStation.code}-${obL2Sched.destinationStation.code}`, nat),
this.getBaseFare(dto.returnScheduleId!, retL1ClassId, `${retL1Origin.station.code}-${retL1Dest.station.code}`, `${retL1Sched.originStation.code}-${retL1Sched.destinationStation.code}`, nat),
this.getBaseFare(dto.returnLeg2ScheduleId!,retL2ClassId, `${retL2Origin.station.code}-${retL2Dest.station.code}`, `${retL2Sched.originStation.code}-${retL2Sched.destinationStation.code}`, nat),
this.getBaseFare(dto.scheduleId, dto.seatClassId, `${obL1Origin.station.code}-${obL1Dest.station.code}`, `${obL1Sched.originStation.code}-${obL1Sched.destinationStation.code}`, nat, dto.originStationId, dto.transitStationId),
this.getBaseFare(dto.leg2ScheduleId!, obL2ClassId, `${obL2Origin.station.code}-${obL2Dest.station.code}`, `${obL2Sched.originStation.code}-${obL2Sched.destinationStation.code}`, nat, dto.transitStationId, dto.leg2DestinationStationId),
this.getBaseFare(dto.returnScheduleId!, retL1ClassId, `${retL1Origin.station.code}-${retL1Dest.station.code}`, `${retL1Sched.originStation.code}-${retL1Sched.destinationStation.code}`, nat, dto.returnOriginStationId, dto.returnTransitStationId),
this.getBaseFare(dto.returnLeg2ScheduleId!,retL2ClassId, `${retL2Origin.station.code}-${retL2Dest.station.code}`, `${retL2Sched.originStation.code}-${retL2Sched.destinationStation.code}`, nat, dto.returnTransitStationId, dto.returnLeg2DestinationStationId),
]);
const combinedBase = (obL1Fare + obL2Fare + retL1Fare + retL2Fare) * adultCount +
@@ -951,17 +972,28 @@ export class GuestBookingService {
segmentRoute?: string,
fullRoute?: string,
nationality?: string,
originStationId?: string,
destinationStationId?: string,
): Promise<number> {
const now = new Date();
// 1. FareRule table — explicit override rules
const candidates = await this.prisma.fareRule.findMany({
where: {
seatClassId,
validFrom: { lte: now },
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
},
});
// 1. FareRule table — explicit override rules (same priority logic as the fare engine)
const [candidates, seatClass] = await Promise.all([
this.prisma.fareRule.findMany({
where: {
seatClassId,
validFrom: { lte: now },
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
},
}),
this.prisma.seatClass.findUnique({
where: { id: seatClassId },
select: { premiumMinor: true, insuranceFeeMinor: true },
}),
]);
const premiumMinor = seatClass?.premiumMinor ?? 0;
const insuranceMinor = seatClass?.insuranceFeeMinor ?? 0;
const priorities = [
{ tripId: scheduleId, route: segmentRoute, nationality },
@@ -982,10 +1014,11 @@ export class GuestBookingService {
const match = candidates.find(
(c) => c.tripId === priority.tripId && c.route === priority.route && c.nationality === priority.nationality,
);
if (match) return match.baseFareMinor;
// Return base fare + seat-class surcharges so the booking total matches the quoted fare
if (match) return match.baseFareMinor + premiumMinor + insuranceMinor;
}
// 2. FareEngine — distance × rate-per-km from the schedule's route
// 2. FareEngine — distance × rate-per-km from the booking's actual segment stations
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
select: { routeId: true, originStationId: true, destinationStationId: true },
@@ -995,12 +1028,15 @@ export class GuestBookingService {
try {
const fare = await this.fareEngine.calculate({
routeId: schedule.routeId,
originStationId: schedule.originStationId,
destinationStationId: schedule.destinationStationId,
// Use the booking's boarding/alighting stations so the distance reflects the
// passenger's actual segment, not the full schedule route.
originStationId: originStationId ?? schedule.originStationId,
destinationStationId: destinationStationId ?? schedule.destinationStationId,
seatClassId,
nationality,
});
return fare.baseFarePerPassengerMinor;
// farePerPassengerMinor already includes base + premiumMinor + insuranceFeeMinor
return fare.farePerPassengerMinor;
} catch {
// FareEngine throws if distanceKm is missing; fall through to error
}

View File

@@ -4,14 +4,14 @@ import { Type } from 'class-transformer';
import { Currency } from '@prisma/client';
// Nationality → home currency mapping (keys are uppercase for case-insensitive lookup)
export const NATIONALITY_CURRENCY_MAP: Record<string, Currency> = {
ETHIOPIAN: Currency.ETB,
DJIBOUTIAN: Currency.DJF,
export const NATIONALITY_CURRENCY_MAP: Record<string, string> = {
ETHIOPIAN: 'ETB',
DJIBOUTIAN: 'DJF',
};
export function resolveCurrencyFromNationality(nationality?: string): Currency {
if (!nationality) return Currency.ETB;
return NATIONALITY_CURRENCY_MAP[nationality.toUpperCase()] ?? Currency.USD;
if (!nationality) return 'ETB' as Currency;
return (NATIONALITY_CURRENCY_MAP[nationality.toUpperCase()] ?? 'USD') as Currency;
}
export class FareCalculateDto {

View File

@@ -284,13 +284,16 @@ export class FareEngineService {
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency);
return fareRules.map(rule => {
const seatClassId = rule.seatClassId;
const taxMinor = Math.round(rule.baseFareMinor * TAX_RATE);
const totalMinor = rule.baseFareMinor + taxMinor;
return {
seatClassId,
seatClassName: 'Unknown',
baseFareMinor: rule.baseFareMinor,
totalMinor: rule.baseFareMinor,
taxMinor,
totalMinor,
billingCurrency,
totalInBillingCurrency: Math.round(rule.baseFareMinor * exchangeRate),
totalInBillingCurrency: Math.round(totalMinor * exchangeRate),
exchangeRate,
source: 'FARE_RULE',
};

View File

@@ -31,6 +31,7 @@ import {
SupportedPaymentMethodDto,
PaymentMethodTypeEnum,
PaymentPlatformDto,
BookingAmountResponseDto,
} from "./payments.dto";
import { PassengerStaff } from "../../common/passenger-guards";
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
@@ -139,16 +140,33 @@ export class PaymentsController {
@ApiOperation({
summary: "List payment systems supported by the platform",
description:
"Returns the global catalog of accepted payment systems. Filter by `currency` (e.g. ETB, DJF, USD) to get methods that settle in that currency, and/or by `region` to match a passenger's nationality. Both filters can be combined.",
"Returns all enabled payment methods. Optionally filter by `region` to narrow to methods available for a passenger's nationality.",
})
@ApiQuery({ name: "currency", required: false, example: "DJF", description: "Settlement currency — ETB, DJF, USD, etc." })
@ApiQuery({ name: "region", enum: PaymentRegionEnum, required: false })
@ApiOkResponse({ type: [SupportedPaymentMethodDto] })
getMethods(
@Query("currency") currency?: string,
@Query("region") region?: PaymentRegionEnum,
) {
return this.service.getSupportedPaymentMethods(region, currency);
return this.service.getSupportedPaymentMethods(region);
}
@Get("booking-amount")
@SetMetadata('isPublic', true)
@ApiOperation({
summary: "Get booking amount in a specific currency",
description:
"Returns the booking total converted from ETB to the requested currency using the latest exchange rate. " +
"If currency is ETB the stored amount is returned as-is (no conversion). " +
"Amounts are returned in major currency units (e.g. 162.50 DJF, not centimes).",
})
@ApiQuery({ name: "bookingId", required: true, description: "Booking UUID" })
@ApiQuery({ name: "currency", required: true, example: "DJF", description: "Target currency: ETB, DJF, or USD" })
@ApiOkResponse({ type: BookingAmountResponseDto })
getBookingAmount(
@Query("bookingId") bookingId: string,
@Query("currency") currency: string,
) {
return this.service.getBookingAmountByCurrency(bookingId, currency);
}
@Get("checkout")

View File

@@ -136,3 +136,9 @@ export class IntentStatusDto {
@ApiPropertyOptional() failureCode?: string;
@ApiPropertyOptional() failureMessage?: string;
}
export class BookingAmountResponseDto {
@ApiProperty({ example: 'booking-uuid' }) booking_id: string;
@ApiProperty({ example: 'DJF', description: 'Currency of the returned amount' }) currency: string;
@ApiProperty({ example: 162.5, description: 'Booking total converted to the requested currency (major units)' }) amount: number;
}

View File

@@ -476,7 +476,7 @@ export class PaymentsService {
});
}
getSupportedPaymentMethods(region?: PaymentRegionEnum, currency?: string) {
getSupportedPaymentMethods(region?: PaymentRegionEnum) {
return this.prisma.paymentMethod.findMany({
where: {
enabled: true,
@@ -490,12 +490,41 @@ export class PaymentsService {
},
}
: {}),
...(currency ? { currency: currency.toUpperCase() } : {}),
},
orderBy: [{ sortOrder: "asc" }, { displayName: "asc" }],
});
}
async getBookingAmountByCurrency(
bookingId: string,
currency: string,
): Promise<{ booking_id: string; currency: string; amount: number }> {
const booking = await this.prisma.booking.findUnique({
where: { id: bookingId },
select: { id: true, totalMinor: true },
});
if (!booking) throw new NotFoundException('Booking not found');
const requestedCurrency = currency.toUpperCase();
const amountInETB = booking.totalMinor / 100;
if (requestedCurrency === 'ETB') {
return { booking_id: bookingId, currency: 'ETB', amount: amountInETB };
}
const exchangeRate = await this.prisma.currencyExchangeRate.findFirst({
where: { fromCurrency: 'ETB' as any, toCurrency: requestedCurrency as any },
orderBy: { effectiveDate: 'desc' },
});
if (!exchangeRate) {
throw new NotFoundException(`Exchange rate not found for ETB → ${requestedCurrency}`);
}
const rate = Number(exchangeRate.rate);
const converted = parseFloat((amountInETB * rate).toFixed(2));
return { booking_id: bookingId, currency: requestedCurrency, amount: converted };
}
/**
* Guard against an implausible paidAt from a provider event (e.g. a Telebirr epoch parsed as
* ms×1000 → year 58429), which Prisma/Postgres rejects and would otherwise dead-letter the

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