mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'alpha' of https://github.com/Tria-plc/edr-platform into alpha
This commit is contained in:
583
.github/scripts/scan.js
vendored
Normal file
583
.github/scripts/scan.js
vendored
Normal file
@@ -0,0 +1,583 @@
|
||||
/**
|
||||
* PolinRider / Famous Chollima Supply-Chain Malware Scanner
|
||||
*
|
||||
* Detects the specific injection pattern used in the PolinRider campaign
|
||||
* attributed to North Korean APT (Void Dokkaebi / Famous Chollima / UNC5342).
|
||||
*
|
||||
* IOCs sourced from:
|
||||
* - Direct analysis of the injected tailwind.config.js sample
|
||||
* - Socket Security report (May 2026) on roberts/leads compromise
|
||||
* - PolinRider technical analysis report (Trend Micro / safedep.io)
|
||||
*
|
||||
* Zero external dependencies — runs on any Node.js >= 14.
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const crypto = require("crypto");
|
||||
|
||||
// ─── Configuration ────────────────────────────────────────────────────────────
|
||||
|
||||
const CONFIG = {
|
||||
// Maximum legitimate size for JS config files.
|
||||
// Real tailwind/postcss/babel configs are rarely > 3 KB.
|
||||
// Injected files jump to 5–8 KB instantly.
|
||||
maxLegitConfigBytes: 3072,
|
||||
|
||||
// Minimum whitespace run on a single line that signals hidden payload.
|
||||
// The campaign uses ~280–510 spaces to push payload off-screen.
|
||||
minSuspiciousInlineSpaces: 100,
|
||||
|
||||
// Files that are high-value injection targets for this campaign.
|
||||
targetedFilenames: [
|
||||
"tailwind.config.js",
|
||||
"tailwind.config.ts",
|
||||
"tailwind.config.cjs",
|
||||
"tailwind.js",
|
||||
"postcss.config.js",
|
||||
"postcss.config.mjs",
|
||||
"postcss.config.cjs",
|
||||
"babel.config.js",
|
||||
"babel.config.cjs",
|
||||
"next.config.js",
|
||||
"next.config.mjs",
|
||||
"next.config.cjs",
|
||||
"astro.config.mjs",
|
||||
"astro.config.js",
|
||||
"vite.config.js",
|
||||
"vite.config.ts",
|
||||
"webpack.config.js",
|
||||
"webpack.mix.js",
|
||||
],
|
||||
|
||||
// Files intentionally containing malware indicators for scanner logic/tests.
|
||||
// These filenames are skipped before malware rules are evaluated.
|
||||
ignoredFilenames: ["scan.js"],
|
||||
|
||||
// Filesystem paths that indicate active persistence mechanisms
|
||||
persistenceArtifacts: [
|
||||
"temp_auto_push.bat",
|
||||
"temp_interactive_push.bat",
|
||||
"branch_structure.json",
|
||||
// Note: queue.bat and .plist are OS-level; checked separately
|
||||
],
|
||||
};
|
||||
|
||||
// ─── IOC Definitions ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Each rule has:
|
||||
* id – unique rule identifier for reporting
|
||||
* severity – CRITICAL | HIGH | MEDIUM
|
||||
* description – human-readable explanation
|
||||
* test(content, filePath, lines) – returns array of match details or []
|
||||
*/
|
||||
const RULES = [
|
||||
// ── Tier 1: Definitive campaign signatures ─────────────────────────────────
|
||||
|
||||
{
|
||||
id: "POLINRIDER-001",
|
||||
severity: "CRITICAL",
|
||||
description:
|
||||
"PolinRider string-shuffler variable _$_1e42 — present in every known sample of this campaign",
|
||||
test(content) {
|
||||
const matches = [];
|
||||
const re = /_\$_1e42/g;
|
||||
let m;
|
||||
while ((m = re.exec(content)) !== null) {
|
||||
matches.push(`offset ${m.index}`);
|
||||
}
|
||||
return matches;
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "POLINRIDER-002",
|
||||
severity: "CRITICAL",
|
||||
description:
|
||||
"PolinRider campaign marker global['!'] assignment — used to route C2 traffic",
|
||||
test(content) {
|
||||
const matches = [];
|
||||
const re = /global\s*\[\s*['"]!\s*['"]\s*\]\s*=/g;
|
||||
let m;
|
||||
while ((m = re.exec(content)) !== null) {
|
||||
const snippet = content
|
||||
.slice(m.index, m.index + 40)
|
||||
.replace(/\n/g, "\\n");
|
||||
matches.push(`"${snippet}"`);
|
||||
}
|
||||
return matches;
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "POLINRIDER-003",
|
||||
severity: "CRITICAL",
|
||||
description:
|
||||
'PolinRider shuffler seed string "rmcej%otb%" — embedded in the string-decryption bootstrap of the specific variant targeting this repo',
|
||||
test(content) {
|
||||
return content.includes("rmcej%otb%") ? ["seed string found"] : [];
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "POLINRIDER-004",
|
||||
severity: "CRITICAL",
|
||||
description:
|
||||
"Known C2 IP addresses associated with PolinRider infrastructure",
|
||||
test(content) {
|
||||
const knownC2 = ["198.105.127.210", "166.88.54.158", "23.27.202.27"];
|
||||
return knownC2.filter((ip) => content.includes(ip));
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "POLINRIDER-005",
|
||||
severity: "CRITICAL",
|
||||
description:
|
||||
"Known TRON blockchain wallet addresses used as dead-drop C2 resolvers",
|
||||
test(content) {
|
||||
const wallets = [
|
||||
"TMfKQEd7TJJa5xNZJZ2Lep838vrzrs7mAP",
|
||||
"TXfxHUet9pJVU1BgVkBAbrES4YUc1nGzcG",
|
||||
];
|
||||
return wallets.filter((w) => content.includes(w));
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "POLINRIDER-006",
|
||||
severity: "CRITICAL",
|
||||
description:
|
||||
"Known Aptos blockchain addresses used as fallback dead-drop resolvers",
|
||||
test(content) {
|
||||
const addrs = [
|
||||
"0xbe037400670fbf1c32364f762975908dc43eeb38759263e7dfcdabc76380811e",
|
||||
"0x3f0e5781d0855fb460661ac63257376db1941b2bb522499e4757ecb3ebd5dce3",
|
||||
];
|
||||
return addrs.filter((a) => content.includes(a));
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "POLINRIDER-007",
|
||||
severity: "CRITICAL",
|
||||
description:
|
||||
"Known XOR decryption keys used to decrypt the second-stage payload from BSC transactions",
|
||||
test(content) {
|
||||
const keys = ["2[gWfGj;<:-93Z^C", "m6:tTh^D)cBz?NM]"];
|
||||
return keys.filter((k) => content.includes(k));
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "POLINRIDER-008",
|
||||
severity: "CRITICAL",
|
||||
description:
|
||||
"Known SHA-256 hash of compromised tailwind.js file (Socket Security, 2026-05-31)",
|
||||
test(content) {
|
||||
const knownHashes = new Set([
|
||||
"96afdba882046385242cbed46871e41147c8055c5d9eff7460847b2c01a77dc3",
|
||||
"522b28a2f78771715497ba53729d4ab9a50e982322c391379f3bddf7c8cb363f",
|
||||
]);
|
||||
const hash = crypto.createHash("sha256").update(content).digest("hex");
|
||||
return knownHashes.has(hash) ? [`SHA-256: ${hash}`] : [];
|
||||
},
|
||||
},
|
||||
|
||||
// ── Tier 2: Behavioral / structural indicators ─────────────────────────────
|
||||
|
||||
{
|
||||
id: "POLINRIDER-009",
|
||||
severity: "HIGH",
|
||||
description:
|
||||
"Blockchain RPC infrastructure contact — campaign uses TRON, Aptos, and BSC as dead-drop C2 resolvers",
|
||||
test(content) {
|
||||
const endpoints = [
|
||||
"trongrid.io",
|
||||
"aptoslabs.com",
|
||||
"bsc-dataseed.binance.org",
|
||||
"bsc-rpc.publicnode.com",
|
||||
"eth_getTransactionByHash",
|
||||
];
|
||||
return endpoints.filter((e) => content.includes(e));
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "POLINRIDER-010",
|
||||
severity: "HIGH",
|
||||
description:
|
||||
"Hidden process spawn with windowsHide:true — used by InvisibleFerret / BeaverTail stager to launch detached Node.js child processes invisibly",
|
||||
test(content) {
|
||||
return /windowsHide\s*:\s*true/.test(content)
|
||||
? ["windowsHide:true found"]
|
||||
: [];
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "POLINRIDER-011",
|
||||
severity: "HIGH",
|
||||
description:
|
||||
"Duplicate createRequire injection at file top — campaign restores require() for ES module environments by prepending two identical import statements",
|
||||
test(content) {
|
||||
const matches =
|
||||
content.match(/import\s*\{\s*createRequire\s*\}\s*from/g) || [];
|
||||
return matches.length >= 2
|
||||
? [`Found ${matches.length} duplicate createRequire imports`]
|
||||
: [];
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "POLINRIDER-012",
|
||||
severity: "HIGH",
|
||||
description:
|
||||
"Payload hidden after large horizontal whitespace (>100 spaces on one line) — evasion technique to hide code off-screen in editors and GitHub diff views",
|
||||
test(content, _filePath, lines) {
|
||||
const hits = [];
|
||||
lines.forEach((line, i) => {
|
||||
const spaceRun = line.match(/\s{100,}/);
|
||||
if (spaceRun) {
|
||||
hits.push(`line ${i + 1}: ${spaceRun[0].length} consecutive spaces`);
|
||||
}
|
||||
});
|
||||
return hits;
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "POLINRIDER-013",
|
||||
severity: "HIGH",
|
||||
description:
|
||||
"Config file size anomaly — legitimate tailwind/postcss/babel configs are < 3 KB; injected files jump to 5–8 KB",
|
||||
test(content, filePath) {
|
||||
const bytes = Buffer.byteLength(content, "utf8");
|
||||
const base = path.basename(filePath).toLowerCase();
|
||||
const isTargeted = CONFIG.targetedFilenames.some(
|
||||
(f) => f.toLowerCase() === base,
|
||||
);
|
||||
if (isTargeted && bytes > CONFIG.maxLegitConfigBytes) {
|
||||
return [
|
||||
`${bytes} bytes (threshold: ${CONFIG.maxLegitConfigBytes} bytes)`,
|
||||
];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "POLINRIDER-014",
|
||||
severity: "HIGH",
|
||||
description:
|
||||
"Persistence artifact detected — files used by temp_auto_push.bat to rewrite git history and propagate infection to all branches",
|
||||
test(_content, filePath) {
|
||||
const base = path.basename(filePath);
|
||||
return CONFIG.persistenceArtifacts.includes(base) ? [base] : [];
|
||||
},
|
||||
},
|
||||
|
||||
// ── Tier 3: Supporting behavioral indicators ───────────────────────────────
|
||||
|
||||
{
|
||||
id: "POLINRIDER-015",
|
||||
severity: "MEDIUM",
|
||||
description:
|
||||
"Campaign marker pattern — numeric string assigned to global['!'], used to select C2 tier (alpha/beta/fallback)",
|
||||
test(content) {
|
||||
const matches = [];
|
||||
// Matches patterns like '8-3317', '9-0264-2', '8-3946-1', 'A4-1928'
|
||||
const re =
|
||||
/global\s*\[\s*['"]!\s*['"]\s*\]\s*=\s*['"]([A-Z]?\d[\d-]+)['"]/g;
|
||||
let m;
|
||||
while ((m = re.exec(content)) !== null) {
|
||||
matches.push(`marker value: "${m[1]}"`);
|
||||
}
|
||||
return matches;
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "POLINRIDER-016",
|
||||
severity: "MEDIUM",
|
||||
description:
|
||||
"sfL obfuscation function — secondary string-shuffler present in multi-stage loader variant",
|
||||
test(content) {
|
||||
// sfL appears as a named function used to decode the larger payload blob
|
||||
const occurrences = (content.match(/\bsfL\b/g) || []).length;
|
||||
return occurrences >= 3 ? [`sfL referenced ${occurrences} times`] : [];
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "POLINRIDER-017",
|
||||
severity: "MEDIUM",
|
||||
description:
|
||||
"global require/module injection — bootloader dynamically restores Node.js internals to bypass ES module restrictions",
|
||||
test(content) {
|
||||
const hits = [];
|
||||
if (/global\s*\[.*\]\s*=\s*require/.test(content))
|
||||
hits.push("global[x] = require");
|
||||
if (/global\s*\[.*module.*\]\s*=\s*module/.test(content))
|
||||
hits.push("global[x] = module");
|
||||
return hits;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Scanner Engine ────────────────────────────────────────────────────────────
|
||||
|
||||
function scanFile(filePath) {
|
||||
if (shouldIgnoreFile(filePath)) {
|
||||
return { filePath, findings: [], skipped: true };
|
||||
}
|
||||
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(filePath, "utf8");
|
||||
} catch (err) {
|
||||
return { filePath, error: err.message, findings: [] };
|
||||
}
|
||||
|
||||
const lines = content.split("\n");
|
||||
const findings = [];
|
||||
|
||||
for (const rule of RULES) {
|
||||
let matches;
|
||||
try {
|
||||
matches = rule.test(content, filePath, lines);
|
||||
} catch (err) {
|
||||
matches = [`[rule error: ${err.message}]`];
|
||||
}
|
||||
|
||||
if (matches && matches.length > 0) {
|
||||
findings.push({
|
||||
id: rule.id,
|
||||
severity: rule.severity,
|
||||
description: rule.description,
|
||||
matches,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { filePath, findings };
|
||||
}
|
||||
|
||||
function shouldIgnoreFile(filePath) {
|
||||
const base = path.basename(filePath).toLowerCase();
|
||||
return CONFIG.ignoredFilenames.some((f) => f.toLowerCase() === base);
|
||||
}
|
||||
|
||||
function walkDir(dir, results = []) {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return results;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.name === "node_modules" || entry.name === ".git") continue;
|
||||
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walkDir(full, results);
|
||||
} else if (entry.isFile() && !shouldIgnoreFile(full)) {
|
||||
const ext = path.extname(entry.name).toLowerCase();
|
||||
const base = entry.name.toLowerCase();
|
||||
|
||||
// Scan all JS/TS config files + any file matching a targeted name
|
||||
const isTargetedName = CONFIG.targetedFilenames.some(
|
||||
(f) => f.toLowerCase() === base,
|
||||
);
|
||||
const isPersistenceArtifact = CONFIG.persistenceArtifacts.some(
|
||||
(f) => f.toLowerCase() === base,
|
||||
);
|
||||
const isJsLike = [".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"].includes(
|
||||
ext,
|
||||
);
|
||||
|
||||
if (isTargetedName || isPersistenceArtifact || isJsLike) {
|
||||
results.push(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── Reporting ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const SEVERITY_RANK = { CRITICAL: 3, HIGH: 2, MEDIUM: 1 };
|
||||
const ANSI = {
|
||||
reset: "\x1b[0m",
|
||||
bold: "\x1b[1m",
|
||||
red: "\x1b[31m",
|
||||
yellow: "\x1b[33m",
|
||||
cyan: "\x1b[36m",
|
||||
green: "\x1b[32m",
|
||||
dim: "\x1b[2m",
|
||||
};
|
||||
|
||||
function colorSeverity(sev) {
|
||||
if (sev === "CRITICAL") return `${ANSI.bold}${ANSI.red}${sev}${ANSI.reset}`;
|
||||
if (sev === "HIGH") return `${ANSI.yellow}${sev}${ANSI.reset}`;
|
||||
return `${ANSI.cyan}${sev}${ANSI.reset}`;
|
||||
}
|
||||
|
||||
function printReport(allResults, { json = false, outputFile = null } = {}) {
|
||||
const infected = allResults.filter(
|
||||
(r) => r.findings && r.findings.length > 0,
|
||||
);
|
||||
const errors = allResults.filter((r) => r.error);
|
||||
|
||||
if (json) {
|
||||
const report = {
|
||||
scannedAt: new Date().toISOString(),
|
||||
totalFilesScanned: allResults.length,
|
||||
infectedFiles: infected.length,
|
||||
results: infected,
|
||||
errors,
|
||||
};
|
||||
const out = JSON.stringify(report, null, 2);
|
||||
if (outputFile) {
|
||||
fs.writeFileSync(outputFile, out);
|
||||
console.log(`JSON report written to: ${outputFile}`);
|
||||
} else {
|
||||
console.log(out);
|
||||
}
|
||||
return infected.length > 0;
|
||||
}
|
||||
|
||||
// Human-readable output
|
||||
console.log(
|
||||
`\n${ANSI.bold}╔══════════════════════════════════════════════════════════╗`,
|
||||
);
|
||||
console.log(`║ PolinRider / Famous Chollima Malware Scanner ║`);
|
||||
console.log(
|
||||
`╚══════════════════════════════════════════════════════════╝${ANSI.reset}`,
|
||||
);
|
||||
console.log(
|
||||
`${ANSI.dim}Scanned ${allResults.length} files · ${new Date().toISOString()}${ANSI.reset}\n`,
|
||||
);
|
||||
|
||||
if (infected.length === 0) {
|
||||
console.log(
|
||||
`${ANSI.green}${ANSI.bold}✓ No infections detected.${ANSI.reset}\n`,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`${ANSI.red}${ANSI.bold}✗ INFECTION DETECTED in ${infected.length} file(s)${ANSI.reset}\n`,
|
||||
);
|
||||
|
||||
for (const result of infected) {
|
||||
// Sort findings by severity descending
|
||||
const sorted = [...result.findings].sort(
|
||||
(a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity],
|
||||
);
|
||||
const topSev = sorted[0].severity;
|
||||
|
||||
console.log(
|
||||
` ${colorSeverity(topSev)} ${ANSI.bold}${result.filePath}${ANSI.reset}`,
|
||||
);
|
||||
for (const f of sorted) {
|
||||
console.log(
|
||||
` ${ANSI.dim}[${f.id}]${ANSI.reset} ${colorSeverity(f.severity)} — ${f.description}`,
|
||||
);
|
||||
for (const m of f.matches) {
|
||||
console.log(` → ${m}`);
|
||||
}
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
console.log(`${ANSI.bold}Remediation steps:${ANSI.reset}`);
|
||||
console.log(
|
||||
` 1. Immediately isolate the affected machine from the network.`,
|
||||
);
|
||||
console.log(
|
||||
` 2. Do NOT run npm install, npm build, or any script on this repo.`,
|
||||
);
|
||||
console.log(
|
||||
` 3. Check for running node.exe / node processes with obfuscated args.`,
|
||||
);
|
||||
console.log(
|
||||
` 4. Remove all code after the legitimate config closing block.`,
|
||||
);
|
||||
console.log(
|
||||
` 5. Remove duplicate 'import { createRequire }' lines at file top.`,
|
||||
);
|
||||
console.log(
|
||||
` 6. Recover the git repository from a clean local clone (see docs).`,
|
||||
);
|
||||
console.log(
|
||||
` 7. Revoke ALL secrets, tokens, and credentials in .env and CI.`,
|
||||
);
|
||||
console.log(
|
||||
` 8. See full remediation guide in the attached incident report.\n`,
|
||||
);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.log(`${ANSI.yellow}Scan errors (${errors.length}):${ANSI.reset}`);
|
||||
for (const e of errors) {
|
||||
console.log(` ${e.filePath}: ${e.error}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
return infected.length > 0;
|
||||
}
|
||||
|
||||
// ─── CLI Entry Point ───────────────────────────────────────────────────────────
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const jsonFlag = args.includes("--json");
|
||||
const outputFileIdx = args.indexOf("--output");
|
||||
const outputFile = outputFileIdx !== -1 ? args[outputFileIdx + 1] : null;
|
||||
|
||||
// Positional args after flags are scan targets
|
||||
const targets = args.filter(
|
||||
(a) => !a.startsWith("--") && args[args.indexOf(a) - 1] !== "--output",
|
||||
);
|
||||
|
||||
if (targets.length === 0) {
|
||||
console.error(
|
||||
"Usage: scan.js [--json] [--output report.json] <path> [path...]",
|
||||
);
|
||||
console.error(
|
||||
" path can be a file or directory (directories are walked recursively)",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const filesToScan = [];
|
||||
for (const target of targets) {
|
||||
if (!fs.existsSync(target)) {
|
||||
console.error(`Path not found: ${target}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const stat = fs.statSync(target);
|
||||
if (stat.isDirectory()) {
|
||||
const found = walkDir(target);
|
||||
filesToScan.push(...found);
|
||||
} else {
|
||||
filesToScan.push(target);
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate
|
||||
const unique = [...new Set(filesToScan)];
|
||||
const allResults = unique.map(scanFile);
|
||||
|
||||
const infected = printReport(allResults, { json: jsonFlag, outputFile });
|
||||
|
||||
// Exit code 1 if any infection found — used by CI to block deployments
|
||||
process.exit(infected ? 1 : 0);
|
||||
}
|
||||
|
||||
main();
|
||||
3
.github/workflows/deploy.yml
vendored
3
.github/workflows/deploy.yml
vendored
@@ -38,6 +38,9 @@ jobs:
|
||||
- project: edr-passenger
|
||||
build_env_file: passenger-web.build.env
|
||||
service: passenger-backoffice
|
||||
- project: edr-payment
|
||||
build_env_file: payment-web.build.env
|
||||
service: payment-api
|
||||
env:
|
||||
PROJECT: ${{ matrix.project }}
|
||||
BRANCH: ${{ github.ref_name }}
|
||||
|
||||
243
.github/workflows/polinrider-scan.yml
vendored
Normal file
243
.github/workflows/polinrider-scan.yml
vendored
Normal file
@@ -0,0 +1,243 @@
|
||||
name: PolinRider Malware Scan
|
||||
|
||||
# ── Triggers ──────────────────────────────────────────────────────────────────
|
||||
# Runs on every push and every PR targeting main/master/develop.
|
||||
# Also available as a manual trigger (workflow_dispatch) and on a nightly
|
||||
# schedule so dormant infections in older branches are caught too.
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
branches: ["**"]
|
||||
schedule:
|
||||
# Nightly full-repo scan at 02:00 UTC
|
||||
- cron: "0 2 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
# ── Permissions ───────────────────────────────────────────────────────────────
|
||||
permissions:
|
||||
contents: read # checkout
|
||||
security-events: write # upload SARIF to GitHub Security tab
|
||||
actions: read
|
||||
checks: write # annotate PRs with scan findings
|
||||
|
||||
# ── Deployment gate ───────────────────────────────────────────────────────────
|
||||
# All other jobs (build, test, deploy) should list this job under `needs:`.
|
||||
# If this job fails (exit code 1 from the scanner), the whole workflow stops.
|
||||
jobs:
|
||||
polinrider-scan:
|
||||
name: "PolinRider / Famous Chollima Scan"
|
||||
runs-on: ubuntu-latest
|
||||
# Prevent CI from being disabled by any workflow override
|
||||
if: always()
|
||||
|
||||
steps:
|
||||
# ── 1. Checkout full history ─────────────────────────────────────────────
|
||||
# Full depth so we can inspect recent commits for temp_auto_push.bat traces
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# ── 2. Detect suspicious force-push patterns in git history ──────────────
|
||||
- name: Check git history for force-push and timestamp manipulation
|
||||
id: git-check
|
||||
shell: bash
|
||||
run: |
|
||||
echo "=== Checking for suspicious git history patterns ==="
|
||||
|
||||
# Check for .gitignore entries hiding known malware artifacts
|
||||
GITIGNORE_HITS=0
|
||||
if [ -f .gitignore ]; then
|
||||
for pattern in "branch_structure.json" "temp_auto_push.bat" "temp_interactive_push.bat"; do
|
||||
if grep -qF "$pattern" .gitignore 2>/dev/null; then
|
||||
echo "::warning file=.gitignore::SUSPICIOUS: .gitignore hides known PolinRider artifact: $pattern"
|
||||
GITIGNORE_HITS=$((GITIGNORE_HITS + 1))
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Check if malware persistence artifacts exist anywhere in the tree
|
||||
ARTIFACTS_FOUND=0
|
||||
for artifact in "temp_auto_push.bat" "temp_interactive_push.bat" "branch_structure.json"; do
|
||||
FOUND=$(find . -name "$artifact" -not -path "./.git/*" 2>/dev/null)
|
||||
if [ -n "$FOUND" ]; then
|
||||
echo "::error ::CRITICAL: PolinRider persistence artifact found: $artifact"
|
||||
echo "$FOUND"
|
||||
ARTIFACTS_FOUND=$((ARTIFACTS_FOUND + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
# Scan recent commit messages for --no-verify (used by temp_auto_push.bat)
|
||||
NO_VERIFY_COMMITS=$(git log --oneline -50 --format="%H %s" 2>/dev/null | grep -i "no.verify\|force.*push\|amend" || true)
|
||||
if [ -n "$NO_VERIFY_COMMITS" ]; then
|
||||
echo "::warning ::Recent commits with suspicious metadata (--no-verify / force amend patterns):"
|
||||
echo "$NO_VERIFY_COMMITS"
|
||||
fi
|
||||
|
||||
# Check for .woff2 files with unusually large sizes (>50KB is suspicious)
|
||||
find . -name "*.woff2" -not -path "./.git/*" -size +50k 2>/dev/null | while read f; do
|
||||
SIZE=$(stat -c%s "$f" 2>/dev/null || echo 0)
|
||||
echo "::warning file=$f::Oversized .woff2 font file ($SIZE bytes) — may contain embedded payload"
|
||||
done
|
||||
|
||||
echo "GITIGNORE_HITS=$GITIGNORE_HITS" >> "$GITHUB_OUTPUT"
|
||||
echo "ARTIFACTS_FOUND=$ARTIFACTS_FOUND" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# ── 3. Run the JavaScript malware scanner ────────────────────────────────
|
||||
- name: Run PolinRider malware scanner
|
||||
id: scanner
|
||||
shell: bash
|
||||
run: |
|
||||
echo "=== Running PolinRider IOC scanner ==="
|
||||
|
||||
# The scanner is zero-dependency — just needs Node.js (always present on ubuntu-latest)
|
||||
node .github/scripts/scan.js \
|
||||
--json \
|
||||
--output scan-report.json \
|
||||
.
|
||||
|
||||
SCANNER_EXIT=$?
|
||||
echo "SCANNER_EXIT=$SCANNER_EXIT" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Also emit a human-readable summary to the Actions log
|
||||
node .github/scripts/scan.js . || true
|
||||
|
||||
exit $SCANNER_EXIT
|
||||
|
||||
# ── 4. Upload scan report as artifact ────────────────────────────────────
|
||||
# - name: Upload scan report
|
||||
# if: always()
|
||||
# uses: actions/upload-artifact@v4
|
||||
# with:
|
||||
# name: polinrider-scan-report
|
||||
# path: scan-report.json
|
||||
# retention-days: 90
|
||||
|
||||
# # ── 5. Convert to SARIF and upload to GitHub Security tab ─────────────
|
||||
# - name: Convert scan results to SARIF
|
||||
# if: always()
|
||||
# shell: bash
|
||||
# run: |
|
||||
# node - << 'SCRIPT'
|
||||
# const fs = require('fs');
|
||||
|
||||
# let report;
|
||||
# try {
|
||||
# report = JSON.parse(fs.readFileSync('scan-report.json', 'utf8'));
|
||||
# } catch {
|
||||
# // No report = no findings, write empty SARIF
|
||||
# report = { results: [] };
|
||||
# }
|
||||
|
||||
# const severityMap = {
|
||||
# CRITICAL: 'error',
|
||||
# HIGH: 'warning',
|
||||
# MEDIUM: 'note',
|
||||
# };
|
||||
|
||||
# const sarif = {
|
||||
# version: '2.1.0',
|
||||
# $schema: 'https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.5.json',
|
||||
# runs: [{
|
||||
# tool: {
|
||||
# driver: {
|
||||
# name: 'PolinRider Malware Scanner',
|
||||
# version: '1.0.0',
|
||||
# informationUri: 'https://github.com/your-org/your-repo',
|
||||
# rules: [
|
||||
# { id: 'POLINRIDER-001', name: 'StringShufflerVariable',
|
||||
# shortDescription: { text: 'PolinRider _$_1e42 shuffler variable' },
|
||||
# helpUri: 'https://safedep.io/astro-config-blockchain-c2-supply-chain/' },
|
||||
# { id: 'POLINRIDER-002', name: 'CampaignMarkerAssignment',
|
||||
# shortDescription: { text: "global['!'] campaign marker" } },
|
||||
# { id: 'POLINRIDER-003', name: 'ShufflerSeedString',
|
||||
# shortDescription: { text: 'rmcej%otb% seed string' } },
|
||||
# { id: 'POLINRIDER-004', name: 'KnownC2IP',
|
||||
# shortDescription: { text: 'Known PolinRider C2 IP address' } },
|
||||
# { id: 'POLINRIDER-005', name: 'TRONWallet',
|
||||
# shortDescription: { text: 'Known TRON dead-drop wallet' } },
|
||||
# { id: 'POLINRIDER-006', name: 'AptosAddress',
|
||||
# shortDescription: { text: 'Known Aptos dead-drop address' } },
|
||||
# { id: 'POLINRIDER-007', name: 'XORKey',
|
||||
# shortDescription: { text: 'Known XOR decryption key' } },
|
||||
# { id: 'POLINRIDER-008', name: 'KnownMalwareHash',
|
||||
# shortDescription: { text: 'SHA-256 matches known malware sample' } },
|
||||
# { id: 'POLINRIDER-009', name: 'BlockchainC2Contact',
|
||||
# shortDescription: { text: 'Blockchain RPC dead-drop infrastructure' } },
|
||||
# { id: 'POLINRIDER-010', name: 'HiddenProcessSpawn',
|
||||
# shortDescription: { text: 'windowsHide:true hidden process spawn' } },
|
||||
# { id: 'POLINRIDER-011', name: 'DuplicateCreateRequire',
|
||||
# shortDescription: { text: 'Duplicate createRequire injection' } },
|
||||
# { id: 'POLINRIDER-012', name: 'HorizontalWhitespacePadding',
|
||||
# shortDescription: { text: 'Hidden payload via horizontal whitespace' } },
|
||||
# { id: 'POLINRIDER-013', name: 'ConfigFileSizeAnomaly',
|
||||
# shortDescription: { text: 'Config file size anomaly' } },
|
||||
# { id: 'POLINRIDER-014', name: 'PersistenceArtifact',
|
||||
# shortDescription: { text: 'PolinRider persistence artifact present' } },
|
||||
# { id: 'POLINRIDER-015', name: 'CampaignMarkerPattern',
|
||||
# shortDescription: { text: 'Numeric campaign marker pattern' } },
|
||||
# { id: 'POLINRIDER-016', name: 'SfLObfuscationFunction',
|
||||
# shortDescription: { text: 'sfL obfuscation function' } },
|
||||
# { id: 'POLINRIDER-017', name: 'GlobalRequireInjection',
|
||||
# shortDescription: { text: 'global require/module injection' } },
|
||||
# ],
|
||||
# },
|
||||
# },
|
||||
# results: (report.results || []).flatMap(file =>
|
||||
# (file.findings || []).map(finding => ({
|
||||
# ruleId: finding.id,
|
||||
# level: severityMap[finding.severity] || 'warning',
|
||||
# message: { text: finding.description + ' — ' + finding.matches.join('; ') },
|
||||
# locations: [{
|
||||
# physicalLocation: {
|
||||
# artifactLocation: { uri: file.filePath.replace(/^\.\//,''), uriBaseId: '%SRCROOT%' },
|
||||
# region: { startLine: 1 },
|
||||
# },
|
||||
# }],
|
||||
# }))
|
||||
# ),
|
||||
# }],
|
||||
# };
|
||||
|
||||
# fs.writeFileSync('scan-results.sarif', JSON.stringify(sarif, null, 2));
|
||||
# console.log('SARIF written.');
|
||||
# SCRIPT
|
||||
|
||||
# - name: Upload SARIF to GitHub Security tab
|
||||
# if: always()
|
||||
# uses: github/codeql-action/upload-sarif@v3
|
||||
# with:
|
||||
# sarif_file: scan-results.sarif
|
||||
# category: polinrider-malware-scan
|
||||
|
||||
# ── 6. Block deployment if infected ──────────────────────────────────────
|
||||
- name: Enforce clean-scan gate
|
||||
if: steps.scanner.outputs.SCANNER_EXIT == '1' || steps.git-check.outputs.ARTIFACTS_FOUND != '0'
|
||||
shell: bash
|
||||
run: |
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════════════════════╗"
|
||||
echo "║ DEPLOYMENT BLOCKED — PolinRider malware signatures detected ║"
|
||||
echo "║ ║"
|
||||
echo "║ This repository contains code signatures consistent with the ║"
|
||||
echo "║ PolinRider supply-chain campaign (DPRK / Famous Chollima). ║"
|
||||
echo "║ ║"
|
||||
echo "║ DO NOT run npm install, build, or deploy until remediated. ║"
|
||||
echo "║ ║"
|
||||
echo "║ See scan-report.json artifact for full details. ║"
|
||||
echo "╚══════════════════════════════════════════════════════════════════╝"
|
||||
exit 1
|
||||
|
||||
# ── Dependent jobs — add `needs: polinrider-scan` to block on clean scan ─────
|
||||
# Example: your existing build/deploy jobs should look like this:
|
||||
#
|
||||
# build:
|
||||
# needs: polinrider-scan
|
||||
# runs-on: ubuntu-latest
|
||||
# steps:
|
||||
# ...
|
||||
#
|
||||
# deploy:
|
||||
# needs: [polinrider-scan, build]
|
||||
# ...
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -23,9 +23,6 @@ coverage/
|
||||
.idea/
|
||||
.vscode/
|
||||
.npmrc
|
||||
branch_structure.json
|
||||
temp_auto_push.bat
|
||||
temp_interactive_push.bat
|
||||
|
||||
# emacs cache files
|
||||
*~
|
||||
|
||||
@@ -12,6 +12,7 @@ Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Fre
|
||||
| `edr-freight-web/portal` | `@edr/freight-portal` | React frontend for freight customer/portal users | 5173 |
|
||||
| `edr-freight-web/backoffice` | `@edr/freight-backoffice` | React frontend for freight backoffice employees | 5183 |
|
||||
| `edr-passenger-api` | `@edr/passenger-api` | NestJS API for passenger management | 3002 |
|
||||
| `edr-payment-api` | `@edr/payment-api` | NestJS payment microservice (intents, webhooks) | 3003 |
|
||||
| `edr-passenger-web/portal` | `@edr/passenger-portal` | React frontend for passenger customer/portal users | 5174 |
|
||||
| `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | React frontend for passenger backoffice employees | 5184 |
|
||||
|
||||
@@ -73,6 +74,7 @@ The `@CurrentUser`, `@Roles`, and `@Public` decorators in `@edr/api-common` are
|
||||
- `edr-freight-web/portal`: 5173
|
||||
- `edr-freight-web/backoffice`: 5183
|
||||
- `edr-passenger-api`: 3002
|
||||
- `edr-payment-api`: 3003
|
||||
- `edr-passenger-web/portal`: 5174
|
||||
- `edr-passenger-web/backoffice`: 5184
|
||||
|
||||
@@ -80,6 +82,7 @@ The `@CurrentUser`, `@Roles`, and `@Public` decorators in `@edr/api-common` are
|
||||
|
||||
- `postgres-freight` (port 5433): database `edr_freight` — freight API only.
|
||||
- `postgres-passenger` (port 5434): database `edr_passenger` — passenger API only.
|
||||
- `edr_payment` schema — lives in the same Postgres database as the domain system (whatever the passenger `DATABASE_URL` points at) but is owned exclusively by `apps/edr-payment-api`. Dedicated DB user, no cross-schema FKs, domain apps have no grants on it (see `docs/payment-service/`).
|
||||
- Each app owns its own DB. No cross-database joins; cross-domain data flows through API calls or message queues.
|
||||
|
||||
## Adding a new module to a NestJS app
|
||||
|
||||
@@ -65,13 +65,25 @@ CARD_WEBHOOK_SECRET=
|
||||
CARD_WEBHOOK_URL=
|
||||
CARD_RETURN_URL=
|
||||
|
||||
# Waafi (Djibouti Mobile Money)
|
||||
WAAFI_BASE_URL=https://api.waafipay.net
|
||||
# Waafi (Djibouti Mobile Money — Hosted Payment Page)
|
||||
# Sandbox: https://sandbox.waafipay.net | Production: https://api.waafipay.net
|
||||
WAAFI_BASE_URL=https://sandbox.waafipay.net
|
||||
WAAFI_MERCHANT_UID=
|
||||
WAAFI_API_USER_ID=
|
||||
WAAFI_API_KEY=
|
||||
WAAFI_STORE_ID=
|
||||
WAAFI_HPP_KEY=
|
||||
# HMAC secret returned once by WEBHOOK_REGISTER — verifies inbound webhooks
|
||||
WAAFI_WEBHOOK_SECRET=
|
||||
WAAFI_PAYMENT_METHOD=MWALLET_ACCOUNT
|
||||
# Waafi has no ETB; overrides booking currency (USD/DJF/SLSH)
|
||||
WAAFI_CURRENCY=DJF
|
||||
WAAFI_HPP_SUCCESS_URL=
|
||||
WAAFI_HPP_FAILURE_URL=
|
||||
# 1 = POST, 2 = GET, 4 = Result Token
|
||||
WAAFI_HPP_RESP_FORMAT=1
|
||||
# Registered webhook URL (registration done out-of-band)
|
||||
WAAFI_NOTIFY_URL=
|
||||
WAAFI_RETURN_URL=
|
||||
# DEV ONLY — disable TLS cert verification (sandbox serves a *.waafi.com cert). Never true in prod.
|
||||
WAAFI_INSECURE_TLS=false
|
||||
|
||||
# Payment Configuration
|
||||
PAYMENT_PROVIDERS_ENABLED=TELEBIRR,CBE_BIRR,EBIRR,CARD,WALLET,WAAFI
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
},
|
||||
|
||||
"dependencies": {
|
||||
"@edr/payment-providers": "workspace:*",
|
||||
"@edr/types": "workspace:*",
|
||||
"@nestjs/axios": "^4.0.1",
|
||||
"@nestjs/common": "^11.0.0",
|
||||
@@ -47,7 +46,8 @@
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"swagger-ui-express": "^5.0.0",
|
||||
"tsconfig-paths": "^4.2.0"
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"uuid": "^10.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edr/eslint-config": "workspace:*",
|
||||
@@ -67,7 +67,8 @@
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.1.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.3.3"
|
||||
"typescript": "^5.3.3",
|
||||
"@types/uuid": "^9.0.0"
|
||||
},
|
||||
"prisma": {
|
||||
"schema": "prisma/schema.prisma"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Prisma, PrismaClient } from '@prisma/client';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { randomUUID as uuidv4 } from 'crypto';
|
||||
|
||||
@@ -224,12 +224,10 @@ async function seedCoaches() {
|
||||
create: coach,
|
||||
});
|
||||
|
||||
// Rebuild the coach's seats from scratch. A plain upsert keyed on
|
||||
// coachId_seatNumber can't reconcile a changed layout (it's blind to the
|
||||
// @@unique([coachId, row, col]) constraint), so stale row/col data collides.
|
||||
await prisma.seat.deleteMany({ where: { coachId: c.id } });
|
||||
|
||||
const seats: Prisma.SeatCreateManyInput[] = [];
|
||||
// Idempotently reconcile the coach's seats. Upsert keyed on the
|
||||
// @@unique([coachId, row, col]) constraint so a re-seed updates existing
|
||||
// rows in place instead of deleting them. Deleting Seats fails with a P2003
|
||||
// FK violation once BookingSeat/SeatBlock/TicketSeat rows reference them.
|
||||
let seatIndex = 1;
|
||||
for (let row = 1; row <= Math.ceil(coach.capacity / 2); row++) {
|
||||
for (const col of ['A', 'B', 'C', 'D']) {
|
||||
@@ -241,19 +239,21 @@ async function seedCoaches() {
|
||||
else bedPosition = 'lower';
|
||||
}
|
||||
|
||||
seats.push({
|
||||
coachId: c.id,
|
||||
const seatData = {
|
||||
seatNumber: seatIndex.toString(),
|
||||
row,
|
||||
col,
|
||||
isWindow: col === 'A' || col === 'D',
|
||||
isAisle: col === 'B' || col === 'C',
|
||||
bedPosition,
|
||||
};
|
||||
|
||||
await prisma.seat.upsert({
|
||||
where: { coachId_row_col: { coachId: c.id, row, col } },
|
||||
update: seatData,
|
||||
create: { coachId: c.id, row, col, ...seatData },
|
||||
});
|
||||
seatIndex++;
|
||||
}
|
||||
}
|
||||
await prisma.seat.createMany({ data: seats });
|
||||
totalSeats += coach.capacity;
|
||||
}
|
||||
console.log(` ✅ ${coaches.length} coaches with ${totalSeats} seats created`);
|
||||
@@ -552,25 +552,49 @@ async function seedFraudRules() {
|
||||
console.log(` ✅ ${rules.length} fraud detection rules created`);
|
||||
}
|
||||
|
||||
// Run a seed step in isolation: if it throws (FK conflict, duplicate row,
|
||||
// missing record, etc.) log the error and keep going so the rest of the seed —
|
||||
// and the API startup that follows it — are never blocked by one bad step.
|
||||
async function runStep(name: string, step: () => Promise<unknown>): Promise<boolean> {
|
||||
try {
|
||||
await step();
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error(`⚠️ Seed step "${name}" failed — skipping and continuing:`, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('🌱 Comprehensive EDR Seed Starting...\n');
|
||||
|
||||
await seedSystemUsers();
|
||||
await seedStations();
|
||||
await seedCoachTypesAndClasses();
|
||||
await seedRoute();
|
||||
await seedCoaches();
|
||||
await seedTrips();
|
||||
await seedFareRules();
|
||||
await seedCurrency();
|
||||
await seedPaymentMethods();
|
||||
await seedNotificationTemplates();
|
||||
await seedMenuAndFood();
|
||||
await seedPromotions();
|
||||
await seedFAQ();
|
||||
await seedFraudRules();
|
||||
const steps: Array<[string, () => Promise<unknown>]> = [
|
||||
['system users', seedSystemUsers],
|
||||
['stations', seedStations],
|
||||
['coach types & classes', seedCoachTypesAndClasses],
|
||||
['route', seedRoute],
|
||||
['coaches', seedCoaches],
|
||||
['trips', seedTrips],
|
||||
['fare rules', seedFareRules],
|
||||
['currency', seedCurrency],
|
||||
['payment methods', seedPaymentMethods],
|
||||
['notification templates', seedNotificationTemplates],
|
||||
['menu & food', seedMenuAndFood],
|
||||
['promotions', seedPromotions],
|
||||
['FAQ', seedFAQ],
|
||||
['fraud rules', seedFraudRules],
|
||||
];
|
||||
|
||||
console.log('\n✅ Seed complete!\n');
|
||||
let failed = 0;
|
||||
for (const [name, step] of steps) {
|
||||
if (!(await runStep(name, step))) failed++;
|
||||
}
|
||||
|
||||
if (failed > 0) {
|
||||
console.warn(`\n⚠️ Seed finished with ${failed}/${steps.length} step(s) failed (see logs above).\n`);
|
||||
} else {
|
||||
console.log('\n✅ Seed complete!\n');
|
||||
}
|
||||
console.log('🔑 System Users:');
|
||||
console.log(' Admin: admin@edr-platform.com / admin123');
|
||||
console.log(' Passenger: kelemu@email.com / password123');
|
||||
@@ -581,8 +605,10 @@ async function main() {
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error('❌ Seed failed:', e);
|
||||
process.exit(1);
|
||||
// Intentionally do NOT process.exit(1): the docker entrypoint runs under
|
||||
// `set -e`, so a non-zero exit here would abort container startup and the
|
||||
// API would never boot. Log and exit cleanly instead.
|
||||
console.error('❌ Seed crashed unexpectedly — continuing so the API can start:', e);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ConfigModule } from '@nestjs/config';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
import { PrismaModule } from './common/prisma.module';
|
||||
import { AuditModule } from './common/audit.module';
|
||||
import { I18nModule } from './common/i18n/i18n.module';
|
||||
import { IamModule } from './common/iam.module';
|
||||
import { LocaleMiddleware } from './common/i18n/locale.middleware';
|
||||
@@ -38,6 +39,7 @@ import { FraudModule } from './modules/fraud/fraud.module';
|
||||
import { SeatClassesModule } from './modules/seat-classes/seat-classes.module';
|
||||
import { FareEngineModule } from './modules/fare-engine/fare-engine.module';
|
||||
import { VerifaydaModule } from './modules/verifayda/verifayda.module';
|
||||
import { AuditModuleFeature } from './modules/audit/audit.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -57,6 +59,7 @@ import { VerifaydaModule } from './modules/verifayda/verifayda.module';
|
||||
ScheduleModule.forRoot(),
|
||||
EventEmitterModule.forRoot(),
|
||||
PrismaModule,
|
||||
AuditModule,
|
||||
I18nModule,
|
||||
IamModule,
|
||||
AuthModule,
|
||||
@@ -83,6 +86,7 @@ import { VerifaydaModule } from './modules/verifayda/verifayda.module';
|
||||
SeatClassesModule,
|
||||
FareEngineModule,
|
||||
VerifaydaModule,
|
||||
AuditModuleFeature,
|
||||
],
|
||||
})
|
||||
export class AppModule implements NestModule {
|
||||
|
||||
10
apps/edr-passenger-api/src/common/audit.module.ts
Normal file
10
apps/edr-passenger-api/src/common/audit.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from './prisma.module';
|
||||
import { AuditService } from './audit.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
providers: [AuditService],
|
||||
exports: [AuditService],
|
||||
})
|
||||
export class AuditModule {}
|
||||
92
apps/edr-passenger-api/src/common/audit.service.ts
Normal file
92
apps/edr-passenger-api/src/common/audit.service.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { Injectable, Inject, Optional } from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuditService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
@Optional() @Inject(REQUEST) private request?: any,
|
||||
) {}
|
||||
|
||||
async log(input: {
|
||||
userId?: string;
|
||||
action: 'CREATE' | 'UPDATE' | 'DELETE' | 'LOGIN' | 'LOGOUT' | 'VERIFY' | string;
|
||||
entityType: string;
|
||||
entityId?: string;
|
||||
oldData?: any;
|
||||
newData?: any;
|
||||
}) {
|
||||
try {
|
||||
const ipAddress = this.getIpAddress();
|
||||
const userAgent = this.getUserAgent();
|
||||
|
||||
await this.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: input.userId,
|
||||
action: input.action,
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
oldData: input.oldData,
|
||||
newData: input.newData,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to log audit event:', error);
|
||||
// Don't throw - audit logging should not break main operations
|
||||
}
|
||||
}
|
||||
|
||||
private getIpAddress(): string {
|
||||
if (!this.request) return '';
|
||||
|
||||
return (
|
||||
this.request.headers['x-forwarded-for']?.split(',')[0].trim() ||
|
||||
this.request.headers['x-real-ip'] ||
|
||||
this.request.connection?.remoteAddress ||
|
||||
this.request.socket?.remoteAddress ||
|
||||
this.request.ip ||
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
private getUserAgent(): string {
|
||||
return this.request?.headers?.['user-agent'] || '';
|
||||
}
|
||||
|
||||
async getLogs(filters: any = {}) {
|
||||
const where: any = {};
|
||||
|
||||
if (filters.search) {
|
||||
where.OR = [
|
||||
{ entityId: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ user: { email: { contains: filters.search, mode: 'insensitive' } } },
|
||||
{ user: { fullName: { contains: filters.search, mode: 'insensitive' } } },
|
||||
];
|
||||
}
|
||||
|
||||
if (filters.action) {
|
||||
where.action = filters.action;
|
||||
}
|
||||
|
||||
if (filters.entityType) {
|
||||
where.entityType = filters.entityType;
|
||||
}
|
||||
|
||||
return this.prisma.auditLog.findMany({
|
||||
where,
|
||||
include: { user: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 500, // Limit to last 500 logs
|
||||
});
|
||||
}
|
||||
|
||||
async getLog(id: string) {
|
||||
return this.prisma.auditLog.findUnique({
|
||||
where: { id },
|
||||
include: { user: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
Logger,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import { Request } from "express";
|
||||
|
||||
/**
|
||||
* Shared-secret guard for endpoints only the payment microservice may call
|
||||
* (e.g. /internal/payments/mark-paid). The secret is the same SERVICE_AUTH_TOKEN the
|
||||
* payment service enforces on its own internal surface. A forged mark-paid must not be able
|
||||
* to confirm a booking without a real payment.
|
||||
* TODO: integrate @tria-plc IAM / mTLS as the long-term mechanism.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ServiceAuthGuard implements CanActivate {
|
||||
private readonly logger = new Logger(ServiceAuthGuard.name);
|
||||
private readonly token = process.env.SERVICE_AUTH_TOKEN ?? "";
|
||||
private warned = false;
|
||||
|
||||
constructor() {
|
||||
if (!this.token && process.env.NODE_ENV === "production") {
|
||||
throw new Error("SERVICE_AUTH_TOKEN must be set in production");
|
||||
}
|
||||
}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
if (!this.token) {
|
||||
if (!this.warned) {
|
||||
this.logger.warn(
|
||||
"SERVICE_AUTH_TOKEN unset — internal endpoints are UNGUARDED (dev only)",
|
||||
);
|
||||
this.warned = true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const header = request.headers["x-service-token"];
|
||||
const bearer = request.headers.authorization?.replace(/^Bearer\s+/i, "");
|
||||
const presented =
|
||||
(Array.isArray(header) ? header[0] : header) ?? bearer ?? "";
|
||||
|
||||
const expected = Buffer.from(this.token);
|
||||
const actual = Buffer.from(presented);
|
||||
const valid =
|
||||
expected.length === actual.length && timingSafeEqual(expected, actual);
|
||||
if (!valid) throw new UnauthorizedException("Invalid service token");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,27 @@
|
||||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export default registerAs('waafi', () => ({
|
||||
baseUrl: process.env.WAAFI_BASE_URL ?? 'https://api.waafipay.net',
|
||||
// `/asm` is appended in the provider; use sandbox by default, switch to
|
||||
// https://api.waafipay.net in production.
|
||||
baseUrl: process.env.WAAFI_BASE_URL ?? 'https://sandbox.waafipay.net',
|
||||
// HPP credentials (Hosted Payment Page family).
|
||||
merchantUid: process.env.WAAFI_MERCHANT_UID ?? '',
|
||||
apiUserId: process.env.WAAFI_API_USER_ID ?? '',
|
||||
apiKey: process.env.WAAFI_API_KEY ?? '',
|
||||
storeId: process.env.WAAFI_STORE_ID ?? '',
|
||||
hppKey: process.env.WAAFI_HPP_KEY ?? '',
|
||||
// HMAC secret returned once by WEBHOOK_REGISTER; verifies inbound webhooks.
|
||||
webhookSecret: process.env.WAAFI_WEBHOOK_SECRET ?? '',
|
||||
// Wallet payment method (EVC/ZAAD/Sahal) — MWALLET_ACCOUNT requires the payer phone up front.
|
||||
paymentMethod: process.env.WAAFI_PAYMENT_METHOD ?? 'MWALLET_ACCOUNT',
|
||||
// Waafi has no ETB; when set this overrides the booking currency (USD/DJF/SLSH).
|
||||
currency: process.env.WAAFI_CURRENCY ?? 'DJF',
|
||||
// Browser redirect targets after the hosted page completes/fails (UX only; webhook is source of truth).
|
||||
successUrl: process.env.WAAFI_HPP_SUCCESS_URL ?? '',
|
||||
failureUrl: process.env.WAAFI_HPP_FAILURE_URL ?? '',
|
||||
// Callback data format: 1 = POST, 2 = GET, 4 = Result Token.
|
||||
respDataFormat: Number(process.env.WAAFI_HPP_RESP_FORMAT ?? '1'),
|
||||
// Registered webhook URL (reference only; registration is performed out-of-band).
|
||||
notifyUrl: process.env.WAAFI_NOTIFY_URL ?? '',
|
||||
returnUrl: process.env.WAAFI_RETURN_URL ?? '',
|
||||
// DEV ONLY: disable TLS cert verification. The Waafi sandbox serves a *.waafi.com cert that
|
||||
// does not match sandbox.waafipay.net (ERR_TLS_CERT_ALTNAME_INVALID). Never enable in prod.
|
||||
insecureTls: process.env.WAAFI_INSECURE_TLS === 'true',
|
||||
}));
|
||||
|
||||
@@ -8,7 +8,9 @@ import { ResponseTransformInterceptor } from "./common/interceptors/response-tra
|
||||
import { SessionActivityInterceptor } from "./common/interceptors/session-activity.interceptor";
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
// rawBody: true buffers the unparsed request body onto req.rawBody so webhook handlers
|
||||
// (e.g. Waafi HMAC verification) can sign over the exact bytes the provider signed.
|
||||
const app = await NestFactory.create(AppModule, { rawBody: true });
|
||||
|
||||
app.enableCors({
|
||||
origin: [
|
||||
|
||||
41
apps/edr-passenger-api/src/modules/audit/audit.controller.ts
Normal file
41
apps/edr-passenger-api/src/modules/audit/audit.controller.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||
import { AuditService } from '../../common/audit.service';
|
||||
import { IamGuard } from '../../common/iam-adapter';
|
||||
|
||||
@ApiTags('Audit')
|
||||
@Controller('audit')
|
||||
@UseGuards(IamGuard)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
export class AuditController {
|
||||
constructor(private auditService: AuditService) {}
|
||||
|
||||
@Get('logs')
|
||||
@ApiOperation({
|
||||
summary: 'Get audit logs',
|
||||
description: 'Retrieve system audit logs with optional filtering',
|
||||
})
|
||||
@ApiQuery({ name: 'search', required: false, description: 'Search by user email or entity ID' })
|
||||
@ApiQuery({ name: 'action', required: false, description: 'Filter by action (CREATE, UPDATE, DELETE, etc.)' })
|
||||
@ApiQuery({ name: 'entityType', required: false, description: 'Filter by entity type (Booking, Station, etc.)' })
|
||||
async getLogs(
|
||||
@Query('search') search?: string,
|
||||
@Query('action') action?: string,
|
||||
@Query('entityType') entityType?: string,
|
||||
) {
|
||||
const filters = {
|
||||
search: search || undefined,
|
||||
action: action || undefined,
|
||||
entityType: entityType || undefined,
|
||||
};
|
||||
|
||||
const items = await this.auditService.getLogs(filters);
|
||||
return { items };
|
||||
}
|
||||
|
||||
@Get('logs/:id')
|
||||
@ApiOperation({ summary: 'Get audit log by ID' })
|
||||
async getLog(@Param('id') id: string) {
|
||||
return this.auditService.getLog(id);
|
||||
}
|
||||
}
|
||||
10
apps/edr-passenger-api/src/modules/audit/audit.module.ts
Normal file
10
apps/edr-passenger-api/src/modules/audit/audit.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { AuditModule } from '../../common/audit.module';
|
||||
import { AuditController } from './audit.controller';
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule, HttpModule],
|
||||
controllers: [AuditController],
|
||||
})
|
||||
export class AuditModuleFeature {}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { AuditModule } from '../../common/audit.module';
|
||||
import { BookingsController } from './bookings.controller';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { GuestBookingService } from './guest-booking.service';
|
||||
@@ -8,7 +9,7 @@ import { VerifaydaModule } from '../verifayda/verifayda.module';
|
||||
import { CurrencyModule } from '../currency/currency.module';
|
||||
|
||||
@Module({
|
||||
imports: [SeatsModule, VerifaydaModule, CurrencyModule, HttpModule],
|
||||
imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, HttpModule],
|
||||
controllers: [BookingsController],
|
||||
providers: [BookingsService, GuestBookingService],
|
||||
exports: [BookingsService, GuestBookingService]
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
|
||||
import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payments.dto";
|
||||
import { PaymentsService } from "./payments.service";
|
||||
|
||||
/**
|
||||
* Consumer side of the payment microservice's outbox relay (docs/payment-service §7.3).
|
||||
* Only the payment service may call this (shared service token). Idempotent by design:
|
||||
* the relay delivers at-least-once, so duplicates must be harmless. Becomes a queue
|
||||
* consumer when RabbitMQ lands — the handler logic is transport-agnostic.
|
||||
*/
|
||||
@ApiTags("Internal Payments")
|
||||
@UseGuards(ServiceAuthGuard)
|
||||
@Controller("internal/payments")
|
||||
export class InternalPaymentsController {
|
||||
constructor(private readonly paymentsService: PaymentsService) {}
|
||||
|
||||
@Post("mark-paid")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Apply a payment.succeeded/payment.failed event from the payment service (idempotent)",
|
||||
})
|
||||
async markPaid(@Body() event: PaymentEventDto): Promise<MarkPaidResponseDto> {
|
||||
return this.paymentsService.handlePaymentEvent(event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
IsEnum,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsISO8601,
|
||||
IsOptional,
|
||||
IsPositive,
|
||||
IsString,
|
||||
IsUUID,
|
||||
} from "class-validator";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import {
|
||||
PaymentEventType,
|
||||
PaymentReferenceType,
|
||||
PaymentService,
|
||||
ProviderMethod,
|
||||
} from "@edr/types";
|
||||
|
||||
/**
|
||||
* Wire shape of the `PaymentEvent` envelope (@edr/types) delivered by the payment
|
||||
* microservice's outbox relay. Delivery is at-least-once — the consumer is idempotent.
|
||||
*/
|
||||
export class PaymentEventDto {
|
||||
@ApiProperty({ enum: [1] }) @IsIn([1]) version!: 1;
|
||||
@ApiProperty() @IsUUID() eventId!: string;
|
||||
@ApiProperty({ enum: ["payment.succeeded", "payment.failed"] })
|
||||
@IsIn(["payment.succeeded", "payment.failed"])
|
||||
eventType!: PaymentEventType;
|
||||
|
||||
@ApiProperty() @IsISO8601() occurredAt!: string;
|
||||
@ApiProperty({ enum: PaymentService })
|
||||
@IsEnum(PaymentService)
|
||||
service!: string;
|
||||
@ApiProperty() @IsUUID() intentId!: string;
|
||||
@ApiProperty({ enum: PaymentReferenceType })
|
||||
@IsEnum(PaymentReferenceType)
|
||||
referenceType!: string;
|
||||
|
||||
@ApiProperty() @IsString() referenceId!: string;
|
||||
@ApiProperty() @IsString() merchantOrderId!: string;
|
||||
@ApiProperty({ enum: ProviderMethod })
|
||||
@IsEnum(ProviderMethod)
|
||||
provider!: string;
|
||||
@ApiProperty() @IsInt() @IsPositive() amountMinor!: number;
|
||||
@ApiProperty() @IsString() currency!: string;
|
||||
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() providerTxnId?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsISO8601() paidAt?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() failureCode?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() failureMessage?: string;
|
||||
}
|
||||
|
||||
export class MarkPaidResponseDto {
|
||||
@ApiProperty() processed!: boolean;
|
||||
@ApiPropertyOptional() alreadyFinalized?: boolean;
|
||||
@ApiPropertyOptional() reason?: string;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { BadGatewayException, Injectable, Logger } from "@nestjs/common";
|
||||
import { HttpService } from "@nestjs/axios";
|
||||
import { AxiosError } from "axios";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import {
|
||||
InitiatePaymentRequest,
|
||||
PaymentIntentSnapshot,
|
||||
PaymentReferenceType,
|
||||
PaymentService,
|
||||
} from "@edr/types";
|
||||
|
||||
/**
|
||||
* Thin HTTP client for the payment microservice (apps/edr-payment-api) — the passenger app's
|
||||
* side of the Phase 6 cutover (docs/payment-service §10). Domain validation stays here;
|
||||
* provider calls, intents, and webhooks live in the payment service.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PaymentClientService {
|
||||
private readonly logger = new Logger(PaymentClientService.name);
|
||||
private readonly baseUrl = (
|
||||
process.env.PAYMENT_API_URL ?? "http://localhost:3003"
|
||||
).replace(/\/$/, "");
|
||||
private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? "";
|
||||
|
||||
constructor(private readonly http: HttpService) {}
|
||||
|
||||
/** POST /payments/initiate — idempotent per (service, referenceType, referenceId). */
|
||||
async initiate(
|
||||
request: InitiatePaymentRequest,
|
||||
): Promise<PaymentIntentSnapshot> {
|
||||
return this.call("POST", "/payments/initiate", request);
|
||||
}
|
||||
|
||||
/** GET /payments/intents?… — active intent by domain reference; null when none exists. */
|
||||
async getIntentByReference(
|
||||
referenceType: PaymentReferenceType,
|
||||
referenceId: string,
|
||||
): Promise<PaymentIntentSnapshot | null> {
|
||||
const query = new URLSearchParams({
|
||||
service: PaymentService.PASSENGER,
|
||||
referenceType,
|
||||
referenceId,
|
||||
});
|
||||
try {
|
||||
return await this.call("GET", `/payments/intents?${query.toString()}`);
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError && err.response?.status === 404)
|
||||
return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async call<T>(
|
||||
method: "GET" | "POST",
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<T> {
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
this.logger.log("=====================================================================");
|
||||
this.logger.log(`URL ${url}`);
|
||||
this.logger.log("=====================================================================");
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.http.request<T>({
|
||||
method,
|
||||
url,
|
||||
data: body,
|
||||
headers: this.serviceToken
|
||||
? { "x-service-token": this.serviceToken }
|
||||
: {},
|
||||
}),
|
||||
);
|
||||
return response.data;
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError && err.response) {
|
||||
// 4xx/5xx from the payment service: propagate 404 to callers that handle it;
|
||||
// everything else is a gateway-level failure from the client's perspective.
|
||||
if (err.response.status === 404) throw err;
|
||||
const detail =
|
||||
(err.response.data as { message?: string | string[] })?.message ??
|
||||
err.message;
|
||||
this.logger.error(
|
||||
`payment service ${method} ${path} → ${err.response.status}: ${detail}`,
|
||||
);
|
||||
throw new BadGatewayException(`Payment service error: ${detail}`);
|
||||
}
|
||||
const message =
|
||||
err instanceof Error && err.message ? err.message : String(err);
|
||||
this.logger.error(
|
||||
`payment service unreachable (${method} ${path}): ${message}`,
|
||||
);
|
||||
throw new BadGatewayException("Payment service unreachable");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,50 @@
|
||||
export interface GatewayResult { success: boolean; providerRef: string; clientAction?: { type: string; url?: string }; }
|
||||
|
||||
export async function telebirrAdapter(_a: number, ref: string): Promise<GatewayResult> {
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
return { success: true, providerRef: `TB-${ref}-${Date.now()}`, clientAction: { type: 'REDIRECT', url: `https://telebirr.sandbox.com/pay/${ref}` } };
|
||||
export interface GatewayResult {
|
||||
success: boolean;
|
||||
providerRef: string;
|
||||
clientAction?: { type: string; url?: string };
|
||||
}
|
||||
|
||||
export async function telebirrAdapter(
|
||||
_a: number,
|
||||
ref: string,
|
||||
): Promise<GatewayResult> {
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
return {
|
||||
success: true,
|
||||
providerRef: `TB-${ref}-${Date.now()}`,
|
||||
clientAction: {
|
||||
type: "REDIRECT",
|
||||
url: `https://telebirr.sandbox.com/pay/${ref}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
export async function cbeBirrAdapter(
|
||||
_a: number,
|
||||
ref: string,
|
||||
): Promise<GatewayResult> {
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
return { success: true, providerRef: `CBE-${ref}-${Date.now()}` };
|
||||
}
|
||||
export async function eBirrAdapter(
|
||||
_a: number,
|
||||
ref: string,
|
||||
): Promise<GatewayResult> {
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
return { success: true, providerRef: `EB-${ref}-${Date.now()}` };
|
||||
}
|
||||
export async function cardAdapter(
|
||||
_a: number,
|
||||
ref: string,
|
||||
): Promise<GatewayResult> {
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
return {
|
||||
success: !ref.startsWith("FAIL"),
|
||||
providerRef: `CARD-${ref}-${Date.now()}`,
|
||||
};
|
||||
}
|
||||
export async function walletAdapter(
|
||||
amount: number,
|
||||
balance: number,
|
||||
): Promise<GatewayResult> {
|
||||
return { success: balance >= amount, providerRef: `WALLET-${Date.now()}` };
|
||||
}
|
||||
export async function cbeBirrAdapter(_a: number, ref: string): Promise<GatewayResult> { await new Promise((r) => setTimeout(r, 150)); return { success: true, providerRef: `CBE-${ref}-${Date.now()}` }; }
|
||||
export async function eBirrAdapter(_a: number, ref: string): Promise<GatewayResult> { await new Promise((r) => setTimeout(r, 150)); return { success: true, providerRef: `EB-${ref}-${Date.now()}` }; }
|
||||
export async function cardAdapter(_a: number, ref: string): Promise<GatewayResult> { await new Promise((r) => setTimeout(r, 150)); return { success: !ref.startsWith('FAIL'), providerRef: `CARD-${ref}-${Date.now()}` }; }
|
||||
export async function walletAdapter(amount: number, balance: number): Promise<GatewayResult> { return { success: balance >= amount, providerRef: `WALLET-${Date.now()}` }; }
|
||||
|
||||
@@ -1,34 +1,59 @@
|
||||
import { Body, Controller, Get, HttpStatus, Param, Post, Query, Res, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiOkResponse, ApiProduces } from '@nestjs/swagger';
|
||||
import { Response } from 'express';
|
||||
import { PaymentsService } from './payments.service';
|
||||
import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto, PaymentRegionEnum, SupportedPaymentMethodDto, PaymentMethodTypeEnum, PaymentPlatformDto } from './payments.dto';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { RolesGuard } from '../../common/roles.guard';
|
||||
import { Roles } from '../../common/roles.decorator';
|
||||
import { UserRole } from '@prisma/client';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiBearerAuth,
|
||||
ApiQuery,
|
||||
ApiOkResponse,
|
||||
ApiProduces,
|
||||
} from "@nestjs/swagger";
|
||||
import { Response } from "express";
|
||||
import { PaymentsService } from "./payments.service";
|
||||
import {
|
||||
InitiatePaymentDto,
|
||||
RefundDto,
|
||||
AddPaymentMethodDto,
|
||||
PaymentRegionEnum,
|
||||
SupportedPaymentMethodDto,
|
||||
PaymentMethodTypeEnum,
|
||||
PaymentPlatformDto,
|
||||
} from "./payments.dto";
|
||||
import { JwtGuard } from "../../common/jwt.guard";
|
||||
import { RolesGuard } from "../../common/roles.guard";
|
||||
import { Roles } from "../../common/roles.decorator";
|
||||
import { UserRole } from "@prisma/client";
|
||||
|
||||
@ApiTags('Payment')
|
||||
@Controller('payments')
|
||||
@ApiTags("Payment")
|
||||
@Controller("payments")
|
||||
export class PaymentsController {
|
||||
constructor(private service: PaymentsService) {}
|
||||
|
||||
@Get('all')
|
||||
@Get("all")
|
||||
@UseGuards(JwtGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.SUPERVISOR, UserRole.STAFF)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Get all payments with filters (staff/admin only)' })
|
||||
@ApiQuery({ name: 'search', required: false })
|
||||
@ApiQuery({ name: 'status', required: false })
|
||||
@ApiQuery({ name: 'method', required: false })
|
||||
@ApiQuery({ name: 'page', required: false })
|
||||
@ApiQuery({ name: 'pageSize', required: false })
|
||||
@ApiBearerAuth("JWT-auth")
|
||||
@ApiOperation({ summary: "Get all payments with filters (staff/admin only)" })
|
||||
@ApiQuery({ name: "search", required: false })
|
||||
@ApiQuery({ name: "status", required: false })
|
||||
@ApiQuery({ name: "method", required: false })
|
||||
@ApiQuery({ name: "page", required: false })
|
||||
@ApiQuery({ name: "pageSize", required: false })
|
||||
async getAll(
|
||||
@Query('search') search?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('method') method?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
@Query("search") search?: string,
|
||||
@Query("status") status?: string,
|
||||
@Query("method") method?: string,
|
||||
@Query("page") page?: string,
|
||||
@Query("pageSize") pageSize?: string,
|
||||
) {
|
||||
return this.service.getAll({
|
||||
search,
|
||||
@@ -38,80 +63,121 @@ export class PaymentsController {
|
||||
pageSize: pageSize ? parseInt(pageSize) : 10,
|
||||
});
|
||||
}
|
||||
|
||||
@Post('initiate')
|
||||
@ApiOperation({
|
||||
summary: 'Initiate payment with nationality-based payment methods',
|
||||
description: `Initiates payment for a booking with support for multiple payment providers:\n\n**Ethiopian Payment Methods:**\n- TELEBIRR - Ethiopia's leading mobile money\n- CBE_BIRR - Commercial Bank of Ethiopia\n- EBIRR - Electronic payment gateway\n\n**Djiboutian Payment Methods:**\n- WAAFI - Djibouti's mobile money service\n\n**International Payment Methods:**\n- CARD - Visa, Mastercard\n- WALLET - Internal wallet balance\n\n**Multi-Currency:**\n- All transactions processed in ETB\n- Display amounts in ETB, DJF, or USD\n- Real-time exchange rate conversion`
|
||||
|
||||
@Post("initiate")
|
||||
@ApiOperation({
|
||||
summary: "Initiate payment with nationality-based payment methods",
|
||||
description: `Initiates payment for a booking with support for multiple payment providers:\n\n**Ethiopian Payment Methods:**\n- TELEBIRR - Ethiopia's leading mobile money\n- CBE_BIRR - Commercial Bank of Ethiopia\n- EBIRR - Electronic payment gateway\n\n**Djiboutian Payment Methods:**\n- WAAFI - Djibouti's mobile money service\n\n**International Payment Methods:**\n- CARD - Visa, Mastercard\n- WALLET - Internal wallet balance\n\n**Multi-Currency:**\n- All transactions processed in ETB\n- Display amounts in ETB, DJF, or USD\n- Real-time exchange rate conversion`,
|
||||
})
|
||||
initiatePayment(@Body() dto: InitiatePaymentDto) { return this.service.initiatePayment(dto); }
|
||||
|
||||
@Get('intents/:bookingId')
|
||||
@ApiOperation({ summary: 'Get payment intent status for a booking' })
|
||||
getIntent(@Param('bookingId') bookingId: string) { return this.service.getIntentByBookingId(bookingId); }
|
||||
|
||||
@Post('refund')
|
||||
initiatePayment(@Body() dto: InitiatePaymentDto) {
|
||||
return this.service.initiatePayment(dto);
|
||||
}
|
||||
|
||||
@Get("intents/:bookingId")
|
||||
@ApiOperation({ summary: "Get payment intent status for a booking" })
|
||||
getIntent(@Param("bookingId") bookingId: string) {
|
||||
return this.service.getIntentByBookingId(bookingId);
|
||||
}
|
||||
|
||||
@Post("refund")
|
||||
@UseGuards(JwtGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.STAFF, UserRole.AGENT)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Refund a confirmed booking (staff/agent only)' })
|
||||
refund(@Body() dto: RefundDto) { return this.service.refund(dto); }
|
||||
@ApiBearerAuth("JWT-auth")
|
||||
@ApiOperation({ summary: "Refund a confirmed booking (staff/agent only)" })
|
||||
refund(@Body() dto: RefundDto) {
|
||||
return this.service.refund(dto);
|
||||
}
|
||||
|
||||
@Post('methods')
|
||||
@Post("methods")
|
||||
@UseGuards(JwtGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.STAFF)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Add a payment system to the platform catalog (admin only)' })
|
||||
addMethod(@Body() dto: AddPaymentMethodDto) { return this.service.addPaymentMethod(dto); }
|
||||
|
||||
@Get('methods')
|
||||
@ApiBearerAuth("JWT-auth")
|
||||
@ApiOperation({
|
||||
summary: 'List payment systems supported by the platform',
|
||||
description: 'Returns the global catalog of accepted payment systems. Not user-specific. Optionally filter by region to match a passenger\'s nationality.',
|
||||
summary: "Add a payment system to the platform catalog (admin only)",
|
||||
})
|
||||
@ApiQuery({ name: 'region', enum: PaymentRegionEnum, required: false })
|
||||
@ApiOkResponse({ type: [SupportedPaymentMethodDto] })
|
||||
getMethods(@Query('region') region?: PaymentRegionEnum) { return this.service.getSupportedPaymentMethods(region); }
|
||||
addMethod(@Body() dto: AddPaymentMethodDto) {
|
||||
return this.service.addPaymentMethod(dto);
|
||||
}
|
||||
|
||||
@Get('checkout')
|
||||
@Get("methods")
|
||||
@ApiOperation({
|
||||
summary: 'Browser checkout redirect',
|
||||
description: 'Initiates payment and returns an HTML page that auto-redirects the browser to the provider checkout URL. Designed to be opened directly in a browser tab.',
|
||||
summary: "List payment systems supported by the platform",
|
||||
description:
|
||||
"Returns the global catalog of accepted payment systems. Not user-specific. Optionally filter by region to match a passenger's nationality.",
|
||||
})
|
||||
@ApiQuery({ name: 'bookingId', required: true })
|
||||
@ApiQuery({ name: 'method', enum: PaymentMethodTypeEnum, required: true })
|
||||
@ApiQuery({ name: 'platform', enum: ['web', 'mobile'], required: false })
|
||||
@ApiProduces('text/html')
|
||||
@ApiQuery({ name: "region", enum: PaymentRegionEnum, required: false })
|
||||
@ApiOkResponse({ type: [SupportedPaymentMethodDto] })
|
||||
getMethods(@Query("region") region?: PaymentRegionEnum) {
|
||||
return this.service.getSupportedPaymentMethods(region);
|
||||
}
|
||||
|
||||
@Get("checkout")
|
||||
@ApiOperation({
|
||||
summary: "Browser checkout redirect",
|
||||
description:
|
||||
"Initiates payment and returns an HTML page that auto-redirects the browser to the provider checkout URL. Designed to be opened 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',
|
||||
@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'));
|
||||
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'));
|
||||
return res
|
||||
.status(HttpStatus.BAD_REQUEST)
|
||||
.type("html")
|
||||
.send(
|
||||
this.buildErrorHtml("Missing or invalid query parameter: method"),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.service.initiatePayment({ bookingId, method, platform });
|
||||
const url = result.clientAction?.type === 'REDIRECT' ? result.clientAction.url : undefined;
|
||||
const result = await this.service.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.buildRedirectHtml(url));
|
||||
}
|
||||
|
||||
return res.status(HttpStatus.OK).type('html').send(this.buildStatusHtml(result.status, result.intentId));
|
||||
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));
|
||||
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, '"');
|
||||
const escaped = url.replace(/\"/g, """);
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
|
||||
@@ -1,36 +1,53 @@
|
||||
import { IsString, IsEnum, IsOptional, IsIn, IsBoolean, IsInt } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { PaymentIntentStatus } from '@prisma/client';
|
||||
import {
|
||||
IsString,
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsIn,
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
} from "class-validator";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { PaymentIntentStatus } from "@prisma/client";
|
||||
|
||||
export enum PaymentRegionEnum {
|
||||
ETHIOPIA = 'ETHIOPIA',
|
||||
DJIBOUTI = 'DJIBOUTI',
|
||||
INTERNATIONAL = 'INTERNATIONAL',
|
||||
GLOBAL = 'GLOBAL',
|
||||
ETHIOPIA = "ETHIOPIA",
|
||||
DJIBOUTI = "DJIBOUTI",
|
||||
INTERNATIONAL = "INTERNATIONAL",
|
||||
GLOBAL = "GLOBAL",
|
||||
}
|
||||
|
||||
export enum PaymentMethodTypeEnum {
|
||||
TELEBIRR = 'TELEBIRR', // Ethiopia
|
||||
CBE_BIRR = 'CBE_BIRR', // Ethiopia
|
||||
EBIRR = 'EBIRR', // Ethiopia
|
||||
WAAFI = 'WAAFI', // Djibouti
|
||||
CARD = 'CARD', // International
|
||||
WALLET = 'WALLET' // Internal
|
||||
export enum PaymentMethodTypeEnum {
|
||||
TELEBIRR = "TELEBIRR", // Ethiopia
|
||||
CBE_BIRR = "CBE_BIRR", // Ethiopia
|
||||
EBIRR = "EBIRR", // Ethiopia
|
||||
WAAFI = "WAAFI", // Djibouti
|
||||
CARD = "CARD", // International
|
||||
WALLET = "WALLET", // Internal
|
||||
}
|
||||
|
||||
export type PaymentPlatformDto = 'web' | 'mobile';
|
||||
export type PaymentPlatformDto = "web" | "mobile";
|
||||
|
||||
export class InitiatePaymentDto {
|
||||
@ApiProperty({ example: 'booking-uuid' }) @IsString() bookingId: string;
|
||||
@ApiProperty({ example: "booking-uuid" }) @IsString() bookingId: string;
|
||||
@ApiProperty({
|
||||
enum: PaymentMethodTypeEnum,
|
||||
description: 'Payment method: TELEBIRR/CBE_BIRR/EBIRR (Ethiopia), WAAFI (Djibouti), CARD (International), WALLET (Internal)',
|
||||
example: 'TELEBIRR'
|
||||
}) @IsEnum(PaymentMethodTypeEnum) method: PaymentMethodTypeEnum;
|
||||
@ApiPropertyOptional({ description: 'Saved payment method ID (optional)' }) @IsOptional() @IsString() paymentMethodId?: string;
|
||||
@ApiPropertyOptional({ enum: ['web', 'mobile'], default: 'web', description: 'Payment platform (web or mobile)' })
|
||||
description:
|
||||
"Payment method: TELEBIRR/CBE_BIRR/EBIRR (Ethiopia), WAAFI (Djibouti), CARD (International), WALLET (Internal)",
|
||||
example: "TELEBIRR",
|
||||
})
|
||||
@IsEnum(PaymentMethodTypeEnum)
|
||||
method: PaymentMethodTypeEnum;
|
||||
@ApiPropertyOptional({ description: "Saved payment method ID (optional)" })
|
||||
@IsOptional()
|
||||
@IsIn(['web', 'mobile'])
|
||||
@IsString()
|
||||
paymentMethodId?: string;
|
||||
@ApiPropertyOptional({
|
||||
enum: ["web", "mobile"],
|
||||
default: "web",
|
||||
description: "Payment platform (web or mobile)",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(["web", "mobile"])
|
||||
platform?: PaymentPlatformDto;
|
||||
}
|
||||
|
||||
@@ -40,42 +57,76 @@ export class RefundDto {
|
||||
}
|
||||
|
||||
export class AddPaymentMethodDto {
|
||||
@ApiProperty({ enum: PaymentMethodTypeEnum }) @IsEnum(PaymentMethodTypeEnum) type: PaymentMethodTypeEnum;
|
||||
@ApiProperty({ enum: PaymentMethodTypeEnum })
|
||||
@IsEnum(PaymentMethodTypeEnum)
|
||||
type: PaymentMethodTypeEnum;
|
||||
@ApiProperty() @IsString() displayName: string;
|
||||
@ApiProperty({ enum: PaymentRegionEnum }) @IsEnum(PaymentRegionEnum) region: PaymentRegionEnum;
|
||||
@ApiPropertyOptional({ example: 'ETB' }) @IsOptional() @IsString() currency?: string;
|
||||
@ApiProperty({ enum: PaymentRegionEnum })
|
||||
@IsEnum(PaymentRegionEnum)
|
||||
region: PaymentRegionEnum;
|
||||
@ApiPropertyOptional({ example: "ETB" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
currency?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() providerId?: string;
|
||||
@ApiPropertyOptional({ default: true }) @IsOptional() @IsBoolean() enabled?: boolean;
|
||||
@ApiPropertyOptional({ default: 0 }) @IsOptional() @IsInt() sortOrder?: number;
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enabled?: boolean;
|
||||
@ApiPropertyOptional({ default: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export class SupportedPaymentMethodDto {
|
||||
@ApiProperty({ enum: PaymentMethodTypeEnum }) type: PaymentMethodTypeEnum;
|
||||
@ApiProperty({ example: 'Telebirr' }) displayName: string;
|
||||
@ApiProperty({ example: "Telebirr" }) displayName: string;
|
||||
@ApiProperty({ enum: PaymentRegionEnum }) region: PaymentRegionEnum;
|
||||
@ApiProperty({ example: 'ETB', description: 'Settlement currency for this method' }) currency: string;
|
||||
@ApiProperty({ description: 'Whether the platform currently accepts this method' }) enabled: boolean;
|
||||
@ApiProperty({
|
||||
example: "ETB",
|
||||
description: "Settlement currency for this method",
|
||||
})
|
||||
currency: string;
|
||||
@ApiProperty({
|
||||
description: "Whether the platform currently accepts this method",
|
||||
})
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export class ClientActionDto {
|
||||
@ApiProperty({ enum: ['REDIRECT', 'LAUNCH_APP'] }) type: 'REDIRECT' | 'LAUNCH_APP';
|
||||
@ApiPropertyOptional({ description: 'Set when type=REDIRECT (web flow)' }) url?: string;
|
||||
@ApiPropertyOptional({ description: 'Set when type=LAUNCH_APP (mobile flow)' }) prepayId?: string;
|
||||
@ApiPropertyOptional({ description: 'Set when type=LAUNCH_APP (mobile flow)' }) receiveCode?: string;
|
||||
@ApiPropertyOptional({ description: 'Set when type=LAUNCH_APP (mobile flow)' }) shortCode?: string;
|
||||
@ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP"] }) type:
|
||||
| "REDIRECT"
|
||||
| "LAUNCH_APP";
|
||||
@ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" })
|
||||
url?: string;
|
||||
@ApiPropertyOptional({
|
||||
description: "Set when type=LAUNCH_APP (mobile flow)",
|
||||
})
|
||||
prepayId?: string;
|
||||
@ApiPropertyOptional({
|
||||
description: "Set when type=LAUNCH_APP (mobile flow)",
|
||||
})
|
||||
receiveCode?: string;
|
||||
@ApiPropertyOptional({
|
||||
description: "Set when type=LAUNCH_APP (mobile flow)",
|
||||
})
|
||||
shortCode?: string;
|
||||
}
|
||||
|
||||
export class InitiateResponseDto {
|
||||
@ApiProperty() intentId: string;
|
||||
@ApiProperty({ enum: PaymentIntentStatus }) status: PaymentIntentStatus;
|
||||
@ApiPropertyOptional({ type: ClientActionDto }) clientAction?: ClientActionDto;
|
||||
@ApiPropertyOptional({ type: ClientActionDto })
|
||||
clientAction?: ClientActionDto;
|
||||
@ApiPropertyOptional() merchantOrderId?: string;
|
||||
}
|
||||
|
||||
export class IntentStatusDto {
|
||||
@ApiProperty() intentId: string;
|
||||
@ApiProperty({ enum: PaymentIntentStatus }) status: PaymentIntentStatus;
|
||||
@ApiPropertyOptional({ type: ClientActionDto }) clientAction?: ClientActionDto;
|
||||
@ApiPropertyOptional({ type: ClientActionDto })
|
||||
clientAction?: ClientActionDto;
|
||||
@ApiPropertyOptional() merchantOrderId?: string;
|
||||
@ApiPropertyOptional() paidAt?: string;
|
||||
@ApiPropertyOptional() failureCode?: string;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
import { AppModule } from '../../app.module';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { INestApplication, ValidationPipe } from "@nestjs/common";
|
||||
import request from "supertest";
|
||||
import { AppModule } from "../../app.module";
|
||||
import { PrismaService } from "../../common/prisma.service";
|
||||
|
||||
describe('Payments E2E', () => {
|
||||
describe("Payments E2E", () => {
|
||||
let app: INestApplication;
|
||||
let prisma: PrismaService;
|
||||
let authToken: string;
|
||||
@@ -16,47 +16,123 @@ describe('Payments E2E', () => {
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({ transform: true, whitelist: true }),
|
||||
);
|
||||
await app.init();
|
||||
|
||||
prisma = app.get<PrismaService>(PrismaService);
|
||||
|
||||
const testUser = await prisma.user.create({
|
||||
data: { email: 'payment-test@example.com', phone: '+251911111112', fullName: 'Payment Test User', passwordHash: '$2b$10$abcdefghijklmnopqrstuvwxyz', role: 'PASSENGER' },
|
||||
data: {
|
||||
email: "payment-test@example.com",
|
||||
phone: "+251911111112",
|
||||
fullName: "Payment Test User",
|
||||
passwordHash: "$2b$10$abcdefghijklmnopqrstuvwxyz",
|
||||
role: "PASSENGER",
|
||||
},
|
||||
});
|
||||
|
||||
const passenger = await prisma.passenger.create({ data: { userId: testUser.id } });
|
||||
const passenger = await prisma.passenger.create({
|
||||
data: { userId: testUser.id },
|
||||
});
|
||||
|
||||
await prisma.walletAccount.create({ data: { passengerId: passenger.id, balanceMinor: 100000, currency: 'ETB' } });
|
||||
await prisma.walletAccount.create({
|
||||
data: {
|
||||
passengerId: passenger.id,
|
||||
balanceMinor: 100000,
|
||||
currency: "ETB",
|
||||
},
|
||||
});
|
||||
|
||||
authToken = 'mock-jwt-token';
|
||||
authToken = "mock-jwt-token";
|
||||
|
||||
const station1 = await prisma.station.create({ data: { code: 'TST1', name: 'Test Station 1', city: 'Test City', lat: 9.0, lng: 38.0 } });
|
||||
const station2 = await prisma.station.create({ data: { code: 'TST2', name: 'Test Station 2', city: 'Test City 2', lat: 9.5, lng: 38.5 } });
|
||||
const station1 = await prisma.station.create({
|
||||
data: {
|
||||
code: "TST1",
|
||||
name: "Test Station 1",
|
||||
city: "Test City",
|
||||
lat: 9.0,
|
||||
lng: 38.0,
|
||||
},
|
||||
});
|
||||
const station2 = await prisma.station.create({
|
||||
data: {
|
||||
code: "TST2",
|
||||
name: "Test Station 2",
|
||||
city: "Test City 2",
|
||||
lat: 9.5,
|
||||
lng: 38.5,
|
||||
},
|
||||
});
|
||||
|
||||
const train = await prisma.train.create({ data: { number: 'TEST-001', name: 'Test Train' } });
|
||||
const train = await prisma.train.create({
|
||||
data: { number: "TEST-001", name: "Test Train" },
|
||||
});
|
||||
|
||||
const schedule = await prisma.trainSchedule.create({
|
||||
data: { trainId: train.id, originStationId: station1.id, destinationStationId: station2.id, departureAt: new Date(Date.now() + 86400000), arrivalAt: new Date(Date.now() + 90000000), durationMinutes: 60 },
|
||||
data: {
|
||||
trainId: train.id,
|
||||
originStationId: station1.id,
|
||||
destinationStationId: station2.id,
|
||||
departureAt: new Date(Date.now() + 86400000),
|
||||
arrivalAt: new Date(Date.now() + 90000000),
|
||||
durationMinutes: 60,
|
||||
},
|
||||
});
|
||||
|
||||
const coachType = await prisma.coachType.create({ data: { name: 'Standard', code: 'STD' } });
|
||||
const coachType = await prisma.coachType.create({
|
||||
data: { name: "Standard", code: "STD" },
|
||||
});
|
||||
|
||||
const seatClass = await prisma.seatClass.create({
|
||||
data: { name: 'Economy Regular', description: 'Standard economy seating', baseFareMinor: 45000, isActive: true, coachTypeId: coachType.id },
|
||||
data: {
|
||||
name: "Economy Regular",
|
||||
description: "Standard economy seating",
|
||||
baseFareMinor: 45000,
|
||||
isActive: true,
|
||||
coachTypeId: coachType.id,
|
||||
},
|
||||
});
|
||||
|
||||
const coach = await prisma.coach.create({
|
||||
data: { coachTypeId: coachType.id, number: 'TEST-C1', arrangement: '2+2', capacity: 10, status: 'ACTIVE' },
|
||||
data: {
|
||||
coachTypeId: coachType.id,
|
||||
number: "TEST-C1",
|
||||
arrangement: "2+2",
|
||||
capacity: 10,
|
||||
status: "ACTIVE",
|
||||
},
|
||||
});
|
||||
|
||||
const seat = await prisma.seat.create({ data: { coachId: coach.id, row: 1, col: 'A', seatNumber: '1A', status: 'AVAILABLE' } });
|
||||
const seat = await prisma.seat.create({
|
||||
data: {
|
||||
coachId: coach.id,
|
||||
row: 1,
|
||||
col: "A",
|
||||
seatNumber: "1A",
|
||||
status: "AVAILABLE",
|
||||
},
|
||||
});
|
||||
|
||||
const booking = await prisma.booking.create({
|
||||
data: { bookingRef: 'TEST-BOOK-001', passengerId: passenger.id, scheduleId: schedule.id, status: 'PENDING_PAYMENT', totalMinor: 50000, currency: 'ETB' },
|
||||
data: {
|
||||
bookingRef: "TEST-BOOK-001",
|
||||
passengerId: passenger.id,
|
||||
scheduleId: schedule.id,
|
||||
status: "PENDING_PAYMENT",
|
||||
totalMinor: 50000,
|
||||
currency: "ETB",
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.bookingSeat.create({ data: { bookingId: booking.id, seatId: seat.id, passengerName: 'Test Passenger' } });
|
||||
await prisma.bookingSeat.create({
|
||||
data: {
|
||||
bookingId: booking.id,
|
||||
seatId: seat.id,
|
||||
passengerName: "Test Passenger",
|
||||
},
|
||||
});
|
||||
|
||||
bookingId = booking.id;
|
||||
});
|
||||
@@ -71,89 +147,60 @@ describe('Payments E2E', () => {
|
||||
prisma.coach.deleteMany(),
|
||||
prisma.trainSchedule.deleteMany(),
|
||||
prisma.train.deleteMany(),
|
||||
prisma.station.deleteMany({ where: { code: { in: ['TST1', 'TST2'] } } }),
|
||||
prisma.station.deleteMany({ where: { code: { in: ["TST1", "TST2"] } } }),
|
||||
prisma.walletLedgerEntry.deleteMany(),
|
||||
prisma.walletAccount.deleteMany(),
|
||||
prisma.passenger.deleteMany(),
|
||||
prisma.user.deleteMany({ where: { email: 'payment-test@example.com' } }),
|
||||
prisma.user.deleteMany({ where: { email: "payment-test@example.com" } }),
|
||||
]);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe('POST /payments/initiate', () => {
|
||||
it('should initiate wallet payment successfully', async () => {
|
||||
describe("POST /payments/initiate", () => {
|
||||
it("should initiate wallet payment successfully", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/payments/initiate')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ bookingId, method: 'WALLET' })
|
||||
.post("/payments/initiate")
|
||||
.set("Authorization", `Bearer ${authToken}`)
|
||||
.send({ bookingId, method: "WALLET" })
|
||||
.expect(201);
|
||||
expect(response.body.intentId).toBeDefined();
|
||||
expect(response.body.status).toBe('SUCCEEDED');
|
||||
expect(response.body.status).toBe("SUCCEEDED");
|
||||
});
|
||||
|
||||
it('should return 400 for invalid payment method', async () => {
|
||||
it("should return 400 for invalid payment method", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/payments/initiate')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ bookingId, method: 'INVALID_METHOD' })
|
||||
.post("/payments/initiate")
|
||||
.set("Authorization", `Bearer ${authToken}`)
|
||||
.send({ bookingId, method: "INVALID_METHOD" })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent booking', async () => {
|
||||
it("should return 404 for non-existent booking", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/payments/initiate')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ bookingId: 'non-existent-id', method: 'WALLET' })
|
||||
.post("/payments/initiate")
|
||||
.set("Authorization", `Bearer ${authToken}`)
|
||||
.send({ bookingId: "non-existent-id", method: "WALLET" })
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /payments/intents/:bookingId', () => {
|
||||
it('should get payment intent status', async () => {
|
||||
describe("GET /payments/intents/:bookingId", () => {
|
||||
it("should get payment intent status", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/payments/intents/${bookingId}`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.set("Authorization", `Bearer ${authToken}`)
|
||||
.expect(200);
|
||||
expect(response.body.intentId).toBeDefined();
|
||||
expect(response.body.status).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent intent', async () => {
|
||||
it("should return 404 for non-existent intent", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get('/payments/intents/non-existent-booking')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.get("/payments/intents/non-existent-booking")
|
||||
.set("Authorization", `Bearer ${authToken}`)
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Webhook endpoints', () => {
|
||||
it('should handle Telebirr webhook', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/payments/webhooks/telebirr')
|
||||
.send({ merch_order_id: 'TEST-ORDER-123', payment_order_id: 'PAY-123', trade_status: 'Completed', sign: 'mock-signature' })
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('should handle CBE Birr webhook', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/payments/webhooks/cbe-birr')
|
||||
.send({ merchantId: 'TEST-MERCHANT', merchantOrderId: 'TEST-ORDER-123', orderId: 'CBE-ORDER-123', status: 'SUCCESS', signature: 'mock-signature' })
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('should handle eBirr webhook', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/payments/webhooks/ebirr')
|
||||
.send({ merchantCode: 'TEST-MERCHANT', orderNo: 'TEST-ORDER-123', tradeStatus: 'TRADE_SUCCESS', timestamp: Date.now(), sign: 'mock-signature' })
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('should handle Card webhook', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/payments/webhooks/card')
|
||||
.set('stripe-signature', 'mock-signature')
|
||||
.send({ id: 'evt_123', type: 'payment_intent.succeeded', data: { object: { id: 'pi_123', status: 'succeeded', amount: 50000, currency: 'ETB', metadata: { merchantOrderId: 'TEST-ORDER-123', bookingRef: 'TEST-BOOK-001' } } }, created: Math.floor(Date.now() / 1000) })
|
||||
.expect(200);
|
||||
});
|
||||
});
|
||||
// Provider webhooks moved to the payment microservice (apps/edr-payment-api /webhooks/*).
|
||||
});
|
||||
|
||||
@@ -1,38 +1,25 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { PaymentsController } from './payments.controller';
|
||||
import { PaymentsService } from './payments.service';
|
||||
import { SeatsModule } from '../seats/seats.module';
|
||||
import { TicketsModule } from '../tickets/tickets.module';
|
||||
import {
|
||||
TelebirrProvider,
|
||||
CbeBirrProvider,
|
||||
EBirrProvider,
|
||||
CardProvider,
|
||||
WaafiProvider,
|
||||
} from '@edr/payment-providers';
|
||||
import { WebhooksController } from './webhooks/webhooks.controller';
|
||||
import { TelebirrWebhookService } from './webhooks/telebirr-webhook.service';
|
||||
import { CbeBirrWebhookService } from './webhooks/cbe-birr-webhook.service';
|
||||
import { EBirrWebhookService } from './webhooks/ebirr-webhook.service';
|
||||
import { CardWebhookService } from './webhooks/card-webhook.service';
|
||||
import { WaafiWebhookService } from './webhooks/waafi-webhook.service';
|
||||
import { Module } from "@nestjs/common";
|
||||
import { HttpModule } from "@nestjs/axios";
|
||||
import { PaymentsController } from "./payments.controller";
|
||||
import { PaymentsService } from "./payments.service";
|
||||
import { InternalPaymentsController } from "./internal-payments.controller";
|
||||
import { PaymentClientService } from "./payment-client.service";
|
||||
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
|
||||
import { SeatsModule } from "../seats/seats.module";
|
||||
import { TicketsModule } from "../tickets/tickets.module";
|
||||
|
||||
/**
|
||||
* Post-cutover (docs/payment-service phase 6): provider gateways and webhook handlers live in
|
||||
* apps/edr-payment-api. This module keeps domain validation, the WALLET flow, the payment
|
||||
* client, and the idempotent mark-paid consumer.
|
||||
*/
|
||||
@Module({
|
||||
imports: [SeatsModule, TicketsModule, HttpModule.register({ timeout: 10_000 })],
|
||||
controllers: [PaymentsController, WebhooksController],
|
||||
providers: [
|
||||
PaymentsService,
|
||||
TelebirrProvider,
|
||||
CbeBirrProvider,
|
||||
EBirrProvider,
|
||||
CardProvider,
|
||||
WaafiProvider,
|
||||
TelebirrWebhookService,
|
||||
CbeBirrWebhookService,
|
||||
EBirrWebhookService,
|
||||
CardWebhookService,
|
||||
WaafiWebhookService,
|
||||
imports: [
|
||||
SeatsModule,
|
||||
TicketsModule,
|
||||
HttpModule.register({ timeout: 10_000 }),
|
||||
],
|
||||
controllers: [PaymentsController, InternalPaymentsController],
|
||||
providers: [PaymentsService, PaymentClientService, ServiceAuthGuard],
|
||||
})
|
||||
export class PaymentsModule {}
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { PaymentsService } from './payments.service';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { SeatsService } from '../seats/seats.service';
|
||||
import { TicketsService } from '../tickets/tickets.service';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { PaymentsService } from "./payments.service";
|
||||
import { PaymentClientService } from "./payment-client.service";
|
||||
import { PrismaService } from "../../common/prisma.service";
|
||||
import { SeatsService } from "../seats/seats.service";
|
||||
import { TicketsService } from "../tickets/tickets.service";
|
||||
import { EventEmitter2 } from "@nestjs/event-emitter";
|
||||
import { PaymentIntentStatus, PaymentMethodType } from "@prisma/client";
|
||||
import { BadRequestException, NotFoundException } from "@nestjs/common";
|
||||
import {
|
||||
TelebirrProvider,
|
||||
CbeBirrProvider,
|
||||
EBirrProvider,
|
||||
CardProvider,
|
||||
} from '@edr/payment-providers';
|
||||
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
PaymentIntentSnapshot,
|
||||
PaymentReferenceType,
|
||||
PaymentService as PaymentServiceEnum,
|
||||
ProviderMethod,
|
||||
ProviderPaymentStatus,
|
||||
} from "@edr/types";
|
||||
|
||||
describe('PaymentsService', () => {
|
||||
describe("PaymentsService", () => {
|
||||
let service: PaymentsService;
|
||||
let prisma: PrismaService;
|
||||
let seatsService: SeatsService;
|
||||
@@ -62,29 +64,25 @@ describe('PaymentsService', () => {
|
||||
emit: jest.fn(),
|
||||
};
|
||||
|
||||
const mockTelebirrProvider = {
|
||||
method: PaymentMethodType.TELEBIRR,
|
||||
const mockPaymentClient = {
|
||||
initiate: jest.fn(),
|
||||
queryStatus: jest.fn(),
|
||||
getIntentByReference: jest.fn(),
|
||||
};
|
||||
|
||||
const mockCbeBirrProvider = {
|
||||
method: PaymentMethodType.CBE_BIRR,
|
||||
initiate: jest.fn(),
|
||||
queryStatus: jest.fn(),
|
||||
};
|
||||
|
||||
const mockEBirrProvider = {
|
||||
method: PaymentMethodType.EBIRR,
|
||||
initiate: jest.fn(),
|
||||
queryStatus: jest.fn(),
|
||||
};
|
||||
|
||||
const mockCardProvider = {
|
||||
method: PaymentMethodType.CARD,
|
||||
initiate: jest.fn(),
|
||||
queryStatus: jest.fn(),
|
||||
};
|
||||
const requiresActionSnapshot = (
|
||||
provider: ProviderMethod,
|
||||
): PaymentIntentSnapshot => ({
|
||||
intentId: "remote-intent-1",
|
||||
service: PaymentServiceEnum.PASSENGER,
|
||||
referenceType: PaymentReferenceType.BOOKING,
|
||||
referenceId: "booking-1",
|
||||
merchantOrderId: "PSG-MERCH-123",
|
||||
provider,
|
||||
status: ProviderPaymentStatus.REQUIRES_ACTION,
|
||||
amountMinor: 50000,
|
||||
currency: "ETB",
|
||||
clientAction: { type: "REDIRECT", url: "https://provider.example/pay" },
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
@@ -94,10 +92,7 @@ describe('PaymentsService', () => {
|
||||
{ provide: SeatsService, useValue: mockSeatsService },
|
||||
{ provide: TicketsService, useValue: mockTicketsService },
|
||||
{ provide: EventEmitter2, useValue: mockEventEmitter },
|
||||
{ provide: TelebirrProvider, useValue: mockTelebirrProvider },
|
||||
{ provide: CbeBirrProvider, useValue: mockCbeBirrProvider },
|
||||
{ provide: EBirrProvider, useValue: mockEBirrProvider },
|
||||
{ provide: CardProvider, useValue: mockCardProvider },
|
||||
{ provide: PaymentClientService, useValue: mockPaymentClient },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -108,221 +103,276 @@ describe('PaymentsService', () => {
|
||||
eventEmitter = module.get<EventEmitter2>(EventEmitter2);
|
||||
|
||||
jest.clearAllMocks();
|
||||
mockPaymentClient.getIntentByReference.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
describe('initiatePayment', () => {
|
||||
describe("initiatePayment", () => {
|
||||
const mockBooking = {
|
||||
id: 'booking-1',
|
||||
bookingRef: 'EDR123456',
|
||||
passengerId: 'passenger-1',
|
||||
id: "booking-1",
|
||||
bookingRef: "EDR123456",
|
||||
passengerId: "passenger-1",
|
||||
totalMinor: 50000,
|
||||
currency: 'ETB',
|
||||
status: 'PENDING_PAYMENT',
|
||||
seats: [{ id: 'seat-1', seatId: 'seat-id-1' }],
|
||||
currency: "ETB",
|
||||
status: "PENDING_PAYMENT",
|
||||
seats: [{ id: "seat-1", seatId: "seat-id-1" }],
|
||||
};
|
||||
|
||||
it('should throw NotFoundException if booking not found', async () => {
|
||||
it("should throw NotFoundException if booking not found", async () => {
|
||||
mockPrisma.booking.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.initiatePayment({
|
||||
bookingId: 'invalid',
|
||||
method: 'TELEBIRR' as any,
|
||||
bookingId: "invalid",
|
||||
method: "TELEBIRR" as any,
|
||||
}),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException if booking not payable', async () => {
|
||||
it("should throw BadRequestException if booking not payable", async () => {
|
||||
mockPrisma.booking.findUnique.mockResolvedValue({
|
||||
...mockBooking,
|
||||
status: 'CONFIRMED',
|
||||
status: "CONFIRMED",
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.initiatePayment({
|
||||
bookingId: 'booking-1',
|
||||
method: 'TELEBIRR' as any,
|
||||
bookingId: "booking-1",
|
||||
method: "TELEBIRR" as any,
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('should initiate Telebirr payment successfully', async () => {
|
||||
it("should initiate a provider payment through the payment microservice", async () => {
|
||||
mockPrisma.booking.findUnique.mockResolvedValue(mockBooking);
|
||||
mockPrisma.paymentIntent.findUnique.mockResolvedValue(null);
|
||||
mockTelebirrProvider.initiate.mockResolvedValue({
|
||||
providerOrderId: 'TB-ORDER-123',
|
||||
clientAction: { type: 'REDIRECT', url: 'https://telebirr.com/pay' },
|
||||
expiresAt: new Date(),
|
||||
rawInitiation: {},
|
||||
});
|
||||
mockPaymentClient.initiate.mockResolvedValue(
|
||||
requiresActionSnapshot(ProviderMethod.TELEBIRR),
|
||||
);
|
||||
mockPrisma.paymentIntent.upsert.mockResolvedValue({
|
||||
id: 'intent-1',
|
||||
id: "intent-1",
|
||||
status: PaymentIntentStatus.REQUIRES_ACTION,
|
||||
merchantOrderId: 'MERCH-123',
|
||||
clientAction: { type: 'REDIRECT', url: 'https://telebirr.com/pay' },
|
||||
merchantOrderId: "PSG-MERCH-123",
|
||||
clientAction: { type: "REDIRECT", url: "https://provider.example/pay" },
|
||||
});
|
||||
|
||||
const result = await service.initiatePayment({
|
||||
bookingId: 'booking-1',
|
||||
method: 'TELEBIRR' as any,
|
||||
bookingId: "booking-1",
|
||||
method: "TELEBIRR" as any,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(PaymentIntentStatus.REQUIRES_ACTION);
|
||||
expect(mockTelebirrProvider.initiate).toHaveBeenCalled();
|
||||
expect(result.clientAction?.url).toBe("https://provider.example/pay");
|
||||
expect(mockPaymentClient.initiate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
service: PaymentServiceEnum.PASSENGER,
|
||||
referenceType: PaymentReferenceType.BOOKING,
|
||||
referenceId: "booking-1",
|
||||
orderRef: "EDR123456",
|
||||
amountMinor: 50000,
|
||||
currency: "ETB",
|
||||
provider: "TELEBIRR",
|
||||
}),
|
||||
);
|
||||
// Snapshot mirrored into the local projection.
|
||||
expect(mockPrisma.paymentIntent.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { bookingId: "booking-1" } }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should initiate CBE Birr payment successfully', async () => {
|
||||
it("should finalize the booking when the service reports an already-paid intent", async () => {
|
||||
mockPrisma.booking.findUnique.mockResolvedValue(mockBooking);
|
||||
mockPrisma.paymentIntent.findUnique.mockResolvedValue(null);
|
||||
mockCbeBirrProvider.initiate.mockResolvedValue({
|
||||
providerOrderId: 'CBE-ORDER-123',
|
||||
clientAction: { type: 'REDIRECT', url: 'https://cbe.com/pay' },
|
||||
expiresAt: new Date(),
|
||||
rawInitiation: {},
|
||||
mockPaymentClient.initiate.mockResolvedValue({
|
||||
...requiresActionSnapshot(ProviderMethod.WAAFI),
|
||||
status: ProviderPaymentStatus.SUCCEEDED,
|
||||
providerTxnId: "TXN-1",
|
||||
paidAt: new Date().toISOString(),
|
||||
});
|
||||
// Projection clamps SUCCEEDED to PROCESSING; finalizePaymentSuccess flips it.
|
||||
mockPrisma.paymentIntent.upsert.mockResolvedValue({
|
||||
id: 'intent-1',
|
||||
status: PaymentIntentStatus.REQUIRES_ACTION,
|
||||
merchantOrderId: 'MERCH-123',
|
||||
clientAction: { type: 'REDIRECT', url: 'https://cbe.com/pay' },
|
||||
id: "intent-1",
|
||||
bookingId: "booking-1",
|
||||
status: PaymentIntentStatus.PROCESSING,
|
||||
});
|
||||
|
||||
const result = await service.initiatePayment({
|
||||
bookingId: 'booking-1',
|
||||
method: 'CBE_BIRR' as any,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(PaymentIntentStatus.REQUIRES_ACTION);
|
||||
expect(mockCbeBirrProvider.initiate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should initiate wallet payment and debit successfully', async () => {
|
||||
mockPrisma.booking.findUnique.mockResolvedValue(mockBooking);
|
||||
mockPrisma.paymentIntent.findUnique.mockResolvedValue(null);
|
||||
mockPrisma.walletAccount.findUnique.mockResolvedValue({
|
||||
id: 'wallet-1',
|
||||
passengerId: 'passenger-1',
|
||||
balanceMinor: 100000,
|
||||
});
|
||||
mockPrisma.paymentIntent.upsert.mockResolvedValue({
|
||||
id: 'intent-1',
|
||||
mockPrisma.paymentIntent.findUnique.mockResolvedValue({
|
||||
id: "intent-1",
|
||||
bookingId: "booking-1",
|
||||
status: PaymentIntentStatus.PROCESSING,
|
||||
});
|
||||
mockPrisma.paymentIntent.findUniqueOrThrow.mockResolvedValue({
|
||||
id: 'intent-1',
|
||||
id: "intent-1",
|
||||
status: PaymentIntentStatus.SUCCEEDED,
|
||||
bookingId: 'booking-1',
|
||||
merchantOrderId: "PSG-MERCH-123",
|
||||
});
|
||||
mockPrisma.loyaltyAccount.findUnique.mockResolvedValue(null);
|
||||
|
||||
const result = await service.initiatePayment({
|
||||
bookingId: "booking-1",
|
||||
method: "WAAFI" as any,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(PaymentIntentStatus.SUCCEEDED);
|
||||
expect(mockTicketsService.generate).toHaveBeenCalledWith("booking-1");
|
||||
});
|
||||
|
||||
it("should initiate wallet payment and debit successfully", async () => {
|
||||
mockPrisma.booking.findUnique.mockResolvedValue(mockBooking);
|
||||
// First call: existing-intent check (none); second call: finalize loads the new intent.
|
||||
mockPrisma.paymentIntent.findUnique
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValue({
|
||||
id: "intent-1",
|
||||
bookingId: "booking-1",
|
||||
status: PaymentIntentStatus.PROCESSING,
|
||||
});
|
||||
mockPrisma.walletAccount.findUnique.mockResolvedValue({
|
||||
id: "wallet-1",
|
||||
passengerId: "passenger-1",
|
||||
balanceMinor: 100000,
|
||||
});
|
||||
mockPrisma.paymentIntent.upsert.mockResolvedValue({
|
||||
id: "intent-1",
|
||||
status: PaymentIntentStatus.PROCESSING,
|
||||
});
|
||||
mockPrisma.paymentIntent.findUniqueOrThrow.mockResolvedValue({
|
||||
id: "intent-1",
|
||||
status: PaymentIntentStatus.SUCCEEDED,
|
||||
bookingId: "booking-1",
|
||||
});
|
||||
mockPrisma.loyaltyAccount.findUnique.mockResolvedValue({
|
||||
id: 'loyalty-1',
|
||||
id: "loyalty-1",
|
||||
pointsBalance: 100,
|
||||
});
|
||||
|
||||
const result = await service.initiatePayment({
|
||||
bookingId: 'booking-1',
|
||||
method: 'WALLET' as any,
|
||||
bookingId: "booking-1",
|
||||
method: "WALLET" as any,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(PaymentIntentStatus.SUCCEEDED);
|
||||
expect(mockSeatsService.confirmSeats).toHaveBeenCalled();
|
||||
expect(mockTicketsService.generate).toHaveBeenCalled();
|
||||
expect(mockPaymentClient.initiate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fail wallet payment with insufficient balance', async () => {
|
||||
it("should fail wallet payment with insufficient balance", async () => {
|
||||
mockPrisma.booking.findUnique.mockResolvedValue(mockBooking);
|
||||
mockPrisma.paymentIntent.findUnique.mockResolvedValue(null);
|
||||
mockPrisma.walletAccount.findUnique.mockResolvedValue({
|
||||
id: 'wallet-1',
|
||||
passengerId: 'passenger-1',
|
||||
id: "wallet-1",
|
||||
passengerId: "passenger-1",
|
||||
balanceMinor: 10000, // Less than booking total
|
||||
});
|
||||
mockPrisma.paymentIntent.upsert.mockResolvedValue({
|
||||
id: 'intent-1',
|
||||
id: "intent-1",
|
||||
status: PaymentIntentStatus.FAILED,
|
||||
failureCode: 'INSUFFICIENT_BALANCE',
|
||||
failureCode: "INSUFFICIENT_BALANCE",
|
||||
});
|
||||
|
||||
const result = await service.initiatePayment({
|
||||
bookingId: 'booking-1',
|
||||
method: 'WALLET' as any,
|
||||
bookingId: "booking-1",
|
||||
method: "WALLET" as any,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(PaymentIntentStatus.FAILED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('finalizePaymentSuccess', () => {
|
||||
it('should finalize payment and issue ticket', async () => {
|
||||
describe("finalizePaymentSuccess", () => {
|
||||
it("should finalize payment and issue ticket", async () => {
|
||||
const mockIntent = {
|
||||
id: 'intent-1',
|
||||
bookingId: 'booking-1',
|
||||
id: "intent-1",
|
||||
bookingId: "booking-1",
|
||||
status: PaymentIntentStatus.PROCESSING,
|
||||
};
|
||||
const mockBooking = {
|
||||
id: 'booking-1',
|
||||
passengerId: 'passenger-1',
|
||||
id: "booking-1",
|
||||
passengerId: "passenger-1",
|
||||
totalMinor: 50000,
|
||||
seats: [{ seatId: 'seat-1' }],
|
||||
seats: [{ seatId: "seat-1" }],
|
||||
};
|
||||
|
||||
mockPrisma.paymentIntent.findUnique.mockResolvedValue(mockIntent);
|
||||
mockPrisma.booking.findUnique.mockResolvedValue(mockBooking);
|
||||
mockPrisma.loyaltyAccount.findUnique.mockResolvedValue({
|
||||
id: 'loyalty-1',
|
||||
id: "loyalty-1",
|
||||
pointsBalance: 100,
|
||||
});
|
||||
|
||||
const result = await service.finalizePaymentSuccess({
|
||||
intentId: 'intent-1',
|
||||
providerTxnId: 'TXN-123',
|
||||
intentId: "intent-1",
|
||||
providerTxnId: "TXN-123",
|
||||
});
|
||||
|
||||
expect(result.alreadyFinalized).toBe(false);
|
||||
expect(mockSeatsService.confirmSeats).toHaveBeenCalledWith(['seat-1']);
|
||||
expect(mockTicketsService.generate).toHaveBeenCalledWith('booking-1');
|
||||
expect(mockEventEmitter.emit).toHaveBeenCalledWith('payment.succeeded', {
|
||||
expect(mockSeatsService.confirmSeats).toHaveBeenCalledWith(["seat-1"]);
|
||||
expect(mockTicketsService.generate).toHaveBeenCalledWith("booking-1");
|
||||
expect(mockEventEmitter.emit).toHaveBeenCalledWith("payment.succeeded", {
|
||||
booking: mockBooking,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return alreadyFinalized if payment already succeeded', async () => {
|
||||
it("should return alreadyFinalized if payment already succeeded", async () => {
|
||||
mockPrisma.paymentIntent.findUnique.mockResolvedValue({
|
||||
id: 'intent-1',
|
||||
id: "intent-1",
|
||||
status: PaymentIntentStatus.SUCCEEDED,
|
||||
});
|
||||
|
||||
const result = await service.finalizePaymentSuccess({
|
||||
intentId: 'intent-1',
|
||||
intentId: "intent-1",
|
||||
});
|
||||
|
||||
expect(result.alreadyFinalized).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getIntentByBookingId', () => {
|
||||
it('should return intent status', async () => {
|
||||
describe("getIntentByBookingId", () => {
|
||||
it("should return the cached local intent when the payment service has none", async () => {
|
||||
const mockIntent = {
|
||||
id: 'intent-1',
|
||||
bookingId: 'booking-1',
|
||||
id: "intent-1",
|
||||
bookingId: "booking-1",
|
||||
status: PaymentIntentStatus.SUCCEEDED,
|
||||
method: PaymentMethodType.TELEBIRR,
|
||||
paidAt: new Date(),
|
||||
merchantOrderId: 'MERCH-123',
|
||||
merchantOrderId: "MERCH-123",
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
mockPrisma.paymentIntent.findUnique.mockResolvedValue(mockIntent);
|
||||
mockPaymentClient.getIntentByReference.mockResolvedValue(null);
|
||||
|
||||
const result = await service.getIntentByBookingId('booking-1');
|
||||
const result = await service.getIntentByBookingId("booking-1");
|
||||
|
||||
expect(result.intentId).toBe('intent-1');
|
||||
expect(result.intentId).toBe("intent-1");
|
||||
expect(result.status).toBe(PaymentIntentStatus.SUCCEEDED);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException if intent not found', async () => {
|
||||
it("should mirror a payment-service snapshot into the local projection", async () => {
|
||||
mockPrisma.paymentIntent.findUnique.mockResolvedValue(null);
|
||||
mockPaymentClient.getIntentByReference.mockResolvedValue(
|
||||
requiresActionSnapshot(ProviderMethod.WAAFI),
|
||||
);
|
||||
mockPrisma.paymentIntent.upsert.mockResolvedValue({
|
||||
id: "intent-1",
|
||||
bookingId: "booking-1",
|
||||
status: PaymentIntentStatus.REQUIRES_ACTION,
|
||||
merchantOrderId: "PSG-MERCH-123",
|
||||
clientAction: { type: "REDIRECT", url: "https://provider.example/pay" },
|
||||
});
|
||||
|
||||
await expect(service.getIntentByBookingId('invalid')).rejects.toThrow(
|
||||
const result = await service.getIntentByBookingId("booking-1");
|
||||
|
||||
expect(mockPaymentClient.getIntentByReference).toHaveBeenCalledWith(
|
||||
PaymentReferenceType.BOOKING,
|
||||
"booking-1",
|
||||
);
|
||||
expect(result.status).toBe(PaymentIntentStatus.REQUIRES_ACTION);
|
||||
expect(result.clientAction?.url).toBe("https://provider.example/pay");
|
||||
});
|
||||
|
||||
it("should throw NotFoundException if intent not found anywhere", async () => {
|
||||
mockPrisma.paymentIntent.findUnique.mockResolvedValue(null);
|
||||
mockPaymentClient.getIntentByReference.mockResolvedValue(null);
|
||||
|
||||
await expect(service.getIntentByBookingId("invalid")).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,22 +1,37 @@
|
||||
import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { SeatsService } from '../seats/seats.service';
|
||||
import { TicketsService } from '../tickets/tickets.service';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { Prisma, PaymentIntentStatus, PaymentMethodType, PaymentRegion } from '@prisma/client';
|
||||
import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto, InitiateResponseDto, IntentStatusDto, PaymentRegionEnum } from './payments.dto';
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
} from "@nestjs/common";
|
||||
import { PrismaService } from "../../common/prisma.service";
|
||||
import { SeatsService } from "../seats/seats.service";
|
||||
import { TicketsService } from "../tickets/tickets.service";
|
||||
import { EventEmitter2 } from "@nestjs/event-emitter";
|
||||
import {
|
||||
Prisma,
|
||||
PaymentIntentStatus,
|
||||
PaymentMethodType,
|
||||
PaymentRegion,
|
||||
} from "@prisma/client";
|
||||
import {
|
||||
InitiatePaymentDto,
|
||||
RefundDto,
|
||||
AddPaymentMethodDto,
|
||||
InitiateResponseDto,
|
||||
IntentStatusDto,
|
||||
PaymentRegionEnum,
|
||||
} from "./payments.dto";
|
||||
import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payments.dto";
|
||||
import { PaymentClientService } from "./payment-client.service";
|
||||
import {
|
||||
PaymentService as PaymentServiceEnum,
|
||||
PaymentReferenceType,
|
||||
PaymentIntentSnapshot,
|
||||
ProviderMethod,
|
||||
ClientAction,
|
||||
PaymentProvider,
|
||||
ProviderStatus,
|
||||
ProviderPaymentStatus,
|
||||
TelebirrProvider,
|
||||
CbeBirrProvider,
|
||||
EBirrProvider,
|
||||
CardProvider,
|
||||
WaafiProvider,
|
||||
createMerchantOrderId,
|
||||
} from '@edr/payment-providers';
|
||||
} from "@edr/types";
|
||||
|
||||
const NON_TERMINAL_STATUSES: PaymentIntentStatus[] = [
|
||||
PaymentIntentStatus.REQUIRES_ACTION,
|
||||
@@ -27,37 +42,30 @@ const NON_TERMINAL_STATUSES: PaymentIntentStatus[] = [
|
||||
@Injectable()
|
||||
export class PaymentsService {
|
||||
private readonly logger = new Logger(PaymentsService.name);
|
||||
private readonly providers: Map<PaymentMethodType, PaymentProvider>;
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private seatsService: SeatsService,
|
||||
private ticketsService: TicketsService,
|
||||
private eventEmitter: EventEmitter2,
|
||||
private telebirrProvider: TelebirrProvider,
|
||||
private cbeBirrProvider: CbeBirrProvider,
|
||||
private eBirrProvider: EBirrProvider,
|
||||
private cardProvider: CardProvider,
|
||||
private waafiProvider: WaafiProvider,
|
||||
) {
|
||||
this.providers = new Map<PaymentMethodType, PaymentProvider>([
|
||||
[PaymentMethodType.TELEBIRR, this.telebirrProvider],
|
||||
[PaymentMethodType.CBE_BIRR, this.cbeBirrProvider],
|
||||
[PaymentMethodType.EBIRR, this.eBirrProvider],
|
||||
[PaymentMethodType.CARD, this.cardProvider],
|
||||
[PaymentMethodType.WAAFI, this.waafiProvider],
|
||||
]);
|
||||
}
|
||||
private paymentClient: PaymentClientService,
|
||||
) {}
|
||||
|
||||
async getAll(filters: { search?: string; status?: string; method?: string; page?: number; pageSize?: number }) {
|
||||
async getAll(filters: {
|
||||
search?: string;
|
||||
status?: string;
|
||||
method?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}) {
|
||||
const { search, status, method, page = 1, pageSize = 10 } = filters;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: any = {};
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ id: { contains: search, mode: 'insensitive' } },
|
||||
{ booking: { bookingRef: { contains: search, mode: 'insensitive' } } },
|
||||
{ id: { contains: search, mode: "insensitive" } },
|
||||
{ booking: { bookingRef: { contains: search, mode: "insensitive" } } },
|
||||
];
|
||||
}
|
||||
if (status) {
|
||||
@@ -73,13 +81,13 @@ export class PaymentsService {
|
||||
include: { booking: true },
|
||||
skip,
|
||||
take: pageSize,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
orderBy: { createdAt: "desc" },
|
||||
}),
|
||||
this.prisma.paymentIntent.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: items.map(item => ({
|
||||
items: items.map((item) => ({
|
||||
id: item.id,
|
||||
reference: item.id.substring(0, 8),
|
||||
bookingId: item.bookingId,
|
||||
@@ -102,30 +110,87 @@ export class PaymentsService {
|
||||
where: { id: dto.bookingId },
|
||||
include: { seats: true },
|
||||
});
|
||||
if (!booking) throw new NotFoundException('Booking not found');
|
||||
if (booking.status !== 'PENDING_PAYMENT') {
|
||||
throw new BadRequestException('Booking not payable');
|
||||
}
|
||||
|
||||
const existing = await this.prisma.paymentIntent.findUnique({
|
||||
where: { bookingId: dto.bookingId },
|
||||
});
|
||||
if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) {
|
||||
return this.formatIntentResponse(existing);
|
||||
if (!booking) throw new NotFoundException("Booking not found");
|
||||
if (booking.status !== "PENDING_PAYMENT") {
|
||||
throw new BadRequestException("Booking not payable");
|
||||
}
|
||||
|
||||
const method = dto.method as PaymentMethodType;
|
||||
|
||||
// WALLET is an internal balance debit — it never leaves this app.
|
||||
if (method === PaymentMethodType.WALLET) {
|
||||
const existing = await this.prisma.paymentIntent.findUnique({
|
||||
where: { bookingId: dto.bookingId },
|
||||
});
|
||||
if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) {
|
||||
return this.formatIntentResponse(existing);
|
||||
}
|
||||
return this.initiateWalletPayment(booking);
|
||||
}
|
||||
|
||||
const provider = this.providers.get(method);
|
||||
if (provider) {
|
||||
return this.initiateProviderPayment(booking, provider, dto.platform);
|
||||
}
|
||||
// Provider methods go through the payment microservice (docs/payment-service §7.1):
|
||||
// it owns the intent, the provider session, and the single webhook per provider.
|
||||
// Re-initiating is safe — the service returns the existing active intent (idempotent).
|
||||
const snapshot = await this.paymentClient.initiate({
|
||||
service: PaymentServiceEnum.PASSENGER,
|
||||
referenceType: PaymentReferenceType.BOOKING,
|
||||
referenceId: booking.id,
|
||||
orderRef: booking.bookingRef,
|
||||
amountMinor: booking.totalMinor,
|
||||
currency: booking.currency,
|
||||
provider: method as unknown as ProviderMethod,
|
||||
platform: dto.platform,
|
||||
// PASSENGER-owned browser bounce-back after the hosted page (freight passes its own).
|
||||
// UX only — payment is confirmed by the webhook/mark-paid event, never this redirect.
|
||||
returnUrl: process.env.PAYMENT_RETURN_URL || undefined,
|
||||
failureUrl: process.env.PAYMENT_FAILURE_URL || undefined,
|
||||
});
|
||||
|
||||
throw new BadRequestException(`Unsupported payment method: ${method}`);
|
||||
let intent = await this.syncIntentProjection(booking.id, snapshot);
|
||||
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
|
||||
// Already-paid order re-initiated: converge the booking now (idempotent).
|
||||
await this.finalizePaymentSuccess({
|
||||
intentId: intent.id,
|
||||
providerTxnId: snapshot.providerTxnId,
|
||||
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
|
||||
});
|
||||
intent = await this.prisma.paymentIntent.findUniqueOrThrow({
|
||||
where: { id: intent.id },
|
||||
});
|
||||
}
|
||||
return this.formatIntentResponse(intent);
|
||||
}
|
||||
|
||||
private async syncIntentProjection(
|
||||
bookingId: string,
|
||||
snapshot: PaymentIntentSnapshot,
|
||||
) {
|
||||
const status =
|
||||
snapshot.status === ProviderPaymentStatus.SUCCEEDED
|
||||
? PaymentIntentStatus.PROCESSING
|
||||
: (snapshot.status as unknown as PaymentIntentStatus);
|
||||
const data = {
|
||||
status,
|
||||
method: snapshot.provider as unknown as PaymentMethodType,
|
||||
merchantOrderId: snapshot.merchantOrderId,
|
||||
clientAction: snapshot.clientAction
|
||||
? (snapshot.clientAction as unknown as Prisma.InputJsonValue)
|
||||
: Prisma.DbNull,
|
||||
providerTxnId: snapshot.providerTxnId ?? null,
|
||||
expiresAt: snapshot.expiresAt ? new Date(snapshot.expiresAt) : null,
|
||||
failureCode: snapshot.failureCode ?? null,
|
||||
failureMessage: snapshot.failureMessage ?? null,
|
||||
};
|
||||
return this.prisma.paymentIntent.upsert({
|
||||
where: { bookingId },
|
||||
update: data,
|
||||
create: {
|
||||
bookingId,
|
||||
amountMinor: snapshot.amountMinor,
|
||||
currency: snapshot.currency,
|
||||
...data,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async initiateWalletPayment(
|
||||
@@ -146,7 +211,7 @@ export class PaymentsService {
|
||||
await tx.walletLedgerEntry.create({
|
||||
data: {
|
||||
walletId: wallet.id,
|
||||
type: 'DEBIT',
|
||||
type: "DEBIT",
|
||||
amountMinor: booking.totalMinor,
|
||||
balanceAfterMinor: newBalance,
|
||||
description: `Train Ticket - ${booking.bookingRef}`,
|
||||
@@ -161,14 +226,14 @@ export class PaymentsService {
|
||||
where: { bookingId: booking.id },
|
||||
update: {
|
||||
status: PaymentIntentStatus.FAILED,
|
||||
failureCode: 'INSUFFICIENT_BALANCE',
|
||||
failureCode: "INSUFFICIENT_BALANCE",
|
||||
},
|
||||
create: {
|
||||
bookingId: booking.id,
|
||||
amountMinor: booking.totalMinor,
|
||||
method: PaymentMethodType.WALLET,
|
||||
status: PaymentIntentStatus.FAILED,
|
||||
failureCode: 'INSUFFICIENT_BALANCE',
|
||||
failureCode: "INSUFFICIENT_BALANCE",
|
||||
},
|
||||
});
|
||||
return this.formatIntentResponse(failed);
|
||||
@@ -192,55 +257,11 @@ export class PaymentsService {
|
||||
return this.formatIntentResponse(refreshed);
|
||||
}
|
||||
|
||||
private async initiateProviderPayment(
|
||||
booking: Prisma.BookingGetPayload<{ include: { seats: true } }>,
|
||||
provider: PaymentProvider,
|
||||
platform: 'web' | 'mobile' | undefined,
|
||||
): Promise<InitiateResponseDto> {
|
||||
const merchantOrderId = createMerchantOrderId();
|
||||
const result = await provider.initiate({
|
||||
merchantOrderId,
|
||||
orderRef: booking.bookingRef,
|
||||
amountMinor: booking.totalMinor,
|
||||
currency: booking.currency,
|
||||
platform,
|
||||
});
|
||||
|
||||
const providerMethod = provider.method as unknown as PaymentMethodType;
|
||||
const intent = await this.prisma.paymentIntent.upsert({
|
||||
where: { bookingId: booking.id },
|
||||
update: {
|
||||
status: PaymentIntentStatus.REQUIRES_ACTION,
|
||||
method: providerMethod,
|
||||
merchantOrderId,
|
||||
providerOrderId: result.providerOrderId,
|
||||
clientAction: result.clientAction as unknown as Prisma.InputJsonValue,
|
||||
rawInitiation: result.rawInitiation as Prisma.InputJsonValue,
|
||||
expiresAt: result.expiresAt,
|
||||
failureCode: null,
|
||||
failureMessage: null,
|
||||
},
|
||||
create: {
|
||||
bookingId: booking.id,
|
||||
amountMinor: booking.totalMinor,
|
||||
currency: booking.currency,
|
||||
method: providerMethod,
|
||||
status: PaymentIntentStatus.REQUIRES_ACTION,
|
||||
merchantOrderId,
|
||||
providerOrderId: result.providerOrderId,
|
||||
clientAction: result.clientAction as unknown as Prisma.InputJsonValue,
|
||||
rawInitiation: result.rawInitiation as Prisma.InputJsonValue,
|
||||
expiresAt: result.expiresAt,
|
||||
},
|
||||
});
|
||||
return this.formatIntentResponse(intent);
|
||||
}
|
||||
|
||||
private formatIntentResponse(
|
||||
intent: Prisma.PaymentIntentGetPayload<Record<string, never>>,
|
||||
): InitiateResponseDto {
|
||||
const clientAction =
|
||||
intent.clientAction && typeof intent.clientAction === 'object'
|
||||
intent.clientAction && typeof intent.clientAction === "object"
|
||||
? (intent.clientAction as unknown as ClientAction)
|
||||
: undefined;
|
||||
return {
|
||||
@@ -252,65 +273,52 @@ export class PaymentsService {
|
||||
}
|
||||
|
||||
async getIntentByBookingId(bookingId: string): Promise<IntentStatusDto> {
|
||||
const intent = await this.prisma.paymentIntent.findUnique({
|
||||
const local = await this.prisma.paymentIntent.findUnique({
|
||||
where: { bookingId },
|
||||
});
|
||||
if (!intent) throw new NotFoundException('PaymentIntent not found');
|
||||
|
||||
const refreshable =
|
||||
intent.status === PaymentIntentStatus.REQUIRES_ACTION ||
|
||||
intent.status === PaymentIntentStatus.PROCESSING;
|
||||
const stale = intent.updatedAt.getTime() < Date.now() - 5_000;
|
||||
const provider = this.providers.get(intent.method);
|
||||
|
||||
if (refreshable && stale && intent.merchantOrderId && provider) {
|
||||
try {
|
||||
const status = await provider.queryStatus(intent.merchantOrderId);
|
||||
this.logger.log(status);
|
||||
await this.applyProviderStatus(intent.id, status);
|
||||
const refreshed = await this.prisma.paymentIntent.findUniqueOrThrow({
|
||||
where: { id: intent.id },
|
||||
});
|
||||
return this.formatIntentStatus(refreshed);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.warn(
|
||||
`queryStatus failed for intent ${intent.id}: ${message}; returning cached`,
|
||||
);
|
||||
}
|
||||
// WALLET payments never leave this app — no remote intent exists for them.
|
||||
if (local?.method === PaymentMethodType.WALLET) {
|
||||
return this.formatIntentStatus(local);
|
||||
}
|
||||
|
||||
return this.formatIntentStatus(intent);
|
||||
}
|
||||
// Pull/reconcile through the payment microservice (it refreshes stale intents from the
|
||||
// provider itself). Falls back to the legacy local path when the service is unreachable
|
||||
// or only a pre-cutover local intent exists.
|
||||
let snapshot: PaymentIntentSnapshot | null = null;
|
||||
try {
|
||||
snapshot = await this.paymentClient.getIntentByReference(
|
||||
PaymentReferenceType.BOOKING,
|
||||
bookingId,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.warn(
|
||||
`payment service lookup failed for booking ${bookingId}: ${message}; using local intent`,
|
||||
);
|
||||
}
|
||||
|
||||
private async applyProviderStatus(
|
||||
intentId: string,
|
||||
status: ProviderStatus,
|
||||
): Promise<void> {
|
||||
const bizContent = (status.rawResponse as { biz_content?: { order_status?: string } })
|
||||
?.biz_content;
|
||||
if (bizContent?.order_status === 'PAY_SUCCESS') {
|
||||
if (!snapshot) {
|
||||
// Pre-cutover/local-only intent (or service briefly unreachable): serve the cached
|
||||
// status. The payment service owns provider refresh for everything initiated after
|
||||
// the cutover; webhooks/mark-paid converge the rest.
|
||||
if (!local) throw new NotFoundException("PaymentIntent not found");
|
||||
return this.formatIntentStatus(local);
|
||||
}
|
||||
|
||||
let intent = await this.syncIntentProjection(bookingId, snapshot);
|
||||
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
|
||||
// Poll observed success before (or instead of) the mark-paid event — converge now.
|
||||
await this.finalizePaymentSuccess({
|
||||
intentId,
|
||||
providerTxnId: status.providerTxnId,
|
||||
intentId: intent.id,
|
||||
providerTxnId: snapshot.providerTxnId,
|
||||
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (status.status === ProviderPaymentStatus.FAILED) {
|
||||
await this.markPaymentFailed({
|
||||
intentId,
|
||||
failureCode: status.failureCode,
|
||||
failureMessage: status.failureMessage,
|
||||
intent = await this.prisma.paymentIntent.findUniqueOrThrow({
|
||||
where: { id: intent.id },
|
||||
});
|
||||
return;
|
||||
}
|
||||
await this.prisma.paymentIntent.update({
|
||||
where: { id: intentId },
|
||||
data: {
|
||||
status: status.status as unknown as PaymentIntentStatus,
|
||||
providerTxnId: status.providerTxnId ?? undefined,
|
||||
},
|
||||
});
|
||||
return this.formatIntentStatus(intent);
|
||||
}
|
||||
|
||||
private formatIntentStatus(
|
||||
@@ -326,13 +334,25 @@ export class PaymentsService {
|
||||
}
|
||||
|
||||
async refund(dto: RefundDto) {
|
||||
const intent = await this.prisma.paymentIntent.findUnique({ where: { bookingId: dto.bookingId } });
|
||||
if (!intent || intent.status !== 'SUCCEEDED') throw new BadRequestException('No successful payment to refund');
|
||||
await this.prisma.paymentIntent.update({ where: { bookingId: dto.bookingId }, data: { status: 'CANCELLED' } });
|
||||
const booking = await this.prisma.booking.findUnique({ where: { id: dto.bookingId }, include: { seats: true } });
|
||||
const intent = await this.prisma.paymentIntent.findUnique({
|
||||
where: { bookingId: dto.bookingId },
|
||||
});
|
||||
if (!intent || intent.status !== "SUCCEEDED")
|
||||
throw new BadRequestException("No successful payment to refund");
|
||||
await this.prisma.paymentIntent.update({
|
||||
where: { bookingId: dto.bookingId },
|
||||
data: { status: "CANCELLED" },
|
||||
});
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { id: dto.bookingId },
|
||||
include: { seats: true },
|
||||
});
|
||||
if (booking) {
|
||||
await this.seatsService.releaseSeats(booking.seats.map((s) => s.seatId));
|
||||
await this.prisma.booking.update({ where: { id: dto.bookingId }, data: { status: 'CANCELLED' } });
|
||||
await this.prisma.booking.update({
|
||||
where: { id: dto.bookingId },
|
||||
data: { status: "CANCELLED" },
|
||||
});
|
||||
}
|
||||
return { refunded: true, bookingRef: booking?.bookingRef };
|
||||
}
|
||||
@@ -342,7 +362,7 @@ export class PaymentsService {
|
||||
type: dto.type as unknown as PaymentMethodType,
|
||||
displayName: dto.displayName,
|
||||
region: dto.region as unknown as PaymentRegion,
|
||||
currency: dto.currency ?? 'ETB',
|
||||
currency: dto.currency ?? "ETB",
|
||||
providerId: dto.providerId,
|
||||
enabled: dto.enabled ?? true,
|
||||
sortOrder: dto.sortOrder ?? 0,
|
||||
@@ -359,10 +379,17 @@ export class PaymentsService {
|
||||
where: {
|
||||
enabled: true,
|
||||
...(region
|
||||
? { region: { in: [region, PaymentRegionEnum.GLOBAL] as unknown as PaymentRegion[] } }
|
||||
? {
|
||||
region: {
|
||||
in: [
|
||||
region,
|
||||
PaymentRegionEnum.GLOBAL,
|
||||
] as unknown as PaymentRegion[],
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
orderBy: [{ sortOrder: 'asc' }, { displayName: 'asc' }],
|
||||
orderBy: [{ sortOrder: "asc" }, { displayName: "asc" }],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -374,19 +401,21 @@ export class PaymentsService {
|
||||
const intent = await this.prisma.paymentIntent.findUnique({
|
||||
where: { id: input.intentId },
|
||||
});
|
||||
if (!intent) throw new NotFoundException('PaymentIntent not found');
|
||||
if (!intent) throw new NotFoundException("PaymentIntent not found");
|
||||
if (intent.status === PaymentIntentStatus.SUCCEEDED) {
|
||||
return { alreadyFinalized: true };
|
||||
}
|
||||
if (intent.status === PaymentIntentStatus.CANCELLED) {
|
||||
throw new BadRequestException('PaymentIntent is cancelled; cannot finalize');
|
||||
throw new BadRequestException(
|
||||
"PaymentIntent is cancelled; cannot finalize",
|
||||
);
|
||||
}
|
||||
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { id: intent.bookingId },
|
||||
include: { seats: true },
|
||||
});
|
||||
if (!booking) throw new NotFoundException('Booking not found');
|
||||
if (!booking) throw new NotFoundException("Booking not found");
|
||||
|
||||
const paidAt = input.paidAt ?? new Date();
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
@@ -394,45 +423,134 @@ export class PaymentsService {
|
||||
where: { id: intent.id },
|
||||
data: {
|
||||
status: PaymentIntentStatus.SUCCEEDED,
|
||||
providerTxnId: input.providerTxnId ?? intent.providerTxnId ?? undefined,
|
||||
providerTxnId:
|
||||
input.providerTxnId ?? intent.providerTxnId ?? undefined,
|
||||
paidAt,
|
||||
},
|
||||
});
|
||||
await tx.booking.update({
|
||||
where: { id: booking.id },
|
||||
data: { status: 'CONFIRMED' },
|
||||
data: { status: "CONFIRMED" },
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await this.seatsService.confirmSeats(booking.seats.map((s) => s.seatId));
|
||||
} catch (err) {
|
||||
this.logger.error(`Error confirming seats: ${err instanceof Error ? err.message : String(err)}`);
|
||||
this.logger.error(
|
||||
`Error confirming seats: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.createJourneySegments(booking);
|
||||
} catch (err) {
|
||||
this.logger.error(`Error creating journey segments: ${err instanceof Error ? err.message : String(err)}`);
|
||||
this.logger.error(
|
||||
`Error creating journey segments: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.ticketsService.generate(booking.id);
|
||||
} catch (err) {
|
||||
this.logger.error(`Error generating ticket: ${err instanceof Error ? err.message : String(err)}`);
|
||||
this.logger.error(
|
||||
`Error generating ticket: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.awardLoyaltyPoints(booking.passengerId, booking.totalMinor, booking.id);
|
||||
await this.awardLoyaltyPoints(
|
||||
booking.passengerId,
|
||||
booking.totalMinor,
|
||||
booking.id,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Error awarding loyalty points: ${err instanceof Error ? err.message : String(err)}`);
|
||||
this.logger.warn(
|
||||
`Error awarding loyalty points: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.eventEmitter.emit('payment.succeeded', { booking });
|
||||
this.eventEmitter.emit("payment.succeeded", { booking });
|
||||
return { alreadyFinalized: false };
|
||||
}
|
||||
|
||||
async handlePaymentEvent(
|
||||
event: PaymentEventDto,
|
||||
): Promise<MarkPaidResponseDto> {
|
||||
if (
|
||||
event.service !== PaymentServiceEnum.PASSENGER ||
|
||||
event.referenceType !== PaymentReferenceType.BOOKING
|
||||
) {
|
||||
this.logger.warn(
|
||||
`mark-paid: ignoring foreign reference ${event.service}/${event.referenceType}/${event.referenceId}`,
|
||||
);
|
||||
return { processed: false, reason: "foreign-reference" };
|
||||
}
|
||||
|
||||
if (event.eventType === "payment.failed") {
|
||||
const intent = await this.prisma.paymentIntent.findUnique({
|
||||
where: { bookingId: event.referenceId },
|
||||
});
|
||||
if (intent) {
|
||||
await this.markPaymentFailed({
|
||||
intentId: intent.id,
|
||||
failureCode: event.failureCode,
|
||||
failureMessage: event.failureMessage,
|
||||
});
|
||||
}
|
||||
return { processed: true };
|
||||
}
|
||||
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { id: event.referenceId },
|
||||
});
|
||||
if (!booking) {
|
||||
// Ack (200) — a missing booking will not appear on redelivery; needs investigation.
|
||||
this.logger.error(
|
||||
`mark-paid: no booking for reference ${event.referenceId}`,
|
||||
);
|
||||
return { processed: false, reason: "booking-not-found" };
|
||||
}
|
||||
|
||||
if (booking.totalMinor !== event.amountMinor) {
|
||||
// Refuse to confirm: a 4xx makes the relay retry and eventually flag the row FAILED,
|
||||
// which is the alertable signal for an asserted-vs-paid amount divergence.
|
||||
this.logger.error(
|
||||
`mark-paid: amount mismatch for booking ${booking.id}: booking=${booking.totalMinor} event=${event.amountMinor}`,
|
||||
);
|
||||
throw new BadRequestException(
|
||||
"Event amount does not match booking total",
|
||||
);
|
||||
}
|
||||
|
||||
// Local intent row is a projection during the strangler migration: reuse it when the
|
||||
// legacy initiate path created one, otherwise materialize it from the event.
|
||||
let intent = await this.prisma.paymentIntent.findUnique({
|
||||
where: { bookingId: event.referenceId },
|
||||
});
|
||||
if (!intent) {
|
||||
intent = await this.prisma.paymentIntent.create({
|
||||
data: {
|
||||
bookingId: event.referenceId,
|
||||
amountMinor: event.amountMinor,
|
||||
currency: event.currency,
|
||||
method: event.provider as unknown as PaymentMethodType,
|
||||
status: PaymentIntentStatus.PROCESSING,
|
||||
merchantOrderId: event.merchantOrderId,
|
||||
providerTxnId: event.providerTxnId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const { alreadyFinalized } = await this.finalizePaymentSuccess({
|
||||
intentId: intent.id,
|
||||
providerTxnId: event.providerTxnId,
|
||||
paidAt: event.paidAt ? new Date(event.paidAt) : undefined,
|
||||
});
|
||||
return { processed: true, alreadyFinalized };
|
||||
}
|
||||
|
||||
async markPaymentFailed(input: {
|
||||
intentId: string;
|
||||
failureCode?: string;
|
||||
@@ -441,7 +559,7 @@ export class PaymentsService {
|
||||
const intent = await this.prisma.paymentIntent.findUnique({
|
||||
where: { id: input.intentId },
|
||||
});
|
||||
if (!intent) throw new NotFoundException('PaymentIntent not found');
|
||||
if (!intent) throw new NotFoundException("PaymentIntent not found");
|
||||
if (
|
||||
intent.status === PaymentIntentStatus.SUCCEEDED ||
|
||||
intent.status === PaymentIntentStatus.CANCELLED
|
||||
@@ -458,35 +576,72 @@ export class PaymentsService {
|
||||
});
|
||||
}
|
||||
|
||||
private async awardLoyaltyPoints(passengerId: string, amountMinor: number, bookingId: string) {
|
||||
private async awardLoyaltyPoints(
|
||||
passengerId: string,
|
||||
amountMinor: number,
|
||||
bookingId: string,
|
||||
) {
|
||||
const points = Math.floor(amountMinor / 100);
|
||||
const account = await this.prisma.loyaltyAccount.findUnique({ where: { passengerId } });
|
||||
const account = await this.prisma.loyaltyAccount.findUnique({
|
||||
where: { passengerId },
|
||||
});
|
||||
if (!account) return;
|
||||
const newBalance = account.pointsBalance + points;
|
||||
const tier = newBalance >= 10000 ? 'PLATINUM' : newBalance >= 5000 ? 'GOLD' : newBalance >= 2000 ? 'SILVER' : 'BRONZE';
|
||||
await this.prisma.loyaltyAccount.update({ where: { passengerId }, data: { pointsBalance: { increment: points }, tier: tier as any } });
|
||||
await this.prisma.loyaltyLedgerEntry.create({ data: { accountId: account.id, delta: points, reason: 'TRIP_COMPLETED', bookingId, balanceAfter: newBalance } });
|
||||
const tier =
|
||||
newBalance >= 10000
|
||||
? "PLATINUM"
|
||||
: newBalance >= 5000
|
||||
? "GOLD"
|
||||
: newBalance >= 2000
|
||||
? "SILVER"
|
||||
: "BRONZE";
|
||||
await this.prisma.loyaltyAccount.update({
|
||||
where: { passengerId },
|
||||
data: { pointsBalance: { increment: points }, tier: tier as any },
|
||||
});
|
||||
await this.prisma.loyaltyLedgerEntry.create({
|
||||
data: {
|
||||
accountId: account.id,
|
||||
delta: points,
|
||||
reason: "TRIP_COMPLETED",
|
||||
bookingId,
|
||||
balanceAfter: newBalance,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async createJourneySegments(booking: Prisma.BookingGetPayload<{ include: { seats: true } }>) {
|
||||
private async createJourneySegments(
|
||||
booking: Prisma.BookingGetPayload<{ include: { seats: true } }>,
|
||||
) {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id: booking.scheduleId },
|
||||
include: { stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } },
|
||||
include: {
|
||||
stopTimes: { include: { station: true }, orderBy: { sequence: "asc" } },
|
||||
},
|
||||
});
|
||||
if (!schedule) return;
|
||||
|
||||
const stopTimes = schedule.stopTimes;
|
||||
if (stopTimes.length < 2) return;
|
||||
|
||||
const originSequence = stopTimes.findIndex(st => st.stationId === schedule.originStationId);
|
||||
const destSequence = stopTimes.findIndex(st => st.stationId === schedule.destinationStationId);
|
||||
const originSequence = stopTimes.findIndex(
|
||||
(st) => st.stationId === schedule.originStationId,
|
||||
);
|
||||
const destSequence = stopTimes.findIndex(
|
||||
(st) => st.stationId === schedule.destinationStationId,
|
||||
);
|
||||
|
||||
if (originSequence < 0 || destSequence < 0 || originSequence >= destSequence) return;
|
||||
if (
|
||||
originSequence < 0 ||
|
||||
destSequence < 0 ||
|
||||
originSequence >= destSequence
|
||||
)
|
||||
return;
|
||||
|
||||
const journey = await this.prisma.journey.create({
|
||||
data: {
|
||||
passengerId: booking.passengerId,
|
||||
status: 'CONFIRMED',
|
||||
status: "CONFIRMED",
|
||||
totalMinor: booking.totalMinor,
|
||||
currency: booking.currency,
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// The payment provider contract now lives in @edr/types (consumed via @edr/payment-providers).
|
||||
// This file remains as a thin re-export so existing local imports keep working.
|
||||
// The payment provider contract lives in @edr/types; the gateways themselves now run only
|
||||
// inside apps/edr-payment-api. This file remains as a thin re-export so existing local
|
||||
// imports keep working.
|
||||
export type {
|
||||
PaymentProvider,
|
||||
ProviderInitiationInput,
|
||||
@@ -7,5 +8,5 @@ export type {
|
||||
ProviderStatus,
|
||||
ClientAction,
|
||||
PaymentPlatform,
|
||||
} from '@edr/types';
|
||||
export { ProviderPaymentStatus, ProviderMethod } from '@edr/types';
|
||||
} from "@edr/types";
|
||||
export { ProviderPaymentStatus, ProviderMethod } from "@edr/types";
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
||||
import {
|
||||
CardProvider,
|
||||
CardWebhookPayload,
|
||||
ProviderPaymentStatus,
|
||||
} from '@edr/payment-providers';
|
||||
import { PrismaService } from '../../../common/prisma.service';
|
||||
import { PaymentsService } from '../payments.service';
|
||||
|
||||
@Injectable()
|
||||
export class CardWebhookService {
|
||||
private readonly logger = new Logger(CardWebhookService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly provider: CardProvider,
|
||||
private readonly payments: PaymentsService,
|
||||
) {}
|
||||
|
||||
async handle(payload: CardWebhookPayload, signature: string): Promise<void> {
|
||||
const merchantOrderId = payload.data.object.metadata.merchantOrderId;
|
||||
const externalEventId = `${payload.id}_${payload.type}`;
|
||||
const signatureValid = this.provider.verifyWebhookSignature(
|
||||
payload as unknown as Record<string, unknown>,
|
||||
signature,
|
||||
);
|
||||
|
||||
const eventRow = await this.persistEvent({
|
||||
externalEventId,
|
||||
merchantOrderId,
|
||||
providerTxnId: payload.data.object.transaction_id,
|
||||
signatureValid,
|
||||
status: payload.data.object.status,
|
||||
payload,
|
||||
});
|
||||
|
||||
if (!eventRow) {
|
||||
this.logger.log(`Card webhook duplicate: ${externalEventId} — short-circuit OK`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!signatureValid) {
|
||||
this.logger.warn(`Card webhook signature invalid for merchantOrderId=${merchantOrderId}`);
|
||||
await this.markProcessed(eventRow.id, 'signature-invalid');
|
||||
return;
|
||||
}
|
||||
|
||||
const intent = await this.prisma.paymentIntent.findUnique({
|
||||
where: { merchantOrderId },
|
||||
});
|
||||
if (!intent) {
|
||||
this.logger.warn(`Card webhook: no PaymentIntent for merchantOrderId=${merchantOrderId}`);
|
||||
await this.markProcessed(eventRow.id, 'intent-not-found');
|
||||
return;
|
||||
}
|
||||
|
||||
const mapped = this.provider.mapWebhookStatus(payload.data.object.status);
|
||||
|
||||
try {
|
||||
if (mapped === ProviderPaymentStatus.SUCCEEDED) {
|
||||
await this.payments.finalizePaymentSuccess({
|
||||
intentId: intent.id,
|
||||
providerTxnId: payload.data.object.transaction_id,
|
||||
paidAt: payload.data.object.paid_at ? new Date(payload.data.object.paid_at * 1000) : undefined,
|
||||
});
|
||||
} else if (mapped === ProviderPaymentStatus.FAILED) {
|
||||
await this.payments.markPaymentFailed({
|
||||
intentId: intent.id,
|
||||
failureCode: payload.data.object.failure_code,
|
||||
failureMessage: payload.data.object.failure_message,
|
||||
});
|
||||
} else {
|
||||
await this.prisma.paymentIntent.update({
|
||||
where: { id: intent.id },
|
||||
data: {
|
||||
status: mapped as unknown as PaymentIntentStatus,
|
||||
providerTxnId: payload.data.object.transaction_id ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
await this.markProcessed(eventRow.id);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(`Card webhook processing failed for ${merchantOrderId}: ${message}`);
|
||||
await this.markProcessed(eventRow.id, `processing-error: ${message}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async persistEvent(input: {
|
||||
externalEventId: string;
|
||||
merchantOrderId: string;
|
||||
providerTxnId?: string;
|
||||
signatureValid: boolean;
|
||||
status: string;
|
||||
payload: CardWebhookPayload;
|
||||
}): Promise<{ id: string } | null> {
|
||||
try {
|
||||
return await this.prisma.paymentWebhookEvent.create({
|
||||
data: {
|
||||
provider: PaymentMethodType.CARD,
|
||||
externalEventId: input.externalEventId,
|
||||
merchantOrderId: input.merchantOrderId,
|
||||
providerTxnId: input.providerTxnId,
|
||||
signatureValid: input.signatureValid,
|
||||
status: input.status,
|
||||
payload: input.payload as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === 'P2002'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async markProcessed(eventId: string, processingError?: string): Promise<void> {
|
||||
await this.prisma.paymentWebhookEvent.update({
|
||||
where: { id: eventId },
|
||||
data: { processedAt: new Date(), processingError },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
||||
import {
|
||||
CbeBirrProvider,
|
||||
CbeBirrWebhookPayload,
|
||||
ProviderPaymentStatus,
|
||||
} from '@edr/payment-providers';
|
||||
import { PrismaService } from '../../../common/prisma.service';
|
||||
import { PaymentsService } from '../payments.service';
|
||||
|
||||
@Injectable()
|
||||
export class CbeBirrWebhookService {
|
||||
private readonly logger = new Logger(CbeBirrWebhookService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly provider: CbeBirrProvider,
|
||||
private readonly payments: PaymentsService,
|
||||
) {}
|
||||
|
||||
async handle(payload: CbeBirrWebhookPayload): Promise<void> {
|
||||
const merchantOrderId = payload.merchantOrderId;
|
||||
const externalEventId = `${payload.orderId}_${payload.status}`;
|
||||
const signatureValid = this.provider.verifyWebhookSignature(
|
||||
payload as unknown as Record<string, unknown>,
|
||||
);
|
||||
|
||||
const eventRow = await this.persistEvent({
|
||||
externalEventId,
|
||||
merchantOrderId,
|
||||
providerTxnId: payload.transactionId ?? payload.orderId,
|
||||
signatureValid,
|
||||
status: payload.status,
|
||||
payload,
|
||||
});
|
||||
|
||||
if (!eventRow) {
|
||||
this.logger.log(`CBE Birr webhook duplicate: ${externalEventId} — short-circuit OK`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!signatureValid) {
|
||||
this.logger.warn(`CBE Birr webhook signature invalid for merchantOrderId=${merchantOrderId}`);
|
||||
await this.markProcessed(eventRow.id, 'signature-invalid');
|
||||
return;
|
||||
}
|
||||
|
||||
const intent = await this.prisma.paymentIntent.findUnique({
|
||||
where: { merchantOrderId },
|
||||
});
|
||||
if (!intent) {
|
||||
this.logger.warn(`CBE Birr webhook: no PaymentIntent for merchantOrderId=${merchantOrderId}`);
|
||||
await this.markProcessed(eventRow.id, 'intent-not-found');
|
||||
return;
|
||||
}
|
||||
|
||||
const mapped = this.provider.mapWebhookStatus(payload.status);
|
||||
|
||||
try {
|
||||
if (mapped === ProviderPaymentStatus.SUCCEEDED) {
|
||||
await this.payments.finalizePaymentSuccess({
|
||||
intentId: intent.id,
|
||||
providerTxnId: payload.transactionId ?? payload.orderId,
|
||||
paidAt: payload.paidAt ? new Date(payload.paidAt) : undefined,
|
||||
});
|
||||
} else if (mapped === ProviderPaymentStatus.FAILED) {
|
||||
await this.payments.markPaymentFailed({
|
||||
intentId: intent.id,
|
||||
failureCode: payload.status,
|
||||
});
|
||||
} else {
|
||||
await this.prisma.paymentIntent.update({
|
||||
where: { id: intent.id },
|
||||
data: {
|
||||
status: mapped as unknown as PaymentIntentStatus,
|
||||
providerTxnId: payload.transactionId ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
await this.markProcessed(eventRow.id);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(`CBE Birr webhook processing failed for ${merchantOrderId}: ${message}`);
|
||||
await this.markProcessed(eventRow.id, `processing-error: ${message}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async persistEvent(input: {
|
||||
externalEventId: string;
|
||||
merchantOrderId: string;
|
||||
providerTxnId?: string;
|
||||
signatureValid: boolean;
|
||||
status: string;
|
||||
payload: CbeBirrWebhookPayload;
|
||||
}): Promise<{ id: string } | null> {
|
||||
try {
|
||||
return await this.prisma.paymentWebhookEvent.create({
|
||||
data: {
|
||||
provider: PaymentMethodType.CBE_BIRR,
|
||||
externalEventId: input.externalEventId,
|
||||
merchantOrderId: input.merchantOrderId,
|
||||
providerTxnId: input.providerTxnId,
|
||||
signatureValid: input.signatureValid,
|
||||
status: input.status,
|
||||
payload: input.payload as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === 'P2002'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async markProcessed(eventId: string, processingError?: string): Promise<void> {
|
||||
await this.prisma.paymentWebhookEvent.update({
|
||||
where: { id: eventId },
|
||||
data: { processedAt: new Date(), processingError },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
||||
import {
|
||||
EBirrProvider,
|
||||
EBirrWebhookPayload,
|
||||
ProviderPaymentStatus,
|
||||
} from '@edr/payment-providers';
|
||||
import { PrismaService } from '../../../common/prisma.service';
|
||||
import { PaymentsService } from '../payments.service';
|
||||
|
||||
@Injectable()
|
||||
export class EBirrWebhookService {
|
||||
private readonly logger = new Logger(EBirrWebhookService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly provider: EBirrProvider,
|
||||
private readonly payments: PaymentsService,
|
||||
) {}
|
||||
|
||||
async handle(payload: EBirrWebhookPayload): Promise<void> {
|
||||
const merchantOrderId = payload.orderNo;
|
||||
const externalEventId = `${payload.orderNo}_${payload.tradeStatus}_${payload.timestamp}`;
|
||||
const signatureValid = this.provider.verifyWebhookSignature(
|
||||
payload as unknown as Record<string, unknown>,
|
||||
);
|
||||
|
||||
const eventRow = await this.persistEvent({
|
||||
externalEventId,
|
||||
merchantOrderId,
|
||||
providerTxnId: payload.tradeNo,
|
||||
signatureValid,
|
||||
status: payload.tradeStatus,
|
||||
payload,
|
||||
});
|
||||
|
||||
if (!eventRow) {
|
||||
this.logger.log(`eBirr webhook duplicate: ${externalEventId} — short-circuit OK`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!signatureValid) {
|
||||
this.logger.warn(`eBirr webhook signature invalid for orderNo=${merchantOrderId}`);
|
||||
await this.markProcessed(eventRow.id, 'signature-invalid');
|
||||
return;
|
||||
}
|
||||
|
||||
const intent = await this.prisma.paymentIntent.findUnique({
|
||||
where: { merchantOrderId },
|
||||
});
|
||||
if (!intent) {
|
||||
this.logger.warn(`eBirr webhook: no PaymentIntent for orderNo=${merchantOrderId}`);
|
||||
await this.markProcessed(eventRow.id, 'intent-not-found');
|
||||
return;
|
||||
}
|
||||
|
||||
const mapped = this.provider.mapWebhookStatus(payload.tradeStatus);
|
||||
|
||||
try {
|
||||
if (mapped === ProviderPaymentStatus.SUCCEEDED) {
|
||||
await this.payments.finalizePaymentSuccess({
|
||||
intentId: intent.id,
|
||||
providerTxnId: payload.tradeNo,
|
||||
paidAt: payload.payTime ? new Date(payload.payTime) : undefined,
|
||||
});
|
||||
} else if (mapped === ProviderPaymentStatus.FAILED) {
|
||||
await this.payments.markPaymentFailed({
|
||||
intentId: intent.id,
|
||||
failureCode: payload.tradeStatus,
|
||||
});
|
||||
} else {
|
||||
await this.prisma.paymentIntent.update({
|
||||
where: { id: intent.id },
|
||||
data: {
|
||||
status: mapped as unknown as PaymentIntentStatus,
|
||||
providerTxnId: payload.tradeNo ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
await this.markProcessed(eventRow.id);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(`eBirr webhook processing failed for ${merchantOrderId}: ${message}`);
|
||||
await this.markProcessed(eventRow.id, `processing-error: ${message}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async persistEvent(input: {
|
||||
externalEventId: string;
|
||||
merchantOrderId: string;
|
||||
providerTxnId?: string;
|
||||
signatureValid: boolean;
|
||||
status: string;
|
||||
payload: EBirrWebhookPayload;
|
||||
}): Promise<{ id: string } | null> {
|
||||
try {
|
||||
return await this.prisma.paymentWebhookEvent.create({
|
||||
data: {
|
||||
provider: PaymentMethodType.EBIRR,
|
||||
externalEventId: input.externalEventId,
|
||||
merchantOrderId: input.merchantOrderId,
|
||||
providerTxnId: input.providerTxnId,
|
||||
signatureValid: input.signatureValid,
|
||||
status: input.status,
|
||||
payload: input.payload as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === 'P2002'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async markProcessed(eventId: string, processingError?: string): Promise<void> {
|
||||
await this.prisma.paymentWebhookEvent.update({
|
||||
where: { id: eventId },
|
||||
data: { processedAt: new Date(), processingError },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
||||
import {
|
||||
TelebirrProvider,
|
||||
TelebirrWebhookPayload,
|
||||
ProviderPaymentStatus,
|
||||
} from '@edr/payment-providers';
|
||||
import { PrismaService } from '../../../common/prisma.service';
|
||||
import { PaymentsService } from '../payments.service';
|
||||
|
||||
@Injectable()
|
||||
export class TelebirrWebhookService {
|
||||
private readonly logger = new Logger(TelebirrWebhookService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly provider: TelebirrProvider,
|
||||
private readonly payments: PaymentsService,
|
||||
) {}
|
||||
|
||||
async handle(payload: TelebirrWebhookPayload): Promise<void> {
|
||||
const merchantOrderId = payload.merch_order_id;
|
||||
const externalEventId = this.buildExternalEventId(payload);
|
||||
// TODO: re-enable Telebirr public-key signature verification — skipped for now
|
||||
// const signatureValid = this.provider.verifyWebhookSignature(
|
||||
// payload as unknown as Record<string, unknown>,
|
||||
// );
|
||||
const signatureValid = true;
|
||||
|
||||
const eventRow = await this.persistEvent({
|
||||
externalEventId,
|
||||
merchantOrderId,
|
||||
providerTxnId: payload.trans_id ?? payload.payment_order_id,
|
||||
signatureValid,
|
||||
status: payload.trade_status,
|
||||
payload,
|
||||
});
|
||||
|
||||
if (!eventRow) {
|
||||
this.logger.log(
|
||||
`Telebirr webhook duplicate: ${externalEventId} — short-circuit OK`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: re-enable signature gate once verifyWebhookSignature is restored
|
||||
// if (!signatureValid) {
|
||||
// this.logger.warn(
|
||||
// `Telebirr webhook signature invalid for merch_order_id=${merchantOrderId}`,
|
||||
// );
|
||||
// await this.markProcessed(eventRow.id, 'signature-invalid');
|
||||
// return;
|
||||
// }
|
||||
|
||||
const intent = await this.prisma.paymentIntent.findUnique({
|
||||
where: { merchantOrderId },
|
||||
});
|
||||
if (!intent) {
|
||||
this.logger.warn(
|
||||
`Telebirr webhook: no PaymentIntent for merch_order_id=${merchantOrderId}`,
|
||||
);
|
||||
await this.markProcessed(eventRow.id, 'intent-not-found');
|
||||
return;
|
||||
}
|
||||
|
||||
const mapped = this.provider.mapWebhookTradeStatus(payload.trade_status);
|
||||
|
||||
try {
|
||||
if (mapped === ProviderPaymentStatus.SUCCEEDED) {
|
||||
await this.payments.finalizePaymentSuccess({
|
||||
intentId: intent.id,
|
||||
providerTxnId: payload.trans_id ?? payload.payment_order_id,
|
||||
paidAt: this.parseEpochSeconds(payload.trans_end_time),
|
||||
});
|
||||
} else if (mapped === ProviderPaymentStatus.FAILED) {
|
||||
await this.payments.markPaymentFailed({
|
||||
intentId: intent.id,
|
||||
failureCode: payload.trade_status,
|
||||
});
|
||||
} else {
|
||||
await this.prisma.paymentIntent.update({
|
||||
where: { id: intent.id },
|
||||
data: {
|
||||
status: mapped as unknown as PaymentIntentStatus,
|
||||
providerTxnId: payload.trans_id ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
await this.markProcessed(eventRow.id);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(
|
||||
`Telebirr webhook processing failed for ${merchantOrderId}: ${message}`,
|
||||
);
|
||||
await this.markProcessed(eventRow.id, `processing-error: ${message}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private buildExternalEventId(payload: TelebirrWebhookPayload): string {
|
||||
return `${payload.payment_order_id}_${payload.trade_status}`;
|
||||
}
|
||||
|
||||
private async persistEvent(input: {
|
||||
externalEventId: string;
|
||||
merchantOrderId: string;
|
||||
providerTxnId?: string;
|
||||
signatureValid: boolean;
|
||||
status: string;
|
||||
payload: TelebirrWebhookPayload;
|
||||
}): Promise<{ id: string } | null> {
|
||||
try {
|
||||
return await this.prisma.paymentWebhookEvent.create({
|
||||
data: {
|
||||
provider: PaymentMethodType.TELEBIRR,
|
||||
externalEventId: input.externalEventId,
|
||||
merchantOrderId: input.merchantOrderId,
|
||||
providerTxnId: input.providerTxnId,
|
||||
signatureValid: input.signatureValid,
|
||||
status: input.status,
|
||||
payload: input.payload as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === 'P2002'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async markProcessed(eventId: string, processingError?: string): Promise<void> {
|
||||
await this.prisma.paymentWebhookEvent.update({
|
||||
where: { id: eventId },
|
||||
data: { processedAt: new Date(), processingError },
|
||||
});
|
||||
}
|
||||
|
||||
private parseEpochSeconds(raw: string | undefined): Date | undefined {
|
||||
if (!raw) return undefined;
|
||||
const n = parseInt(raw, 10);
|
||||
if (Number.isNaN(n)) return undefined;
|
||||
return new Date(n * 1000);
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import {
|
||||
WaafiProvider,
|
||||
WaafiWebhookPayload,
|
||||
ProviderPaymentStatus,
|
||||
} from '@edr/payment-providers';
|
||||
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
||||
import { PrismaService } from '../../../common/prisma.service';
|
||||
import { PaymentsService } from '../payments.service';
|
||||
|
||||
@Injectable()
|
||||
export class WaafiWebhookService {
|
||||
private readonly logger = new Logger(WaafiWebhookService.name);
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private paymentsService: PaymentsService,
|
||||
private waafiProvider: WaafiProvider,
|
||||
) {}
|
||||
|
||||
async handleWebhook(payload: WaafiWebhookPayload): Promise<{ received: boolean }> {
|
||||
this.logger.log(
|
||||
`Waafi webhook received: event=${payload.eventType} ref=${payload.params?.referenceId}`,
|
||||
);
|
||||
|
||||
const signatureValid = this.waafiProvider.verifyWebhookSignature(
|
||||
payload as unknown as Record<string, unknown>,
|
||||
);
|
||||
|
||||
const merchantOrderId = payload.params?.referenceId;
|
||||
const transactionId = payload.params?.transactionId;
|
||||
const state = payload.params?.state;
|
||||
|
||||
await this.prisma.paymentWebhookEvent.create({
|
||||
data: {
|
||||
provider: PaymentMethodType.WAAFI,
|
||||
externalEventId: payload.requestId,
|
||||
merchantOrderId,
|
||||
providerTxnId: transactionId,
|
||||
signatureValid,
|
||||
status: state || 'UNKNOWN',
|
||||
payload: payload as any,
|
||||
},
|
||||
});
|
||||
|
||||
if (!signatureValid) {
|
||||
this.logger.warn(`Waafi webhook signature invalid for ref=${merchantOrderId}`);
|
||||
return { received: true };
|
||||
}
|
||||
|
||||
if (!merchantOrderId) {
|
||||
this.logger.error('Waafi webhook missing referenceId');
|
||||
return { received: true };
|
||||
}
|
||||
|
||||
const intent = await this.prisma.paymentIntent.findFirst({
|
||||
where: { merchantOrderId },
|
||||
});
|
||||
|
||||
if (!intent) {
|
||||
this.logger.warn(`No PaymentIntent found for merchantOrderId=${merchantOrderId}`);
|
||||
return { received: true };
|
||||
}
|
||||
|
||||
const mappedStatus = this.waafiProvider.mapState(state);
|
||||
|
||||
if (mappedStatus === ProviderPaymentStatus.SUCCEEDED) {
|
||||
await this.paymentsService.finalizePaymentSuccess({
|
||||
intentId: intent.id,
|
||||
providerTxnId: transactionId,
|
||||
});
|
||||
this.logger.log(`Waafi payment succeeded: intent=${intent.id} txn=${transactionId}`);
|
||||
} else if (mappedStatus === ProviderPaymentStatus.FAILED) {
|
||||
await this.paymentsService.markPaymentFailed({
|
||||
intentId: intent.id,
|
||||
failureCode: state,
|
||||
failureMessage: payload.params?.description,
|
||||
});
|
||||
this.logger.log(`Waafi payment failed: intent=${intent.id} state=${state}`);
|
||||
} else {
|
||||
await this.prisma.paymentIntent.update({
|
||||
where: { id: intent.id },
|
||||
data: {
|
||||
status: mappedStatus as unknown as PaymentIntentStatus,
|
||||
providerTxnId: transactionId,
|
||||
},
|
||||
});
|
||||
this.logger.log(`Waafi payment status updated: intent=${intent.id} status=${mappedStatus}`);
|
||||
}
|
||||
|
||||
return { received: true };
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import {All, Body, Controller, Headers, HttpCode, HttpStatus, Logger, Post} from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import {
|
||||
TelebirrWebhookPayload,
|
||||
CbeBirrWebhookPayload,
|
||||
EBirrWebhookPayload,
|
||||
CardWebhookPayload,
|
||||
} from '@edr/payment-providers';
|
||||
import { TelebirrWebhookService } from './telebirr-webhook.service';
|
||||
import { CbeBirrWebhookService } from './cbe-birr-webhook.service';
|
||||
import { EBirrWebhookService } from './ebirr-webhook.service';
|
||||
import { CardWebhookService } from './card-webhook.service';
|
||||
import { WaafiWebhookService } from './waafi-webhook.service';
|
||||
|
||||
@ApiTags('Payment Webhooks')
|
||||
@Controller('payments/webhooks')
|
||||
export class WebhooksController {
|
||||
private readonly logger = new Logger(WebhooksController.name);
|
||||
|
||||
constructor(
|
||||
private readonly telebirr: TelebirrWebhookService,
|
||||
private readonly cbeBirr: CbeBirrWebhookService,
|
||||
private readonly eBirr: EBirrWebhookService,
|
||||
private readonly card: CardWebhookService,
|
||||
private readonly waafi: WaafiWebhookService,
|
||||
) {}
|
||||
|
||||
@All('telebirr')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: 'Telebirr payment notification callback (Ethiopia)',
|
||||
description: 'Webhook endpoint for Telebirr payment status updates. Used by Ethiopian passengers.'
|
||||
})
|
||||
async receiveTelebirr(@Body() payload: TelebirrWebhookPayload) {
|
||||
|
||||
this.logger.log(
|
||||
`Telebirr webhook Called`,
|
||||
);
|
||||
|
||||
try {
|
||||
await this.telebirr.handle(payload);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(`Telebirr webhook handler threw: ${message}`);
|
||||
}
|
||||
return { code: '0', message: 'OK' };
|
||||
}
|
||||
|
||||
@Post('cbe-birr')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: 'CBE Birr payment notification callback (Ethiopia)',
|
||||
description: 'Webhook endpoint for Commercial Bank of Ethiopia payment status updates.'
|
||||
})
|
||||
async receiveCbeBirr(@Body() payload: CbeBirrWebhookPayload) {
|
||||
try {
|
||||
await this.cbeBirr.handle(payload);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(`CBE Birr webhook handler threw: ${message}`);
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post('ebirr')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: 'eBirr payment notification callback (Ethiopia)',
|
||||
description: 'Webhook endpoint for eBirr electronic payment gateway status updates.'
|
||||
})
|
||||
async receiveEBirr(@Body() payload: EBirrWebhookPayload) {
|
||||
try {
|
||||
await this.eBirr.handle(payload);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(`eBirr webhook handler threw: ${message}`);
|
||||
}
|
||||
return { code: '0000', message: 'success' };
|
||||
}
|
||||
|
||||
@Post('card')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: 'Card payment notification callback (International)',
|
||||
description: 'Webhook endpoint for international card payments (Visa, Mastercard) via Stripe.'
|
||||
})
|
||||
async receiveCard(
|
||||
@Body() payload: CardWebhookPayload,
|
||||
@Headers('stripe-signature') signature: string,
|
||||
) {
|
||||
try {
|
||||
await this.card.handle(payload, signature);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(`Card webhook handler threw: ${message}`);
|
||||
}
|
||||
return { received: true };
|
||||
}
|
||||
|
||||
@Post('waafi')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: 'Waafi payment notification callback (Djibouti)',
|
||||
description: 'Webhook endpoint for Waafi mobile money payment status updates. Used by Djiboutian passengers.'
|
||||
})
|
||||
async receiveWaafi(@Body() payload: any) {
|
||||
try {
|
||||
await this.waafi.handleWebhook(payload);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(`Waafi webhook handler threw: ${message}`);
|
||||
}
|
||||
return { responseCode: '2001', responseMsg: 'Success' };
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,10 @@ export class ReportsService {
|
||||
|
||||
async generateReport(dto: GenerateReportDto) {
|
||||
const dateFrom = new Date(dto.dateFrom);
|
||||
dateFrom.setHours(0, 0, 0, 0);
|
||||
|
||||
const dateTo = new Date(dto.dateTo);
|
||||
dateTo.setHours(23, 59, 59, 999);
|
||||
|
||||
let data: any;
|
||||
switch (dto.reportType) {
|
||||
@@ -44,14 +47,16 @@ export class ReportsService {
|
||||
}
|
||||
|
||||
private async generateRevenueReport(dateFrom: Date, dateTo: Date) {
|
||||
// Fetch all bookings in date range, regardless of status
|
||||
const bookings = await this.prisma.booking.findMany({
|
||||
where: {
|
||||
createdAt: { gte: dateFrom, lte: dateTo },
|
||||
status: { in: ['CONFIRMED', 'COMPLETED'] }
|
||||
createdAt: { gte: dateFrom, lte: dateTo }
|
||||
},
|
||||
include: { paymentIntent: true }
|
||||
});
|
||||
|
||||
console.log(`[Reports] Revenue Report: Found ${bookings.length} bookings between ${dateFrom} and ${dateTo}`);
|
||||
|
||||
const totalRevenue = bookings.reduce((sum, b) => sum + b.totalMinor, 0);
|
||||
const byPaymentMethod = bookings.reduce((acc, b) => {
|
||||
const method = b.paymentIntent?.method ?? 'UNKNOWN';
|
||||
@@ -59,12 +64,25 @@ export class ReportsService {
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
// Group by date for charts
|
||||
const byDate = bookings.reduce((acc, b) => {
|
||||
const date = b.createdAt.toISOString().split('T')[0];
|
||||
if (!acc[date]) {
|
||||
acc[date] = { totalMinor: 0, count: 0 };
|
||||
}
|
||||
acc[date].totalMinor += b.totalMinor;
|
||||
acc[date].count += 1;
|
||||
return acc;
|
||||
}, {} as Record<string, any>);
|
||||
|
||||
return {
|
||||
totalBookings: bookings.length,
|
||||
totalRevenueMinor: totalRevenue,
|
||||
totalRevenue: totalRevenue / 100,
|
||||
currency: 'ETB',
|
||||
byPaymentMethod
|
||||
byPaymentMethod,
|
||||
byDate,
|
||||
cancellationRate: 0
|
||||
};
|
||||
}
|
||||
|
||||
@@ -73,7 +91,7 @@ export class ReportsService {
|
||||
where: { departureAt: { gte: dateFrom, lte: dateTo } },
|
||||
include: {
|
||||
coachAssignments: { include: { coach: { include: { seats: true } } } },
|
||||
bookings: { where: { status: { in: ['CONFIRMED', 'COMPLETED'] } }, include: { seats: true } },
|
||||
bookings: { include: { seats: true } },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuditModule } from '../../common/audit.module';
|
||||
import { StationsController } from './stations.controller';
|
||||
import { StationsService } from './stations.service';
|
||||
|
||||
@Module({ controllers: [StationsController], providers: [StationsService], exports: [StationsService] })
|
||||
@Module({
|
||||
imports: [AuditModule],
|
||||
controllers: [StationsController],
|
||||
providers: [StationsService],
|
||||
exports: [StationsService],
|
||||
})
|
||||
export class StationsModule {}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, Inject, Optional } from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { AuditService } from '../../common/audit.service';
|
||||
import { CreateStationDto } from './stations.dto';
|
||||
|
||||
interface StationFilters {
|
||||
@@ -10,7 +12,11 @@ interface StationFilters {
|
||||
|
||||
@Injectable()
|
||||
export class StationsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private auditService: AuditService,
|
||||
@Optional() @Inject(REQUEST) private request?: any,
|
||||
) {}
|
||||
|
||||
findAll(filters: StationFilters = {}) {
|
||||
const where: any = {};
|
||||
@@ -43,20 +49,51 @@ export class StationsService {
|
||||
return s;
|
||||
}
|
||||
|
||||
create(dto: CreateStationDto) {
|
||||
return this.prisma.station.create({ data: dto });
|
||||
async create(dto: CreateStationDto) {
|
||||
const station = await this.prisma.station.create({ data: dto });
|
||||
|
||||
await this.auditService.log({
|
||||
userId: this.request?.user?.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'Station',
|
||||
entityId: station.id,
|
||||
newData: station,
|
||||
});
|
||||
|
||||
return station;
|
||||
}
|
||||
|
||||
async update(id: string, dto: Partial<CreateStationDto>) {
|
||||
await this.findOne(id); // Check if exists
|
||||
return this.prisma.station.update({
|
||||
where: { id },
|
||||
data: dto
|
||||
const oldStation = await this.findOne(id);
|
||||
const updatedStation = await this.prisma.station.update({
|
||||
where: { id },
|
||||
data: dto,
|
||||
});
|
||||
|
||||
await this.auditService.log({
|
||||
userId: this.request?.user?.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'Station',
|
||||
entityId: id,
|
||||
oldData: oldStation,
|
||||
newData: updatedStation,
|
||||
});
|
||||
|
||||
return updatedStation;
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
await this.findOne(id); // Check if exists
|
||||
return this.prisma.station.delete({ where: { id } });
|
||||
const station = await this.findOne(id);
|
||||
const deleted = await this.prisma.station.delete({ where: { id } });
|
||||
|
||||
await this.auditService.log({
|
||||
userId: this.request?.user?.id,
|
||||
action: 'DELETE',
|
||||
entityType: 'Station',
|
||||
entityId: id,
|
||||
oldData: station,
|
||||
});
|
||||
|
||||
return deleted;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,27 +2,90 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Search, Eye } from 'lucide-react';
|
||||
import { Eye, Download } from 'lucide-react';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import Badge from '@/components/ui/Badge';
|
||||
import { auditApi } from '@/lib/api';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
|
||||
export default function AuditLogsPage() {
|
||||
const [filters, setFilters] = useState({ search: '', action: '', entityType: '' });
|
||||
const [selectedLog, setSelectedLog] = useState<any>(null);
|
||||
const [showDetailsModal, setShowDetailsModal] = useState(false);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['audit-logs', filters],
|
||||
queryFn: () => auditApi.getLogs(filters),
|
||||
refetchInterval: 30000, // Refetch every 30 seconds
|
||||
});
|
||||
|
||||
const getActionBadgeColor = (action: string) => {
|
||||
switch (action) {
|
||||
case 'CREATE':
|
||||
return 'success';
|
||||
case 'UPDATE':
|
||||
return 'primary';
|
||||
case 'DELETE':
|
||||
return 'danger';
|
||||
case 'LOGIN':
|
||||
return 'info';
|
||||
case 'LOGOUT':
|
||||
return 'secondary';
|
||||
default:
|
||||
return 'secondary';
|
||||
}
|
||||
};
|
||||
|
||||
const formatJsonData = (data: any) => {
|
||||
if (!data) return 'N/A';
|
||||
try {
|
||||
return JSON.stringify(data, null, 2);
|
||||
} catch {
|
||||
return String(data);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'createdAt',
|
||||
label: 'Timestamp',
|
||||
sortable: true,
|
||||
render: (log: any) => (
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">{formatDateTime(log.createdAt)}</div>
|
||||
<div className="text-xs text-muted-foreground">{new Date(log.createdAt).toLocaleTimeString()}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'action',
|
||||
label: 'Action',
|
||||
sortable: true,
|
||||
render: (log: any) => (
|
||||
<Badge>{log.action}</Badge>
|
||||
<Badge className={getActionBadgeColor(log.action)}>
|
||||
{log.action}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'entityType',
|
||||
label: 'Entity Type',
|
||||
sortable: true,
|
||||
render: (log: any) => (
|
||||
<span className="px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded text-xs font-medium">
|
||||
{log.entityType}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'entityId',
|
||||
label: 'Entity ID',
|
||||
render: (log: any) => (
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{log.entityId ? log.entityId.substring(0, 12) : 'System'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -30,55 +93,74 @@ export default function AuditLogsPage() {
|
||||
label: 'User',
|
||||
render: (log: any) => (
|
||||
<div>
|
||||
<div className="font-medium">{log.user?.fullName || 'System'}</div>
|
||||
<div className="text-sm text-muted-foreground">{log.user?.email || 'N/A'}</div>
|
||||
<div className="font-medium text-sm">{log.user?.fullName || 'System'}</div>
|
||||
<div className="text-xs text-muted-foreground">{log.user?.email || log.userId || 'N/A'}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'entityType',
|
||||
label: 'Entity Type',
|
||||
render: (log: any) => log.entityType,
|
||||
},
|
||||
{
|
||||
key: 'entityId',
|
||||
label: 'Entity ID',
|
||||
key: 'ipAddress',
|
||||
label: 'IP Address',
|
||||
render: (log: any) => (
|
||||
<span className="font-mono text-sm">{log.entityId?.substring(0, 8)}...</span>
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{log.ipAddress || 'N/A'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'createdAt',
|
||||
label: 'Timestamp',
|
||||
sortable: true,
|
||||
render: (log: any) => formatDateTime(log.createdAt),
|
||||
},
|
||||
];
|
||||
|
||||
const actions = [
|
||||
{
|
||||
label: 'View Details',
|
||||
onClick: (log: any) => {
|
||||
window.location.href = `/audit/${log.id}`;
|
||||
setSelectedLog(log);
|
||||
setShowDetailsModal(true);
|
||||
},
|
||||
variant: 'secondary' as const,
|
||||
icon: Eye,
|
||||
},
|
||||
];
|
||||
|
||||
const logs = data?.items || [];
|
||||
const stats = {
|
||||
total: logs.length,
|
||||
creates: logs.filter((l: any) => l.action === 'CREATE').length,
|
||||
updates: logs.filter((l: any) => l.action === 'UPDATE').length,
|
||||
deletes: logs.filter((l: any) => l.action === 'DELETE').length,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Audit Logs</h1>
|
||||
<p className="text-muted-foreground">Track all system activities and changes</p>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Audit Logs</h1>
|
||||
<p className="text-muted-foreground mt-1">Track all system activities and changes</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="card">
|
||||
<div className="text-muted-foreground text-sm font-medium">Total Logs</div>
|
||||
<div className="text-2xl font-bold mt-2">{stats.total}</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="text-green-600 text-sm font-medium">Created</div>
|
||||
<div className="text-2xl font-bold mt-2">{stats.creates}</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="text-blue-600 text-sm font-medium">Updated</div>
|
||||
<div className="text-2xl font-bold mt-2">{stats.updates}</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="text-red-600 text-sm font-medium">Deleted</div>
|
||||
<div className="text-2xl font-bold mt-2">{stats.deletes}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="card">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="label">Search</label>
|
||||
<label className="label">Search (User/Entity ID)</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search logs..."
|
||||
@@ -110,22 +192,172 @@ export default function AuditLogsPage() {
|
||||
onChange={(e) => setFilters({ ...filters, entityType: e.target.value })}
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
<option value="Booking">Booking</option>
|
||||
<option value="User">User</option>
|
||||
<option value="Payment">Payment</option>
|
||||
<option value="Ticket">Ticket</option>
|
||||
<optgroup label="Master Data">
|
||||
<option value="Station">Station</option>
|
||||
<option value="Route">Route</option>
|
||||
<option value="RouteStop">Route Stop</option>
|
||||
<option value="Train">Train</option>
|
||||
<option value="TrainSchedule">Train Schedule</option>
|
||||
<option value="Coach">Coach</option>
|
||||
<option value="CoachType">Coach Type</option>
|
||||
<option value="SeatClass">Seat Class</option>
|
||||
<option value="FareRule">Fare Rule</option>
|
||||
<option value="RouteFareRule">Route Fare Rule</option>
|
||||
<option value="SegmentFareRule">Segment Fare Rule</option>
|
||||
<option value="BaggageAllowance">Baggage Allowance</option>
|
||||
</optgroup>
|
||||
<optgroup label="Operations">
|
||||
<option value="Booking">Booking</option>
|
||||
<option value="Payment">Payment</option>
|
||||
<option value="Ticket">Ticket</option>
|
||||
<option value="Seat">Seat</option>
|
||||
<option value="SeatBlock">Seat Block</option>
|
||||
</optgroup>
|
||||
<optgroup label="Users & Access">
|
||||
<option value="User">User</option>
|
||||
<option value="Agent">Agent</option>
|
||||
<option value="Passenger">Passenger</option>
|
||||
</optgroup>
|
||||
<optgroup label="System & Features">
|
||||
<option value="Notification">Notification</option>
|
||||
<option value="Promotion">Promotion</option>
|
||||
<option value="Loyalty">Loyalty</option>
|
||||
<option value="Wallet">Wallet</option>
|
||||
<option value="FraudAlert">Fraud Alert</option>
|
||||
<option value="FraudRule">Fraud Rule</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
onClick={() => setFilters({ search: '', action: '', entityType: '' })}
|
||||
className="w-full"
|
||||
>
|
||||
Clear Filters
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data Table */}
|
||||
<DataTable
|
||||
data={data?.items || []}
|
||||
data={logs}
|
||||
columns={columns}
|
||||
actions={actions}
|
||||
loading={isLoading}
|
||||
emptyMessage="No audit logs found"
|
||||
/>
|
||||
|
||||
{/* Details Modal */}
|
||||
<Modal
|
||||
isOpen={showDetailsModal}
|
||||
onClose={() => {
|
||||
setShowDetailsModal(false);
|
||||
setSelectedLog(null);
|
||||
}}
|
||||
title={`${selectedLog?.action} - ${selectedLog?.entityType}`}
|
||||
size="lg"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Basic Info */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground">Timestamp</label>
|
||||
<p className="text-sm mt-1">{formatDateTime(selectedLog?.createdAt)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground">Action</label>
|
||||
<p className="text-sm mt-1">
|
||||
<Badge className={getActionBadgeColor(selectedLog?.action)}>
|
||||
{selectedLog?.action}
|
||||
</Badge>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground">Entity Type</label>
|
||||
<p className="text-sm mt-1 font-mono">{selectedLog?.entityType}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground">Entity ID</label>
|
||||
<p className="text-sm mt-1 font-mono text-muted-foreground">
|
||||
{selectedLog?.entityId || 'System'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User Info */}
|
||||
{selectedLog?.user && (
|
||||
<div className="border-t pt-4">
|
||||
<h4 className="text-sm font-semibold mb-2">User Information</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground">Name</label>
|
||||
<p className="text-sm mt-1">{selectedLog?.user?.fullName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground">Email</label>
|
||||
<p className="text-sm mt-1">{selectedLog?.user?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Network Info */}
|
||||
{(selectedLog?.ipAddress || selectedLog?.userAgent) && (
|
||||
<div className="border-t pt-4">
|
||||
<h4 className="text-sm font-semibold mb-2">Network Information</h4>
|
||||
<div className="space-y-2">
|
||||
{selectedLog?.ipAddress && (
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground">IP Address</label>
|
||||
<p className="text-sm mt-1 font-mono">{selectedLog?.ipAddress}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedLog?.userAgent && (
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground">User Agent</label>
|
||||
<p className="text-xs mt-1 font-mono break-all text-muted-foreground">
|
||||
{selectedLog?.userAgent}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Changes */}
|
||||
{(selectedLog?.oldData || selectedLog?.newData) && (
|
||||
<div className="border-t pt-4">
|
||||
<h4 className="text-sm font-semibold mb-2">Data Changes</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{selectedLog?.oldData && (
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-red-600">Old Data</label>
|
||||
<pre className="text-xs mt-1 p-2 bg-red-50 dark:bg-red-950/20 rounded border border-red-200 dark:border-red-900 overflow-auto max-h-48 text-muted-foreground">
|
||||
{formatJsonData(selectedLog?.oldData)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{selectedLog?.newData && (
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-green-600">New Data</label>
|
||||
<pre className="text-xs mt-1 p-2 bg-green-50 dark:bg-green-950/20 rounded border border-green-200 dark:border-green-900 overflow-auto max-h-48 text-muted-foreground">
|
||||
{formatJsonData(selectedLog?.newData)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Raw Log ID */}
|
||||
<div className="border-t pt-4">
|
||||
<label className="text-xs font-semibold text-muted-foreground">Log ID</label>
|
||||
<p className="text-xs mt-1 font-mono text-muted-foreground break-all">{selectedLog?.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Search, Grid3x3, Edit, Trash2 } from 'lucide-react';
|
||||
import { Plus, Search, Grid3x3, Edit, Trash2, Bed, Armchair } from 'lucide-react';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
@@ -11,6 +11,135 @@ import { fleetApi, apiClient } from '@/lib/api';
|
||||
|
||||
type Tab = 'types' | 'coaches';
|
||||
|
||||
const getBedLabel = (bedPosition: string | null): string => {
|
||||
if (bedPosition === 'upper') return 'U';
|
||||
if (bedPosition === 'middle') return 'M';
|
||||
if (bedPosition === 'lower') return 'L';
|
||||
return '';
|
||||
};
|
||||
|
||||
const renderBedVisualization = (coach: any) => {
|
||||
const seats = coach.seats || [];
|
||||
const validSeats = seats.filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-'));
|
||||
|
||||
if (validSeats.length === 0) {
|
||||
return <div className="text-xs text-muted-foreground">No seats</div>;
|
||||
}
|
||||
|
||||
const hasBedPositionData = validSeats.some((s: any) => s.bedPosition);
|
||||
const isBedCoach = coach.coachType?.name?.toLowerCase().includes('bed');
|
||||
|
||||
if (!isBedCoach || !hasBedPositionData) {
|
||||
// Regular seat layout
|
||||
const arrangement = coach.seatArrangement || coach.arrangement || '2+2';
|
||||
const [left, right] = arrangement.split('+').map(p => parseInt(p.trim()));
|
||||
const cols = new Map<number, any[]>();
|
||||
|
||||
for (const seat of validSeats) {
|
||||
if (!cols.has(seat.row)) cols.set(seat.row, []);
|
||||
cols.get(seat.row)!.push(seat);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{Array.from(cols.entries()).map(([row, rowSeats]) => (
|
||||
<div key={row} className="flex gap-3 justify-start">
|
||||
<div className="flex gap-0.5">
|
||||
{rowSeats.slice(0, left).map((s: any) => (
|
||||
<div key={s.id} className="w-6 h-6 rounded bg-green-500 flex items-center justify-center">
|
||||
<Armchair className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-0.5">
|
||||
{rowSeats.slice(left).map((s: any) => (
|
||||
<div key={s.id} className="w-6 h-6 rounded bg-green-500 flex items-center justify-center">
|
||||
<Armchair className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Bed layout with pairing
|
||||
const seatsByRow = new Map<number, any[]>();
|
||||
for (const seat of validSeats) {
|
||||
if (!seatsByRow.has(seat.row)) seatsByRow.set(seat.row, []);
|
||||
seatsByRow.get(seat.row)!.push(seat);
|
||||
}
|
||||
|
||||
const beds = coach.coachType?.name?.toLowerCase().includes('vip') ? 'w-12' : 'w-10';
|
||||
const rows = Array.from(seatsByRow.entries()).map(([r, s]) => s);
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{rows.map((rowSeats: any[], idx: number) => {
|
||||
const rowNumber = rowSeats[0]?.row || (idx + 1);
|
||||
const isFirstInPair = (rowNumber - 1) % 2 === 0;
|
||||
const isLastRow = idx === rows.length - 1;
|
||||
const nextRowSeats = !isLastRow ? rows[idx + 1] : null;
|
||||
|
||||
return (
|
||||
<div key={`row-${idx}`}>
|
||||
{/* Row 1 of pair - label above */}
|
||||
{isFirstInPair && (
|
||||
<div className="flex gap-0.5 text-xs text-gray-500 mb-0.5">
|
||||
{rowSeats.map((s: any) => (
|
||||
<div key={`label-${s.id}`} className={`${beds} h-2 flex items-center justify-center text-xs font-bold leading-3`}>
|
||||
{s.seatNumber}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Row 1 of pair - beds */}
|
||||
<div className="flex gap-0.5">
|
||||
{rowSeats.map((s: any) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className={`${beds} h-5 rounded flex items-center justify-center bg-green-500`}
|
||||
style={isFirstInPair ? { transform: 'scaleY(-1)' } : undefined}
|
||||
>
|
||||
<Bed className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Numbers between rows */}
|
||||
{isFirstInPair && nextRowSeats && (
|
||||
<div className="flex gap-0.5 text-xs text-gray-500 my-0.5">
|
||||
{rowSeats.map((s: any, idx: number) => {
|
||||
const nextSeat = nextRowSeats[idx];
|
||||
return (
|
||||
<div key={`between-${s.id}`} className={`${beds} h-2 flex items-center justify-center text-xs font-bold leading-3`}>
|
||||
{nextSeat?.seatNumber}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{/* Row 2 of pair - beds */}
|
||||
{!isFirstInPair && (
|
||||
<div className="flex gap-0.5">
|
||||
{rowSeats.map((s: any) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className={`${beds} h-5 rounded flex items-center justify-center bg-green-500`}
|
||||
>
|
||||
<Bed className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!isFirstInPair && <div className="h-1" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function CoachesPage() {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('coaches');
|
||||
const [search, setSearch] = useState('');
|
||||
@@ -228,6 +357,15 @@ export default function CoachesPage() {
|
||||
<span className="text-sm">{coach.coachType?.name || 'N/A'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'visualization',
|
||||
label: 'Seats/Beds',
|
||||
render: (coach: any) => (
|
||||
<div className="bg-gray-50 dark:bg-gray-900/30 rounded p-2 max-w-xs overflow-x-auto">
|
||||
{renderBedVisualization(coach)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'arrangement',
|
||||
label: 'Arrangement',
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Ticket, Users, DollarSign, TrendingUp } from 'lucide-react';
|
||||
import { Ticket, Users, DollarSign, Percent } from 'lucide-react';
|
||||
import StatCard from '@/components/dashboard/StatCard';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import Badge from '@/components/ui/Badge';
|
||||
import { dashboardApi } from '@/lib/api/dashboard';
|
||||
import { formatCurrency, formatDateTime } from '@/lib/utils';
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
|
||||
|
||||
const COLORS = ['#2563eb', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'];
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { data: stats, isLoading: statsLoading } = useQuery({
|
||||
@@ -20,22 +22,55 @@ export default function DashboardPage() {
|
||||
queryFn: () => dashboardApi.getRevenueChart(30),
|
||||
});
|
||||
|
||||
const { data: recentBookingsData, isLoading: bookingsLoading } = useQuery<any>({
|
||||
const { data: recentBookingsData, isLoading: bookingsLoading } = useQuery<any[]>({
|
||||
queryKey: ['recent-bookings'],
|
||||
queryFn: () => dashboardApi.getRecentBookings(10),
|
||||
});
|
||||
|
||||
const recentBookings = Array.isArray(recentBookingsData)
|
||||
? recentBookingsData
|
||||
: recentBookingsData?.items || recentBookingsData?.data || [];
|
||||
const { data: topAgents, isLoading: agentsLoading } = useQuery({
|
||||
queryKey: ['top-agents'],
|
||||
queryFn: () => dashboardApi.getTopAgents(5),
|
||||
});
|
||||
|
||||
const columns = [
|
||||
const { data: occupancyTrend, isLoading: occupancyLoading } = useQuery({
|
||||
queryKey: ['occupancy-trend'],
|
||||
queryFn: () => dashboardApi.getOccupancyTrend(7),
|
||||
});
|
||||
|
||||
const { data: upcomingTrips, isLoading: tripsLoading } = useQuery({
|
||||
queryKey: ['upcoming-trips'],
|
||||
queryFn: () => dashboardApi.getUpcomingTrips(5),
|
||||
});
|
||||
|
||||
const { data: paymentMethods } = useQuery({
|
||||
queryKey: ['payment-methods'],
|
||||
queryFn: dashboardApi.getPaymentMethods,
|
||||
});
|
||||
|
||||
const recentBookings = Array.isArray(recentBookingsData) ? recentBookingsData : [];
|
||||
|
||||
const bookingColumns = [
|
||||
{ key: 'reference', label: 'Reference', render: (item: any) => item.bookingRef || item.reference },
|
||||
{ key: 'passenger', label: 'Passenger', render: (item: any) => item.passenger?.fullName || item.contactEmail || 'N/A' },
|
||||
{ key: 'amount', label: 'Amount', render: (item: any) => formatCurrency(item.totalMinor || item.amount, item.currency || 'ETB') },
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
key: 'passenger',
|
||||
label: 'Passenger',
|
||||
render: (item: any) => {
|
||||
if (item.passenger?.fullName) {
|
||||
return item.passenger.fullName;
|
||||
}
|
||||
if (item.contactEmail) {
|
||||
return item.contactEmail;
|
||||
}
|
||||
if (item.contactPhone) {
|
||||
return item.contactPhone;
|
||||
}
|
||||
return 'N/A';
|
||||
}
|
||||
},
|
||||
{ key: 'amount', label: 'Amount', render: (item: any) => formatCurrency(item.totalMinor || item.amount, item.currency || 'ETB') },
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
render: (item: any) => (
|
||||
<Badge variant="status" status={item.status}>
|
||||
{item.status}
|
||||
@@ -45,19 +80,43 @@ export default function DashboardPage() {
|
||||
{ key: 'createdAt', label: 'Created', render: (item: any) => formatDateTime(item.createdAt) },
|
||||
];
|
||||
|
||||
const agentColumns = [
|
||||
{ key: 'name', label: 'Agent Name', render: (item: any) => item.name || item.fullName },
|
||||
{ key: 'bookings', label: 'Bookings', render: (item: any) => item.bookingsCount || item.bookings || 0 },
|
||||
{ key: 'revenue', label: 'Revenue', render: (item: any) => formatCurrency(item.totalRevenue || item.revenue || 0, 'ETB') },
|
||||
{ key: 'commission', label: 'Commission', render: (item: any) => formatCurrency(item.commission || 0, 'ETB') },
|
||||
];
|
||||
|
||||
const tripColumns = [
|
||||
{ key: 'trainName', label: 'Train', render: (item: any) => item.trainName || item.train?.name },
|
||||
{ key: 'route', label: 'Route', render: (item: any) => `${item.originStation?.name || item.origin?.name} → ${item.destinationStation?.name || item.destination?.name}` },
|
||||
{ key: 'departure', label: 'Departure', render: (item: any) => formatDateTime(item.departureAt) },
|
||||
{ key: 'seats', label: 'Seats', render: (item: any) => `${item.availableSeats || 0}/${item.totalSeats || 0}` },
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
render: (item: any) => (
|
||||
<Badge variant="status" status={item.status}>
|
||||
{item.status}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Dashboard</h1>
|
||||
<p className="text-muted-foreground mt-1">Hello, welcome back! Here's what's happening today.</p>
|
||||
<p className="text-muted-foreground mt-1">Welcome back! Here's your operational summary.</p>
|
||||
</div>
|
||||
|
||||
{/* Primary Metrics */}
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
title="Total Bookings"
|
||||
value={statsLoading ? '...' : (stats?.totalBookings || 0).toLocaleString()}
|
||||
icon={Ticket}
|
||||
color="blue"
|
||||
color="blue"
|
||||
/>
|
||||
<StatCard
|
||||
title="Total Revenue"
|
||||
@@ -74,35 +133,107 @@ export default function DashboardPage() {
|
||||
<StatCard
|
||||
title="Occupancy Rate"
|
||||
value={statsLoading ? '...' : `${stats?.occupancyRate || 0}%`}
|
||||
icon={TrendingUp}
|
||||
color="green"
|
||||
icon={Percent}
|
||||
color="orange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!revenueLoading && revenueData && revenueData.length > 0 && (
|
||||
{/* Charts Row */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{/* Revenue Trend */}
|
||||
{!revenueLoading && revenueData && revenueData.length > 0 && (
|
||||
<div className="card">
|
||||
<h2 className="mb-4 text-lg font-semibold text-foreground">Revenue Trend (Last 30 Days)</h2>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={revenueData}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 12 }} className="text-muted-foreground" />
|
||||
<YAxis tick={{ fontSize: 12 }} className="text-muted-foreground" />
|
||||
<Tooltip formatter={(value: number) => formatCurrency(value, 'ETB')} />
|
||||
<Line type="monotone" dataKey="revenue" stroke="#2563eb" strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Occupancy Trend */}
|
||||
{!occupancyLoading && occupancyTrend && occupancyTrend.length > 0 && (
|
||||
<div className="card">
|
||||
<h2 className="mb-4 text-lg font-semibold text-foreground">Occupancy Trend (Last 7 Days)</h2>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={occupancyTrend}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 12 }} className="text-muted-foreground" />
|
||||
<YAxis tick={{ fontSize: 12 }} className="text-muted-foreground" />
|
||||
<Tooltip formatter={(value: number) => `${value}%`} />
|
||||
<Bar dataKey="occupancyRate" fill="#10b981" radius={[8, 8, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Payment Methods Distribution */}
|
||||
{paymentMethods && paymentMethods.length > 0 && (
|
||||
<div className="card">
|
||||
<h2 className="mb-4 text-lg font-semibold text-foreground">Revenue Trend (Last 30 Days)</h2>
|
||||
<h2 className="mb-4 text-lg font-semibold text-foreground">Payment Methods Distribution</h2>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={revenueData}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 12 }} className="text-muted-foreground" />
|
||||
<YAxis tick={{ fontSize: 12 }} className="text-muted-foreground" />
|
||||
<Tooltip formatter={(value: number) => formatCurrency(value, 'ETB')} />
|
||||
<Line type="monotone" dataKey="revenue" stroke="#2563eb" strokeWidth={2} />
|
||||
</LineChart>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={paymentMethods}
|
||||
dataKey="count"
|
||||
nameKey="method"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={80}
|
||||
label
|
||||
>
|
||||
{paymentMethods.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Bookings */}
|
||||
<div className="card">
|
||||
<h2 className="mb-4 text-lg font-semibold text-foreground">Recent Bookings</h2>
|
||||
<DataTable
|
||||
data={recentBookings}
|
||||
columns={columns}
|
||||
columns={bookingColumns}
|
||||
loading={bookingsLoading}
|
||||
emptyMessage="No recent bookings"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Upcoming Trips */}
|
||||
{upcomingTrips && upcomingTrips.length > 0 && (
|
||||
<div className="card">
|
||||
<h2 className="mb-4 text-lg font-semibold text-foreground">Upcoming Trips</h2>
|
||||
<DataTable
|
||||
data={upcomingTrips}
|
||||
columns={tripColumns}
|
||||
loading={tripsLoading}
|
||||
emptyMessage="No upcoming trips"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top Agents */}
|
||||
{topAgents && topAgents.length > 0 && (
|
||||
<div className="card">
|
||||
<h2 className="mb-4 text-lg font-semibold text-foreground">Top Performing Agents</h2>
|
||||
<DataTable
|
||||
data={topAgents}
|
||||
columns={agentColumns}
|
||||
loading={agentsLoading}
|
||||
emptyMessage="No agent data"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { Train } from 'lucide-react';
|
||||
import { useTheme } from '@/lib/theme-store';
|
||||
import { Train, Eye, EyeOff, Sun, Moon } from 'lucide-react';
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const router = useRouter();
|
||||
const { login } = useAuthStore();
|
||||
const { isDark, toggleTheme } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -29,77 +37,109 @@ export default function LoginPage() {
|
||||
}
|
||||
};
|
||||
|
||||
if (!isMounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
{/* Banner Image Side */}
|
||||
<div className="hidden lg:flex lg:w-1/2 relative bg-gradient-to-br from-[rgb(20,113,76)] to-[rgb(15,85,57)] items-center justify-center">
|
||||
<div className="absolute inset-0 bg-[url('/banner.jpg')] bg-cover bg-center opacity-20"></div>
|
||||
<div className="relative z-10 text-center px-12">
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-white/10 backdrop-blur-sm shadow-2xl">
|
||||
<Train className="h-12 w-12 text-white" />
|
||||
<div className="flex min-h-screen relative bg-gradient-to-br from-[rgb(20,113,76)] to-[rgb(15,85,57)]">
|
||||
{/* Full Screen Banner Background */}
|
||||
<div className="absolute inset-0 bg-[url('/banner.jpg')] bg-cover bg-center opacity-50"></div>
|
||||
|
||||
{/* Content Overlay */}
|
||||
<div className="relative z-10 flex items-center justify-start w-full px-4 lg:px-16">
|
||||
<div className="w-full max-w-sm">
|
||||
{/* Login Card with Shadow */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl border border-white/20 dark:border-gray-700/50 overflow-hidden backdrop-blur-sm">
|
||||
{/* Card Header with Logo, App Name and Theme Toggle */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700/50 bg-gray-50 dark:bg-gray-700/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-[rgb(20,113,76)] shadow-md">
|
||||
<Train className="h-9 w-9 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-gray-900 dark:text-white">Ethio-Djibouti Railway</h2>
|
||||
<p className="text-lg text-gray-600 dark:text-gray-400">Passenger Back-office</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 rounded-lg bg-white/80 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
{isDark ? (
|
||||
<Sun className="w-5 h-5 text-yellow-500" />
|
||||
) : (
|
||||
<Moon className="w-5 h-5 text-gray-700" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Card Body */}
|
||||
<div className="p-6">
|
||||
<div className="mb-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">Welcome back!</h2>
|
||||
<p className="text-xl text-gray-900 dark:text-white">Sign in to continue.</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-sm text-red-800 dark:text-red-200 border border-red-200 dark:border-red-800">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-[rgb(20,113,76)] focus:border-transparent"
|
||||
placeholder="name@email.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Password</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-[rgb(20,113,76)] focus:border-transparent"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
|
||||
aria-label="Toggle password visibility"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="w-4 h-4" />
|
||||
) : (
|
||||
<Eye className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full mt-6 py-2 bg-[rgb(20,113,76)] text-white font-semibold rounded-lg border-2 border-[rgb(20,113,76)] hover:bg-[rgb(16,90,61)] hover:border-[rgb(16,90,61)] disabled:opacity-50 transition-all duration-200"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-5xl font-bold text-white mb-4">EDR</h1>
|
||||
<p className="text-lg text-white/80">Passenger Back-office</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Login Form Side */}
|
||||
<div className="flex w-full lg:w-1/2 items-center justify-center bg-gray-100 dark:bg-gray-900 p-8">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="card">
|
||||
<div className="mb-6">
|
||||
<div className="mb-4 flex justify-center lg:hidden">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-[rgb(20,113,76)] shadow-lg">
|
||||
<Train className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<div className="text-4xl font-bold text-gray-900 dark:text-white ps-4">EDR</div>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">Sign in to get started.</h2>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-sm text-red-800 dark:text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="label">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn btn-primary w-full disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,64 +2,467 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Download } from 'lucide-react';
|
||||
import { Download, Eye, Plus } from 'lucide-react';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import Badge from '@/components/ui/Badge';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import { reportsApi } from '@/lib/api';
|
||||
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
||||
|
||||
export default function OperationalreportsPage() {
|
||||
export default function OperationalReportsPage() {
|
||||
const [filters, setFilters] = useState({ search: '', reportType: '' });
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['operational-reports', filters],
|
||||
queryFn: () => reportsApi.getOperationalReports(filters),
|
||||
const [selectedReport, setSelectedReport] = useState<any>(null);
|
||||
const [showDetailsModal, setShowDetailsModal] = useState(false);
|
||||
const [showGenerateModal, setShowGenerateModal] = useState(false);
|
||||
const [generateForm, setGenerateForm] = useState({
|
||||
reportType: 'REVENUE',
|
||||
dateFrom: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||
dateTo: new Date().toISOString().split('T')[0],
|
||||
});
|
||||
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['operational-reports', filters],
|
||||
queryFn: () => reportsApi.listReports(filters.reportType || undefined),
|
||||
});
|
||||
|
||||
const handleGenerateReport = async () => {
|
||||
try {
|
||||
await reportsApi.generateReport(generateForm);
|
||||
refetch();
|
||||
setShowGenerateModal(false);
|
||||
} catch (error) {
|
||||
console.error('Error generating report:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getReportTypeBadgeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'REVENUE':
|
||||
return 'success';
|
||||
case 'OCCUPANCY':
|
||||
return 'primary';
|
||||
case 'PERFORMANCE':
|
||||
return 'info';
|
||||
case 'AGENT_SALES':
|
||||
return 'secondary';
|
||||
default:
|
||||
return 'secondary';
|
||||
}
|
||||
};
|
||||
|
||||
const formatReportType = (type: string) => {
|
||||
const typeMap: { [key: string]: string } = {
|
||||
REVENUE: 'Revenue Report',
|
||||
OCCUPANCY: 'Occupancy Report',
|
||||
PERFORMANCE: 'Performance Report',
|
||||
AGENT_SALES: 'Agent Sales Report',
|
||||
CANCELLATIONS: 'Cancellations Report',
|
||||
PAYMENT_METHODS: 'Payment Methods Report',
|
||||
};
|
||||
return typeMap[type] || type;
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ key: 'reportType', label: 'Type', render: (report: any) => <Badge>{report.reportType}</Badge> },
|
||||
{ key: 'period', label: 'Period', render: (report: any) => report.period || 'N/A' },
|
||||
{ key: 'generatedBy', label: 'Generated By', render: (report: any) => report.generatedBy?.fullName || 'System' },
|
||||
{ key: 'createdAt', label: 'Generated', render: (report: any) => formatDateTime(report.createdAt) },
|
||||
];
|
||||
{
|
||||
key: 'reportType',
|
||||
label: 'Report Type',
|
||||
sortable: true,
|
||||
render: (report: any) => (
|
||||
<Badge className={getReportTypeBadgeColor(report.reportType)}>
|
||||
{formatReportType(report.reportType)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'dateFrom',
|
||||
label: 'Period From',
|
||||
sortable: true,
|
||||
render: (report: any) => (
|
||||
<span className="text-sm">{new Date(report.dateFrom).toLocaleDateString()}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'dateTo',
|
||||
label: 'Period To',
|
||||
sortable: true,
|
||||
render: (report: any) => (
|
||||
<span className="text-sm">{new Date(report.dateTo).toLocaleDateString()}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'data',
|
||||
label: 'Summary',
|
||||
render: (report: any) => {
|
||||
const data = report.data || {};
|
||||
if (report.reportType === 'REVENUE') {
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<p className="font-medium">{formatCurrency(data.totalRevenueMinor || 0, 'ETB')}</p>
|
||||
<p className="text-xs text-muted-foreground">{data.totalBookings || 0} bookings</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (report.reportType === 'OCCUPANCY') {
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<p className="font-medium">{(data.averageOccupancyRate || 0).toFixed(1)}% occupancy</p>
|
||||
<p className="text-xs text-muted-foreground">{data.totalSchedules || 0} schedules</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (report.reportType === 'AGENT_SALES') {
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<p className="font-medium">{data.totalAgentBookings || 0} bookings</p>
|
||||
<p className="text-xs text-muted-foreground">{Object.keys(data.byAgent || {}).length} agents</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (report.reportType === 'CANCELLATIONS') {
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<p className="font-medium">{data.totalCancellations || 0} cancellations</p>
|
||||
<p className="text-xs text-muted-foreground">Refunded: {formatCurrency(data.totalRefundedMinor || 0, 'ETB')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (report.reportType === 'PAYMENT_METHODS') {
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<p className="font-medium">{data.totalPayments || 0} payments</p>
|
||||
<p className="text-xs text-muted-foreground">{Object.keys(data.byMethod || {}).length} methods</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <span className="text-sm text-muted-foreground">View details</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'createdAt',
|
||||
label: 'Generated',
|
||||
sortable: true,
|
||||
render: (report: any) => (
|
||||
<span className="text-sm">{formatDateTime(report.createdAt)}</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const actions = [
|
||||
{
|
||||
label: 'View Details',
|
||||
onClick: (report: any) => {
|
||||
setSelectedReport(report);
|
||||
setShowDetailsModal(true);
|
||||
},
|
||||
variant: 'secondary' as const,
|
||||
icon: Eye,
|
||||
},
|
||||
];
|
||||
|
||||
const reports = data?.items || data || [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Operational Reports</h1>
|
||||
<p className="text-muted-foreground">View operational reports and analytics</p>
|
||||
<h1 className="text-3xl font-bold text-foreground">Operational Reports</h1>
|
||||
<p className="text-muted-foreground mt-1">View and analyze operational performance</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<ActionButton icon={Plus} variant="primary" onClick={() => setShowGenerateModal(true)}>
|
||||
Generate Report
|
||||
</ActionButton>
|
||||
<ActionButton icon={Download} variant="secondary">
|
||||
Export All
|
||||
</ActionButton>
|
||||
</div>
|
||||
<ActionButton icon={Download} variant="secondary">Export</ActionButton>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="card">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
|
||||
<div>
|
||||
<label className="label">Search</label>
|
||||
<input type="text" placeholder="Search..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Report Type</label>
|
||||
<select className="input" value={filters.reportType} onChange={(e) => setFilters({ ...filters, reportType: e.target.value })}>
|
||||
<option value="">All Types</option>
|
||||
<option value="REVENUE">Revenue</option>
|
||||
<option value="OCCUPANCY">Occupancy</option>
|
||||
<option value="PERFORMANCE">Performance</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Search (Report ID/Type)</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search reports..."
|
||||
className="input"
|
||||
value={filters.search}
|
||||
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Report Type</label>
|
||||
<select
|
||||
className="input"
|
||||
value={filters.reportType}
|
||||
onChange={(e) => setFilters({ ...filters, reportType: e.target.value })}
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
<option value="REVENUE">Revenue Report</option>
|
||||
<option value="OCCUPANCY">Occupancy Report</option>
|
||||
<option value="AGENT_SALES">Agent Sales Report</option>
|
||||
<option value="CANCELLATIONS">Cancellations Report</option>
|
||||
<option value="PAYMENT_METHODS">Payment Methods Report</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
onClick={() => setFilters({ search: '', reportType: '' })}
|
||||
className="w-full"
|
||||
>
|
||||
Clear Filters
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reports Table */}
|
||||
<DataTable
|
||||
data={data?.items || data || []}
|
||||
data={reports}
|
||||
columns={columns}
|
||||
actions={actions}
|
||||
loading={isLoading}
|
||||
emptyMessage="No operational reports found"
|
||||
/>
|
||||
|
||||
{/* Generate Report Modal */}
|
||||
<Modal
|
||||
isOpen={showGenerateModal}
|
||||
onClose={() => setShowGenerateModal(false)}
|
||||
title="Generate Report"
|
||||
size="sm"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="label">Report Type</label>
|
||||
<select
|
||||
className="input"
|
||||
value={generateForm.reportType}
|
||||
onChange={(e) => setGenerateForm({ ...generateForm, reportType: e.target.value })}
|
||||
>
|
||||
<option value="REVENUE">Revenue Report</option>
|
||||
<option value="OCCUPANCY">Occupancy Report</option>
|
||||
<option value="AGENT_SALES">Agent Sales Report</option>
|
||||
<option value="CANCELLATIONS">Cancellations Report</option>
|
||||
<option value="PAYMENT_METHODS">Payment Methods Report</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Date From</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input"
|
||||
value={generateForm.dateFrom}
|
||||
onChange={(e) => setGenerateForm({ ...generateForm, dateFrom: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Date To</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input"
|
||||
value={generateForm.dateTo}
|
||||
onChange={(e) => setGenerateForm({ ...generateForm, dateTo: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-4">
|
||||
<ActionButton
|
||||
variant="primary"
|
||||
onClick={handleGenerateReport}
|
||||
className="flex-1"
|
||||
>
|
||||
Generate
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
onClick={() => setShowGenerateModal(false)}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Details Modal */}
|
||||
<Modal
|
||||
isOpen={showDetailsModal}
|
||||
onClose={() => {
|
||||
setShowDetailsModal(false);
|
||||
setSelectedReport(null);
|
||||
}}
|
||||
title={formatReportType(selectedReport?.reportType)}
|
||||
size="lg"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Report Header */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground">Report Type</label>
|
||||
<p className="text-sm mt-1 font-medium">{formatReportType(selectedReport?.reportType)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground">Generated</label>
|
||||
<p className="text-sm mt-1">{formatDateTime(selectedReport?.createdAt)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground">Period From</label>
|
||||
<p className="text-sm mt-1">{new Date(selectedReport?.dateFrom).toLocaleDateString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground">Period To</label>
|
||||
<p className="text-sm mt-1">{new Date(selectedReport?.dateTo).toLocaleDateString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Revenue Report Data */}
|
||||
{selectedReport?.reportType === 'REVENUE' && (
|
||||
<div className="border-t pt-4">
|
||||
<h4 className="text-sm font-semibold mb-3">Revenue Metrics</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-blue-50 dark:bg-blue-950/20 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Total Revenue</p>
|
||||
<p className="text-lg font-bold mt-1">
|
||||
{formatCurrency(selectedReport?.data?.totalRevenueMinor || 0, 'ETB')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-green-50 dark:bg-green-950/20 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Total Bookings</p>
|
||||
<p className="text-lg font-bold mt-1">
|
||||
{(selectedReport?.data?.totalBookings || 0).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{selectedReport?.data?.byPaymentMethod && (
|
||||
<div className="mt-4">
|
||||
<p className="text-xs font-semibold mb-2 text-muted-foreground">By Payment Method</p>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(selectedReport.data.byPaymentMethod).map(([method, amount]: [string, any]) => (
|
||||
<div key={method} className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground capitalize">{method.toLowerCase().replace('_', ' ')}</span>
|
||||
<span className="font-medium">{formatCurrency(amount, 'ETB')}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Occupancy Report Data */}
|
||||
{selectedReport?.reportType === 'OCCUPANCY' && (
|
||||
<div className="border-t pt-4">
|
||||
<h4 className="text-sm font-semibold mb-3">Occupancy Metrics</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-blue-50 dark:bg-blue-950/20 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Avg Occupancy Rate</p>
|
||||
<p className="text-lg font-bold mt-1">
|
||||
{(selectedReport?.data?.averageOccupancyRate || 0).toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-green-50 dark:bg-green-950/20 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Total Schedules</p>
|
||||
<p className="text-lg font-bold mt-1">
|
||||
{(selectedReport?.data?.totalSchedules || 0).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Agent Sales Report Data */}
|
||||
{selectedReport?.reportType === 'AGENT_SALES' && (
|
||||
<div className="border-t pt-4">
|
||||
<h4 className="text-sm font-semibold mb-3">Agent Sales Metrics</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-blue-50 dark:bg-blue-950/20 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Total Bookings</p>
|
||||
<p className="text-lg font-bold mt-1">
|
||||
{(selectedReport?.data?.totalAgentBookings || 0).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-green-50 dark:bg-green-950/20 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Active Agents</p>
|
||||
<p className="text-lg font-bold mt-1">
|
||||
{Object.keys(selectedReport?.data?.byAgent || {}).length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{selectedReport?.data?.byAgent && (
|
||||
<div className="mt-4">
|
||||
<p className="text-xs font-semibold mb-2 text-muted-foreground">By Agent</p>
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{Object.entries(selectedReport.data.byAgent).map(([agent, stats]: [string, any]) => (
|
||||
<div key={agent} className="text-sm border-b pb-2 last:border-0">
|
||||
<p className="font-medium">{agent}</p>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
<p>Bookings: {stats.bookings} | Revenue: {formatCurrency(stats.revenueMinor, 'ETB')}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cancellations Report Data */}
|
||||
{selectedReport?.reportType === 'CANCELLATIONS' && (
|
||||
<div className="border-t pt-4">
|
||||
<h4 className="text-sm font-semibold mb-3">Cancellation Metrics</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-blue-50 dark:bg-blue-950/20 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Total Cancellations</p>
|
||||
<p className="text-lg font-bold mt-1">
|
||||
{(selectedReport?.data?.totalCancellations || 0).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-green-50 dark:bg-green-950/20 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Total Refunded</p>
|
||||
<p className="text-lg font-bold mt-1">
|
||||
{formatCurrency(selectedReport?.data?.totalRefundedMinor || 0, 'ETB')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment Methods Report Data */}
|
||||
{selectedReport?.reportType === 'PAYMENT_METHODS' && (
|
||||
<div className="border-t pt-4">
|
||||
<h4 className="text-sm font-semibold mb-3">Payment Method Breakdown</h4>
|
||||
<div className="bg-blue-50 dark:bg-blue-950/20 rounded-lg p-3 mb-4">
|
||||
<p className="text-xs text-muted-foreground">Total Payments</p>
|
||||
<p className="text-lg font-bold mt-1">
|
||||
{(selectedReport?.data?.totalPayments || 0).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
{selectedReport?.data?.byMethod && (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(selectedReport.data.byMethod).map(([method, stats]: [string, any]) => (
|
||||
<div key={method} className="flex justify-between items-center p-3 bg-gray-50 dark:bg-gray-900 rounded">
|
||||
<div>
|
||||
<p className="text-sm font-medium capitalize">{method.toLowerCase().replace('_', ' ')}</p>
|
||||
<p className="text-xs text-muted-foreground">{stats.count} transactions</p>
|
||||
</div>
|
||||
<p className="font-bold">{formatCurrency(stats.totalMinor, 'ETB')}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Report ID */}
|
||||
<div className="border-t pt-4">
|
||||
<label className="text-xs font-semibold text-muted-foreground">Report ID</label>
|
||||
<p className="text-xs mt-1 font-mono text-muted-foreground break-all">{selectedReport?.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,124 +1,315 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Download, Calendar } from 'lucide-react';
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Download, TrendingUp, Users, DollarSign, AlertCircle } from 'lucide-react';
|
||||
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
|
||||
import { bookingsApi } from '@/lib/api';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
|
||||
const revenueByRoute = [
|
||||
{ route: 'Addis - Djibouti', revenue: 125000000 },
|
||||
{ route: 'Addis - Dire Dawa', revenue: 85000000 },
|
||||
{ route: 'Dire Dawa - Djibouti', revenue: 45000000 },
|
||||
];
|
||||
|
||||
const bookingsByClass = [
|
||||
{ name: 'Economy Regular', value: 65, color: '#3b82f6' },
|
||||
{ name: 'Economy Bed', value: 25, color: '#10b981' },
|
||||
{ name: 'VIP Bed', value: 10, color: '#f59e0b' },
|
||||
];
|
||||
|
||||
const occupancyData = [
|
||||
{ month: 'Jan', rate: 72 },
|
||||
{ month: 'Feb', rate: 78 },
|
||||
{ month: 'Mar', rate: 85 },
|
||||
{ month: 'Apr', rate: 82 },
|
||||
{ month: 'May', rate: 88 },
|
||||
{ month: 'Jun', rate: 91 },
|
||||
];
|
||||
const COLORS = ['#3b82f6', '#10b981', '#f59e0b'];
|
||||
|
||||
export default function ReportsPage() {
|
||||
const [dateRange, setDateRange] = useState('last-30-days');
|
||||
const [dateRange, setDateRange] = useState('30');
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
|
||||
const getDateRange = () => {
|
||||
const end = new Date();
|
||||
end.setHours(23, 59, 59, 999);
|
||||
const start = new Date();
|
||||
|
||||
switch (dateRange) {
|
||||
case '7':
|
||||
start.setDate(end.getDate() - 7);
|
||||
break;
|
||||
case '30':
|
||||
start.setDate(end.getDate() - 30);
|
||||
break;
|
||||
case '90':
|
||||
start.setDate(end.getDate() - 90);
|
||||
break;
|
||||
default:
|
||||
if (startDate && endDate) {
|
||||
return { startDate, endDate };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
startDate: start.toISOString().split('T')[0],
|
||||
endDate: end.toISOString().split('T')[0],
|
||||
};
|
||||
};
|
||||
|
||||
const dates = getDateRange();
|
||||
|
||||
// Fetch all bookings
|
||||
const { data: bookingsData, isLoading } = useQuery({
|
||||
queryKey: ['all-bookings'],
|
||||
queryFn: () => bookingsApi.getAll({ pageSize: 1000 }),
|
||||
});
|
||||
|
||||
// Filter bookings by date range
|
||||
const bookings = Array.isArray(bookingsData?.items)
|
||||
? bookingsData.items.filter((b: any) => {
|
||||
const bookingDate = new Date(b.createdAt).toISOString().split('T')[0];
|
||||
return bookingDate >= dates.startDate && bookingDate <= dates.endDate;
|
||||
})
|
||||
: [];
|
||||
|
||||
// Calculate metrics
|
||||
const totalRevenue = bookings.reduce((sum, b: any) => sum + (b.totalMinor || 0), 0);
|
||||
const totalBookings = bookings.length;
|
||||
const avgTicketPrice = totalBookings > 0 ? Math.round(totalRevenue / totalBookings) : 0;
|
||||
|
||||
// Group by date for revenue chart
|
||||
const byDate = bookings.reduce((acc, b: any) => {
|
||||
const date = new Date(b.createdAt).toISOString().split('T')[0];
|
||||
if (!acc[date]) {
|
||||
acc[date] = { totalMinor: 0, count: 0 };
|
||||
}
|
||||
acc[date].totalMinor += b.totalMinor || 0;
|
||||
acc[date].count += 1;
|
||||
return acc;
|
||||
}, {} as Record<string, any>);
|
||||
|
||||
const chartData = Object.entries(byDate)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([date, d]: [string, any]) => ({
|
||||
date: new Date(date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
|
||||
revenue: (d.totalMinor || 0) / 100,
|
||||
bookings: d.count || 0,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Reports & Analytics</h1>
|
||||
<p className="text-gray-600">View detailed reports and analytics</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<select className="input w-48" value={dateRange} onChange={(e) => setDateRange(e.target.value)}>
|
||||
<option value="last-7-days">Last 7 Days</option>
|
||||
<option value="last-30-days">Last 30 Days</option>
|
||||
<option value="last-90-days">Last 90 Days</option>
|
||||
<option value="custom">Custom Range</option>
|
||||
</select>
|
||||
<button className="btn btn-primary flex items-center gap-2">
|
||||
<Download className="h-4 w-4" />
|
||||
Export Report
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<div className="card">
|
||||
<h3 className="mb-4 text-lg font-semibold text-gray-900">Revenue by Route</h3>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={revenueByRoute}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="route" tick={{ fontSize: 12 }} />
|
||||
<YAxis tick={{ fontSize: 12 }} />
|
||||
<Tooltip formatter={(value: number) => formatCurrency(value, 'ETB')} />
|
||||
<Bar dataKey="revenue" fill="#2563eb" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3 className="mb-4 text-lg font-semibold text-gray-900">Bookings by Class</h3>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={bookingsByClass}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={({ name, value }) => `${name}: ${value}%`}
|
||||
outerRadius={100}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
>
|
||||
{bookingsByClass.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="card lg:col-span-2">
|
||||
<h3 className="mb-4 text-lg font-semibold text-gray-900">Occupancy Rate Trend</h3>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={occupancyData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="month" />
|
||||
<YAxis />
|
||||
<Tooltip formatter={(value: number) => `${value}%`} />
|
||||
<Bar dataKey="rate" fill="#10b981" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Reports & Analytics</h1>
|
||||
<p className="text-muted-foreground mt-1">View detailed reports and performance metrics</p>
|
||||
</div>
|
||||
|
||||
{/* Date Range Selector */}
|
||||
<div className="card">
|
||||
<h3 className="mb-4 text-lg font-semibold text-gray-900">Quick Stats</h3>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
<div className="rounded-lg bg-blue-50 p-4">
|
||||
<p className="text-sm text-blue-600">Total Revenue</p>
|
||||
<p className="mt-1 text-2xl font-bold text-blue-900">{formatCurrency(255000000, 'ETB')}</p>
|
||||
<div className="flex items-end gap-4 flex-wrap">
|
||||
<div>
|
||||
<label className="label">Date Range</label>
|
||||
<select
|
||||
className="input"
|
||||
value={dateRange}
|
||||
onChange={(e) => setDateRange(e.target.value)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<option value="7">Last 7 Days</option>
|
||||
<option value="30">Last 30 Days</option>
|
||||
<option value="90">Last 90 Days</option>
|
||||
<option value="custom">Custom Range</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="rounded-lg bg-green-50 p-4">
|
||||
<p className="text-sm text-green-600">Total Bookings</p>
|
||||
<p className="mt-1 text-2xl font-bold text-green-900">1,247</p>
|
||||
|
||||
{dateRange === 'custom' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="label">Start Date</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">End Date</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ActionButton icon={Download} variant="secondary" disabled={isLoading}>
|
||||
Export
|
||||
</ActionButton>
|
||||
</div>
|
||||
{isLoading && (
|
||||
<p className="text-xs text-muted-foreground mt-2">Loading...</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Key Metrics */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="card">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">Total Revenue</p>
|
||||
<p className="text-2xl font-bold mt-2">ETB {Math.round(totalRevenue / 100).toLocaleString()}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Last {dateRange} days</p>
|
||||
</div>
|
||||
<DollarSign className="h-8 w-8 text-blue-500 opacity-20" />
|
||||
</div>
|
||||
<div className="rounded-lg bg-purple-50 p-4">
|
||||
<p className="text-sm text-purple-600">Avg. Ticket Price</p>
|
||||
<p className="mt-1 text-2xl font-bold text-purple-900">{formatCurrency(42500, 'ETB')}</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">Total Bookings</p>
|
||||
<p className="text-2xl font-bold mt-2">{totalBookings.toLocaleString()}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">All bookings</p>
|
||||
</div>
|
||||
<Users className="h-8 w-8 text-green-500 opacity-20" />
|
||||
</div>
|
||||
<div className="rounded-lg bg-green-50 p-4">
|
||||
<p className="text-sm text-[rgb(20,113,76)]">Cancellation Rate</p>
|
||||
<p className="mt-1 text-2xl font-bold text-green-900">3.2%</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">Avg. Ticket Price</p>
|
||||
<p className="text-2xl font-bold mt-2">ETB {(avgTicketPrice / 100).toLocaleString()}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Per booking</p>
|
||||
</div>
|
||||
<TrendingUp className="h-8 w-8 text-purple-500 opacity-20" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">Avg. Daily Revenue</p>
|
||||
<p className="text-2xl font-bold mt-2">ETB {chartData.length > 0 ? Math.round((totalRevenue / 100) / chartData.length).toLocaleString() : '0'}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Daily average</p>
|
||||
</div>
|
||||
<AlertCircle className="h-8 w-8 text-orange-500 opacity-20" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Revenue Trend */}
|
||||
<div className="card">
|
||||
<h3 className="text-lg font-semibold mb-4">Revenue Trend</h3>
|
||||
{chartData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 12 }} />
|
||||
<YAxis tick={{ fontSize: 12 }} />
|
||||
<Tooltip formatter={(value: number) => `ETB ${Math.round(value).toLocaleString()}`} />
|
||||
<Legend />
|
||||
<Line type="monotone" dataKey="revenue" stroke="#3b82f6" dot={{ r: 5 }} activeDot={{ r: 7 }} strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
|
||||
No data available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Daily Bookings */}
|
||||
<div className="card">
|
||||
<h3 className="text-lg font-semibold mb-4">Daily Bookings</h3>
|
||||
{chartData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 12 }} />
|
||||
<YAxis tick={{ fontSize: 12 }} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="bookings" fill="#10b981" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
|
||||
No data available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Booking Status Distribution */}
|
||||
<div className="card">
|
||||
<h3 className="text-lg font-semibold mb-4">Booking Status</h3>
|
||||
{bookings.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={[
|
||||
{ name: 'Confirmed', value: bookings.filter((b: any) => b.status === 'CONFIRMED').length },
|
||||
{ name: 'Completed', value: bookings.filter((b: any) => b.status === 'COMPLETED').length },
|
||||
{ name: 'Cancelled', value: bookings.filter((b: any) => b.status === 'CANCELLED').length },
|
||||
{ name: 'Other', value: bookings.filter((b: any) => !['CONFIRMED', 'COMPLETED', 'CANCELLED'].includes(b.status)).length },
|
||||
].filter(d => d.value > 0)}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={({ name, value }) => `${name}: ${value}`}
|
||||
outerRadius={100}
|
||||
dataKey="value"
|
||||
>
|
||||
{COLORS.map((color, idx) => <Cell key={idx} fill={color} />)}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
|
||||
No data available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Top Payment Methods */}
|
||||
<div className="card">
|
||||
<h3 className="text-lg font-semibold mb-4">Payment Methods</h3>
|
||||
{bookings.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{Object.entries(
|
||||
bookings.reduce((acc, b: any) => {
|
||||
const method = b.paymentIntent?.method || 'Unknown';
|
||||
acc[method] = (acc[method] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>)
|
||||
)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, 5)
|
||||
.map(([method, count]) => (
|
||||
<div key={method} className="flex justify-between items-center p-2 bg-gray-50 dark:bg-gray-900 rounded">
|
||||
<span className="text-sm capitalize">{method.toLowerCase().replace(/_/g, ' ')}</span>
|
||||
<span className="font-semibold">{count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
|
||||
No data available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<div className="card">
|
||||
<h3 className="text-lg font-semibold mb-4">Summary</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<p className="text-sm text-muted-foreground">Total Days with Bookings</p>
|
||||
<p className="text-xl font-bold mt-2">{chartData.length}</p>
|
||||
</div>
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<p className="text-sm text-muted-foreground">Confirmed Bookings</p>
|
||||
<p className="text-xl font-bold mt-2">{bookings.filter((b: any) => b.status === 'CONFIRMED').length}</p>
|
||||
</div>
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<p className="text-sm text-muted-foreground">Completed Bookings</p>
|
||||
<p className="text-xl font-bold mt-2">{bookings.filter((b: any) => b.status === 'COMPLETED').length}</p>
|
||||
</div>
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<p className="text-sm text-muted-foreground">Cancelled Bookings</p>
|
||||
<p className="text-xl font-bold mt-2">{bookings.filter((b: any) => b.status === 'CANCELLED').length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { seatsApi, schedulesApi } from '@/lib/api';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { seatsApi, schedulesApi, fleetApi } from '@/lib/api';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import ActionButton from '@/components/ui/ActionButton'
|
||||
import { Armchair, Lock, Unlock, Bed, X, RotateCcw } from 'lucide-react';
|
||||
import { Armchair, Lock, Unlock, Bed, X, RotateCcw, ChevronDown, Train } from 'lucide-react';
|
||||
|
||||
export default function SeatsPage() {
|
||||
const [selectedSchedule, setSelectedSchedule] = useState('');
|
||||
const [expandedCoaches, setExpandedCoaches] = useState<Set<string>>(new Set());
|
||||
const [showBlockModal, setShowBlockModal] = useState(false);
|
||||
const [showRemoveModal, setShowRemoveModal] = useState(false);
|
||||
const [selectedSeat, setSelectedSeat] = useState<any>(null);
|
||||
@@ -26,6 +27,11 @@ export default function SeatsPage() {
|
||||
enabled: !!selectedSchedule,
|
||||
});
|
||||
|
||||
const { data: coachTypesData } = useQuery({
|
||||
queryKey: ['coachTypes'],
|
||||
queryFn: () => fleetApi.getCoaches(),
|
||||
});
|
||||
|
||||
const blockMutation = useMutation({
|
||||
mutationFn: ({ seatId, reason }: any) => seatsApi.block(seatId, { reason }),
|
||||
onSuccess: () => {
|
||||
@@ -62,6 +68,16 @@ export default function SeatsPage() {
|
||||
const schedules = schedulesData?.items || schedulesData?.data || [];
|
||||
const coaches = seatMapData?.coaches || [];
|
||||
|
||||
const toggleCoach = (coachId: string) => {
|
||||
const newExpanded = new Set(expandedCoaches);
|
||||
if (newExpanded.has(coachId)) {
|
||||
newExpanded.delete(coachId);
|
||||
} else {
|
||||
newExpanded.add(coachId);
|
||||
}
|
||||
setExpandedCoaches(newExpanded);
|
||||
};
|
||||
|
||||
const handleBlock = (seat: any) => {
|
||||
setSelectedSeat(seat);
|
||||
setShowBlockModal(true);
|
||||
@@ -126,6 +142,12 @@ export default function SeatsPage() {
|
||||
return '';
|
||||
};
|
||||
|
||||
const formatBedSeatNumber = (seat: any): string => {
|
||||
if (!seat.seatNumber || !seat.bedPosition) return seat.seatNumber || '';
|
||||
const label = getBedLabel(seat.bedPosition);
|
||||
return `${seat.seatNumber}${label}`;
|
||||
};
|
||||
|
||||
const renderCoachSeats = (coach: any, isBedCoach: boolean) => {
|
||||
const allSeats = coach.seats || [];
|
||||
const validSeats = allSeats.filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-'));
|
||||
@@ -138,14 +160,13 @@ export default function SeatsPage() {
|
||||
const hasBedPositionData = validSeats.some((s: any) => s.bedPosition);
|
||||
|
||||
if (isBedCoach && hasBedPositionData) {
|
||||
// Render bed coach with flipping effect and bed position labels
|
||||
const arrangement = parseSeatArrangement(coach.seatArrangement);
|
||||
const seatsPerRow = arrangement[0] + (arrangement[1] || 0);
|
||||
const allSeatsForLayout = [...validSeats, ...removedSeats];
|
||||
const rows = [];
|
||||
const rows: any[][] = [];
|
||||
const seatClassStr = typeof coach?.seatClass === 'string' ? coach.seatClass : (coach?.seatClass?.name || '');
|
||||
const isVipBed = seatClassStr.toLowerCase().includes('vip');
|
||||
const bedWidth = isVipBed ? 'w-24' : 'w-16';
|
||||
const bedWidth = isVipBed ? 'w-20' : 'w-16';
|
||||
|
||||
for (let i = 0; i < allSeatsForLayout.length; i += seatsPerRow) {
|
||||
rows.push(allSeatsForLayout.slice(i, i + seatsPerRow));
|
||||
@@ -154,23 +175,14 @@ export default function SeatsPage() {
|
||||
return (
|
||||
<div className="space-y-0">
|
||||
{rows.map((rowSeats: any[], idx: number) => {
|
||||
const rowNumber = rowSeats[0]?.row || (idx + 1);
|
||||
const shouldFlipIcon = rowNumber % 2 === 0;
|
||||
const shouldFlipRow = rowNumber % 2 === 1;
|
||||
const showSpacing = idx % 2 === 1;
|
||||
const isFirstInPair = idx % 2 === 0;
|
||||
const shouldFlipIcon = !isFirstInPair;
|
||||
const isLastRow = idx === rows.length - 1;
|
||||
const nextRowSeats = !isLastRow ? rows[idx + 1] : null;
|
||||
|
||||
return (
|
||||
<div key={`bed-row-${idx}`}>
|
||||
{shouldFlipIcon && (
|
||||
<div className="flex gap-0.5 justify-start text-xs text-muted-foreground mb-1">
|
||||
{rowSeats.map((seat: any) => (
|
||||
<div key={`num-before-${seat.id}`} className={`${bedWidth} h-4 flex items-center justify-center text-xs font-bold mb-0.5 h-3 leading-3 text-foreground`}>
|
||||
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-0.5 justify-start">
|
||||
<div className="flex gap-0.5 justify-center">
|
||||
{rowSeats.map((seat: any) => (
|
||||
<SeatIcon
|
||||
key={seat.id}
|
||||
@@ -188,16 +200,23 @@ export default function SeatsPage() {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{!shouldFlipIcon && (
|
||||
<div className="flex gap-0.5 justify-start text-xs text-muted-foreground mb-1">
|
||||
{rowSeats.map((seat: any) => (
|
||||
<div key={`num-after-${seat.id}`} className={`${bedWidth} h-4 flex items-center justify-center text-xs font-bold mb-0.5 h-3 leading-3 text-foreground`}>
|
||||
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''}
|
||||
</div>
|
||||
))}
|
||||
{isFirstInPair && nextRowSeats && (
|
||||
<div className="flex gap-0.5 justify-center text-xs my-1">
|
||||
{rowSeats.map((seat: any, seatIdx: number) => {
|
||||
const currentSeat = rowSeats[seatIdx];
|
||||
const nextSeat = nextRowSeats[seatIdx];
|
||||
const currentFormatted = currentSeat ? formatBedSeatNumber(currentSeat) : '';
|
||||
const nextFormatted = nextSeat ? formatBedSeatNumber(nextSeat) : '';
|
||||
return (
|
||||
<div key={`num-between-${seat.id}`} className={`${bedWidth} flex flex-col items-center justify-center text-xs font-bold mb-1 leading-3 text-foreground`}>
|
||||
<div className="mb-1">{currentFormatted}</div>
|
||||
<div className="mt-1">{nextFormatted}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{showSpacing && <div className="h-2" />}
|
||||
{!isFirstInPair && <div className="h-2" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -205,7 +224,6 @@ export default function SeatsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Regular armchair layout
|
||||
const arrangement = parseSeatArrangement(coach.seatArrangement);
|
||||
const leftCount = arrangement[0];
|
||||
const rightCount = arrangement[1] || 0;
|
||||
@@ -231,25 +249,24 @@ export default function SeatsPage() {
|
||||
const rightSeats = rowSeats.slice(leftCount);
|
||||
const rowNumber = rowSeats[0]?.row || 1;
|
||||
const shouldFlipArmchair = rowNumber % 2 === 0;
|
||||
const shouldFlipRow = rowNumber % 2 === 0;
|
||||
const showSpacing = rowIdx % 2 === 1;
|
||||
|
||||
return (
|
||||
<div key={`row-${rowSeats[0]?.id}`}>
|
||||
{shouldFlipArmchair && (
|
||||
<div className="flex gap-0.5 justify-start text-xs text-muted-foreground mb-1">
|
||||
<div className="flex gap-0.5 justify-center text-xs text-muted-foreground mb-1">
|
||||
<div className="flex gap-0.5">
|
||||
{leftSeats.map((seat: any) => (
|
||||
<div key={`num-before-left-${seat.id}`} className="w-11 h-4 flex items-center justify-center text-xs font-bold mb-0.5 h-3 leading-3 text-foreground">
|
||||
<div key={`num-before-left-${seat.id}`} className="w-11 h-4 flex items-center justify-center text-xs font-bold mb-0.5 leading-3 text-foreground">
|
||||
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{rightSeats.length > 0 && <div className="w-3" />}
|
||||
{rightSeats.length > 0 && <div className="w-8" />}
|
||||
{rightSeats.length > 0 && (
|
||||
<div className="flex gap-0.5">
|
||||
{rightSeats.map((seat: any) => (
|
||||
<div key={`num-before-right-${seat.id}`} className="w-11 h-4 flex items-center justify-center text-xs font-bold mb-0.5 h-3 leading-3 text-foreground">
|
||||
<div key={`num-before-right-${seat.id}`} className="w-11 h-4 flex items-center justify-center text-xs font-bold mb-0.5 leading-3 text-foreground">
|
||||
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
|
||||
</div>
|
||||
))}
|
||||
@@ -257,7 +274,7 @@ export default function SeatsPage() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-0.5 justify-start">
|
||||
<div className="flex gap-0.5 justify-center">
|
||||
<div className="flex gap-0.5">
|
||||
{leftSeats.map((seat: any) => (
|
||||
<SeatIcon
|
||||
@@ -276,7 +293,7 @@ export default function SeatsPage() {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{rightSeats.length > 0 && <div className="w-3" />}
|
||||
{rightSeats.length > 0 && <div className="w-8" />}
|
||||
{rightSeats.length > 0 && (
|
||||
<div className="flex gap-0.5">
|
||||
{rightSeats.map((seat: any) => (
|
||||
@@ -300,19 +317,19 @@ export default function SeatsPage() {
|
||||
</div>
|
||||
|
||||
{!shouldFlipArmchair && (
|
||||
<div className="flex gap-0.5 justify-start text-xs text-muted-foreground mb-1">
|
||||
<div className="flex gap-0.5 justify-center text-xs text-muted-foreground mb-1">
|
||||
<div className="flex gap-0.5">
|
||||
{leftSeats.map((seat: any) => (
|
||||
<div key={`num-left-${seat.id}`} className="w-11 h-4 flex items-center justify-center text-xs font-bold mb-0.5 h-3 leading-3 text-foreground">
|
||||
<div key={`num-left-${seat.id}`} className="w-11 h-4 flex items-center justify-center text-xs font-bold mb-0.5 leading-3 text-foreground">
|
||||
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{rightSeats.length > 0 && <div className="w-3" />}
|
||||
{rightSeats.length > 0 && <div className="w-8" />}
|
||||
{rightSeats.length > 0 && (
|
||||
<div className="flex gap-0.5">
|
||||
{rightSeats.map((seat: any) => (
|
||||
<div key={`num-right-${seat.id}`} className="w-11 h-4 flex items-center justify-center text-xs font-bold mb-0.5 h-3 leading-3 text-foreground">
|
||||
<div key={`num-right-${seat.id}`} className="w-11 h-4 flex items-center justify-center text-xs font-bold mb-0.5 leading-3 text-foreground">
|
||||
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
|
||||
</div>
|
||||
))}
|
||||
@@ -336,15 +353,13 @@ export default function SeatsPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Seat Management</h1>
|
||||
<p className="text-muted-foreground mt-1">View and manage seat availability by schedule</p>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Seat Management</h1>
|
||||
<p className="text-muted-foreground mt-1">View and manage seats by coach</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="mb-6">
|
||||
{!selectedSchedule ? (
|
||||
<div className="card">
|
||||
<label className="label">Select Schedule</label>
|
||||
<select
|
||||
value={selectedSchedule}
|
||||
@@ -363,69 +378,122 @@ export default function SeatsPage() {
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{!selectedSchedule ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<div className="text-center py-12 text-muted-foreground mt-8">
|
||||
<Armchair className="h-12 w-12 mx-auto mb-3 opacity-50" />
|
||||
<p>Select a schedule to view seat map</p>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
|
||||
<p className="text-muted-foreground mt-3">Loading seats...</p>
|
||||
</div>
|
||||
) : coachesWithSeats.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<p>No coaches with seats found for this schedule</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-6 p-4 bg-muted/50 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-green-500"></div>
|
||||
<span className="text-sm">Available</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-red-500"></div>
|
||||
<span className="text-sm">Booked</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-yellow-500"></div>
|
||||
<span className="text-sm">Held</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-gray-500"></div>
|
||||
<span className="text-sm">Blocked</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded border-2 border-dashed border-gray-400"></div>
|
||||
<span className="text-sm">Removed</span>
|
||||
</div>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div className="card text-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
|
||||
<p className="text-muted-foreground mt-3">Loading seats...</p>
|
||||
</div>
|
||||
) : coachesWithSeats.length === 0 ? (
|
||||
<div className="card text-center py-12 text-muted-foreground">
|
||||
<p>No coaches with seats found for this schedule</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Left Column: Schedule Selector & Legends */}
|
||||
<div className="card h-fit sticky top-6 space-y-6">
|
||||
{/* Schedule Selector */}
|
||||
<div>
|
||||
<label className="label">Select Schedule</label>
|
||||
<select
|
||||
value={selectedSchedule}
|
||||
onChange={(e) => setSelectedSchedule(e.target.value)}
|
||||
className="input"
|
||||
>
|
||||
<option value="">Select a schedule...</option>
|
||||
{schedules.map((schedule: any) => {
|
||||
const trainNumber = schedule.train?.trainNumber || schedule.train?.name || 'N/A';
|
||||
const routeName = schedule.route?.name || 'N/A';
|
||||
const date = schedule.departureAt ? new Date(schedule.departureAt).toLocaleDateString() : 'N/A';
|
||||
return (
|
||||
<option key={schedule.id} value={schedule.id}>
|
||||
{trainNumber} - {routeName} - {date}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{coachesWithSeats.map((coach: any) => {
|
||||
const isBedCoach = (coach.seatClass && coach.seatClass.toLowerCase().includes('bed')) ||
|
||||
(coach.mode && coach.mode.toLowerCase().includes('bed'));
|
||||
const seats = (coach.seats || []).filter((s: any) => s.seatNumber);
|
||||
|
||||
return (
|
||||
<div key={coach.id} className="flex flex-col gap-4">
|
||||
<div className="mb-3">
|
||||
<h3 className="font-semibold text-sm">Coach {coach.coachNumber}</h3>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 dark:bg-gray-900/30 rounded-lg w-64 border border-gray-200 dark:border-gray-700 p-2">
|
||||
{renderCoachSeats(coach, isBedCoach)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Seat Legends - Vertical */}
|
||||
<div className="space-y-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<h3 className="font-semibold text-sm text-foreground">Seat Status</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-5 h-5 rounded bg-green-500"></div>
|
||||
<span className="text-sm text-muted-foreground">Available</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-5 h-5 rounded bg-red-500"></div>
|
||||
<span className="text-sm text-muted-foreground">Booked</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-5 h-5 rounded bg-yellow-500"></div>
|
||||
<span className="text-sm text-muted-foreground">Held</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-5 h-5 rounded bg-gray-500"></div>
|
||||
<span className="text-sm text-muted-foreground">Blocked</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-5 h-5 rounded border-2 border-dashed border-gray-400"></div>
|
||||
<span className="text-sm text-muted-foreground">Removed</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Column: Coaches with Locomotive - Single Column */}
|
||||
<div className="space-y-4 w-80">
|
||||
{/* Locomotive Icon Card */}
|
||||
<div className="bg-gradient-to-r from-[rgb(20,113,76)] to-[rgb(15,85,57)] rounded-lg border-2 border-[rgb(20,113,76)] flex items-center justify-center shadow-lg p-6 h-24">
|
||||
<Train className="w-14 h-14 text-white" />
|
||||
</div>
|
||||
|
||||
{/* Coaches List - Single Column */}
|
||||
{coachesWithSeats.map((coach: any, index: number) => {
|
||||
const coachData = coachTypesData?.items?.find((c: any) => c.id === coach.id) || coach;
|
||||
const coachTypeName = coachData?.coachType?.type || 'Coach';
|
||||
const isBedCoach = coachTypeName.toLowerCase().includes('bed');
|
||||
const seats = (coach.seats || []).filter((s: any) => s.seatNumber);
|
||||
const isExpanded = expandedCoaches.has(coach.id);
|
||||
const seatOrBedLabel = isBedCoach ? 'beds' : 'seats';
|
||||
|
||||
return (
|
||||
<div key={coach.id} className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden bg-white dark:bg-gray-800/50 shadow-md hover:shadow-lg transition-shadow">
|
||||
{/* Coach Header */}
|
||||
<button
|
||||
onClick={() => toggleCoach(coach.id)}
|
||||
className="w-full px-4 py-3 flex items-center justify-between bg-gradient-to-r from-[rgb(20,113,76)]/10 to-[rgb(20,113,76)]/5 dark:from-[rgb(20,113,76)]/20 dark:to-[rgb(20,113,76)]/10 hover:from-[rgb(20,113,76)]/20 hover:to-[rgb(20,113,76)]/15 dark:hover:from-[rgb(20,113,76)]/30 dark:hover:to-[rgb(20,113,76)]/20 transition-all border-b border-[rgb(20,113,76)]/20 dark:border-[rgb(20,113,76)]/30"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`transform transition-transform ${isExpanded ? 'rotate-180' : ''}`}>
|
||||
<ChevronDown className="w-5 h-5 text-[rgb(20,113,76)]" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="font-semibold text-foreground">Coach {coach.coachNumber}</p>
|
||||
<p className="text-xs text-muted-foreground">{coachTypeName} • {seats.length} {seatOrBedLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Coach Content - Seat Map */}
|
||||
{isExpanded && (
|
||||
<div className="px-4 py-4 bg-white dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="bg-gray-50 dark:bg-gray-900/30 rounded-lg p-3 inline-block">
|
||||
{renderCoachSeats(coach, isBedCoach)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
isOpen={showBlockModal}
|
||||
@@ -439,8 +507,7 @@ export default function SeatsPage() {
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Block seat <strong>{selectedSeat?.seatNumber}</strong> in Coach{' '}
|
||||
<strong>{selectedSeat?.coach?.coachNumber}</strong>
|
||||
Block seat <strong>{selectedSeat?.seatNumber}</strong> in Coach <strong>{selectedSeat?.coach?.coachNumber}</strong>
|
||||
</p>
|
||||
<div>
|
||||
<label className="label">Reason for Blocking *</label>
|
||||
@@ -485,8 +552,7 @@ export default function SeatsPage() {
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Remove seat <strong>{selectedSeat?.seatNumber}</strong> from Coach{' '}
|
||||
<strong>{selectedSeat?.coach?.coachNumber}</strong>
|
||||
Remove seat <strong>{selectedSeat?.seatNumber}</strong> from Coach <strong>{selectedSeat?.coach?.coachNumber}</strong>
|
||||
</p>
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
|
||||
<p className="text-sm text-yellow-800">
|
||||
@@ -581,7 +647,7 @@ function SeatIcon({
|
||||
return (
|
||||
<div className="relative group flex flex-col items-center">
|
||||
{!hideNumber && (
|
||||
<span className="text-xs font-bold mb-0.5 h-3 leading-3 text-foreground">
|
||||
<span className="text-xs font-bold mb-0.5 leading-3 text-foreground">
|
||||
{seat.seatNumber}
|
||||
</span>
|
||||
)}
|
||||
@@ -590,7 +656,7 @@ function SeatIcon({
|
||||
<div
|
||||
className={`${width} h-11 rounded flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity ${color}`}
|
||||
title={`${seat.seatNumber} - ${seat.bedPosition} - ${status}`}
|
||||
style={seat.row % 2 === 1 ? { transform: 'scaleY(-1)' } : undefined}
|
||||
style={!shouldFlipIcon ? { transform: 'scaleY(-1)' } : undefined}
|
||||
>
|
||||
<Bed className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
|
||||
@@ -78,16 +78,15 @@ const navigationSections = [
|
||||
items: [
|
||||
{ name: 'Loyalty Program', href: '/loyalty', icon: Gift },
|
||||
{ name: 'Support Center', href: '/support', icon: MessageSquare },
|
||||
{ name: 'Notifications', href: '/notifications', icon: Bell },
|
||||
{ name: 'Food & Dining', href: '/food', icon: Utensils },
|
||||
{ name: 'Notifications', href: '/notifications', icon: Bell },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Security & Compliance',
|
||||
items: [
|
||||
{ name: 'Audit Logs', href: '/audit', icon: AlertTriangle },
|
||||
{ name: 'Fraud Detection', href: '/fraud', icon: Shield },
|
||||
{ name: 'Verifayda Integration', href: '/verifayda', icon: UserCheck },
|
||||
{ name: 'Audit Logs', href: '/audit', icon: AlertTriangle },
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2,15 +2,186 @@ import { apiClient } from '@/lib/api-client';
|
||||
import { DashboardStats, RevenueData } from '@/types';
|
||||
|
||||
export const dashboardApi = {
|
||||
getStats: () => {
|
||||
return apiClient.get<DashboardStats>('/dashboard/stats');
|
||||
getStats: async () => {
|
||||
try {
|
||||
// Fetch bookings and passengers data in parallel
|
||||
const [bookingsRes, passengersRes] = await Promise.all([
|
||||
apiClient.get<any>('/bookings?pageSize=1'),
|
||||
apiClient.get<any>('/passengers?pageSize=1'),
|
||||
]);
|
||||
|
||||
const bookingsTotal = bookingsRes?.meta?.total || 0;
|
||||
const passengersTotal = passengersRes?.meta?.total || 0;
|
||||
|
||||
// Calculate revenue from bookings
|
||||
const allBookingsRes = await apiClient.get<any>('/bookings?pageSize=100');
|
||||
const allBookings = Array.isArray(allBookingsRes) ? allBookingsRes : allBookingsRes?.items || [];
|
||||
const totalRevenue = allBookings.reduce((sum: number, b: any) => sum + (b.totalMinor || 0), 0);
|
||||
|
||||
// Calculate average occupancy (placeholder - would need dedicated endpoint)
|
||||
const occupancyRate = Math.floor(Math.random() * 100); // Replace with actual data
|
||||
|
||||
return {
|
||||
totalBookings: bookingsTotal,
|
||||
totalRevenue: totalRevenue,
|
||||
totalPassengers: passengersTotal,
|
||||
occupancyRate: occupancyRate,
|
||||
totalTripsToday: 0,
|
||||
activeTrips: 0,
|
||||
cancelledBookings: 0,
|
||||
averageTicketPrice: allBookings.length > 0 ? totalRevenue / allBookings.length : 0,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch dashboard stats:', error);
|
||||
return {
|
||||
totalBookings: 0,
|
||||
totalRevenue: 0,
|
||||
totalPassengers: 0,
|
||||
occupancyRate: 0,
|
||||
totalTripsToday: 0,
|
||||
activeTrips: 0,
|
||||
cancelledBookings: 0,
|
||||
averageTicketPrice: 0,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
getRevenueChart: (days: number = 30) => {
|
||||
return apiClient.get<RevenueData[]>(`/dashboard/revenue?days=${days}`);
|
||||
getRevenueChart: async (days: number = 30) => {
|
||||
try {
|
||||
const response = await apiClient.get<RevenueData[]>(`/dashboard/revenue?days=${days}`);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch revenue chart:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
getRecentBookings: (limit: number = 10) => {
|
||||
return apiClient.get<any[]>(`/dashboard/recent-bookings?limit=${limit}`);
|
||||
getRecentBookings: async (limit: number = 10) => {
|
||||
try {
|
||||
const response = await apiClient.get<any>(`/bookings?pageSize=${limit}`);
|
||||
// Extract items from paginated response
|
||||
const bookings = Array.isArray(response) ? response : response?.items || [];
|
||||
|
||||
return bookings.map((booking: any) => ({
|
||||
id: booking.id,
|
||||
bookingRef: booking.bookingRef,
|
||||
status: booking.status,
|
||||
totalMinor: booking.totalMinor,
|
||||
currency: booking.currency || 'ETB',
|
||||
displayCurrency: booking.displayCurrency,
|
||||
displayTotalMinor: booking.displayTotalMinor,
|
||||
contactEmail: booking.contactEmail,
|
||||
contactPhone: booking.contactPhone,
|
||||
createdAt: booking.createdAt,
|
||||
passenger: booking.passenger ? {
|
||||
id: booking.passenger.id,
|
||||
fullName: booking.passenger.fullName,
|
||||
email: booking.passenger.email,
|
||||
} : null,
|
||||
schedule: booking.schedule,
|
||||
paymentIntent: booking.paymentIntent,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch recent bookings:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
getTopAgents: async (limit: number = 5) => {
|
||||
try {
|
||||
const response = await apiClient.get<any[]>(`/agents/top?limit=${limit}`);
|
||||
return response || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch top agents:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
getOccupancyTrend: async (days: number = 7) => {
|
||||
try {
|
||||
const response = await apiClient.get<any[]>(`/dashboard/occupancy?days=${days}`);
|
||||
return response || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch occupancy trend:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
getUpcomingTrips: async (limit: number = 5) => {
|
||||
try {
|
||||
const response = await apiClient.get<any[]>(`/schedules/upcoming?limit=${limit}`);
|
||||
return response || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch upcoming trips:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
getPaymentMethods: async () => {
|
||||
try {
|
||||
const response = await apiClient.get<any[]>('/dashboard/payment-methods');
|
||||
return response || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch payment methods:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
getPassengerStats: async () => {
|
||||
try {
|
||||
const response = await apiClient.get<any>('/dashboard/passenger-stats');
|
||||
return response || {
|
||||
totalPassengers: 0,
|
||||
newPassengersToday: 0,
|
||||
activePassengers: 0,
|
||||
loyaltyPoints: 0,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch passenger stats:', error);
|
||||
return {
|
||||
totalPassengers: 0,
|
||||
newPassengersToday: 0,
|
||||
activePassengers: 0,
|
||||
loyaltyPoints: 0,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
getTransactionSummary: async (days: number = 30) => {
|
||||
try {
|
||||
const response = await apiClient.get<any>(`/dashboard/transactions?days=${days}`);
|
||||
return response || {
|
||||
totalTransactions: 0,
|
||||
successfulTransactions: 0,
|
||||
failedTransactions: 0,
|
||||
totalAmount: 0,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch transaction summary:', error);
|
||||
return {
|
||||
totalTransactions: 0,
|
||||
successfulTransactions: 0,
|
||||
failedTransactions: 0,
|
||||
totalAmount: 0,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
getLiveMetrics: async () => {
|
||||
try {
|
||||
const response = await apiClient.get<any>('/dashboard/live-metrics');
|
||||
return response || {
|
||||
onlineUsers: 0,
|
||||
activeBookings: 0,
|
||||
activePayments: 0,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch live metrics:', error);
|
||||
return {
|
||||
onlineUsers: 0,
|
||||
activeBookings: 0,
|
||||
activePayments: 0,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -364,14 +364,12 @@ export const foodApi = {
|
||||
|
||||
// Reports API
|
||||
export const reportsApi = {
|
||||
getOperationalReports: async (params?: any) => {
|
||||
const query = new URLSearchParams(params as Record<string, string>).toString();
|
||||
const response = await apiClient.get<any>(`/reports/operational${query ? `?${query}` : ''}`);
|
||||
if (response?.data) {
|
||||
return Array.isArray(response.data) ? { items: response.data } : response;
|
||||
}
|
||||
return Array.isArray(response) ? { items: response } : response;
|
||||
generateReport: (data: any) => apiClient.post<any>('/reports/generate', data),
|
||||
getReport: (reportId: string) => apiClient.get<any>(`/reports/${reportId}`),
|
||||
listReports: async (reportType?: string) => {
|
||||
const query = reportType ? `?type=${reportType}` : '';
|
||||
const response = await apiClient.get<any>(`/reports${query}`);
|
||||
if (Array.isArray(response)) return { items: response };
|
||||
return response?.data ? (Array.isArray(response.data) ? { items: response.data } : response) : { items: [] };
|
||||
},
|
||||
getRevenue: (params?: any) => apiClient.get<any>('/reports/revenue', { params }),
|
||||
getOccupancy: (params?: any) => apiClient.get<any>('/reports/occupancy', { params }),
|
||||
};
|
||||
|
||||
@@ -1,40 +1,40 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: ['class'],
|
||||
darkMode: ["class"],
|
||||
content: [
|
||||
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
card: 'hsl(var(--card))',
|
||||
'card-foreground': 'hsl(var(--card-foreground))',
|
||||
popover: 'hsl(var(--popover))',
|
||||
'popover-foreground': 'hsl(var(--popover-foreground))',
|
||||
primary: 'hsl(var(--primary))',
|
||||
'primary-foreground': 'hsl(var(--primary-foreground))',
|
||||
secondary: 'hsl(var(--secondary))',
|
||||
'secondary-foreground': 'hsl(var(--secondary-foreground))',
|
||||
muted: 'hsl(var(--muted))',
|
||||
'muted-foreground': 'hsl(var(--muted-foreground))',
|
||||
accent: 'hsl(var(--accent))',
|
||||
'accent-foreground': 'hsl(var(--accent-foreground))',
|
||||
destructive: 'hsl(var(--destructive))',
|
||||
'destructive-foreground': 'hsl(var(--destructive-foreground))',
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
card: "hsl(var(--card))",
|
||||
"card-foreground": "hsl(var(--card-foreground))",
|
||||
popover: "hsl(var(--popover))",
|
||||
"popover-foreground": "hsl(var(--popover-foreground))",
|
||||
primary: "hsl(var(--primary))",
|
||||
"primary-foreground": "hsl(var(--primary-foreground))",
|
||||
secondary: "hsl(var(--secondary))",
|
||||
"secondary-foreground": "hsl(var(--secondary-foreground))",
|
||||
muted: "hsl(var(--muted))",
|
||||
"muted-foreground": "hsl(var(--muted-foreground))",
|
||||
accent: "hsl(var(--accent))",
|
||||
"accent-foreground": "hsl(var(--accent-foreground))",
|
||||
destructive: "hsl(var(--destructive))",
|
||||
"destructive-foreground": "hsl(var(--destructive-foreground))",
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)',
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
};
|
||||
@@ -1,92 +1,88 @@
|
||||
import { createRequire } from 'module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: 'class',
|
||||
darkMode: "class",
|
||||
content: [
|
||||
'./src/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
"./src/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: 'rgb(20 113 76)',
|
||||
primary: "rgb(20 113 76)",
|
||||
},
|
||||
backgroundColor: {
|
||||
primary: 'rgb(20 113 76)',
|
||||
primary: "rgb(20 113 76)",
|
||||
},
|
||||
textColor: {
|
||||
primary: 'rgb(20 113 76)',
|
||||
primary: "rgb(20 113 76)",
|
||||
},
|
||||
borderColor: {
|
||||
primary: 'rgb(20 113 76)',
|
||||
primary: "rgb(20 113 76)",
|
||||
},
|
||||
ringColor: {
|
||||
primary: 'rgb(20 113 76)',
|
||||
primary: "rgb(20 113 76)",
|
||||
},
|
||||
animation: {
|
||||
'bounce-in': 'bounce-in 0.5s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
'float': 'float 6s ease-in-out infinite',
|
||||
'shimmer': 'shimmer 2s infinite',
|
||||
'slide-in-left': 'slide-in-left 0.5s ease-out',
|
||||
'slide-in-right': 'slide-in-right 0.5s ease-out',
|
||||
"bounce-in": "bounce-in 0.5s cubic-bezier(0.34, 1.56, 0.64, 1)",
|
||||
float: "float 6s ease-in-out infinite",
|
||||
shimmer: "shimmer 2s infinite",
|
||||
"slide-in-left": "slide-in-left 0.5s ease-out",
|
||||
"slide-in-right": "slide-in-right 0.5s ease-out",
|
||||
},
|
||||
keyframes: {
|
||||
'bounce-in': {
|
||||
'0%': {
|
||||
opacity: '0',
|
||||
transform: 'translateY(20px) scale(0.9)',
|
||||
"bounce-in": {
|
||||
"0%": {
|
||||
opacity: "0",
|
||||
transform: "translateY(20px) scale(0.9)",
|
||||
},
|
||||
'50%': {
|
||||
opacity: '1',
|
||||
"50%": {
|
||||
opacity: "1",
|
||||
},
|
||||
'100%': {
|
||||
opacity: '1',
|
||||
transform: 'translateY(0) scale(1)',
|
||||
"100%": {
|
||||
opacity: "1",
|
||||
transform: "translateY(0) scale(1)",
|
||||
},
|
||||
},
|
||||
'float': {
|
||||
'0%, 100%': {
|
||||
transform: 'translateY(0px)',
|
||||
float: {
|
||||
"0%, 100%": {
|
||||
transform: "translateY(0px)",
|
||||
},
|
||||
'50%': {
|
||||
transform: 'translateY(-20px)',
|
||||
"50%": {
|
||||
transform: "translateY(-20px)",
|
||||
},
|
||||
},
|
||||
'shimmer': {
|
||||
'0%': {
|
||||
'background-position': '-1000px 0',
|
||||
shimmer: {
|
||||
"0%": {
|
||||
"background-position": "-1000px 0",
|
||||
},
|
||||
'100%': {
|
||||
'background-position': '1000px 0',
|
||||
"100%": {
|
||||
"background-position": "1000px 0",
|
||||
},
|
||||
},
|
||||
'slide-in-left': {
|
||||
'from': {
|
||||
opacity: '0',
|
||||
transform: 'translateX(-20px)',
|
||||
"slide-in-left": {
|
||||
from: {
|
||||
opacity: "0",
|
||||
transform: "translateX(-20px)",
|
||||
},
|
||||
'to': {
|
||||
opacity: '1',
|
||||
transform: 'translateX(0)',
|
||||
to: {
|
||||
opacity: "1",
|
||||
transform: "translateX(0)",
|
||||
},
|
||||
},
|
||||
'slide-in-right': {
|
||||
'from': {
|
||||
opacity: '0',
|
||||
transform: 'translateX(20px)',
|
||||
"slide-in-right": {
|
||||
from: {
|
||||
opacity: "0",
|
||||
transform: "translateX(20px)",
|
||||
},
|
||||
'to': {
|
||||
opacity: '1',
|
||||
transform: 'translateX(0)',
|
||||
to: {
|
||||
opacity: "1",
|
||||
transform: "translateX(0)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
};
|
||||
41
apps/edr-payment-api/Dockerfile
Normal file
41
apps/edr-payment-api/Dockerfile
Normal file
@@ -0,0 +1,41 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# Build from monorepo root: docker build -f apps/edr-freight-api/Dockerfile .
|
||||
|
||||
FROM node:24.15.0-alpine AS base
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN corepack enable
|
||||
WORKDIR /app
|
||||
|
||||
FROM base AS pruner
|
||||
COPY . .
|
||||
RUN pnpm dlx turbo prune "@edr/payment-api" --docker
|
||||
|
||||
FROM base AS installer
|
||||
COPY --from=pruner /app/out/json/ .
|
||||
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
FROM base AS builder
|
||||
COPY --from=installer /app/ .
|
||||
COPY --from=pruner /app/out/full/ .
|
||||
RUN pnpm turbo build --filter="@edr/payment-api..."
|
||||
|
||||
FROM base AS deployer
|
||||
COPY --from=builder /app/ .
|
||||
RUN pnpm deploy --filter="@edr/payment-api" --prod --legacy --ignore-scripts /deploy
|
||||
|
||||
FROM node:24.15.0-alpine AS runner
|
||||
RUN apk add --no-cache libc6-compat
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app
|
||||
RUN addgroup --system --gid 1001 nodejs \
|
||||
&& adduser --system --uid 1001 --ingroup nodejs nestjs
|
||||
COPY --from=deployer --chown=nestjs:nodejs /deploy .
|
||||
COPY apps/edr-payment-api/docker-entrypoint.sh /docker-entrypoint.sh
|
||||
RUN chmod +x /docker-entrypoint.sh \
|
||||
&& chown -R nestjs:nodejs /app
|
||||
USER nestjs
|
||||
EXPOSE 3008
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
CMD ["node", "dist/main.js"]
|
||||
9
apps/edr-payment-api/docker-entrypoint.sh
Normal file
9
apps/edr-payment-api/docker-entrypoint.sh
Normal file
@@ -0,0 +1,9 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
cd /app
|
||||
|
||||
# npm run executes the same package.json scripts as pnpm run (pnpm reinstalls in deploy layout)
|
||||
node dist/scripts/migrate.js
|
||||
|
||||
exec "$@"
|
||||
8
apps/edr-payment-api/nest-cli.json
Normal file
8
apps/edr-payment-api/nest-cli.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": false
|
||||
}
|
||||
}
|
||||
74
apps/edr-payment-api/package.json
Normal file
74
apps/edr-payment-api/package.json
Normal file
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"name": "@edr/payment-api",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": "EDR Payment Microservice — owns provider integration, payment intents, webhooks, and outbox notifications for the whole platform",
|
||||
"scripts": {
|
||||
"clean": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true}); fs.rmSync('.tsbuildinfo',{force:true});\"",
|
||||
"predev": "pnpm run clean",
|
||||
"dev": "nest start --watch",
|
||||
"prebuild": "pnpm run clean",
|
||||
"build": "nest build",
|
||||
"start": "node dist/main.js",
|
||||
"lint": "eslint src",
|
||||
"test": "jest",
|
||||
"type-check": "tsc --noEmit",
|
||||
"migration:run": "node dist/scripts/migrate.js",
|
||||
"migration:revert": "ts-node src/scripts/migrate-revert.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edr/api-common": "workspace:*",
|
||||
"@edr/payment-providers": "workspace:*",
|
||||
"@edr/types": "workspace:*",
|
||||
"@nestjs/axios": "^4.0.1",
|
||||
"@nestjs/common": "^11.0.0",
|
||||
"@nestjs/config": "^4.0.0",
|
||||
"@nestjs/core": "^11.0.0",
|
||||
"@nestjs/platform-express": "^11.0.0",
|
||||
"@nestjs/schedule": "^6.0.0",
|
||||
"@nestjs/swagger": "^11.4.2",
|
||||
"@nestjs/typeorm": "^11.0.1",
|
||||
"axios": "^1.16.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"dotenv": "^17.4.2",
|
||||
"pg": "^8.13.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"typeorm": "0.3.30"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edr/eslint-config": "workspace:*",
|
||||
"@edr/tsconfig": "workspace:*",
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.0",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^29.5.13",
|
||||
"@types/node": "^20.14.0",
|
||||
"@types/pg": "^8.6.7",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.5.4"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
51
apps/edr-payment-api/src/app.module.ts
Normal file
51
apps/edr-payment-api/src/app.module.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||
import { ScheduleModule } from "@nestjs/schedule";
|
||||
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
|
||||
import appConfig from "./config/app.config";
|
||||
import databaseConfig from "./config/database.config";
|
||||
import notifierConfig from "./config/notifier.config";
|
||||
import telebirrConfig from "./config/telebirr.config";
|
||||
import waafiConfig from "./config/waafi.config";
|
||||
import cbeConfig from "./config/cbe.config";
|
||||
import ebirrConfig from "./config/ebirr.config";
|
||||
import cardConfig from "./config/card.config";
|
||||
import dmoneyConfig from "./config/dmoney.config";
|
||||
import { HealthModule } from "./modules/health/health.module";
|
||||
import { IntentsModule } from "./modules/intents/intents.module";
|
||||
import { OutboxModule } from "./modules/outbox/outbox.module";
|
||||
import { ProvidersModule } from "./modules/providers/providers.module";
|
||||
import { ReconciliationModule } from "./modules/reconciliation/reconciliation.module";
|
||||
import { WebhooksModule } from "./modules/webhooks/webhooks.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [
|
||||
appConfig,
|
||||
databaseConfig,
|
||||
notifierConfig,
|
||||
telebirrConfig,
|
||||
waafiConfig,
|
||||
cbeConfig,
|
||||
ebirrConfig,
|
||||
cardConfig,
|
||||
dmoneyConfig,
|
||||
],
|
||||
}),
|
||||
TypeOrmModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) =>
|
||||
config.get<TypeOrmModuleOptions>("database") as TypeOrmModuleOptions,
|
||||
}),
|
||||
ScheduleModule.forRoot(),
|
||||
HealthModule,
|
||||
ProvidersModule,
|
||||
IntentsModule,
|
||||
WebhooksModule,
|
||||
OutboxModule,
|
||||
ReconciliationModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
55
apps/edr-payment-api/src/common/guards/service-auth.guard.ts
Normal file
55
apps/edr-payment-api/src/common/guards/service-auth.guard.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
Logger,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import { Request } from "express";
|
||||
|
||||
/**
|
||||
* Shared-secret service-to-service auth for the internal surface (/payments/*).
|
||||
* Callers send `x-service-token: <SERVICE_AUTH_TOKEN>` (or `Authorization: Bearer …`).
|
||||
* Webhook endpoints are intentionally NOT behind this guard — they are provider-facing and
|
||||
* authenticate via signature verification instead.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ServiceAuthGuard implements CanActivate {
|
||||
private readonly logger = new Logger(ServiceAuthGuard.name);
|
||||
private readonly token: string;
|
||||
private warned = false;
|
||||
|
||||
constructor(config: ConfigService) {
|
||||
this.token = config.get<string>("app.serviceAuthToken") ?? "";
|
||||
if (!this.token && process.env.NODE_ENV === "production") {
|
||||
throw new Error("SERVICE_AUTH_TOKEN must be set in production");
|
||||
}
|
||||
}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
if (!this.token) {
|
||||
if (!this.warned) {
|
||||
this.logger.warn(
|
||||
"SERVICE_AUTH_TOKEN unset — internal endpoints are UNGUARDED (dev only)",
|
||||
);
|
||||
this.warned = true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const header = request.headers["x-service-token"];
|
||||
const bearer = request.headers.authorization?.replace(/^Bearer\s+/i, "");
|
||||
const presented =
|
||||
(Array.isArray(header) ? header[0] : header) ?? bearer ?? "";
|
||||
|
||||
const expected = Buffer.from(this.token);
|
||||
const actual = Buffer.from(presented);
|
||||
const valid =
|
||||
expected.length === actual.length && timingSafeEqual(expected, actual);
|
||||
if (!valid) throw new UnauthorizedException("Invalid service token");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
21
apps/edr-payment-api/src/config/app.config.ts
Normal file
21
apps/edr-payment-api/src/config/app.config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { registerAs } from "@nestjs/config";
|
||||
|
||||
export default registerAs("app", () => ({
|
||||
port: parseInt(process.env.PORT ?? "3003", 10),
|
||||
/**
|
||||
* Shared secret for service-to-service auth (apps -> /payments/*, payment -> mark-paid).
|
||||
* Required in production; in development an empty value disables the guard with a warning.
|
||||
* TODO: integrate @tria-plc IAM / mTLS as the long-term mechanism (docs/payment-service §14).
|
||||
*/
|
||||
serviceAuthToken: process.env.SERVICE_AUTH_TOKEN ?? "",
|
||||
reconciliation: {
|
||||
/** How often the stale-intent sweep runs. */
|
||||
sweepIntervalMs: parseInt(
|
||||
process.env.RECONCILE_SWEEP_INTERVAL_MS ?? "60000",
|
||||
10,
|
||||
),
|
||||
/** An intent is "stale" when non-terminal and untouched for this long. */
|
||||
staleAfterMs: parseInt(process.env.RECONCILE_STALE_AFTER_MS ?? "60000", 10),
|
||||
batchSize: parseInt(process.env.RECONCILE_BATCH_SIZE ?? "20", 10),
|
||||
},
|
||||
}));
|
||||
9
apps/edr-payment-api/src/config/card.config.ts
Normal file
9
apps/edr-payment-api/src/config/card.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { registerAs } from "@nestjs/config";
|
||||
|
||||
export default registerAs("card", () => ({
|
||||
baseUrl: process.env.CARD_BASE_URL || "",
|
||||
apiKey: process.env.CARD_API_KEY || "",
|
||||
webhookSecret: process.env.CARD_WEBHOOK_SECRET || "",
|
||||
webhookUrl: process.env.CARD_WEBHOOK_URL || "",
|
||||
returnUrl: process.env.CARD_RETURN_URL || "",
|
||||
}));
|
||||
9
apps/edr-payment-api/src/config/cbe.config.ts
Normal file
9
apps/edr-payment-api/src/config/cbe.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { registerAs } from "@nestjs/config";
|
||||
|
||||
export default registerAs("cbe", () => ({
|
||||
baseUrl: process.env.CBE_BASE_URL || "",
|
||||
merchantId: process.env.CBE_MERCHANT_ID || "",
|
||||
secretKey: process.env.CBE_SECRET_KEY || "",
|
||||
notifyUrl: process.env.CBE_NOTIFY_URL || "",
|
||||
returnUrl: process.env.CBE_RETURN_URL || "",
|
||||
}));
|
||||
36
apps/edr-payment-api/src/config/database.config.ts
Normal file
36
apps/edr-payment-api/src/config/database.config.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { registerAs } from "@nestjs/config";
|
||||
import { TypeOrmModuleOptions } from "@nestjs/typeorm";
|
||||
import { DataSourceOptions } from "typeorm";
|
||||
|
||||
/**
|
||||
* Shared connection options for the Nest TypeORM module and the standalone DataSource
|
||||
* (migration CLI). Payment tables live in the SAME Postgres database as the domain system
|
||||
* (edr_database by default) but in the dedicated `edr_payment` schema; logical ownership is
|
||||
* enforced with a dedicated DB user in non-dev environments (grants only on this schema).
|
||||
*/
|
||||
export function buildDataSourceOptions(): DataSourceOptions {
|
||||
return {
|
||||
type: "postgres",
|
||||
host: process.env.DB_HOST ?? "localhost",
|
||||
port: parseInt(process.env.DB_PORT ?? "5432", 10),
|
||||
username: process.env.DB_USER ?? "edr",
|
||||
password: process.env.DB_PASSWORD ?? "",
|
||||
database: process.env.DB_NAME ?? "edr_database",
|
||||
schema: process.env.DB_SCHEMA ?? "edr_payment",
|
||||
entities: [__dirname + "/../**/*.entity.{ts,js}"],
|
||||
migrations: [__dirname + "/../migrations/*.{ts,js}"],
|
||||
// Schema changes go through migrations only — never synchronize (house rule).
|
||||
synchronize: false,
|
||||
logging: process.env.NODE_ENV === "development",
|
||||
};
|
||||
}
|
||||
|
||||
export default registerAs(
|
||||
"database",
|
||||
(): TypeOrmModuleOptions => ({
|
||||
...buildDataSourceOptions(),
|
||||
autoLoadEntities: true,
|
||||
// Run pending migrations on boot (main.ts ensures the database/schema exist first).
|
||||
migrationsRun: true,
|
||||
}),
|
||||
);
|
||||
10
apps/edr-payment-api/src/config/dmoney.config.ts
Normal file
10
apps/edr-payment-api/src/config/dmoney.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { registerAs } from "@nestjs/config";
|
||||
|
||||
export default registerAs("dmoney", () => ({
|
||||
baseUrl: process.env.DMONEY_BASE_URL ?? "",
|
||||
appId: process.env.DMONEY_APP_ID ?? "",
|
||||
appSecret: process.env.DMONEY_APP_SECRET ?? "",
|
||||
publicKey: process.env.DMONEY_PUBLIC_KEY ?? "",
|
||||
privateKey: process.env.DMONEY_PRIVATE_KEY ?? "",
|
||||
notifyUrl: process.env.DMONEY_NOTIFY_URL ?? "",
|
||||
}));
|
||||
9
apps/edr-payment-api/src/config/ebirr.config.ts
Normal file
9
apps/edr-payment-api/src/config/ebirr.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { registerAs } from "@nestjs/config";
|
||||
|
||||
export default registerAs("ebirr", () => ({
|
||||
baseUrl: process.env.EBIRR_BASE_URL || "",
|
||||
merchantCode: process.env.EBIRR_MERCHANT_CODE || "",
|
||||
secretKey: process.env.EBIRR_SECRET_KEY || "",
|
||||
notifyUrl: process.env.EBIRR_NOTIFY_URL || "",
|
||||
returnUrl: process.env.EBIRR_RETURN_URL || "",
|
||||
}));
|
||||
52
apps/edr-payment-api/src/config/ensure-schema.ts
Normal file
52
apps/edr-payment-api/src/config/ensure-schema.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Client } from "pg";
|
||||
|
||||
const IDENTIFIER = /^[a-z_][a-z0-9_]*$/;
|
||||
|
||||
function connectionEnv() {
|
||||
return {
|
||||
host: process.env.DB_HOST ?? "localhost",
|
||||
port: parseInt(process.env.DB_PORT ?? "5432", 10),
|
||||
user: process.env.DB_USER ?? "edr",
|
||||
password: process.env.DB_PASSWORD ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Dev/bootstrap convenience: make sure the `edr_payment` schema exists in the shared
|
||||
* database before TypeORM initializes (the migrations table itself lives in the schema, so
|
||||
* migrations cannot create it). In production the schema/grants are provisioned out-of-band
|
||||
* by ops; this is then a no-op.
|
||||
*/
|
||||
export async function ensurePaymentSchema(): Promise<void> {
|
||||
const database = process.env.DB_NAME ?? "edr_database";
|
||||
const schema = process.env.DB_SCHEMA ?? "edr_payment";
|
||||
if (!IDENTIFIER.test(database) || !IDENTIFIER.test(schema)) {
|
||||
throw new Error(
|
||||
`Invalid DB_NAME/DB_SCHEMA identifier: ${database}/${schema}`,
|
||||
);
|
||||
}
|
||||
|
||||
let client = new Client({ ...connectionEnv(), database });
|
||||
try {
|
||||
await client.connect();
|
||||
} catch (err) {
|
||||
// 3D000 = database does not exist — create it from the maintenance DB, then reconnect.
|
||||
if ((err as { code?: string }).code !== "3D000") throw err;
|
||||
await client.end().catch(() => undefined);
|
||||
const admin = new Client({ ...connectionEnv(), database: "postgres" });
|
||||
await admin.connect();
|
||||
try {
|
||||
await admin.query(`CREATE DATABASE "${database}"`);
|
||||
} finally {
|
||||
await admin.end();
|
||||
}
|
||||
client = new Client({ ...connectionEnv(), database });
|
||||
await client.connect();
|
||||
}
|
||||
|
||||
try {
|
||||
await client.query(`CREATE SCHEMA IF NOT EXISTS "${schema}"`);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
15
apps/edr-payment-api/src/config/notifier.config.ts
Normal file
15
apps/edr-payment-api/src/config/notifier.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { registerAs } from "@nestjs/config";
|
||||
|
||||
export default registerAs("notifier", () => ({
|
||||
/** mark-paid callback URL per owning service (PaymentService discriminator routes here). */
|
||||
passengerUrl:
|
||||
process.env.PAYMENT_NOTIFY_PASSENGER_URL ??
|
||||
"http://localhost:3002/internal/payments/mark-paid",
|
||||
freightUrl:
|
||||
process.env.PAYMENT_NOTIFY_FREIGHT_URL ??
|
||||
"http://localhost:3001/internal/payments/mark-paid",
|
||||
relayIntervalMs: parseInt(process.env.OUTBOX_RELAY_INTERVAL_MS ?? "5000", 10),
|
||||
maxAttempts: parseInt(process.env.OUTBOX_MAX_ATTEMPTS ?? "10", 10),
|
||||
httpTimeoutMs: parseInt(process.env.NOTIFY_HTTP_TIMEOUT_MS ?? "10000", 10),
|
||||
relayBatchSize: parseInt(process.env.OUTBOX_RELAY_BATCH_SIZE ?? "20", 10),
|
||||
}));
|
||||
16
apps/edr-payment-api/src/config/telebirr.config.ts
Normal file
16
apps/edr-payment-api/src/config/telebirr.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { registerAs } from "@nestjs/config";
|
||||
|
||||
export default registerAs("telebirr", () => ({
|
||||
baseUrl: process.env.TELEBIRR_BASE_URL ?? "",
|
||||
webBaseUrl: process.env.TELEBIRR_WEB_BASE_URL ?? "",
|
||||
fabricAppId: process.env.TELEBIRR_FABRIC_APP_ID ?? "",
|
||||
appSecret: process.env.TELEBIRR_APP_SECRET ?? "",
|
||||
merchantAppId: process.env.TELEBIRR_MERCHANT_APP_ID ?? "",
|
||||
merchantCode: process.env.TELEBIRR_MERCHANT_CODE ?? "",
|
||||
notifyUrl: process.env.TELEBIRR_NOTIFY_URL ?? "",
|
||||
returnUrl: process.env.TELEBIRR_RETURN_URL ?? "",
|
||||
timeoutExpress: process.env.TELEBIRR_TIMEOUT_EXPRESS ?? "15m",
|
||||
privateKey: process.env.TELEBIRR_PRIVATE_KEY ?? "",
|
||||
publicKey: process.env.TELEBIRR_PUBLIC_KEY ?? "",
|
||||
insecureTls: process.env.TELEBIRR_INSECURE_TLS === "true",
|
||||
}));
|
||||
27
apps/edr-payment-api/src/config/waafi.config.ts
Normal file
27
apps/edr-payment-api/src/config/waafi.config.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { registerAs } from "@nestjs/config";
|
||||
|
||||
export default registerAs("waafi", () => ({
|
||||
// `/asm` is appended in the provider; use sandbox by default, switch to
|
||||
// https://api.waafipay.net in production.
|
||||
baseUrl: process.env.WAAFI_BASE_URL ?? "https://sandbox.waafipay.net",
|
||||
// HPP credentials (Hosted Payment Page family).
|
||||
merchantUid: process.env.WAAFI_MERCHANT_UID ?? "",
|
||||
storeId: process.env.WAAFI_STORE_ID ?? "",
|
||||
hppKey: process.env.WAAFI_HPP_KEY ?? "",
|
||||
// HMAC secret returned once by WEBHOOK_REGISTER; verifies inbound webhooks.
|
||||
webhookSecret: process.env.WAAFI_WEBHOOK_SECRET ?? "",
|
||||
// Wallet payment method (EVC/ZAAD/Sahal) — MWALLET_ACCOUNT requires the payer phone up front.
|
||||
paymentMethod: process.env.WAAFI_PAYMENT_METHOD ?? "MWALLET_ACCOUNT",
|
||||
// Waafi has no ETB; when set this overrides the asserted currency (USD/DJF/SLSH).
|
||||
currency: process.env.WAAFI_CURRENCY ?? "DJF",
|
||||
// Browser redirect targets after the hosted page completes/fails (UX only; webhook is source of truth).
|
||||
successUrl: process.env.WAAFI_HPP_SUCCESS_URL ?? "",
|
||||
failureUrl: process.env.WAAFI_HPP_FAILURE_URL ?? "",
|
||||
// Callback data format: 1 = POST, 2 = GET, 4 = Result Token.
|
||||
respDataFormat: Number(process.env.WAAFI_HPP_RESP_FORMAT ?? "1"),
|
||||
// Registered webhook URL (reference only; registration is performed out-of-band).
|
||||
notifyUrl: process.env.WAAFI_NOTIFY_URL ?? "",
|
||||
// DEV ONLY: disable TLS cert verification. The Waafi sandbox serves a *.waafi.com cert that
|
||||
// does not match sandbox.waafipay.net (ERR_TLS_CERT_ALTNAME_INVALID). Never enable in prod.
|
||||
insecureTls: process.env.WAAFI_INSECURE_TLS === "true",
|
||||
}));
|
||||
8
apps/edr-payment-api/src/data-source.ts
Normal file
8
apps/edr-payment-api/src/data-source.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import "dotenv/config";
|
||||
import { DataSource } from "typeorm";
|
||||
import { buildDataSourceOptions } from "./config/database.config";
|
||||
|
||||
/** Standalone DataSource for the TypeORM CLI and the migrate script. */
|
||||
export const AppDataSource = new DataSource(buildDataSourceOptions());
|
||||
|
||||
export default AppDataSource;
|
||||
54
apps/edr-payment-api/src/main.ts
Normal file
54
apps/edr-payment-api/src/main.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import "reflect-metadata";
|
||||
import "dotenv/config";
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { ValidationPipe } from "@nestjs/common";
|
||||
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
|
||||
import { AppModule } from "./app.module";
|
||||
import { ensurePaymentSchema } from "./config/ensure-schema";
|
||||
|
||||
async function bootstrap() {
|
||||
// The edr_payment database/schema must exist before TypeORM boots (migrationsRun: true).
|
||||
await ensurePaymentSchema();
|
||||
|
||||
// rawBody: true buffers the unparsed request body onto req.rawBody so webhook handlers
|
||||
// (e.g. Waafi HMAC verification) can sign over the exact bytes the provider signed.
|
||||
const app = await NestFactory.create(AppModule, { rawBody: true });
|
||||
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidUnknownValues: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle("EDR Payment API")
|
||||
.setDescription(
|
||||
"Platform payment microservice: payment intents, provider integration, the single " +
|
||||
"registered webhook per provider, and reliable (outbox) notification of the owning app. " +
|
||||
"Internal endpoints (/payments/*) require the x-service-token header; /webhooks/* is the " +
|
||||
"only public surface. See docs/payment-service/.",
|
||||
)
|
||||
.setVersion("1.0.0")
|
||||
.addApiKey(
|
||||
{ type: "apiKey", name: "x-service-token", in: "header" },
|
||||
"service-token",
|
||||
)
|
||||
.build();
|
||||
SwaggerModule.setup(
|
||||
"api-docs",
|
||||
app,
|
||||
SwaggerModule.createDocument(app, config),
|
||||
{
|
||||
customSiteTitle: "EDR Payment API",
|
||||
swaggerOptions: { persistAuthorization: true },
|
||||
},
|
||||
);
|
||||
|
||||
const port = process.env.PORT ?? 3003;
|
||||
await app.listen(port);
|
||||
console.log(`🚀 EDR Payment API running on port ${port}`);
|
||||
console.log(`📚 Swagger: http://localhost:${port}/api-docs`);
|
||||
}
|
||||
bootstrap();
|
||||
@@ -0,0 +1,124 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Initial edr_payment schema: payment_intent, payment_webhook_event, notification_outbox.
|
||||
*
|
||||
* Enum-valued columns are varchar on purpose (values mirror the @edr/types enums) so new
|
||||
* providers/statuses never need an ALTER TYPE. uuid defaults use gen_random_uuid() (built into
|
||||
* Postgres 13+; no extension required).
|
||||
*/
|
||||
export class InitPaymentSchema1781136000000 implements MigrationInterface {
|
||||
name = "InitPaymentSchema1781136000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// Defensive — bootstrap (ensure-schema) normally creates this before migrations run.
|
||||
await queryRunner.query(`CREATE SCHEMA IF NOT EXISTS "edr_payment"`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "edr_payment"."payment_intent" (
|
||||
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"updated_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"deleted_at" timestamptz,
|
||||
"service" varchar(16) NOT NULL,
|
||||
"reference_type" varchar(16) NOT NULL,
|
||||
"reference_id" varchar(64) NOT NULL,
|
||||
"merchant_order_id" varchar(64) NOT NULL,
|
||||
"provider" varchar(16) NOT NULL,
|
||||
"provider_order_id" varchar(128),
|
||||
"provider_txn_id" varchar(128),
|
||||
"amount_minor" integer NOT NULL,
|
||||
"confirmed_amount_minor" integer,
|
||||
"currency" varchar(8) NOT NULL,
|
||||
"status" varchar(24) NOT NULL DEFAULT 'REQUIRES_ACTION',
|
||||
"client_action" jsonb,
|
||||
"failure_code" varchar(64),
|
||||
"failure_message" text,
|
||||
"idempotency_key" varchar(128),
|
||||
"expires_at" timestamptz,
|
||||
"paid_at" timestamptz,
|
||||
"raw_initiation" jsonb,
|
||||
CONSTRAINT "pk_payment_intent" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "uq_payment_intent_merchant_order_id" UNIQUE ("merchant_order_id")
|
||||
)
|
||||
`);
|
||||
// One ACTIVE intent per domain order; terminal-failed attempts remain as audit rows.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX "uq_payment_intent_active_reference"
|
||||
ON "edr_payment"."payment_intent" ("service", "reference_type", "reference_id")
|
||||
WHERE status NOT IN ('FAILED','CANCELLED') AND deleted_at IS NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX "idx_payment_intent_provider_txn"
|
||||
ON "edr_payment"."payment_intent" ("provider_txn_id")
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX "idx_payment_intent_sweep"
|
||||
ON "edr_payment"."payment_intent" ("status", "updated_at")
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX "idx_payment_intent_idempotency"
|
||||
ON "edr_payment"."payment_intent" ("service", "idempotency_key")
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "edr_payment"."payment_webhook_event" (
|
||||
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"updated_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"deleted_at" timestamptz,
|
||||
"provider" varchar(16) NOT NULL,
|
||||
"external_event_id" varchar(191) NOT NULL,
|
||||
"merchant_order_id" varchar(64),
|
||||
"provider_txn_id" varchar(128),
|
||||
"signature_valid" boolean NOT NULL DEFAULT false,
|
||||
"status" varchar(64),
|
||||
"payload" jsonb NOT NULL,
|
||||
"received_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"processed_at" timestamptz,
|
||||
"processing_error" text,
|
||||
CONSTRAINT "pk_payment_webhook_event" PRIMARY KEY ("id")
|
||||
)
|
||||
`);
|
||||
// The webhook dedupe key: duplicate provider deliveries hit this and short-circuit.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX "uq_payment_webhook_event_external"
|
||||
ON "edr_payment"."payment_webhook_event" ("provider", "external_event_id")
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "edr_payment"."notification_outbox" (
|
||||
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"updated_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"deleted_at" timestamptz,
|
||||
"event_type" varchar(32) NOT NULL,
|
||||
"service" varchar(16) NOT NULL,
|
||||
"intent_id" uuid NOT NULL,
|
||||
"reference_type" varchar(16) NOT NULL,
|
||||
"reference_id" varchar(64) NOT NULL,
|
||||
"payload" jsonb NOT NULL,
|
||||
"status" varchar(16) NOT NULL DEFAULT 'PENDING',
|
||||
"attempts" integer NOT NULL DEFAULT 0,
|
||||
"next_retry_at" timestamptz,
|
||||
"last_error" text,
|
||||
"sent_at" timestamptz,
|
||||
CONSTRAINT "pk_notification_outbox" PRIMARY KEY ("id")
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX "idx_notification_outbox_relay"
|
||||
ON "edr_payment"."notification_outbox" ("status", "next_retry_at")
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX "idx_notification_outbox_intent"
|
||||
ON "edr_payment"."notification_outbox" ("intent_id")
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE "edr_payment"."notification_outbox"`);
|
||||
await queryRunner.query(`DROP TABLE "edr_payment"."payment_webhook_event"`);
|
||||
await queryRunner.query(`DROP TABLE "edr_payment"."payment_intent"`);
|
||||
}
|
||||
}
|
||||
16
apps/edr-payment-api/src/modules/health/health.controller.ts
Normal file
16
apps/edr-payment-api/src/modules/health/health.controller.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Controller, Get } from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
@ApiTags("Health")
|
||||
@Controller("health")
|
||||
export class HealthController {
|
||||
@Get()
|
||||
@ApiOperation({ summary: "Liveness probe" })
|
||||
check() {
|
||||
return {
|
||||
status: "ok",
|
||||
service: "edr-payment-api",
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
7
apps/edr-payment-api/src/modules/health/health.module.ts
Normal file
7
apps/edr-payment-api/src/modules/health/health.module.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { HealthController } from "./health.controller";
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
IsEnum,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsPositive,
|
||||
IsString,
|
||||
Length,
|
||||
MaxLength,
|
||||
} from "class-validator";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import {
|
||||
InitiatePaymentRequest,
|
||||
PaymentPlatform,
|
||||
PaymentReferenceType,
|
||||
PaymentService,
|
||||
ProviderMethod,
|
||||
} from "@edr/types";
|
||||
|
||||
/** Wire shape is the shared `InitiatePaymentRequest` contract from @edr/types. */
|
||||
export class InitiatePaymentRequestDto implements InitiatePaymentRequest {
|
||||
@ApiProperty({ enum: PaymentService })
|
||||
@IsEnum(PaymentService)
|
||||
service!: PaymentService;
|
||||
|
||||
@ApiProperty({ enum: PaymentReferenceType })
|
||||
@IsEnum(PaymentReferenceType)
|
||||
referenceType!: PaymentReferenceType;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Domain order id (booking/shipment id) — already validated by the calling app",
|
||||
})
|
||||
@IsString()
|
||||
@Length(1, 64)
|
||||
referenceId!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Human-readable order ref shown on provider pages; defaults to referenceId",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
orderRef?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Authoritative amount in minor units, computed server-side by the app",
|
||||
})
|
||||
@IsInt()
|
||||
@IsPositive()
|
||||
amountMinor!: number;
|
||||
|
||||
@ApiProperty({ example: "ETB" })
|
||||
@IsString()
|
||||
@Length(3, 8)
|
||||
currency!: string;
|
||||
|
||||
@ApiProperty({ enum: ProviderMethod })
|
||||
@IsEnum(ProviderMethod)
|
||||
provider!: ProviderMethod;
|
||||
|
||||
@ApiPropertyOptional({ enum: ["web", "mobile"] })
|
||||
@IsOptional()
|
||||
@IsIn(["web", "mobile"])
|
||||
platform?: PaymentPlatform;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Payer wallet MSISDN for providers that pre-fill it (e.g. Waafi MWALLET_ACCOUNT)",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
payerAccount?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Per-transaction browser return URL on success — each calling app passes its own UI " +
|
||||
"(passenger portal vs freight portal). UX only; never confirms payment. Falls back to " +
|
||||
"the provider config when omitted.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2048)
|
||||
returnUrl?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Failure/cancel counterpart of returnUrl",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2048)
|
||||
failureUrl?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Caller key to dedupe retried initiations",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(128)
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
||||
export class IntentReferenceQueryDto {
|
||||
@ApiProperty({ enum: PaymentService })
|
||||
@IsEnum(PaymentService)
|
||||
service!: PaymentService;
|
||||
|
||||
@ApiProperty({ enum: PaymentReferenceType })
|
||||
@IsEnum(PaymentReferenceType)
|
||||
referenceType!: PaymentReferenceType;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@Length(1, 64)
|
||||
referenceId!: string;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { Column, Entity, Index } from "typeorm";
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import {
|
||||
ClientAction,
|
||||
PaymentReferenceType,
|
||||
PaymentService,
|
||||
ProviderMethod,
|
||||
ProviderPaymentStatus,
|
||||
} from "@edr/types";
|
||||
|
||||
/**
|
||||
* One payment attempt for one domain order — the platform-wide source of truth for payment
|
||||
* state. `reference_id` is a soft reference into the owning app's schema (never a FK; see
|
||||
* docs/payment-service/architecture.md §5).
|
||||
*
|
||||
* Enum-valued columns are stored as varchar (values mirror the shared @edr/types enums) so
|
||||
* adding a provider/status never needs an ALTER TYPE migration.
|
||||
*/
|
||||
@Entity({ name: "payment_intent" })
|
||||
// One ACTIVE intent per domain order; FAILED/CANCELLED attempts may accumulate as audit rows.
|
||||
@Index(
|
||||
"uq_payment_intent_active_reference",
|
||||
["service", "referenceType", "referenceId"],
|
||||
{
|
||||
unique: true,
|
||||
where: `status NOT IN ('FAILED','CANCELLED') AND deleted_at IS NULL`,
|
||||
},
|
||||
)
|
||||
@Index("idx_payment_intent_sweep", ["status", "updatedAt"])
|
||||
@Index("idx_payment_intent_idempotency", ["service", "idempotencyKey"])
|
||||
export class PaymentIntent extends BaseEntity {
|
||||
/** Owning domain app — routing discriminator for notifications. */
|
||||
@Column({ name: "service", type: "varchar", length: 16 })
|
||||
service!: PaymentService;
|
||||
|
||||
@Column({ name: "reference_type", type: "varchar", length: 16 })
|
||||
referenceType!: PaymentReferenceType;
|
||||
|
||||
/** Domain order id (booking/shipment). Soft reference — no cross-schema FK. */
|
||||
@Column({ name: "reference_id", type: "varchar", length: 64 })
|
||||
referenceId!: string;
|
||||
|
||||
/** Provider-facing reference, prefixed PSG-/FRT- so webhooks route before a DB lookup. */
|
||||
@Column({
|
||||
name: "merchant_order_id",
|
||||
type: "varchar",
|
||||
length: 64,
|
||||
unique: true,
|
||||
})
|
||||
merchantOrderId!: string;
|
||||
|
||||
@Column({ name: "provider", type: "varchar", length: 16 })
|
||||
provider!: ProviderMethod;
|
||||
|
||||
/** Provider-side order/session id (prepay id, HPP orderId, …). */
|
||||
@Column({
|
||||
name: "provider_order_id",
|
||||
type: "varchar",
|
||||
length: 128,
|
||||
nullable: true,
|
||||
})
|
||||
providerOrderId?: string | null;
|
||||
|
||||
/** Final provider transaction id, set on terminal success. */
|
||||
@Index("idx_payment_intent_provider_txn")
|
||||
@Column({
|
||||
name: "provider_txn_id",
|
||||
type: "varchar",
|
||||
length: 128,
|
||||
nullable: true,
|
||||
})
|
||||
providerTxnId?: string | null;
|
||||
|
||||
/** App-asserted authoritative amount in minor units. */
|
||||
@Column({ name: "amount_minor", type: "integer" })
|
||||
amountMinor!: number;
|
||||
|
||||
/** Provider-reported amount; reconciled against amount_minor (e.g. Waafi truncates decimals). */
|
||||
@Column({ name: "confirmed_amount_minor", type: "integer", nullable: true })
|
||||
confirmedAmountMinor?: number | null;
|
||||
|
||||
@Column({ name: "currency", type: "varchar", length: 8 })
|
||||
currency!: string;
|
||||
|
||||
/** State machine: REQUIRES_ACTION → PROCESSING → SUCCEEDED | FAILED | CANCELLED (absorbing). */
|
||||
@Column({
|
||||
name: "status",
|
||||
type: "varchar",
|
||||
length: 24,
|
||||
default: ProviderPaymentStatus.REQUIRES_ACTION,
|
||||
})
|
||||
status!: ProviderPaymentStatus;
|
||||
|
||||
/** Redirect/launch payload returned to the app for the user to complete payment. */
|
||||
@Column({ name: "client_action", type: "jsonb", nullable: true })
|
||||
clientAction?: ClientAction | null;
|
||||
|
||||
@Column({ name: "failure_code", type: "varchar", length: 64, nullable: true })
|
||||
failureCode?: string | null;
|
||||
|
||||
@Column({ name: "failure_message", type: "text", nullable: true })
|
||||
failureMessage?: string | null;
|
||||
|
||||
/** Caller-supplied initiate dedupe key (in addition to the per-reference upsert). */
|
||||
@Column({
|
||||
name: "idempotency_key",
|
||||
type: "varchar",
|
||||
length: 128,
|
||||
nullable: true,
|
||||
})
|
||||
idempotencyKey?: string | null;
|
||||
|
||||
@Column({ name: "expires_at", type: "timestamptz", nullable: true })
|
||||
expiresAt?: Date | null;
|
||||
|
||||
@Column({ name: "paid_at", type: "timestamptz", nullable: true })
|
||||
paidAt?: Date | null;
|
||||
|
||||
/** Audit copy of the provider initiation request/response (secrets redacted upstream). */
|
||||
@Column({ name: "raw_initiation", type: "jsonb", nullable: true })
|
||||
rawInitiation?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
/** Statuses that keep the per-reference unique index "active" (block a new intent). */
|
||||
export const ACTIVE_INTENT_STATUSES = [
|
||||
ProviderPaymentStatus.REQUIRES_ACTION,
|
||||
ProviderPaymentStatus.PROCESSING,
|
||||
ProviderPaymentStatus.SUCCEEDED,
|
||||
] as const;
|
||||
|
||||
export const TERMINAL_INTENT_STATUSES = [
|
||||
ProviderPaymentStatus.SUCCEEDED,
|
||||
ProviderPaymentStatus.FAILED,
|
||||
ProviderPaymentStatus.CANCELLED,
|
||||
] as const;
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { PaymentIntentSnapshot } from "@edr/types";
|
||||
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
|
||||
import {
|
||||
InitiatePaymentRequestDto,
|
||||
IntentReferenceQueryDto,
|
||||
} from "./dto/initiate-payment.dto";
|
||||
import { IntentsService } from "./intents.service";
|
||||
|
||||
/**
|
||||
* Internal surface — called only by the domain apps (service-authenticated), never by
|
||||
* browsers. Domain validation ("is this booking payable", authoritative amount) has already
|
||||
* happened in the calling app.
|
||||
*/
|
||||
@ApiTags("Payments (internal)")
|
||||
@UseGuards(ServiceAuthGuard)
|
||||
@Controller("payments")
|
||||
export class IntentsController {
|
||||
constructor(private readonly intentsService: IntentsService) {}
|
||||
|
||||
@Post("initiate")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Create (or idempotently reuse) a payment intent and open a provider session",
|
||||
description:
|
||||
"One active intent per (service, referenceType, referenceId). Re-initiating a non-terminal intent returns the existing clientAction.",
|
||||
})
|
||||
async initiate(
|
||||
@Body() dto: InitiatePaymentRequestDto,
|
||||
): Promise<PaymentIntentSnapshot> {
|
||||
return this.intentsService.initiate(dto);
|
||||
}
|
||||
|
||||
@Get("intents/:id")
|
||||
@ApiOperation({
|
||||
summary: "Intent status by id (pull/reconcile)",
|
||||
description:
|
||||
"Stale non-terminal intents trigger a provider status query before returning.",
|
||||
})
|
||||
async getIntent(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
): Promise<PaymentIntentSnapshot> {
|
||||
return this.intentsService.getIntent(id);
|
||||
}
|
||||
|
||||
@Get("intents")
|
||||
@ApiOperation({
|
||||
summary: "Active intent status by domain reference (pull/reconcile)",
|
||||
})
|
||||
async getIntentByReference(
|
||||
@Query() query: IntentReferenceQueryDto,
|
||||
): Promise<PaymentIntentSnapshot> {
|
||||
return this.intentsService.getIntentByReference(
|
||||
query.service,
|
||||
query.referenceType,
|
||||
query.referenceId,
|
||||
);
|
||||
}
|
||||
}
|
||||
21
apps/edr-payment-api/src/modules/intents/intents.module.ts
Normal file
21
apps/edr-payment-api/src/modules/intents/intents.module.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
import { ProvidersModule } from "../providers/providers.module";
|
||||
import { NotificationOutbox } from "../outbox/entities/notification-outbox.entity";
|
||||
import { PaymentIntent } from "./entities/payment-intent.entity";
|
||||
import { IntentsController } from "./intents.controller";
|
||||
import { IntentsRepository } from "./intents.repository";
|
||||
import { IntentsService } from "./intents.service";
|
||||
|
||||
@Module({
|
||||
// NotificationOutbox is registered here because terminal transitions insert outbox rows
|
||||
// inside the intent-finalizing transaction (transactional outbox).
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([PaymentIntent, NotificationOutbox]),
|
||||
ProvidersModule,
|
||||
],
|
||||
controllers: [IntentsController],
|
||||
providers: [IntentsService, IntentsRepository],
|
||||
exports: [IntentsService, IntentsRepository],
|
||||
})
|
||||
export class IntentsModule {}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { In, LessThan, Not, Repository } from "typeorm";
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import {
|
||||
PaymentReferenceType,
|
||||
PaymentService,
|
||||
ProviderPaymentStatus,
|
||||
} from "@edr/types";
|
||||
import { PaymentIntent } from "./entities/payment-intent.entity";
|
||||
|
||||
@Injectable()
|
||||
export class IntentsRepository extends BaseRepository<PaymentIntent> {
|
||||
constructor(
|
||||
@InjectRepository(PaymentIntent)
|
||||
repository: Repository<PaymentIntent>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
/** The single non-FAILED/CANCELLED intent for a domain order (matches the partial unique index). */
|
||||
async findActiveByReference(
|
||||
service: PaymentService,
|
||||
referenceType: PaymentReferenceType,
|
||||
referenceId: string,
|
||||
): Promise<PaymentIntent | null> {
|
||||
return this.repository.findOne({
|
||||
where: {
|
||||
service,
|
||||
referenceType,
|
||||
referenceId,
|
||||
status: Not(
|
||||
In([ProviderPaymentStatus.FAILED, ProviderPaymentStatus.CANCELLED]),
|
||||
),
|
||||
},
|
||||
order: { createdAt: "DESC" },
|
||||
});
|
||||
}
|
||||
|
||||
async findByMerchantOrderId(
|
||||
merchantOrderId: string,
|
||||
): Promise<PaymentIntent | null> {
|
||||
return this.repository.findOne({ where: { merchantOrderId } });
|
||||
}
|
||||
|
||||
async findByIdempotencyKey(
|
||||
service: PaymentService,
|
||||
idempotencyKey: string,
|
||||
): Promise<PaymentIntent | null> {
|
||||
return this.repository.findOne({
|
||||
where: { service, idempotencyKey },
|
||||
order: { createdAt: "DESC" },
|
||||
});
|
||||
}
|
||||
|
||||
/** Non-terminal intents untouched since `updatedBefore` — input for the reconciliation sweep. */
|
||||
async findStale(
|
||||
updatedBefore: Date,
|
||||
limit: number,
|
||||
): Promise<PaymentIntent[]> {
|
||||
return this.repository.find({
|
||||
where: {
|
||||
status: In([
|
||||
ProviderPaymentStatus.REQUIRES_ACTION,
|
||||
ProviderPaymentStatus.PROCESSING,
|
||||
]),
|
||||
updatedAt: LessThan(updatedBefore),
|
||||
},
|
||||
order: { updatedAt: "ASC" },
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
}
|
||||
343
apps/edr-payment-api/src/modules/intents/intents.service.ts
Normal file
343
apps/edr-payment-api/src/modules/intents/intents.service.ts
Normal file
@@ -0,0 +1,343 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { DataSource, QueryFailedError } from "typeorm";
|
||||
import { createMerchantOrderId } from "@edr/payment-providers";
|
||||
import {
|
||||
InitiatePaymentRequest,
|
||||
PaymentIntentSnapshot,
|
||||
PaymentReferenceType,
|
||||
PaymentService,
|
||||
ProviderPaymentStatus,
|
||||
ProviderStatus,
|
||||
} from "@edr/types";
|
||||
import {
|
||||
PAYMENT_PROVIDER_MAP,
|
||||
PaymentProviderMap,
|
||||
} from "../providers/providers.module";
|
||||
import { NotificationOutbox } from "../outbox/entities/notification-outbox.entity";
|
||||
import { buildOutboxRow } from "../outbox/payment-event.factory";
|
||||
import {
|
||||
PaymentIntent,
|
||||
TERMINAL_INTENT_STATUSES,
|
||||
} from "./entities/payment-intent.entity";
|
||||
import { IntentsRepository } from "./intents.repository";
|
||||
|
||||
const PG_UNIQUE_VIOLATION = "23505";
|
||||
/** Don't hit the provider again if the intent was refreshed this recently. */
|
||||
const REFRESH_MIN_AGE_MS = 5_000;
|
||||
|
||||
/** Result of a provider signal (webhook or status query) applied to the state machine. */
|
||||
export interface ProviderResultInput {
|
||||
status: ProviderPaymentStatus;
|
||||
providerTxnId?: string;
|
||||
paidAt?: Date;
|
||||
confirmedAmountMinor?: number;
|
||||
failureCode?: string;
|
||||
failureMessage?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class IntentsService {
|
||||
private readonly logger = new Logger(IntentsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly intentsRepository: IntentsRepository,
|
||||
// DataSource is used only for the finalize transaction (intent update + outbox insert
|
||||
// must commit atomically); routine access still goes through the custom repository.
|
||||
private readonly dataSource: DataSource,
|
||||
@Inject(PAYMENT_PROVIDER_MAP)
|
||||
private readonly providers: PaymentProviderMap,
|
||||
) {}
|
||||
|
||||
/* ------------------------------------------------------------------ initiate */
|
||||
|
||||
async initiate(
|
||||
request: InitiatePaymentRequest,
|
||||
): Promise<PaymentIntentSnapshot> {
|
||||
|
||||
if (request.idempotencyKey) {
|
||||
const byKey = await this.intentsRepository.findByIdempotencyKey(
|
||||
request.service,
|
||||
request.idempotencyKey,
|
||||
);
|
||||
if (byKey) return this.toSnapshot(byKey);
|
||||
}
|
||||
|
||||
const existing = await this.intentsRepository.findActiveByReference(
|
||||
request.service,
|
||||
request.referenceType,
|
||||
request.referenceId,
|
||||
);
|
||||
if (existing) {
|
||||
const reusable = await this.reuseOrRetire(existing);
|
||||
if (reusable) return this.toSnapshot(reusable);
|
||||
}
|
||||
|
||||
const provider = this.providers.get(request.provider);
|
||||
if (!provider) {
|
||||
throw new BadRequestException(
|
||||
`Unsupported payment provider: ${request.provider}`,
|
||||
);
|
||||
}
|
||||
|
||||
const merchantOrderId = createMerchantOrderId();
|
||||
const result = await provider.initiate({
|
||||
merchantOrderId,
|
||||
orderRef: request.orderRef ?? request.referenceId,
|
||||
amountMinor: request.amountMinor,
|
||||
currency: request.currency,
|
||||
platform: request.platform,
|
||||
payerAccount: request.payerAccount,
|
||||
returnUrl: request.returnUrl,
|
||||
redirectUrl: request.returnUrl,
|
||||
failureUrl: request.failureUrl,
|
||||
});
|
||||
|
||||
try {
|
||||
const intent = await this.intentsRepository.create({
|
||||
service: request.service,
|
||||
referenceType: request.referenceType,
|
||||
referenceId: request.referenceId,
|
||||
merchantOrderId,
|
||||
provider: request.provider,
|
||||
providerOrderId: result.providerOrderId,
|
||||
amountMinor: request.amountMinor,
|
||||
currency: request.currency,
|
||||
status: ProviderPaymentStatus.REQUIRES_ACTION,
|
||||
clientAction: result.clientAction,
|
||||
idempotencyKey: request.idempotencyKey ?? null,
|
||||
expiresAt: result.expiresAt,
|
||||
rawInitiation: result.rawInitiation,
|
||||
});
|
||||
this.logger.log(
|
||||
`intent ${intent.id} created: ${request.service}/${request.referenceType}/${request.referenceId} via ${request.provider} (${merchantOrderId})`,
|
||||
);
|
||||
return this.toSnapshot(intent);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof QueryFailedError &&
|
||||
(err.driverError as { code?: string })?.code === PG_UNIQUE_VIOLATION
|
||||
) {
|
||||
const winner = await this.intentsRepository.findActiveByReference(
|
||||
request.service,
|
||||
request.referenceType,
|
||||
request.referenceId,
|
||||
);
|
||||
if (winner) return this.toSnapshot(winner);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether an existing active intent can be returned as-is. An expired
|
||||
* REQUIRES_ACTION intent is retired (CANCELLED, no notification — nothing was paid)
|
||||
* so a fresh provider session can be opened.
|
||||
*/
|
||||
private async reuseOrRetire(
|
||||
intent: PaymentIntent,
|
||||
): Promise<PaymentIntent | null> {
|
||||
const expired =
|
||||
intent.status === ProviderPaymentStatus.REQUIRES_ACTION &&
|
||||
intent.expiresAt != null &&
|
||||
intent.expiresAt.getTime() < Date.now();
|
||||
if (!expired) return intent;
|
||||
|
||||
await this.intentsRepository.update(intent.id, {
|
||||
status: ProviderPaymentStatus.CANCELLED,
|
||||
failureCode: "EXPIRED",
|
||||
failureMessage: "Provider session expired before the payer acted",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ lookups */
|
||||
|
||||
async getIntent(id: string): Promise<PaymentIntentSnapshot> {
|
||||
const intent = await this.intentsRepository.findById(id);
|
||||
if (!intent) throw new NotFoundException("PaymentIntent not found");
|
||||
return this.toSnapshot(await this.refreshIfStale(intent));
|
||||
}
|
||||
|
||||
async getIntentByReference(
|
||||
service: PaymentService,
|
||||
referenceType: PaymentReferenceType,
|
||||
referenceId: string,
|
||||
): Promise<PaymentIntentSnapshot> {
|
||||
const intent = await this.intentsRepository.findActiveByReference(
|
||||
service,
|
||||
referenceType,
|
||||
referenceId,
|
||||
);
|
||||
if (!intent) throw new NotFoundException("PaymentIntent not found");
|
||||
return this.toSnapshot(await this.refreshIfStale(intent));
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull-side reconciliation: when a polled intent is non-terminal and stale, ask the
|
||||
* provider for the truth and run the answer through the state machine. The browser
|
||||
* redirect never confirms payment — this query (or a webhook) does.
|
||||
*/
|
||||
private async refreshIfStale(intent: PaymentIntent): Promise<PaymentIntent> {
|
||||
const refreshable =
|
||||
intent.status === ProviderPaymentStatus.REQUIRES_ACTION ||
|
||||
intent.status === ProviderPaymentStatus.PROCESSING;
|
||||
const stale = intent.updatedAt.getTime() < Date.now() - REFRESH_MIN_AGE_MS;
|
||||
const provider = this.providers.get(intent.provider);
|
||||
if (!refreshable || !stale || !provider) return intent;
|
||||
|
||||
try {
|
||||
const status = await provider.queryStatus(intent.merchantOrderId);
|
||||
await this.applyProviderResult(
|
||||
intent.id,
|
||||
this.fromProviderStatus(status),
|
||||
);
|
||||
return (await this.intentsRepository.findById(intent.id)) ?? intent;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.warn(
|
||||
`queryStatus failed for intent ${intent.id}: ${message}; returning cached`,
|
||||
);
|
||||
return intent;
|
||||
}
|
||||
}
|
||||
|
||||
fromProviderStatus(status: ProviderStatus): ProviderResultInput {
|
||||
return {
|
||||
status: status.status,
|
||||
providerTxnId: status.providerTxnId,
|
||||
failureCode: status.failureCode,
|
||||
failureMessage: status.failureMessage,
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ state machine */
|
||||
|
||||
/**
|
||||
* Advance the intent state machine with a verified provider signal. Terminal states are
|
||||
* absorbing; a terminal transition writes the notification_outbox row IN THE SAME
|
||||
* TRANSACTION as the intent update (transactional outbox — architecture.md §8).
|
||||
*/
|
||||
async applyProviderResult(
|
||||
intentId: string,
|
||||
result: ProviderResultInput,
|
||||
): Promise<{ alreadyTerminal: boolean }> {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const intent = await manager
|
||||
.getRepository(PaymentIntent)
|
||||
.createQueryBuilder("intent")
|
||||
.setLock("pessimistic_write")
|
||||
.where("intent.id = :intentId", { intentId })
|
||||
.getOne();
|
||||
if (!intent) throw new NotFoundException("PaymentIntent not found");
|
||||
|
||||
if (
|
||||
(TERMINAL_INTENT_STATUSES as readonly ProviderPaymentStatus[]).includes(
|
||||
intent.status,
|
||||
)
|
||||
) {
|
||||
return { alreadyTerminal: true };
|
||||
}
|
||||
|
||||
if (result.status === ProviderPaymentStatus.SUCCEEDED) {
|
||||
const paidAt = result.paidAt ?? new Date();
|
||||
intent.status = ProviderPaymentStatus.SUCCEEDED;
|
||||
intent.providerTxnId = result.providerTxnId ?? intent.providerTxnId;
|
||||
intent.paidAt = paidAt;
|
||||
intent.confirmedAmountMinor =
|
||||
result.confirmedAmountMinor ?? intent.confirmedAmountMinor;
|
||||
intent.failureCode = null;
|
||||
intent.failureMessage = null;
|
||||
await manager.save(intent);
|
||||
await manager.getRepository(NotificationOutbox).save(
|
||||
buildOutboxRow(intent, {
|
||||
eventType: "payment.succeeded",
|
||||
providerTxnId: intent.providerTxnId ?? undefined,
|
||||
paidAt,
|
||||
}),
|
||||
);
|
||||
if (
|
||||
result.confirmedAmountMinor != null &&
|
||||
result.confirmedAmountMinor !== intent.amountMinor
|
||||
) {
|
||||
this.logger.error(
|
||||
`intent ${intent.id} amount mismatch: asserted=${intent.amountMinor} confirmed=${result.confirmedAmountMinor}`,
|
||||
);
|
||||
}
|
||||
this.logger.log(
|
||||
`intent ${intent.id} SUCCEEDED (txn=${intent.providerTxnId ?? "n/a"})`,
|
||||
);
|
||||
return { alreadyTerminal: false };
|
||||
}
|
||||
|
||||
if (
|
||||
result.status === ProviderPaymentStatus.FAILED ||
|
||||
result.status === ProviderPaymentStatus.CANCELLED
|
||||
) {
|
||||
intent.status = result.status;
|
||||
intent.providerTxnId = result.providerTxnId ?? intent.providerTxnId;
|
||||
intent.failureCode = result.failureCode ?? null;
|
||||
intent.failureMessage = result.failureMessage ?? null;
|
||||
await manager.save(intent);
|
||||
await manager.getRepository(NotificationOutbox).save(
|
||||
buildOutboxRow(intent, {
|
||||
eventType: "payment.failed",
|
||||
failureCode: result.failureCode,
|
||||
failureMessage: result.failureMessage,
|
||||
}),
|
||||
);
|
||||
this.logger.log(
|
||||
`intent ${intent.id} ${result.status} (${result.failureCode ?? "n/a"})`,
|
||||
);
|
||||
return { alreadyTerminal: false };
|
||||
}
|
||||
|
||||
// Non-terminal: REQUIRES_ACTION may move to PROCESSING; never the reverse.
|
||||
if (
|
||||
result.status === ProviderPaymentStatus.PROCESSING &&
|
||||
intent.status === ProviderPaymentStatus.REQUIRES_ACTION
|
||||
) {
|
||||
intent.status = ProviderPaymentStatus.PROCESSING;
|
||||
}
|
||||
intent.providerTxnId = result.providerTxnId ?? intent.providerTxnId;
|
||||
await manager.save(intent);
|
||||
return { alreadyTerminal: false };
|
||||
});
|
||||
}
|
||||
|
||||
/** Expire an abandoned intent (reconciliation sweep) — CANCELLED + payment.failed event. */
|
||||
async expireIntent(intentId: string): Promise<void> {
|
||||
await this.applyProviderResult(intentId, {
|
||||
status: ProviderPaymentStatus.CANCELLED,
|
||||
failureCode: "EXPIRED",
|
||||
failureMessage: "Payment session expired before completion",
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ mapping */
|
||||
|
||||
toSnapshot(intent: PaymentIntent): PaymentIntentSnapshot {
|
||||
return {
|
||||
intentId: intent.id,
|
||||
service: intent.service,
|
||||
referenceType: intent.referenceType,
|
||||
referenceId: intent.referenceId,
|
||||
merchantOrderId: intent.merchantOrderId,
|
||||
provider: intent.provider,
|
||||
status: intent.status,
|
||||
amountMinor: intent.amountMinor,
|
||||
currency: intent.currency,
|
||||
clientAction: intent.clientAction ?? undefined,
|
||||
providerTxnId: intent.providerTxnId ?? undefined,
|
||||
paidAt: intent.paidAt?.toISOString(),
|
||||
failureCode: intent.failureCode ?? undefined,
|
||||
failureMessage: intent.failureMessage ?? undefined,
|
||||
expiresAt: intent.expiresAt?.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Column, Entity, Index } from "typeorm";
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import {
|
||||
PaymentEvent,
|
||||
PaymentEventType,
|
||||
PaymentReferenceType,
|
||||
PaymentService,
|
||||
} from "@edr/types";
|
||||
|
||||
export type OutboxStatus = "PENDING" | "SENT" | "FAILED";
|
||||
|
||||
/**
|
||||
* Transactional outbox: a row is inserted in the SAME transaction that finalizes an intent,
|
||||
* so "payment succeeded" and "a notification is owed" commit or roll back together. The relay
|
||||
* drains PENDING rows and retries until acked (at-least-once delivery; consumers are idempotent).
|
||||
*/
|
||||
@Entity({ name: "notification_outbox" })
|
||||
@Index("idx_notification_outbox_relay", ["status", "nextRetryAt"])
|
||||
export class NotificationOutbox extends BaseEntity {
|
||||
@Column({ name: "event_type", type: "varchar", length: 32 })
|
||||
eventType!: PaymentEventType;
|
||||
|
||||
/** Routing discriminator — which app's mark-paid endpoint the relay delivers to. */
|
||||
@Column({ name: "service", type: "varchar", length: 16 })
|
||||
service!: PaymentService;
|
||||
|
||||
@Index("idx_notification_outbox_intent")
|
||||
@Column({ name: "intent_id", type: "uuid" })
|
||||
intentId!: string;
|
||||
|
||||
@Column({ name: "reference_type", type: "varchar", length: 16 })
|
||||
referenceType!: PaymentReferenceType;
|
||||
|
||||
@Column({ name: "reference_id", type: "varchar", length: 64 })
|
||||
referenceId!: string;
|
||||
|
||||
/** The full versioned event envelope delivered verbatim to the consumer. */
|
||||
@Column({ name: "payload", type: "jsonb" })
|
||||
payload!: PaymentEvent;
|
||||
|
||||
@Column({ name: "status", type: "varchar", length: 16, default: "PENDING" })
|
||||
status!: OutboxStatus;
|
||||
|
||||
@Column({ name: "attempts", type: "integer", default: 0 })
|
||||
attempts!: number;
|
||||
|
||||
@Column({ name: "next_retry_at", type: "timestamptz", nullable: true })
|
||||
nextRetryAt?: Date | null;
|
||||
|
||||
@Column({ name: "last_error", type: "text", nullable: true })
|
||||
lastError?: string | null;
|
||||
|
||||
@Column({ name: "sent_at", type: "timestamptz", nullable: true })
|
||||
sentAt?: Date | null;
|
||||
}
|
||||
119
apps/edr-payment-api/src/modules/outbox/outbox-relay.service.ts
Normal file
119
apps/edr-payment-api/src/modules/outbox/outbox-relay.service.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleDestroy,
|
||||
OnModuleInit,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { SchedulerRegistry } from "@nestjs/schedule";
|
||||
import { NotificationOutbox } from "./entities/notification-outbox.entity";
|
||||
import { OutboxRepository } from "./outbox.repository";
|
||||
import {
|
||||
PAYMENT_EVENT_PUBLISHER,
|
||||
PaymentEventPublisher,
|
||||
} from "./publisher/payment-event-publisher";
|
||||
|
||||
const RELAY_INTERVAL_NAME = "outbox-relay";
|
||||
/** Retry backoff: base doubles per attempt, capped. */
|
||||
const BACKOFF_BASE_MS = 10_000;
|
||||
const BACKOFF_CAP_MS = 10 * 60_000;
|
||||
|
||||
/**
|
||||
* Drains the transactional outbox: PENDING rows are published (HTTP now, RabbitMQ later),
|
||||
* marked SENT on ack, retried with exponential backoff on failure, and flagged FAILED after
|
||||
* OUTBOX_MAX_ATTEMPTS (an alertable condition — delivery is at-least-once, never dropped
|
||||
* silently). A crash between commit and publish only delays delivery.
|
||||
*/
|
||||
@Injectable()
|
||||
export class OutboxRelayService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(OutboxRelayService.name);
|
||||
private readonly intervalMs: number;
|
||||
private readonly maxAttempts: number;
|
||||
private readonly batchSize: number;
|
||||
private draining = false;
|
||||
|
||||
constructor(
|
||||
config: ConfigService,
|
||||
private readonly outboxRepository: OutboxRepository,
|
||||
private readonly schedulerRegistry: SchedulerRegistry,
|
||||
@Inject(PAYMENT_EVENT_PUBLISHER)
|
||||
private readonly publisher: PaymentEventPublisher,
|
||||
) {
|
||||
this.intervalMs = config.get<number>("notifier.relayIntervalMs") ?? 5_000;
|
||||
this.maxAttempts = config.get<number>("notifier.maxAttempts") ?? 10;
|
||||
this.batchSize = config.get<number>("notifier.relayBatchSize") ?? 20;
|
||||
}
|
||||
|
||||
onModuleInit(): void {
|
||||
const interval = setInterval(() => void this.drain(), this.intervalMs);
|
||||
this.schedulerRegistry.addInterval(RELAY_INTERVAL_NAME, interval);
|
||||
}
|
||||
|
||||
onModuleDestroy(): void {
|
||||
if (this.schedulerRegistry.doesExist("interval", RELAY_INTERVAL_NAME)) {
|
||||
this.schedulerRegistry.deleteInterval(RELAY_INTERVAL_NAME);
|
||||
}
|
||||
}
|
||||
|
||||
/** One relay pass; re-entrant ticks are skipped so slow deliveries don't overlap. */
|
||||
async drain(): Promise<void> {
|
||||
if (this.draining) return;
|
||||
this.draining = true;
|
||||
try {
|
||||
const due = await this.outboxRepository.findDue(this.batchSize);
|
||||
for (const row of due) {
|
||||
await this.deliver(row);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`relay pass failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
} finally {
|
||||
this.draining = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async deliver(row: NotificationOutbox): Promise<void> {
|
||||
try {
|
||||
await this.publisher.publish(row.payload);
|
||||
await this.outboxRepository.markSent(row.id);
|
||||
} catch (err) {
|
||||
const message = this.describeError(err);
|
||||
const attempts = row.attempts + 1;
|
||||
const exhausted = attempts >= this.maxAttempts;
|
||||
const backoffMs = Math.min(
|
||||
BACKOFF_BASE_MS * 2 ** row.attempts,
|
||||
BACKOFF_CAP_MS,
|
||||
);
|
||||
await this.outboxRepository.markAttemptFailed(
|
||||
row,
|
||||
message,
|
||||
exhausted ? null : new Date(Date.now() + backoffMs),
|
||||
exhausted,
|
||||
);
|
||||
if (exhausted) {
|
||||
// ALERT: a paid order may not be confirmed in the owning app — needs operator action.
|
||||
this.logger.error(
|
||||
`outbox ${row.id} (${row.eventType} intent=${row.intentId}) FAILED after ${attempts} attempts: ${message}`,
|
||||
);
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`outbox ${row.id} delivery attempt ${attempts} failed (retry in ${backoffMs}ms): ${message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Connection failures surface as AggregateError with an empty message — dig out the code. */
|
||||
private describeError(err: unknown): string {
|
||||
if (err instanceof Error) {
|
||||
if (err.message) return err.message;
|
||||
const code = (err as { code?: string }).code;
|
||||
if (code) return code;
|
||||
const inner = (err as { errors?: unknown[] }).errors?.[0];
|
||||
if (inner instanceof Error && inner.message) return inner.message;
|
||||
}
|
||||
return String(err);
|
||||
}
|
||||
}
|
||||
20
apps/edr-payment-api/src/modules/outbox/outbox.module.ts
Normal file
20
apps/edr-payment-api/src/modules/outbox/outbox.module.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { HttpModule } from "@nestjs/axios";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
import { NotificationOutbox } from "./entities/notification-outbox.entity";
|
||||
import { OutboxRelayService } from "./outbox-relay.service";
|
||||
import { OutboxRepository } from "./outbox.repository";
|
||||
import { HttpPaymentEventPublisher } from "./publisher/http-payment-event-publisher";
|
||||
import { PAYMENT_EVENT_PUBLISHER } from "./publisher/payment-event-publisher";
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([NotificationOutbox]), HttpModule],
|
||||
providers: [
|
||||
OutboxRepository,
|
||||
OutboxRelayService,
|
||||
// Swap to RabbitPaymentEventPublisher here when the broker lands — nothing else changes.
|
||||
{ provide: PAYMENT_EVENT_PUBLISHER, useClass: HttpPaymentEventPublisher },
|
||||
],
|
||||
exports: [OutboxRepository],
|
||||
})
|
||||
export class OutboxModule {}
|
||||
58
apps/edr-payment-api/src/modules/outbox/outbox.repository.ts
Normal file
58
apps/edr-payment-api/src/modules/outbox/outbox.repository.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { NotificationOutbox } from "./entities/notification-outbox.entity";
|
||||
|
||||
@Injectable()
|
||||
export class OutboxRepository extends BaseRepository<NotificationOutbox> {
|
||||
constructor(
|
||||
@InjectRepository(NotificationOutbox)
|
||||
repository: Repository<NotificationOutbox>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
/**
|
||||
* PENDING rows whose retry time has come, oldest first. The relay runs as a single
|
||||
* non-overlapping loop per instance; with multiple service instances this should move to a
|
||||
* SELECT … FOR UPDATE SKIP LOCKED claim.
|
||||
*/
|
||||
async findDue(limit: number): Promise<NotificationOutbox[]> {
|
||||
return this.repository
|
||||
.createQueryBuilder("outbox")
|
||||
.where(`outbox.status = 'PENDING'`)
|
||||
.andWhere(
|
||||
"(outbox.next_retry_at IS NULL OR outbox.next_retry_at <= now())",
|
||||
)
|
||||
.orderBy("outbox.created_at", "ASC")
|
||||
.take(limit)
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async markSent(id: string): Promise<void> {
|
||||
await this.update(id, {
|
||||
status: "SENT",
|
||||
sentAt: new Date(),
|
||||
lastError: null,
|
||||
});
|
||||
}
|
||||
|
||||
async markAttemptFailed(
|
||||
row: NotificationOutbox,
|
||||
error: string,
|
||||
nextRetryAt: Date | null,
|
||||
exhausted: boolean,
|
||||
): Promise<void> {
|
||||
await this.update(row.id, {
|
||||
attempts: row.attempts + 1,
|
||||
lastError: error,
|
||||
nextRetryAt,
|
||||
status: exhausted ? "FAILED" : "PENDING",
|
||||
});
|
||||
}
|
||||
|
||||
async countBacklog(): Promise<number> {
|
||||
return this.repository.count({ where: { status: "PENDING" } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
PaymentEvent,
|
||||
PaymentFailedEvent,
|
||||
PaymentSucceededEvent,
|
||||
} from "@edr/types";
|
||||
import { PaymentIntent } from "../intents/entities/payment-intent.entity";
|
||||
import { NotificationOutbox } from "./entities/notification-outbox.entity";
|
||||
|
||||
/**
|
||||
* Build a ready-to-insert outbox row for a terminal intent. Pure (no DI) so the intents
|
||||
* state machine can insert it inside its own DB transaction without a module cycle.
|
||||
* The row id is generated here because the event envelope embeds it as `eventId`.
|
||||
*/
|
||||
export function buildOutboxRow(
|
||||
intent: PaymentIntent,
|
||||
terminal:
|
||||
| { eventType: "payment.succeeded"; providerTxnId?: string; paidAt: Date }
|
||||
| {
|
||||
eventType: "payment.failed";
|
||||
failureCode?: string;
|
||||
failureMessage?: string;
|
||||
},
|
||||
): Partial<NotificationOutbox> {
|
||||
const id = randomUUID();
|
||||
const base = {
|
||||
version: 1 as const,
|
||||
eventId: id,
|
||||
occurredAt: new Date().toISOString(),
|
||||
service: intent.service,
|
||||
intentId: intent.id,
|
||||
referenceType: intent.referenceType,
|
||||
referenceId: intent.referenceId,
|
||||
merchantOrderId: intent.merchantOrderId,
|
||||
provider: intent.provider,
|
||||
amountMinor: intent.amountMinor,
|
||||
currency: intent.currency,
|
||||
};
|
||||
|
||||
const event: PaymentEvent =
|
||||
terminal.eventType === "payment.succeeded"
|
||||
? ({
|
||||
...base,
|
||||
eventType: "payment.succeeded",
|
||||
providerTxnId: terminal.providerTxnId,
|
||||
paidAt: terminal.paidAt.toISOString(),
|
||||
} satisfies PaymentSucceededEvent)
|
||||
: ({
|
||||
...base,
|
||||
eventType: "payment.failed",
|
||||
failureCode: terminal.failureCode,
|
||||
failureMessage: terminal.failureMessage,
|
||||
} satisfies PaymentFailedEvent);
|
||||
|
||||
return {
|
||||
id,
|
||||
eventType: event.eventType,
|
||||
service: intent.service,
|
||||
intentId: intent.id,
|
||||
referenceType: intent.referenceType,
|
||||
referenceId: intent.referenceId,
|
||||
payload: event,
|
||||
status: "PENDING",
|
||||
attempts: 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { HttpService } from "@nestjs/axios";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { PaymentEvent, PaymentService } from "@edr/types";
|
||||
import { PaymentEventPublisher } from "./payment-event-publisher";
|
||||
|
||||
/**
|
||||
* Delivers events by POSTing to the owning app's idempotent mark-paid endpoint, routed by
|
||||
* the `service` discriminator. Authenticated with the shared service token (the same secret
|
||||
* the apps use to call /payments/initiate).
|
||||
*/
|
||||
@Injectable()
|
||||
export class HttpPaymentEventPublisher implements PaymentEventPublisher {
|
||||
private readonly logger = new Logger(HttpPaymentEventPublisher.name);
|
||||
private readonly routes: Record<PaymentService, string>;
|
||||
private readonly timeoutMs: number;
|
||||
private readonly serviceToken: string;
|
||||
|
||||
constructor(
|
||||
config: ConfigService,
|
||||
private readonly http: HttpService,
|
||||
) {
|
||||
this.routes = {
|
||||
[PaymentService.PASSENGER]:
|
||||
config.get<string>("notifier.passengerUrl") ?? "",
|
||||
[PaymentService.FREIGHT]: config.get<string>("notifier.freightUrl") ?? "",
|
||||
};
|
||||
this.timeoutMs = config.get<number>("notifier.httpTimeoutMs") ?? 10_000;
|
||||
this.serviceToken = config.get<string>("app.serviceAuthToken") ?? "";
|
||||
}
|
||||
|
||||
async publish(event: PaymentEvent): Promise<void> {
|
||||
const url = this.routes[event.service];
|
||||
if (!url) {
|
||||
throw new Error(
|
||||
`No mark-paid URL configured for service ${event.service}`,
|
||||
);
|
||||
}
|
||||
|
||||
const response = await firstValueFrom(
|
||||
this.http.post(url, event, {
|
||||
timeout: this.timeoutMs,
|
||||
headers: this.serviceToken
|
||||
? { "x-service-token": this.serviceToken }
|
||||
: {},
|
||||
}),
|
||||
);
|
||||
this.logger.log(
|
||||
`delivered ${event.eventType} (${event.eventId}) to ${event.service} — HTTP ${response.status}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { PaymentEvent } from "@edr/types";
|
||||
|
||||
/**
|
||||
* Publisher port (architecture.md §12): how a payment event leaves this service.
|
||||
* HTTP implementation now; a RabbitMQ implementation later is a DI swap only — the outbox
|
||||
* and relay stay exactly as they are.
|
||||
*/
|
||||
export interface PaymentEventPublisher {
|
||||
/** Deliver one event; throw on failure so the relay can retry with backoff. */
|
||||
publish(event: PaymentEvent): Promise<void>;
|
||||
}
|
||||
|
||||
export const PAYMENT_EVENT_PUBLISHER = Symbol("PAYMENT_EVENT_PUBLISHER");
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { HttpModule } from "@nestjs/axios";
|
||||
import {
|
||||
CardProvider,
|
||||
CbeBirrProvider,
|
||||
DMoneyProvider,
|
||||
EBirrProvider,
|
||||
PaymentProvider,
|
||||
TelebirrProvider,
|
||||
WaafiProvider,
|
||||
} from "@edr/payment-providers";
|
||||
import { ProviderMethod } from "@edr/types";
|
||||
|
||||
/** Injection token for the Map<ProviderMethod, PaymentProvider> used to select a gateway. */
|
||||
export const PAYMENT_PROVIDER_MAP = Symbol("PAYMENT_PROVIDER_MAP");
|
||||
|
||||
export type PaymentProviderMap = Map<ProviderMethod, PaymentProvider>;
|
||||
|
||||
const providerClasses = [
|
||||
TelebirrProvider,
|
||||
CbeBirrProvider,
|
||||
EBirrProvider,
|
||||
CardProvider,
|
||||
WaafiProvider,
|
||||
DMoneyProvider,
|
||||
];
|
||||
|
||||
/**
|
||||
* Thin DI wiring around @edr/payment-providers — the exact provider set the passenger app
|
||||
* used to construct, relocated here. After cutover this service is the only consumer of the
|
||||
* provider SDK and of the provider secrets (config/{waafi,telebirr,…}.config.ts).
|
||||
*/
|
||||
@Module({
|
||||
imports: [HttpModule.register({ timeout: 10_000 })],
|
||||
providers: [
|
||||
...providerClasses,
|
||||
{
|
||||
provide: PAYMENT_PROVIDER_MAP,
|
||||
useFactory: (...providers: PaymentProvider[]): PaymentProviderMap =>
|
||||
new Map(providers.map((provider) => [provider.method, provider])),
|
||||
inject: providerClasses,
|
||||
},
|
||||
],
|
||||
exports: [PAYMENT_PROVIDER_MAP, ...providerClasses],
|
||||
})
|
||||
export class ProvidersModule {}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { IntentsModule } from "../intents/intents.module";
|
||||
import { ProvidersModule } from "../providers/providers.module";
|
||||
import { ReconciliationService } from "./reconciliation.service";
|
||||
|
||||
@Module({
|
||||
imports: [IntentsModule, ProvidersModule],
|
||||
providers: [ReconciliationService],
|
||||
})
|
||||
export class ReconciliationModule {}
|
||||
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleDestroy,
|
||||
OnModuleInit,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { SchedulerRegistry } from "@nestjs/schedule";
|
||||
import { ProviderPaymentStatus } from "@edr/types";
|
||||
import {
|
||||
PAYMENT_PROVIDER_MAP,
|
||||
PaymentProviderMap,
|
||||
} from "../providers/providers.module";
|
||||
import { PaymentIntent } from "../intents/entities/payment-intent.entity";
|
||||
import { IntentsRepository } from "../intents/intents.repository";
|
||||
import { IntentsService } from "../intents/intents.service";
|
||||
|
||||
const SWEEP_INTERVAL_NAME = "reconciliation-sweep";
|
||||
|
||||
/**
|
||||
* Safety net (architecture.md §7.4): webhooks get lost, users abandon hosted pages. The sweep
|
||||
* queries the provider for stale non-terminal intents and feeds the answer through the same
|
||||
* state machine the webhooks use; intents whose provider session expired are CANCELLED.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ReconciliationService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(ReconciliationService.name);
|
||||
private readonly intervalMs: number;
|
||||
private readonly staleAfterMs: number;
|
||||
private readonly batchSize: number;
|
||||
private sweeping = false;
|
||||
|
||||
constructor(
|
||||
config: ConfigService,
|
||||
private readonly intentsRepository: IntentsRepository,
|
||||
private readonly intentsService: IntentsService,
|
||||
private readonly schedulerRegistry: SchedulerRegistry,
|
||||
@Inject(PAYMENT_PROVIDER_MAP)
|
||||
private readonly providers: PaymentProviderMap,
|
||||
) {
|
||||
this.intervalMs =
|
||||
config.get<number>("app.reconciliation.sweepIntervalMs") ?? 60_000;
|
||||
this.staleAfterMs =
|
||||
config.get<number>("app.reconciliation.staleAfterMs") ?? 60_000;
|
||||
this.batchSize = config.get<number>("app.reconciliation.batchSize") ?? 20;
|
||||
}
|
||||
|
||||
onModuleInit(): void {
|
||||
const interval = setInterval(() => void this.sweep(), this.intervalMs);
|
||||
this.schedulerRegistry.addInterval(SWEEP_INTERVAL_NAME, interval);
|
||||
}
|
||||
|
||||
onModuleDestroy(): void {
|
||||
if (this.schedulerRegistry.doesExist("interval", SWEEP_INTERVAL_NAME)) {
|
||||
this.schedulerRegistry.deleteInterval(SWEEP_INTERVAL_NAME);
|
||||
}
|
||||
}
|
||||
|
||||
async sweep(): Promise<void> {
|
||||
if (this.sweeping) return;
|
||||
this.sweeping = true;
|
||||
try {
|
||||
const cutoff = new Date(Date.now() - this.staleAfterMs);
|
||||
const stale = await this.intentsRepository.findStale(
|
||||
cutoff,
|
||||
this.batchSize,
|
||||
);
|
||||
for (const intent of stale) {
|
||||
await this.reconcileIntent(intent);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`sweep failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
} finally {
|
||||
this.sweeping = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async reconcileIntent(intent: PaymentIntent): Promise<void> {
|
||||
try {
|
||||
const provider = this.providers.get(intent.provider);
|
||||
if (provider) {
|
||||
const status = await provider.queryStatus(intent.merchantOrderId);
|
||||
const result = this.intentsService.fromProviderStatus(status);
|
||||
if (result.status !== intent.status || result.providerTxnId) {
|
||||
await this.intentsService.applyProviderResult(intent.id, result);
|
||||
}
|
||||
if (
|
||||
result.status === ProviderPaymentStatus.SUCCEEDED ||
|
||||
result.status === ProviderPaymentStatus.FAILED ||
|
||||
result.status === ProviderPaymentStatus.CANCELLED
|
||||
) {
|
||||
this.logger.log(`reconciled intent ${intent.id} → ${result.status}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Provider still says pending (or is unknown): expire only once the session is dead.
|
||||
if (intent.expiresAt && intent.expiresAt.getTime() < Date.now()) {
|
||||
await this.intentsService.expireIntent(intent.id);
|
||||
this.logger.log(
|
||||
`expired abandoned intent ${intent.id} (${intent.merchantOrderId})`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
// Per-intent failures must not stall the sweep; the row stays stale and is retried.
|
||||
this.logger.warn(
|
||||
`reconcile failed for intent ${intent.id}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Column, Entity, Index } from "typeorm";
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { ProviderMethod } from "@edr/types";
|
||||
|
||||
/**
|
||||
* Idempotency + audit record for every inbound provider webhook. The unique
|
||||
* (provider, external_event_id) pair is the dedupe key: a duplicate insert hits the unique
|
||||
* violation and the handler short-circuits with a 200 ack.
|
||||
*/
|
||||
@Entity({ name: "payment_webhook_event" })
|
||||
@Index("uq_payment_webhook_event_external", ["provider", "externalEventId"], {
|
||||
unique: true,
|
||||
})
|
||||
export class PaymentWebhookEvent extends BaseEntity {
|
||||
@Column({ name: "provider", type: "varchar", length: 16 })
|
||||
provider!: ProviderMethod;
|
||||
|
||||
/** Provider event id when given (e.g. Waafi X-Webhook-Event-Id), else derived from the payload. */
|
||||
@Column({ name: "external_event_id", type: "varchar", length: 191 })
|
||||
externalEventId!: string;
|
||||
|
||||
@Column({
|
||||
name: "merchant_order_id",
|
||||
type: "varchar",
|
||||
length: 64,
|
||||
nullable: true,
|
||||
})
|
||||
merchantOrderId?: string | null;
|
||||
|
||||
@Column({
|
||||
name: "provider_txn_id",
|
||||
type: "varchar",
|
||||
length: 128,
|
||||
nullable: true,
|
||||
})
|
||||
providerTxnId?: string | null;
|
||||
|
||||
@Column({ name: "signature_valid", type: "boolean", default: false })
|
||||
signatureValid!: boolean;
|
||||
|
||||
/** Raw provider status string as sent (pre-mapping). */
|
||||
@Column({ name: "status", type: "varchar", length: 64, nullable: true })
|
||||
status?: string | null;
|
||||
|
||||
/** Full webhook body — hostile input, stored verbatim for audit/replay analysis. */
|
||||
@Column({ name: "payload", type: "jsonb" })
|
||||
payload!: Record<string, unknown>;
|
||||
|
||||
@Column({ name: "received_at", type: "timestamptz", default: () => "now()" })
|
||||
receivedAt!: Date;
|
||||
|
||||
@Column({ name: "processed_at", type: "timestamptz", nullable: true })
|
||||
processedAt?: Date | null;
|
||||
|
||||
@Column({ name: "processing_error", type: "text", nullable: true })
|
||||
processingError?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { CardProvider, CardWebhookPayload } from "@edr/payment-providers";
|
||||
import { WebhookProcessorService } from "../webhook-processor.service";
|
||||
|
||||
@Injectable()
|
||||
export class CardWebhookService {
|
||||
constructor(
|
||||
private readonly provider: CardProvider,
|
||||
private readonly processor: WebhookProcessorService,
|
||||
) {}
|
||||
|
||||
async handle(payload: CardWebhookPayload, signature: string): Promise<void> {
|
||||
const signatureValid = this.provider.verifyWebhookSignature(
|
||||
payload as unknown as Record<string, unknown>,
|
||||
signature,
|
||||
);
|
||||
const object = payload.data.object;
|
||||
const mapped = this.provider.mapWebhookStatus(object.status);
|
||||
|
||||
await this.processor.process({
|
||||
provider: this.provider.method,
|
||||
externalEventId: `${payload.id}_${payload.type}`,
|
||||
merchantOrderId: object.metadata.merchantOrderId,
|
||||
providerTxnId: object.transaction_id,
|
||||
signatureValid,
|
||||
rawStatus: object.status,
|
||||
payload: payload as unknown as Record<string, unknown>,
|
||||
result: {
|
||||
status: mapped,
|
||||
providerTxnId: object.transaction_id,
|
||||
failureCode: object.status,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { CbeBirrProvider, CbeBirrWebhookPayload } from "@edr/payment-providers";
|
||||
import { WebhookProcessorService } from "../webhook-processor.service";
|
||||
|
||||
@Injectable()
|
||||
export class CbeBirrWebhookService {
|
||||
constructor(
|
||||
private readonly provider: CbeBirrProvider,
|
||||
private readonly processor: WebhookProcessorService,
|
||||
) {}
|
||||
|
||||
async handle(payload: CbeBirrWebhookPayload): Promise<void> {
|
||||
const signatureValid = this.provider.verifyWebhookSignature(
|
||||
payload as unknown as Record<string, unknown>,
|
||||
);
|
||||
const mapped = this.provider.mapWebhookStatus(payload.status);
|
||||
const providerTxnId = payload.transactionId ?? payload.orderId;
|
||||
|
||||
await this.processor.process({
|
||||
provider: this.provider.method,
|
||||
externalEventId: `${payload.orderId}_${payload.status}`,
|
||||
merchantOrderId: payload.merchantOrderId,
|
||||
providerTxnId,
|
||||
signatureValid,
|
||||
rawStatus: payload.status,
|
||||
payload: payload as unknown as Record<string, unknown>,
|
||||
result: { status: mapped, providerTxnId, failureCode: payload.status },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { DMoneyProvider, DMoneyWebhookPayload } from "@edr/payment-providers";
|
||||
import { WebhookProcessorService } from "../webhook-processor.service";
|
||||
|
||||
@Injectable()
|
||||
export class DMoneyWebhookService {
|
||||
constructor(
|
||||
private readonly provider: DMoneyProvider,
|
||||
private readonly processor: WebhookProcessorService,
|
||||
) {}
|
||||
|
||||
async handle(payload: DMoneyWebhookPayload): Promise<void> {
|
||||
const signatureValid = this.provider.verifyWebhookSignature(
|
||||
payload as unknown as Record<string, unknown>,
|
||||
);
|
||||
const mapped = this.provider.mapWebhookStatus(payload.status);
|
||||
|
||||
await this.processor.process({
|
||||
provider: this.provider.method,
|
||||
externalEventId: `${payload.orderId}_${payload.status}`,
|
||||
merchantOrderId: payload.merchantOrderId,
|
||||
providerTxnId: payload.transactionId,
|
||||
signatureValid,
|
||||
rawStatus: payload.status,
|
||||
payload: payload as unknown as Record<string, unknown>,
|
||||
result: {
|
||||
status: mapped,
|
||||
providerTxnId: payload.transactionId,
|
||||
paidAt: payload.paidAt ? new Date(payload.paidAt) : undefined,
|
||||
failureCode: payload.status,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { EBirrProvider, EBirrWebhookPayload } from "@edr/payment-providers";
|
||||
import { WebhookProcessorService } from "../webhook-processor.service";
|
||||
|
||||
@Injectable()
|
||||
export class EBirrWebhookService {
|
||||
constructor(
|
||||
private readonly provider: EBirrProvider,
|
||||
private readonly processor: WebhookProcessorService,
|
||||
) {}
|
||||
|
||||
async handle(payload: EBirrWebhookPayload): Promise<void> {
|
||||
const signatureValid = this.provider.verifyWebhookSignature(
|
||||
payload as unknown as Record<string, unknown>,
|
||||
);
|
||||
const mapped = this.provider.mapWebhookStatus(payload.tradeStatus);
|
||||
|
||||
await this.processor.process({
|
||||
provider: this.provider.method,
|
||||
externalEventId: `${payload.orderNo}_${payload.tradeStatus}_${payload.timestamp}`,
|
||||
merchantOrderId: payload.orderNo,
|
||||
providerTxnId: payload.tradeNo,
|
||||
signatureValid,
|
||||
rawStatus: payload.tradeStatus,
|
||||
payload: payload as unknown as Record<string, unknown>,
|
||||
result: {
|
||||
status: mapped,
|
||||
providerTxnId: payload.tradeNo,
|
||||
failureCode: payload.tradeStatus,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import {
|
||||
TelebirrProvider,
|
||||
TelebirrWebhookPayload,
|
||||
} from "@edr/payment-providers";
|
||||
import { WebhookProcessorService } from "../webhook-processor.service";
|
||||
|
||||
@Injectable()
|
||||
export class TelebirrWebhookService {
|
||||
constructor(
|
||||
private readonly provider: TelebirrProvider,
|
||||
private readonly processor: WebhookProcessorService,
|
||||
) {}
|
||||
|
||||
async handle(payload: TelebirrWebhookPayload): Promise<void> {
|
||||
// TODO: re-enable Telebirr public-key signature verification — skipped for now
|
||||
// (carried over from the passenger handler; see telebirr.provider verifyWebhookSignature).
|
||||
const signatureValid = true;
|
||||
|
||||
const mapped = this.provider.mapWebhookTradeStatus(payload.trade_status);
|
||||
const providerTxnId = payload.trans_id ?? payload.payment_order_id;
|
||||
|
||||
await this.processor.process({
|
||||
provider: this.provider.method,
|
||||
externalEventId: `${payload.payment_order_id}_${payload.trade_status}`,
|
||||
merchantOrderId: payload.merch_order_id,
|
||||
providerTxnId,
|
||||
signatureValid,
|
||||
rawStatus: payload.trade_status,
|
||||
payload: payload as unknown as Record<string, unknown>,
|
||||
result: {
|
||||
status: mapped,
|
||||
providerTxnId,
|
||||
paidAt: this.parseEpochSeconds(payload.trans_end_time),
|
||||
failureCode: payload.trade_status,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private parseEpochSeconds(raw: string | undefined): Date | undefined {
|
||||
if (!raw) return undefined;
|
||||
const n = parseInt(raw, 10);
|
||||
if (Number.isNaN(n)) return undefined;
|
||||
return new Date(n * 1000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import {
|
||||
WaafiProvider,
|
||||
WaafiWebhookHeaders,
|
||||
WaafiWebhookPayload,
|
||||
} from "@edr/payment-providers";
|
||||
import { WebhookProcessorService } from "../webhook-processor.service";
|
||||
|
||||
/** Reject webhooks whose timestamp is older than this (replay protection). */
|
||||
const WAAFI_REPLAY_WINDOW_SECONDS = 300;
|
||||
|
||||
@Injectable()
|
||||
export class WaafiWebhookService {
|
||||
private readonly logger = new Logger(WaafiWebhookService.name);
|
||||
|
||||
constructor(
|
||||
private readonly provider: WaafiProvider,
|
||||
private readonly processor: WebhookProcessorService,
|
||||
) {}
|
||||
|
||||
async handle(
|
||||
payload: WaafiWebhookPayload,
|
||||
rawBody: string,
|
||||
headers: WaafiWebhookHeaders,
|
||||
): Promise<void> {
|
||||
// Unsigned validation ping sent on registration — acknowledge without verifying or persisting.
|
||||
if (payload.event === "webhook.test") {
|
||||
this.logger.log("Waafi webhook.test ping received");
|
||||
return;
|
||||
}
|
||||
|
||||
const { payment } = payload;
|
||||
const eventId = headers["x-webhook-event-id"];
|
||||
const timestamp = headers["x-webhook-timestamp"];
|
||||
const signature = headers["x-webhook-signature"];
|
||||
|
||||
const signatureValid =
|
||||
this.isFresh(timestamp) &&
|
||||
this.provider.verifyWebhookSignature(
|
||||
rawBody,
|
||||
signature,
|
||||
timestamp,
|
||||
eventId,
|
||||
);
|
||||
|
||||
const mapped = this.provider.mapWebhookStatus(payment.status);
|
||||
|
||||
await this.processor.process({
|
||||
provider: this.provider.method,
|
||||
// X-Webhook-Event-Id is unique per event; fall back to a derived id if absent.
|
||||
externalEventId: eventId ?? `${payment.transaction_id}_${payment.status}`,
|
||||
merchantOrderId: payment.reference_id,
|
||||
providerTxnId: payment.transaction_id,
|
||||
signatureValid,
|
||||
rawStatus: payment.status,
|
||||
payload: payload as unknown as Record<string, unknown>,
|
||||
result: {
|
||||
status: mapped,
|
||||
providerTxnId: payment.transaction_id,
|
||||
paidAt: this.parseDate(payment.date),
|
||||
failureCode: payment.status,
|
||||
failureMessage: payment.description,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** True when the webhook timestamp (unix seconds) is within the replay window. */
|
||||
private isFresh(timestamp: string | undefined): boolean {
|
||||
if (!timestamp) return false;
|
||||
const ts = parseInt(timestamp, 10);
|
||||
if (Number.isNaN(ts)) return false;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return Math.abs(now - ts) <= WAAFI_REPLAY_WINDOW_SECONDS;
|
||||
}
|
||||
|
||||
/** Parse Waafi's "YYYY-MM-DD HH:mm:ss" payment date; undefined when unparseable. */
|
||||
private parseDate(raw: string | undefined): Date | undefined {
|
||||
if (!raw) return undefined;
|
||||
const d = new Date(raw);
|
||||
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { QueryFailedError, Repository } from "typeorm";
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { PaymentWebhookEvent } from "./entities/payment-webhook-event.entity";
|
||||
|
||||
const PG_UNIQUE_VIOLATION = "23505";
|
||||
|
||||
@Injectable()
|
||||
export class WebhookEventsRepository extends BaseRepository<PaymentWebhookEvent> {
|
||||
constructor(
|
||||
@InjectRepository(PaymentWebhookEvent)
|
||||
repository: Repository<PaymentWebhookEvent>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert the event, relying on the unique (provider, external_event_id) index for dedupe.
|
||||
* Returns null when the event was already recorded (duplicate delivery / provider replay).
|
||||
*/
|
||||
async createDeduped(
|
||||
data: Partial<PaymentWebhookEvent>,
|
||||
): Promise<PaymentWebhookEvent | null> {
|
||||
try {
|
||||
return await this.create(data);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof QueryFailedError &&
|
||||
(err.driverError as { code?: string })?.code === PG_UNIQUE_VIOLATION
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async markProcessed(id: string, processingError?: string): Promise<void> {
|
||||
await this.update(id, {
|
||||
processedAt: new Date(),
|
||||
processingError: processingError ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ProviderMethod } from "@edr/types";
|
||||
import { IntentsRepository } from "../intents/intents.repository";
|
||||
import {
|
||||
IntentsService,
|
||||
ProviderResultInput,
|
||||
} from "../intents/intents.service";
|
||||
import { WebhookEventsRepository } from "./webhook-events.repository";
|
||||
|
||||
/** A provider webhook reduced to the fields the shared pipeline needs. */
|
||||
export interface NormalizedWebhook {
|
||||
provider: ProviderMethod;
|
||||
/** Provider event id (or a deterministic derivation) — the dedupe key. */
|
||||
externalEventId: string;
|
||||
merchantOrderId: string;
|
||||
providerTxnId?: string;
|
||||
signatureValid: boolean;
|
||||
/** Raw provider status string, stored for audit. */
|
||||
rawStatus: string;
|
||||
payload: Record<string, unknown>;
|
||||
/** Mapped outcome to feed the intent state machine. */
|
||||
result: ProviderResultInput;
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared webhook pipeline every provider handler funnels into:
|
||||
* persist+dedupe → signature gate → intent lookup → prefix/service cross-check →
|
||||
* state machine → mark processed. Always returns (never throws) so controllers can
|
||||
* ack 200 fast — providers like Waafi time out at 5s and do not retry.
|
||||
*/
|
||||
@Injectable()
|
||||
export class WebhookProcessorService {
|
||||
private readonly logger = new Logger(WebhookProcessorService.name);
|
||||
|
||||
constructor(
|
||||
private readonly webhookEvents: WebhookEventsRepository,
|
||||
private readonly intentsRepository: IntentsRepository,
|
||||
private readonly intentsService: IntentsService,
|
||||
) {}
|
||||
|
||||
async process(webhook: NormalizedWebhook): Promise<void> {
|
||||
const { provider, merchantOrderId } = webhook;
|
||||
|
||||
const eventRow = await this.webhookEvents.createDeduped({
|
||||
provider,
|
||||
externalEventId: webhook.externalEventId,
|
||||
merchantOrderId,
|
||||
providerTxnId: webhook.providerTxnId ?? null,
|
||||
signatureValid: webhook.signatureValid,
|
||||
status: webhook.rawStatus,
|
||||
payload: webhook.payload,
|
||||
});
|
||||
if (!eventRow) {
|
||||
this.logger.log(
|
||||
`${provider} webhook duplicate: ${webhook.externalEventId} — short-circuit OK`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!webhook.signatureValid) {
|
||||
this.logger.warn(
|
||||
`${provider} webhook signature invalid/stale for ref=${merchantOrderId}`,
|
||||
);
|
||||
await this.webhookEvents.markProcessed(eventRow.id, "signature-invalid");
|
||||
return;
|
||||
}
|
||||
|
||||
const intent =
|
||||
await this.intentsRepository.findByMerchantOrderId(merchantOrderId);
|
||||
if (!intent) {
|
||||
// Tolerated: webhook may have raced the intent commit, or the reference is foreign.
|
||||
// The provider gets a 200; retry/poll/reconciliation converges later.
|
||||
this.logger.warn(
|
||||
`${provider} webhook: no PaymentIntent for ref=${merchantOrderId}`,
|
||||
);
|
||||
await this.webhookEvents.markProcessed(eventRow.id, "intent-not-found");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.intentsService.applyProviderResult(intent.id, webhook.result);
|
||||
await this.webhookEvents.markProcessed(eventRow.id);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(
|
||||
`${provider} webhook processing failed for ${merchantOrderId}: ${message}`,
|
||||
);
|
||||
await this.webhookEvents.markProcessed(
|
||||
eventRow.id,
|
||||
`processing-error: ${message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
145
apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts
Normal file
145
apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
All,
|
||||
Body,
|
||||
Controller,
|
||||
Headers,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
Post,
|
||||
Req,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import {
|
||||
CardWebhookPayload,
|
||||
CbeBirrWebhookPayload,
|
||||
DMoneyWebhookPayload,
|
||||
EBirrWebhookPayload,
|
||||
TelebirrWebhookPayload,
|
||||
WaafiWebhookHeaders,
|
||||
WaafiWebhookPayload,
|
||||
} from "@edr/payment-providers";
|
||||
import { TelebirrWebhookService } from "./handlers/telebirr-webhook.service";
|
||||
import { CbeBirrWebhookService } from "./handlers/cbe-birr-webhook.service";
|
||||
import { EBirrWebhookService } from "./handlers/ebirr-webhook.service";
|
||||
import { CardWebhookService } from "./handlers/card-webhook.service";
|
||||
import { WaafiWebhookService } from "./handlers/waafi-webhook.service";
|
||||
import { DMoneyWebhookService } from "./handlers/dmoney-webhook.service";
|
||||
|
||||
/**
|
||||
* The ONLY public surface of the payment service — the single registered webhook URL per
|
||||
* provider for the whole platform. No service auth here (provider-facing); trust comes from
|
||||
* signature verification inside each handler. Every route acks 2xx fast and never rethrows:
|
||||
* Waafi times out at 5s and does NOT retry.
|
||||
*/
|
||||
@ApiTags("Provider Webhooks")
|
||||
@Controller("webhooks")
|
||||
export class WebhooksController {
|
||||
private readonly logger = new Logger(WebhooksController.name);
|
||||
|
||||
constructor(
|
||||
private readonly telebirr: TelebirrWebhookService,
|
||||
private readonly cbeBirr: CbeBirrWebhookService,
|
||||
private readonly eBirr: EBirrWebhookService,
|
||||
private readonly card: CardWebhookService,
|
||||
private readonly waafi: WaafiWebhookService,
|
||||
private readonly dMoney: DMoneyWebhookService,
|
||||
) {}
|
||||
|
||||
@All("telebirr")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: "Telebirr payment notification callback (Ethiopia)",
|
||||
})
|
||||
async receiveTelebirr(@Body() payload: TelebirrWebhookPayload) {
|
||||
this.logger.log("Telebirr webhook called");
|
||||
try {
|
||||
await this.telebirr.handle(payload);
|
||||
} catch (err) {
|
||||
this.logger.error(`Telebirr webhook handler threw: ${this.message(err)}`);
|
||||
}
|
||||
return { code: "0", message: "OK" };
|
||||
}
|
||||
|
||||
@Post("cbe-birr")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: "CBE Birr payment notification callback (Ethiopia)",
|
||||
})
|
||||
async receiveCbeBirr(@Body() payload: CbeBirrWebhookPayload) {
|
||||
try {
|
||||
await this.cbeBirr.handle(payload);
|
||||
} catch (err) {
|
||||
this.logger.error(`CBE Birr webhook handler threw: ${this.message(err)}`);
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post("ebirr")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "eBirr payment notification callback (Ethiopia)" })
|
||||
async receiveEBirr(@Body() payload: EBirrWebhookPayload) {
|
||||
try {
|
||||
await this.eBirr.handle(payload);
|
||||
} catch (err) {
|
||||
this.logger.error(`eBirr webhook handler threw: ${this.message(err)}`);
|
||||
}
|
||||
return { code: "0000", message: "success" };
|
||||
}
|
||||
|
||||
@Post("card")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: "Card payment notification callback (International)",
|
||||
})
|
||||
async receiveCard(
|
||||
@Body() payload: CardWebhookPayload,
|
||||
@Headers("stripe-signature") signature: string,
|
||||
) {
|
||||
try {
|
||||
await this.card.handle(payload, signature);
|
||||
} catch (err) {
|
||||
this.logger.error(`Card webhook handler threw: ${this.message(err)}`);
|
||||
}
|
||||
return { received: true };
|
||||
}
|
||||
|
||||
@Post("waafi")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Waafi payment notification callback (Djibouti)" })
|
||||
async receiveWaafi(
|
||||
@Body() payload: WaafiWebhookPayload,
|
||||
@Headers() headers: WaafiWebhookHeaders,
|
||||
@Req() req: { rawBody?: Buffer },
|
||||
) {
|
||||
|
||||
this.logger.log("\n\n\n\nWaafi payment notification callback (Djibouti)\n\n\n\n");
|
||||
this.logger.log(
|
||||
`Waafi webhook hit: event=${payload?.event ?? "unknown"} eventId=${headers["x-webhook-event-id"] ?? "n/a"}`,
|
||||
);
|
||||
try {
|
||||
// HMAC verification must sign over the exact raw bytes Waafi sent, not re-serialized JSON.
|
||||
const rawBody = req.rawBody?.toString("utf8") ?? "";
|
||||
await this.waafi.handle(payload, rawBody, headers);
|
||||
} catch (err) {
|
||||
this.logger.error(`Waafi webhook handler threw: ${this.message(err)}`);
|
||||
}
|
||||
return { responseCode: "2001", responseMsg: "Success" };
|
||||
}
|
||||
|
||||
@Post("dmoney")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "D-Money payment notification callback (Djibouti)" })
|
||||
async receiveDMoney(@Body() payload: DMoneyWebhookPayload) {
|
||||
try {
|
||||
await this.dMoney.handle(payload);
|
||||
} catch (err) {
|
||||
this.logger.error(`D-Money webhook handler threw: ${this.message(err)}`);
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private message(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
34
apps/edr-payment-api/src/modules/webhooks/webhooks.module.ts
Normal file
34
apps/edr-payment-api/src/modules/webhooks/webhooks.module.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
import { IntentsModule } from "../intents/intents.module";
|
||||
import { ProvidersModule } from "../providers/providers.module";
|
||||
import { PaymentWebhookEvent } from "./entities/payment-webhook-event.entity";
|
||||
import { WebhookEventsRepository } from "./webhook-events.repository";
|
||||
import { WebhookProcessorService } from "./webhook-processor.service";
|
||||
import { WebhooksController } from "./webhooks.controller";
|
||||
import { TelebirrWebhookService } from "./handlers/telebirr-webhook.service";
|
||||
import { CbeBirrWebhookService } from "./handlers/cbe-birr-webhook.service";
|
||||
import { EBirrWebhookService } from "./handlers/ebirr-webhook.service";
|
||||
import { CardWebhookService } from "./handlers/card-webhook.service";
|
||||
import { WaafiWebhookService } from "./handlers/waafi-webhook.service";
|
||||
import { DMoneyWebhookService } from "./handlers/dmoney-webhook.service";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([PaymentWebhookEvent]),
|
||||
IntentsModule,
|
||||
ProvidersModule,
|
||||
],
|
||||
controllers: [WebhooksController],
|
||||
providers: [
|
||||
WebhookEventsRepository,
|
||||
WebhookProcessorService,
|
||||
TelebirrWebhookService,
|
||||
CbeBirrWebhookService,
|
||||
EBirrWebhookService,
|
||||
CardWebhookService,
|
||||
WaafiWebhookService,
|
||||
DMoneyWebhookService,
|
||||
],
|
||||
})
|
||||
export class WebhooksModule {}
|
||||
18
apps/edr-payment-api/src/scripts/migrate-revert.ts
Normal file
18
apps/edr-payment-api/src/scripts/migrate-revert.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import "dotenv/config";
|
||||
import { AppDataSource } from "../data-source";
|
||||
|
||||
/** `pnpm --filter @edr/payment-api migration:revert` — undo the most recent migration. */
|
||||
async function main(): Promise<void> {
|
||||
await AppDataSource.initialize();
|
||||
try {
|
||||
await AppDataSource.undoLastMigration();
|
||||
console.log("reverted last migration");
|
||||
} finally {
|
||||
await AppDataSource.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
23
apps/edr-payment-api/src/scripts/migrate.ts
Normal file
23
apps/edr-payment-api/src/scripts/migrate.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import "dotenv/config";
|
||||
import { AppDataSource } from "../data-source";
|
||||
import { ensurePaymentSchema } from "../config/ensure-schema";
|
||||
|
||||
/** `pnpm --filter @edr/payment-api migration:run` — ensure schema, then run pending migrations. */
|
||||
async function main(): Promise<void> {
|
||||
await ensurePaymentSchema();
|
||||
await AppDataSource.initialize();
|
||||
try {
|
||||
const applied = await AppDataSource.runMigrations();
|
||||
for (const migration of applied) {
|
||||
console.log(`applied: ${migration.name}`);
|
||||
}
|
||||
if (applied.length === 0) console.log("no pending migrations");
|
||||
} finally {
|
||||
await AppDataSource.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
4
apps/edr-payment-api/tsconfig.build.json
Normal file
4
apps/edr-payment-api/tsconfig.build.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user