Merge pull request #161 from Tria-plc/freight_feature/payments

Freight feature/payments
This commit is contained in:
yaschalew10
2026-06-15 11:15:26 +03:00
committed by GitHub
216 changed files with 16356 additions and 8625 deletions

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

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

View File

@@ -6,18 +6,6 @@ on:
- main
- dev
- staging
paths:
- "apps/edr-freight-api/**"
- "apps/edr-freight-web/**"
- "apps/edr-passenger-api/**"
- "apps/edr-passenger-web/**"
- "packages/**"
- "infrastructure/docker/Dockerfile.web"
- "infrastructure/nginx/**"
- "docker-compose.yaml"
- "pnpm-lock.yaml"
- "scripts/deploy/**"
- ".github/workflows/deploy.yml"
workflow_dispatch:
concurrency:
@@ -50,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
View 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
View File

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

View File

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

View File

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

View File

@@ -438,7 +438,7 @@ The API uses two authentication schemes:
| **Routes** | `/routes` | JWT/IAM | Reusable route templates with ordered stops |
| **Schedules** | `/schedules` | JWT/IAM | Trip schedules, fare rules, status updates |
| **Fleet** | `/fleet` | JWT/IAM | Train services, coaches, seat configurations |
| **Seat Classes** | `/seat-classes` | Public/JWT/IAM | Seat class management and configuration |
| **Seat Classes** | `/seat-classes` or `/classes` | Public/JWT/IAM | Seat class management and configuration |
| **Segment Seats** | `/segments/seats` | Public/JWT | Segment-based seat availability and booking |
| **Agents** | `/agents` | IAM | Agent booking, shifts, commissions, reconciliation |
| **Fraud Detection** | `/fraud` | IAM | Fraud alerts, rules management, user blocking |

View File

@@ -21,6 +21,7 @@
"@edr/api-common": "workspace:*",
"@edr/payment-providers": "workspace:*",
"@edr/types": "workspace:*",
"@golevelup/nestjs-rabbitmq": "^5.5.0",
"@nestjs/axios": "^4.0.1",
"@nestjs/common": "^11.0.0",
"@nestjs/config": "^4.0.0",

View File

@@ -10,6 +10,7 @@ import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.
import appConfig from "./config/app.config";
import databaseConfig from "./config/database.config";
import telebirrConfig from "./config/telebirr.config";
import rabbitmqConfig from "./config/rabbitmq.config";
import { BookingsModule } from "./modules/bookings/bookings.module";
import { FilesModule } from "./modules/files/files.module";
@@ -57,7 +58,7 @@ import { OverviewModule } from './modules/overview/overview.module';
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [appConfig, databaseConfig, telebirrConfig],
load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig],
}),
ScheduleModule.forRoot(),
// EventEmitterModule.forRoot(),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -272,6 +272,7 @@ export class Booking extends BaseEntity {
@Column({ name: 'hold_expires_at', type: 'timestamptz', nullable: true })
holdExpiresAt?: Date | null;
@Column({ name: 'scheduled_at', type: 'timestamptz', nullable: true })
scheduledAt?: Date | null;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,22 +1,61 @@
import { ProviderPaymentStatus } from "@edr/types";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsIn, IsOptional, IsString } from "class-validator";
import { IsEnum, IsIn, IsOptional, IsString } from "class-validator";
export type PaymentPlatformDto = "web" | "mobile";
export enum PaymentMethodTypeEnum {
TELEBIRR = "TELEBIRR",
CBE_BIRR = "CBE_BIRR",
EBIRR = "EBIRR",
WAAFI = "WAAFI",
CARD = "CARD",
DMONEY = "DMONEY",
}
export class InitiatePaymentDto {
@ApiProperty({ example: "booking-uuid" })
@IsString()
bookingId!: string;
@ApiProperty({ enum: ["TELEBIRR"], example: "TELEBIRR" })
@IsIn(["TELEBIRR"])
method!: "TELEBIRR";
@ApiProperty({
enum: PaymentMethodTypeEnum,
description: "Payment method: TELEBIRR/CBE_BIRR/EBIRR (Ethiopia), WAAFI (Djibouti), CARD (International), DMONEY",
example: "TELEBIRR",
})
@IsEnum(PaymentMethodTypeEnum)
method!: PaymentMethodTypeEnum;
@ApiPropertyOptional({ enum: ["web", "mobile"], default: "web" })
@IsOptional()
@IsIn(["web", "mobile"])
platform?: PaymentPlatformDto;
@ApiPropertyOptional({ description: "Payer account / mobile number (e.g. for Waafi MWALLET)" })
@IsOptional()
@IsString()
payerAccount?: string;
@ApiPropertyOptional({ description: "Browser return URL after successful payment" })
@IsOptional()
@IsString()
returnUrl?: string;
@ApiPropertyOptional({ description: "Browser return URL after failed/cancelled payment" })
@IsOptional()
@IsString()
failureUrl?: string;
}
export class RefundDto {
@ApiProperty({ example: "booking-uuid" })
@IsString()
bookingId!: string;
@ApiPropertyOptional({ description: "Optional reason for refund" })
@IsOptional()
@IsString()
reason?: string;
}
export class ClientActionDto {

View File

@@ -1,48 +0,0 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsOptional, IsString } from "class-validator";
export class TelebirrDto {
@ApiProperty()
@IsString()
merch_order_id!: string;
@IsOptional()
@IsString()
payment_order_id!: string;
@ApiProperty({ default: "SUCCEEDED"})
@IsString()
trade_status!: string;
@IsOptional()
@IsString()
trans_id?: string;
@IsOptional()
@IsString()
total_amount?: string;
@IsOptional()
@IsString()
trans_currency?: string;
@IsOptional()
@IsString()
notify_time?: string;
@IsOptional()
@IsString()
trans_end_time?: string;
@IsOptional()
@IsString()
sign!: string;
@IsOptional()
@IsString()
sign_type?: string;
[key: string]: unknown;
}

View File

@@ -1,57 +0,0 @@
import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common';
import { TelebirrDto } from '../dto/telebirr.dto';
import { PaymentRepository } from '../../payment.repository';
import { DataSource } from 'typeorm';
import { Booking } from '../../../bookings/entities/booking.entity';
import { TelebirrProvider, ProviderPaymentStatus } from '@edr/payment-providers';
import { BookingBatchService } from '../../../train-scheduling/booking-batch.service';
@Injectable()
export class TelebirrWebhookService {
private readonly logger = new Logger(TelebirrWebhookService.name);
constructor(
private readonly datasource: DataSource,
private readonly paymentRepo: PaymentRepository,
private readonly telebirrProvider: TelebirrProvider,
@Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatchService: BookingBatchService,
) { }
verifyTelebirrNotification(payload: TelebirrDto) {
return this.telebirrProvider.verifyWebhookSignature(payload as unknown as Record<string, unknown>);
}
async handle(payload: TelebirrDto): Promise<void> {
const payment = await this.paymentRepo.findOneBy({ merchantOrderId: payload.merch_order_id })
if (!payment) {
this.logger.warn(`Webhook received for unknown merchantOrderId: ${payload.merch_order_id}`);
return;
}
const mapped = this.telebirrProvider.mapWebhookTradeStatus(payload.trade_status);
switch (mapped) {
case ProviderPaymentStatus.SUCCEEDED:
await this.paymentRepo.update(
{ id: payment.id },
{ status: "success", paidAt: new Date() },
);
if (payment.type === "booking") {
await this.datasource.manager.update(
Booking,
{ id: payment.refId },
{ paymentStatus: "PAID" },
);
await this.bookingBatchService.ensurePaidBookingAllocated(payment.refId);
}
break;
case ProviderPaymentStatus.FAILED:
await this.paymentRepo.update({ id: payment.id }, { status: "failed" });
break;
case ProviderPaymentStatus.PROCESSING:
await this.paymentRepo.update({ id: payment.id }, { status: "processing" });
break;
}
}
}

View File

@@ -1,38 +0,0 @@
import { Body, Controller, HttpCode, HttpStatus, Logger, Post, } from '@nestjs/common';
import { TelebirrWebhookService } from './providers/telebirr.service';
import { ApiOperation } from '@nestjs/swagger';
import { TelebirrDto } from './dto/telebirr.dto';
import { Public } from '@edr/api-common';
@Controller("payments-webhooks")
@Public()
export class WebhookController {
constructor(private readonly telebirr: TelebirrWebhookService) { }
private readonly logger = new Logger(WebhookController.name);
@Post('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: TelebirrDto) {
this.logger.log(
`Telebirr webhook Called`,
);
try {
const verified = this.telebirr.verifyTelebirrNotification(payload)
if (!verified) {
throw new Error("Telebirr webhook signature verification failed")
}
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' };
}
}

View File

@@ -50,7 +50,7 @@ const CONTAINER_TYPES = [
const DEMO_BOOKINGS = [
{
reference: "BKG-CONT-001",
reference: "BKG_CONT_001",
containerCode: "40FT",
quantity: 20,
totalWeightTons: 500,
@@ -61,7 +61,7 @@ const DEMO_BOOKINGS = [
paymentStatus: "PAID",
},
{
reference: "BKG-CONT-002",
reference: "BKG_ONT_02",
containerCode: "20FT",
quantity: 10,
totalWeightTons: 300,
@@ -72,7 +72,7 @@ const DEMO_BOOKINGS = [
paymentStatus: "PAID",
},
{
reference: "BKG-CONT-003",
reference: "BKG_ONT_03",
containerCode: "40FT",
quantity: 15,
totalWeightTons: 450,
@@ -83,7 +83,7 @@ const DEMO_BOOKINGS = [
paymentStatus: "PAID",
},
{
reference: "BKG-CONT-007",
reference: "BKG_ONT_07",
containerCode: "20FT",
quantity: 6,
totalWeightTons: 180,
@@ -94,7 +94,7 @@ const DEMO_BOOKINGS = [
paymentStatus: "PAID",
},
{
reference: "BKG-CONT-008",
reference: "BKG_ONT_08",
containerCode: "40FT",
quantity: 4,
totalWeightTons: 120,
@@ -105,7 +105,7 @@ const DEMO_BOOKINGS = [
paymentStatus: "PAID",
},
{
reference: "BKG-CONT-009",
reference: "BKG_ONT_09",
containerCode: "20FT",
quantity: 5,
totalWeightTons: 110,
@@ -116,7 +116,7 @@ const DEMO_BOOKINGS = [
paymentStatus: "PAID",
},
{
reference: "BKG-CONT-004",
reference: "BKG_ONT_04",
containerCode: "40FT",
quantity: 12,
totalWeightTons: 360,
@@ -124,10 +124,10 @@ const DEMO_BOOKINGS = [
destinationCode: "DIRE_DAWA",
scheduledDate: "2026-06-20T08:00:00.000Z",
status: "PAID",
paymentStatus: "PAID",
paymentStatus: "PAID"
},
{
reference: "BKG-CONT-005",
reference: "BKG_ONT_05",
containerCode: "20FT",
quantity: 8,
totalWeightTons: 160,
@@ -372,7 +372,7 @@ export class DemoBookingsSeeder {
companyId: company.id,
status: demoBooking.status,
scheduledDate: new Date(demoBooking.scheduledDate),
totalAmount: 0,
totalAmount: 2,
paymentStatus: demoBooking.paymentStatus,
contractType: "NEW",
serviceTypeId: serviceType.id,
@@ -386,7 +386,7 @@ export class DemoBookingsSeeder {
shippingLineId: null,
cargoTotalWeightVgm: demoBooking.totalWeightTons,
isHazardous: false,
paymentCurrency: "USD",
paymentCurrency: "ETB",
allowConsolidation: false,
priorityScore: 0,
versionNumber: 1,

View File

@@ -1,6 +1,6 @@
# App
NODE_ENV=development
PORT=3002
PORT=4000
# Database (Prisma)
DATABASE_URL=postgresql://edr:edr_secret@localhost:5432/edr_passenger?schema=edr_passenger
@@ -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
@@ -104,3 +116,12 @@ FAYDA_CLAIMS_LOCALES=en am
FAYDA_SESSION_TTL_MINUTES=10
GITHUB_PACKAGE_TOKEN=
# --- Payment event consumer (RabbitMQ) -------------------------------------------------------
# Consumes payment.succeeded / payment.failed events from the payment microservice. Separate
# from any RABBITMQ_URL used by the IAM/notification modules so the two connections are
# independent. Points at the dedicated `payment` vhost on the (shared) broker.
# Local dev broker (docker): amqp://edr:edr_secret@localhost:5672/payment
PAYMENT_RABBITMQ_URL=amqp://edr:edr_secret@localhost:5672/payment
# Max unacknowledged payment events this consumer holds at once.
PAYMENT_EVENTS_PREFETCH=10

View File

@@ -12,18 +12,17 @@
"test:e2e": "jest --config ./test/jest-e2e.json",
"type-check": "tsc --noEmit",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:seed": "ts-node prisma/seed-complete.ts",
"prisma:migrate": "prisma migrate deploy",
"prisma:migrate:dev": "prisma migrate dev",
"prisma:seed": "ts-node prisma/seed.ts",
"prisma:seed-full": "ts-node prisma/seed.ts",
"prisma:backfill": "ts-node prisma/backfill-fields.ts",
"prisma:verify": "ts-node prisma/verify-backfill.ts"
},
"prisma": {
"seed": "ts-node prisma/seed.ts"
},
"dependencies": {
"@edr/payment-providers": "workspace:*",
"@edr/types": "workspace:*",
"@golevelup/nestjs-rabbitmq": "^5.5.0",
"@nestjs/axios": "^4.0.1",
"@nestjs/common": "^11.0.0",
"@nestjs/config": "^4.0.4",
@@ -70,6 +69,9 @@
"ts-node": "^10.9.2",
"typescript": "^5.3.3"
},
"prisma": {
"schema": "prisma/schema.prisma"
},
"jest": {
"moduleFileExtensions": [
"js",

View File

@@ -1,5 +0,0 @@
-- AlterEnum
ALTER TYPE "PaymentMethodType" ADD VALUE 'WAAFI';
-- AlterTable
ALTER TABLE "FareRule" ADD COLUMN "nationality" TEXT;

View File

@@ -1,28 +0,0 @@
-- AlterTable
ALTER TABLE "Booking" ADD COLUMN "contactEmail" TEXT,
ADD COLUMN "contactPhone" TEXT;
-- CreateTable
CREATE TABLE "SavedPassengerProfile" (
"id" TEXT NOT NULL,
"userId" TEXT,
"deviceId" TEXT,
"passengerName" TEXT NOT NULL,
"dateOfBirth" TIMESTAMP(3) NOT NULL,
"idDocumentType" "IdDocumentType" NOT NULL,
"passportNumber" TEXT,
"passportCountry" TEXT,
"nationality" TEXT,
"phone" TEXT,
"email" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SavedPassengerProfile_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "SavedPassengerProfile_userId_idx" ON "SavedPassengerProfile"("userId");
-- CreateIndex
CREATE INDEX "SavedPassengerProfile_deviceId_idx" ON "SavedPassengerProfile"("deviceId");

View File

@@ -1,55 +0,0 @@
/*
Warnings:
- A unique constraint covering the columns `[faydaSub]` on the table `User` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "passenger"."BookingSeat" ADD COLUMN "faydaSub" TEXT,
ADD COLUMN "faydaVerifiedAt" TIMESTAMP(3),
ADD COLUMN "faydaVerifiedName" TEXT;
-- AlterTable
ALTER TABLE "passenger"."User" ADD COLUMN "faydaSub" TEXT,
ADD COLUMN "faydaVerified" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "faydaVerifiedAt" TIMESTAMP(3);
-- CreateTable
CREATE TABLE "passenger"."FaydaVerificationSession" (
"id" TEXT NOT NULL,
"state" TEXT NOT NULL,
"codeVerifier" TEXT NOT NULL,
"purpose" TEXT NOT NULL DEFAULT 'PURCHASE',
"saveToAccount" BOOLEAN NOT NULL DEFAULT false,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"errorCode" TEXT,
"errorDescription" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3) NOT NULL,
"completedAt" TIMESTAMP(3),
"userId" TEXT,
"bookingId" TEXT,
CONSTRAINT "FaydaVerificationSession_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "FaydaVerificationSession_state_key" ON "passenger"."FaydaVerificationSession"("state");
-- CreateIndex
CREATE INDEX "FaydaVerificationSession_userId_idx" ON "passenger"."FaydaVerificationSession"("userId");
-- CreateIndex
CREATE INDEX "FaydaVerificationSession_bookingId_idx" ON "passenger"."FaydaVerificationSession"("bookingId");
-- CreateIndex
CREATE INDEX "FaydaVerificationSession_state_idx" ON "passenger"."FaydaVerificationSession"("state");
-- CreateIndex
CREATE INDEX "FaydaVerificationSession_expiresAt_idx" ON "passenger"."FaydaVerificationSession"("expiresAt");
-- CreateIndex
CREATE UNIQUE INDEX "User_faydaSub_key" ON "passenger"."User"("faydaSub");
-- AddForeignKey
ALTER TABLE "passenger"."FaydaVerificationSession" ADD CONSTRAINT "FaydaVerificationSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "passenger"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -1,26 +0,0 @@
/*
Warnings:
- You are about to drop the column `maskedHint` on the `PaymentMethod` table. All the data in the column will be lost.
- You are about to drop the column `userId` on the `PaymentMethod` table. All the data in the column will be lost.
- A unique constraint covering the columns `[type]` on the table `PaymentMethod` will be added. If there are existing duplicate values, this will fail.
- Added the required column `updatedAt` to the `PaymentMethod` table without a default value. This is not possible if the table is not empty.
*/
-- CreateEnum
CREATE TYPE "PaymentRegion" AS ENUM ('ETHIOPIA', 'DJIBOUTI', 'INTERNATIONAL', 'GLOBAL');
-- DropIndex
DROP INDEX "PaymentMethod_userId_isDefault_idx";
-- AlterTable
ALTER TABLE "PaymentMethod" DROP COLUMN "maskedHint",
DROP COLUMN "userId",
ADD COLUMN "currency" TEXT NOT NULL DEFAULT 'ETB',
ADD COLUMN "enabled" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "region" "PaymentRegion" NOT NULL DEFAULT 'GLOBAL',
ADD COLUMN "sortOrder" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "PaymentMethod_type_key" ON "PaymentMethod"("type");

View File

@@ -1,3 +0,0 @@
-- AlterTable
ALTER TABLE "FaydaVerificationSession" ADD COLUMN "authCode" TEXT,
ADD COLUMN "platform" TEXT NOT NULL DEFAULT 'WEB';

View File

@@ -1,2 +0,0 @@
-- AddForeignKey
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -23,7 +23,10 @@ CREATE TYPE "Currency" AS ENUM ('ETB', 'DJF', 'USD');
CREATE TYPE "BookingStatus" AS ENUM ('DRAFT', 'PENDING_PAYMENT', 'CONFIRMED', 'CANCELLED', 'COMPLETED', 'NO_SHOW', 'REFUNDED');
-- CreateEnum
CREATE TYPE "PaymentMethodType" AS ENUM ('TELEBIRR', 'CBE_BIRR', 'EBIRR', 'CARD', 'WALLET');
CREATE TYPE "PaymentRegion" AS ENUM ('ETHIOPIA', 'DJIBOUTI', 'INTERNATIONAL', 'GLOBAL');
-- CreateEnum
CREATE TYPE "PaymentMethodType" AS ENUM ('TELEBIRR', 'CBE_BIRR', 'EBIRR', 'CARD', 'WALLET', 'WAAFI');
-- CreateEnum
CREATE TYPE "PaymentIntentStatus" AS ENUM ('REQUIRES_ACTION', 'PROCESSING', 'SUCCEEDED', 'FAILED', 'CANCELLED', 'REFUNDED');
@@ -55,12 +58,25 @@ CREATE TYPE "FoodOrderStatus" AS ENUM ('PENDING', 'PREPARING', 'READY', 'DELIVER
-- CreateEnum
CREATE TYPE "DevicePlatform" AS ENUM ('IOS', 'ANDROID', 'WEB');
-- CreateTable
CREATE TABLE "CoachType" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" TEXT NOT NULL DEFAULT 'passenger',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CoachType_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SeatClass" (
"id" TEXT NOT NULL,
"coachTypeId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"basePrice" INTEGER NOT NULL,
"baseFareMinor" INTEGER NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
@@ -86,6 +102,9 @@ CREATE TABLE "User" (
"lastLoginAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"faydaVerified" BOOLEAN NOT NULL DEFAULT false,
"faydaVerifiedAt" TIMESTAMP(3),
"faydaSub" TEXT,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
@@ -211,16 +230,11 @@ CREATE TABLE "TripLiveStatus" (
-- CreateTable
CREATE TABLE "Coach" (
"id" TEXT NOT NULL,
"coachNumber" TEXT NOT NULL,
"label" TEXT NOT NULL,
"seatClassId" TEXT NOT NULL,
"coachType" TEXT,
"mode" TEXT NOT NULL DEFAULT 'seat',
"seatArrangement" TEXT,
"bedArrangement" TEXT,
"amenities" JSONB,
"totalUnits" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"coachTypeId" TEXT NOT NULL,
"number" TEXT NOT NULL,
"arrangement" TEXT NOT NULL DEFAULT '2+2',
"capacity" INTEGER NOT NULL DEFAULT 0,
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
@@ -243,10 +257,9 @@ CREATE TABLE "CoachAssignment" (
CREATE TABLE "Seat" (
"id" TEXT NOT NULL,
"coachId" TEXT NOT NULL,
"seatNumber" TEXT NOT NULL,
"row" INTEGER NOT NULL,
"col" TEXT NOT NULL,
"label" TEXT NOT NULL,
"seatNumber" TEXT,
"kind" "SeatKind" NOT NULL DEFAULT 'STANDARD',
"status" "SeatStatus" NOT NULL DEFAULT 'AVAILABLE',
"heldUntil" TIMESTAMP(3),
@@ -254,7 +267,6 @@ CREATE TABLE "Seat" (
"isAisle" BOOLEAN NOT NULL DEFAULT false,
"bedPosition" TEXT,
"premiumFeeMinor" INTEGER NOT NULL DEFAULT 0,
"eligibility" TEXT,
CONSTRAINT "Seat_pkey" PRIMARY KEY ("id")
);
@@ -278,6 +290,7 @@ CREATE TABLE "FareRule" (
"id" TEXT NOT NULL,
"tripId" TEXT,
"route" TEXT,
"nationality" TEXT,
"seatClassId" TEXT NOT NULL,
"baseFareMinor" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'ETB',
@@ -303,6 +316,8 @@ CREATE TABLE "Booking" (
"displayCurrency" "Currency",
"displayTotalMinor" INTEGER,
"bookingType" TEXT NOT NULL DEFAULT 'ONE_WAY',
"contactEmail" TEXT,
"contactPhone" TEXT,
"userAgent" TEXT,
"source" TEXT NOT NULL DEFAULT 'WEB',
"promoCode" TEXT,
@@ -327,6 +342,9 @@ CREATE TABLE "BookingSeat" (
"passportCountry" TEXT,
"verifaydaVerified" BOOLEAN NOT NULL DEFAULT false,
"verifaydaData" JSONB,
"faydaVerifiedAt" TIMESTAMP(3),
"faydaSub" TEXT,
"faydaVerifiedName" TEXT,
"seatLabelSnapshot" TEXT,
"fareMinor" INTEGER,
"displayCurrency" "Currency",
@@ -338,13 +356,16 @@ CREATE TABLE "BookingSeat" (
-- CreateTable
CREATE TABLE "PaymentMethod" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" "PaymentMethodType" NOT NULL,
"displayName" TEXT NOT NULL,
"maskedHint" TEXT,
"region" "PaymentRegion" NOT NULL DEFAULT 'GLOBAL',
"currency" TEXT NOT NULL DEFAULT 'ETB',
"providerId" TEXT,
"isDefault" BOOLEAN NOT NULL DEFAULT false,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PaymentMethod_pkey" PRIMARY KEY ("id")
);
@@ -423,6 +444,16 @@ CREATE TABLE "Ticket" (
CONSTRAINT "Ticket_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TicketSeat" (
"id" TEXT NOT NULL,
"ticketId" TEXT NOT NULL,
"seatId" TEXT NOT NULL,
"seatIndex" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "TicketSeat_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LoyaltyAccount" (
"id" TEXT NOT NULL,
@@ -1016,8 +1047,51 @@ CREATE TABLE "VerifaydaVerification" (
CONSTRAINT "VerifaydaVerification_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SavedPassengerProfile" (
"id" TEXT NOT NULL,
"userId" TEXT,
"deviceId" TEXT,
"passengerName" TEXT NOT NULL,
"dateOfBirth" TIMESTAMP(3) NOT NULL,
"idDocumentType" "IdDocumentType" NOT NULL,
"passportNumber" TEXT,
"passportCountry" TEXT,
"nationality" TEXT,
"phone" TEXT,
"email" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SavedPassengerProfile_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FaydaVerificationSession" (
"id" TEXT NOT NULL,
"state" TEXT NOT NULL,
"codeVerifier" TEXT NOT NULL,
"purpose" TEXT NOT NULL DEFAULT 'PURCHASE',
"platform" TEXT NOT NULL DEFAULT 'WEB',
"saveToAccount" BOOLEAN NOT NULL DEFAULT false,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"errorCode" TEXT,
"errorDescription" TEXT,
"authCode" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3) NOT NULL,
"completedAt" TIMESTAMP(3),
"userId" TEXT,
"bookingId" TEXT,
CONSTRAINT "FaydaVerificationSession_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "SeatClass_name_key" ON "SeatClass"("name");
CREATE INDEX "SeatClass_coachTypeId_idx" ON "SeatClass"("coachTypeId");
-- CreateIndex
CREATE UNIQUE INDEX "SeatClass_coachTypeId_name_key" ON "SeatClass"("coachTypeId", "name");
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
@@ -1025,6 +1099,9 @@ CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "User_phone_key" ON "User"("phone");
-- CreateIndex
CREATE UNIQUE INDEX "User_faydaSub_key" ON "User"("faydaSub");
-- CreateIndex
CREATE UNIQUE INDEX "Session_token_key" ON "Session"("token");
@@ -1053,7 +1130,10 @@ CREATE UNIQUE INDEX "TripStopTime_scheduleId_sequence_key" ON "TripStopTime"("sc
CREATE UNIQUE INDEX "TripLiveStatus_scheduleId_key" ON "TripLiveStatus"("scheduleId");
-- CreateIndex
CREATE UNIQUE INDEX "Coach_coachNumber_key" ON "Coach"("coachNumber");
CREATE UNIQUE INDEX "Coach_number_key" ON "Coach"("number");
-- CreateIndex
CREATE INDEX "Coach_coachTypeId_idx" ON "Coach"("coachTypeId");
-- CreateIndex
CREATE INDEX "CoachAssignment_scheduleId_idx" ON "CoachAssignment"("scheduleId");
@@ -1062,11 +1142,14 @@ CREATE INDEX "CoachAssignment_scheduleId_idx" ON "CoachAssignment"("scheduleId")
CREATE UNIQUE INDEX "CoachAssignment_scheduleId_positionNumber_key" ON "CoachAssignment"("scheduleId", "positionNumber");
-- CreateIndex
CREATE UNIQUE INDEX "Seat_coachId_row_col_key" ON "Seat"("coachId", "row", "col");
CREATE INDEX "Seat_coachId_idx" ON "Seat"("coachId");
-- CreateIndex
CREATE UNIQUE INDEX "Seat_coachId_seatNumber_key" ON "Seat"("coachId", "seatNumber");
-- CreateIndex
CREATE UNIQUE INDEX "Seat_coachId_row_col_key" ON "Seat"("coachId", "row", "col");
-- CreateIndex
CREATE INDEX "SeatHold_expiresAt_idx" ON "SeatHold"("expiresAt");
@@ -1077,7 +1160,7 @@ CREATE UNIQUE INDEX "Booking_bookingRef_key" ON "Booking"("bookingRef");
CREATE INDEX "Booking_passengerId_status_idx" ON "Booking"("passengerId", "status");
-- CreateIndex
CREATE INDEX "PaymentMethod_userId_isDefault_idx" ON "PaymentMethod"("userId", "isDefault");
CREATE UNIQUE INDEX "PaymentMethod_type_key" ON "PaymentMethod"("type");
-- CreateIndex
CREATE UNIQUE INDEX "PaymentIntent_bookingId_key" ON "PaymentIntent"("bookingId");
@@ -1100,6 +1183,12 @@ CREATE UNIQUE INDEX "PaymentWebhookEvent_provider_externalEventId_key" ON "Payme
-- CreateIndex
CREATE UNIQUE INDEX "Ticket_bookingId_key" ON "Ticket"("bookingId");
-- CreateIndex
CREATE INDEX "TicketSeat_ticketId_idx" ON "TicketSeat"("ticketId");
-- CreateIndex
CREATE INDEX "TicketSeat_seatId_idx" ON "TicketSeat"("seatId");
-- CreateIndex
CREATE UNIQUE INDEX "LoyaltyAccount_passengerId_key" ON "LoyaltyAccount"("passengerId");
@@ -1202,6 +1291,30 @@ CREATE INDEX "VerifaydaVerification_nationalId_idx" ON "VerifaydaVerification"("
-- CreateIndex
CREATE INDEX "VerifaydaVerification_bookingId_idx" ON "VerifaydaVerification"("bookingId");
-- CreateIndex
CREATE INDEX "SavedPassengerProfile_userId_idx" ON "SavedPassengerProfile"("userId");
-- CreateIndex
CREATE INDEX "SavedPassengerProfile_deviceId_idx" ON "SavedPassengerProfile"("deviceId");
-- CreateIndex
CREATE UNIQUE INDEX "FaydaVerificationSession_state_key" ON "FaydaVerificationSession"("state");
-- CreateIndex
CREATE INDEX "FaydaVerificationSession_userId_idx" ON "FaydaVerificationSession"("userId");
-- CreateIndex
CREATE INDEX "FaydaVerificationSession_bookingId_idx" ON "FaydaVerificationSession"("bookingId");
-- CreateIndex
CREATE INDEX "FaydaVerificationSession_state_idx" ON "FaydaVerificationSession"("state");
-- CreateIndex
CREATE INDEX "FaydaVerificationSession_expiresAt_idx" ON "FaydaVerificationSession"("expiresAt");
-- AddForeignKey
ALTER TABLE "SeatClass" ADD CONSTRAINT "SeatClass_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") REFERENCES "CoachType"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1214,6 +1327,9 @@ ALTER TABLE "TravelerProfile" ADD CONSTRAINT "TravelerProfile_passengerId_fkey"
-- AddForeignKey
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_trainId_fkey" FOREIGN KEY ("trainId") REFERENCES "Train"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_originStationId_fkey" FOREIGN KEY ("originStationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -1230,7 +1346,7 @@ ALTER TABLE "TripStopTime" ADD CONSTRAINT "TripStopTime_stationId_fkey" FOREIGN
ALTER TABLE "TripLiveStatus" ADD CONSTRAINT "TripLiveStatus_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Coach" ADD CONSTRAINT "Coach_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "Coach" ADD CONSTRAINT "Coach_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") REFERENCES "CoachType"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -1265,6 +1381,12 @@ ALTER TABLE "PaymentRefund" ADD CONSTRAINT "PaymentRefund_paymentIntentId_fkey"
-- AddForeignKey
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LoyaltyAccount" ADD CONSTRAINT "LoyaltyAccount_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -1363,3 +1485,6 @@ ALTER TABLE "SeatBlock" ADD CONSTRAINT "SeatBlock_seatId_fkey" FOREIGN KEY ("sea
-- AddForeignKey
ALTER TABLE "FraudAlert" ADD CONSTRAINT "FraudAlert_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FaydaVerificationSession" ADD CONSTRAINT "FaydaVerificationSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,28 @@
-- CreateTable
CREATE TABLE "SegmentFareRule" (
"id" TEXT NOT NULL,
"routeId" TEXT NOT NULL,
"originStopSequence" INTEGER NOT NULL,
"destinationStopSequence" INTEGER NOT NULL,
"seatClassId" TEXT NOT NULL,
"baseFareMinor" INTEGER NOT NULL,
"nationality" TEXT,
"currency" TEXT NOT NULL DEFAULT 'ETB',
"validFrom" TIMESTAMP(3) NOT NULL,
"validUntil" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SegmentFareRule_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "SegmentFareRule_routeId_seatClassId_idx" ON "SegmentFareRule"("routeId", "seatClassId");
-- CreateIndex
CREATE UNIQUE INDEX "SegmentFareRule_routeId_originStopSequence_destinationStopS_key" ON "SegmentFareRule"("routeId", "originStopSequence", "destinationStopSequence", "seatClassId", "nationality");
-- AddForeignKey
ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -1,30 +0,0 @@
-- Add contact fields to Booking table
ALTER TABLE "passenger"."Booking"
ADD COLUMN "contactEmail" TEXT,
ADD COLUMN "contactPhone" TEXT;
-- Create SavedPassengerProfile table
CREATE TABLE "passenger"."SavedPassengerProfile" (
"id" TEXT NOT NULL,
"userId" TEXT,
"deviceId" TEXT,
"passengerName" TEXT NOT NULL,
"dateOfBirth" TIMESTAMP(3) NOT NULL,
"idDocumentType" "passenger"."IdDocumentType" NOT NULL,
"passportNumber" TEXT,
"passportCountry" TEXT,
"nationality" TEXT,
"phone" TEXT,
"email" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SavedPassengerProfile_pkey" PRIMARY KEY ("id")
);
-- Create indexes
CREATE INDEX "SavedPassengerProfile_userId_idx" ON "passenger"."SavedPassengerProfile"("userId");
CREATE INDEX "SavedPassengerProfile_deviceId_idx" ON "passenger"."SavedPassengerProfile"("deviceId");
-- Add comment
COMMENT ON TABLE "passenger"."SavedPassengerProfile" IS 'Stores passenger details for quick rebooking (by userId or deviceId)';

View File

@@ -70,17 +70,34 @@ enum Currency {
@@schema("passenger")
}
model SeatClass {
model CoachType {
id String @id @default(uuid())
name String @unique
description String?
basePrice Int
isActive Boolean @default(true)
code String
name String
type String @default("passenger") // 'passenger', 'sleeper', 'dining', 'baggage'
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
coaches Coach[]
seatClasses SeatClass[]
@@schema("passenger")
}
model SeatClass {
id String @id @default(uuid())
coachTypeId String
name String
description String?
baseFareMinor Int
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
coachType CoachType @relation(fields: [coachTypeId], references: [id])
fareRules FareRule[]
routeFareRules RouteFareRule[]
segmentFares SegmentFareRule[]
@@unique([coachTypeId, name])
@@index([coachTypeId])
@@schema("passenger")
}
@@ -385,21 +402,17 @@ model TripLiveStatus {
model Coach {
id String @id @default(uuid())
coachNumber String @unique
label String
seatClassId String
coachType String?
mode String @default("seat") // 'seat', 'bed', 'convertible'
seatArrangement String?
bedArrangement String?
amenities Json?
totalUnits Int @default(0)
isActive Boolean @default(true)
coachTypeId String
number String @unique
arrangement String @default("2+2") // e.g., '2+2', '3+2', '2+2+2'
capacity Int @default(0) // Total seats/beds
status String @default("ACTIVE") // 'ACTIVE', 'MAINTENANCE', 'INACTIVE'
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
seatClass SeatClass @relation(fields: [seatClassId], references: [id])
coachType CoachType @relation(fields: [coachTypeId], references: [id])
seats Seat[]
assignments CoachAssignment[]
@@index([coachTypeId])
@@schema("passenger")
}
@@ -422,10 +435,9 @@ model CoachAssignment {
model Seat {
id String @id @default(uuid())
coachId String
seatNumber String // Auto-generated: e.g., '1', '2', '3' (unique per coach)
row Int
col String
label String
seatNumber String?
kind SeatKind @default(STANDARD)
status SeatStatus @default(AVAILABLE)
heldUntil DateTime?
@@ -433,12 +445,13 @@ model Seat {
isAisle Boolean @default(false)
bedPosition String? // 'lower', 'middle', 'upper'
premiumFeeMinor Int @default(0)
eligibility String?
coach Coach @relation(fields: [coachId], references: [id])
bookingSeats BookingSeat[]
blocks SeatBlock[]
@@unique([coachId, row, col])
ticketSeats TicketSeat[]
@@unique([coachId, seatNumber])
@@unique([coachId, row, col])
@@index([coachId])
@@schema("passenger")
}
@@ -627,6 +640,20 @@ model Ticket {
validatorId String?
booking Booking @relation(fields: [bookingId], references: [id])
validationLogs GateValidationLog[]
seats TicketSeat[]
@@schema("passenger")
}
model TicketSeat {
id String @id @default(uuid())
ticketId String
seatId String
seatIndex Int @default(0)
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
seat Seat @relation(fields: [seatId], references: [id])
@@index([ticketId])
@@index([seatId])
@@schema("passenger")
}
@@ -963,6 +990,7 @@ model Route {
createdAt DateTime @default(now())
stops RouteStop[]
fareRules RouteFareRule[]
segmentFares SegmentFareRule[]
schedules TrainSchedule[]
@@schema("passenger")
@@ -1002,6 +1030,26 @@ model RouteFareRule {
@@schema("passenger")
}
model SegmentFareRule {
id String @id @default(uuid())
routeId String
originStopSequence Int
destinationStopSequence Int
seatClassId String
baseFareMinor Int
nationality String? // Optional: Ethiopian, Djiboutian, Other
currency String @default("ETB")
validFrom DateTime
validUntil DateTime?
createdAt DateTime @default(now())
route Route @relation(fields: [routeId], references: [id], onDelete: Cascade)
seatClass SeatClass @relation(fields: [seatClassId], references: [id])
@@unique([routeId, originStopSequence, destinationStopSequence, seatClassId, nationality])
@@index([routeId, seatClassId])
@@schema("passenger")
}
model Agent {
id String @id @default(uuid())
userId String @unique

View File

@@ -1,231 +0,0 @@
import { PrismaClient, SeatKind } from '@prisma/client';
import * as bcrypt from 'bcrypt';
const prisma = new PrismaClient();
async function main() {
console.log('🌱 Starting complete seed...\n');
// 1. STATIONS
console.log('📍 Seeding stations...');
const stationData = [
{ code: 'SBT', name: 'Sebeta', city: 'Sebeta', countryCode: 'ET', lat: 8.9167, lng: 38.6167 },
{ code: 'LBU', name: 'Labu', city: 'Labu', countryCode: 'ET', lat: 8.8500, lng: 38.7000 },
{ code: 'IND', name: 'Indode', city: 'Indode', countryCode: 'ET', lat: 8.7800, lng: 38.8200 },
{ code: 'BSH', name: 'Bishoftu', city: 'Bishoftu', countryCode: 'ET', lat: 8.7500, lng: 38.9833 },
{ code: 'MJO', name: 'Mojo', city: 'Mojo', countryCode: 'ET', lat: 8.6000, lng: 39.1200 },
{ code: 'ADM', name: 'Adama', city: 'Adama', countryCode: 'ET', lat: 8.5400, lng: 39.2675 },
{ code: 'DDW', name: 'Diredawa', city: 'Diredawa', countryCode: 'ET', lat: 9.5931, lng: 41.8661 },
{ code: 'NGD', name: 'Nagad', city: 'Nagad', countryCode: 'DJ', timezone: 'Africa/Djibouti', lat: 11.5720, lng: 43.1456 },
];
const stations = [];
for (const s of stationData) {
stations.push(await prisma.station.upsert({ where: { code: s.code }, update: {}, create: s }));
}
console.log(`${stations.length} stations\n`);
// 2. SEAT CLASSES
console.log('💺 Seeding seat classes...');
const scEconomy = await prisma.seatClass.upsert({
where: { name: 'Economy Regular' },
update: {},
create: { name: 'Economy Regular', description: 'Standard economy', basePrice: 25000, isActive: true },
});
const scBed = await prisma.seatClass.upsert({
where: { name: 'Economy Bed' },
update: {},
create: { name: 'Economy Bed', description: 'Economy bed', basePrice: 35000, isActive: true },
});
console.log(`✅ 2 seat classes\n`);
// 3. ROUTES
console.log('🛤️ Seeding routes...');
const route1 = await prisma.route.upsert({
where: { code: 'SBT-NGD' },
update: {},
create: { code: 'SBT-NGD', name: 'Sebeta-Nagad Express', effectiveFrom: new Date('2026-01-01'), active: true },
});
await prisma.routeStop.createMany({
data: [
{ routeId: route1.id, stationId: stations[0].id, sequence: 1, distanceKm: 0 },
{ routeId: route1.id, stationId: stations[1].id, sequence: 2, distanceKm: 15 },
{ routeId: route1.id, stationId: stations[2].id, sequence: 3, distanceKm: 28 },
{ routeId: route1.id, stationId: stations[3].id, sequence: 4, distanceKm: 45 },
{ routeId: route1.id, stationId: stations[4].id, sequence: 5, distanceKm: 73 },
{ routeId: route1.id, stationId: stations[5].id, sequence: 6, distanceKm: 99 },
{ routeId: route1.id, stationId: stations[6].id, sequence: 7, distanceKm: 378 },
{ routeId: route1.id, stationId: stations[7].id, sequence: 8, distanceKm: 756 },
],
skipDuplicates: true,
});
await prisma.routeFareRule.createMany({
data: [
{ routeId: route1.id, seatClassId: scEconomy.id, passengerCategory: 'ADULT', baseFareMinor: 65000, validFrom: new Date('2026-01-01') },
{ routeId: route1.id, seatClassId: scEconomy.id, passengerCategory: 'CHILD', baseFareMinor: 65000, validFrom: new Date('2026-01-01') },
{ routeId: route1.id, seatClassId: scBed.id, passengerCategory: 'ADULT', baseFareMinor: 91000, validFrom: new Date('2026-01-01') },
{ routeId: route1.id, seatClassId: scBed.id, passengerCategory: 'CHILD', baseFareMinor: 91000, validFrom: new Date('2026-01-01') },
],
skipDuplicates: true,
});
console.log(`✅ 1 route with stops and fares\n`);
// 4. TRAINS
console.log('🚂 Seeding trains...');
const train = await prisma.train.upsert({
where: { number: '301' },
update: {},
create: { number: '301', name: 'Express 301', description: 'Main Express' },
});
console.log(`✅ 1 train\n`);
// 5. COACHES & SEATS
console.log('🚃 Seeding coaches...');
const coach1 = await prisma.coach.upsert({
where: { coachNumber: 'C-A1' },
update: {},
create: { coachNumber: 'C-A1', label: 'A', seatClassId: scEconomy.id, mode: 'seat', totalUnits: 20 },
});
const existingSeats = await prisma.seat.count({ where: { coachId: coach1.id } });
if (existingSeats === 0) {
const seats = [];
for (let row = 1; row <= 5; row++) {
for (const col of ['A', 'B', 'C', 'D']) {
seats.push({
coachId: coach1.id,
row,
col,
label: `${row}${col}`,
seatNumber: `A${row}${col}`,
kind: 'STANDARD' as SeatKind,
});
}
}
await prisma.seat.createMany({ data: seats });
}
console.log(`✅ 1 coach with 20 seats\n`);
// 6. SCHEDULE
console.log('📅 Seeding schedule...');
const existingSchedules = await prisma.trainSchedule.findMany({ where: { trainId: train.id }, select: { id: true } });
if (existingSchedules.length > 0) {
const scheduleIds = existingSchedules.map(s => s.id);
const bookingIds = (
await prisma.booking.findMany({ where: { scheduleId: { in: scheduleIds } }, select: { id: true } })
).map(b => b.id);
// Delete booking children in FK-safe order before deleting the bookings themselves
await prisma.foodOrderItem.deleteMany({ where: { order: { bookingId: { in: bookingIds } } } });
await prisma.foodOrder.deleteMany({ where: { bookingId: { in: bookingIds } } });
await prisma.paymentIntent.deleteMany({ where: { bookingId: { in: bookingIds } } });
await prisma.ticket.deleteMany({ where: { bookingId: { in: bookingIds } } });
await prisma.agentBooking.deleteMany({ where: { bookingId: { in: bookingIds } } });
await prisma.bookingModification.deleteMany({ where: { bookingId: { in: bookingIds } } });
await prisma.bookingCancellation.deleteMany({ where: { bookingId: { in: bookingIds } } });
await prisma.baggageBooking.deleteMany({ where: { bookingId: { in: bookingIds } } });
await prisma.bookingSeat.deleteMany({ where: { bookingId: { in: bookingIds } } });
await prisma.booking.deleteMany({ where: { scheduleId: { in: scheduleIds } } });
await prisma.fareRule.deleteMany({ where: { tripId: { in: scheduleIds } } });
await prisma.tripStopTime.deleteMany({ where: { scheduleId: { in: scheduleIds } } });
await prisma.coachAssignment.deleteMany({ where: { scheduleId: { in: scheduleIds } } });
await prisma.trainSchedule.deleteMany({ where: { trainId: train.id } });
}
const schedule = await prisma.trainSchedule.create({
data: {
trainId: train.id,
routeId: route1.id,
originStationId: stations[0].id,
destinationStationId: stations[7].id,
departureAt: new Date('2026-06-15T06:00:00Z'),
arrivalAt: new Date('2026-06-15T22:00:00Z'),
durationMinutes: 960,
stopsCount: 8,
},
});
await prisma.coachAssignment.create({
data: { scheduleId: schedule.id, coachId: coach1.id, positionNumber: 1 },
});
await prisma.tripStopTime.createMany({
data: [
{ scheduleId: schedule.id, stationId: stations[0].id, sequence: 1, plannedDepartureAt: new Date('2026-06-15T06:00:00Z'), status: 'UPCOMING' },
{ scheduleId: schedule.id, stationId: stations[1].id, sequence: 2, plannedArrivalAt: new Date('2026-06-15T07:00:00Z'), plannedDepartureAt: new Date('2026-06-15T07:05:00Z'), status: 'UPCOMING' },
{ scheduleId: schedule.id, stationId: stations[2].id, sequence: 3, plannedArrivalAt: new Date('2026-06-15T08:00:00Z'), plannedDepartureAt: new Date('2026-06-15T08:05:00Z'), status: 'UPCOMING' },
{ scheduleId: schedule.id, stationId: stations[3].id, sequence: 4, plannedArrivalAt: new Date('2026-06-15T09:00:00Z'), plannedDepartureAt: new Date('2026-06-15T09:10:00Z'), status: 'UPCOMING' },
{ scheduleId: schedule.id, stationId: stations[4].id, sequence: 5, plannedArrivalAt: new Date('2026-06-15T10:00:00Z'), plannedDepartureAt: new Date('2026-06-15T10:10:00Z'), status: 'UPCOMING' },
{ scheduleId: schedule.id, stationId: stations[5].id, sequence: 6, plannedArrivalAt: new Date('2026-06-15T11:00:00Z'), plannedDepartureAt: new Date('2026-06-15T11:15:00Z'), status: 'UPCOMING' },
{ scheduleId: schedule.id, stationId: stations[6].id, sequence: 7, plannedArrivalAt: new Date('2026-06-15T15:00:00Z'), plannedDepartureAt: new Date('2026-06-15T15:20:00Z'), status: 'UPCOMING' },
{ scheduleId: schedule.id, stationId: stations[7].id, sequence: 8, plannedArrivalAt: new Date('2026-06-15T22:00:00Z'), status: 'UPCOMING' },
],
});
console.log(`✅ 1 schedule with stops\n`);
// 7. USERS
console.log('👥 Seeding users...');
const adminHash = await bcrypt.hash('admin123', 10);
const userHash = await bcrypt.hash('password123', 10);
await prisma.user.upsert({
where: { email: 'admin@edr-platform.com' },
update: {},
create: { fullName: 'Admin', email: 'admin@edr-platform.com', phone: '+251900000000', passwordHash: adminHash, role: 'ADMIN' },
});
const user = await prisma.user.upsert({
where: { email: 'abebe@email.com' },
update: {},
create: { fullName: 'Abebe Kebede', email: 'abebe@email.com', phone: '+251912345678', passwordHash: userHash, nationality: 'Ethiopian' },
});
let passenger = await prisma.passenger.findUnique({ where: { userId: user.id } });
if (!passenger) {
passenger = await prisma.passenger.create({ data: { userId: user.id } });
await prisma.loyaltyAccount.create({ data: { passengerId: passenger.id, pointsBalance: 1000, tier: 'BRONZE' } });
await prisma.walletAccount.create({ data: { passengerId: passenger.id, balanceMinor: 100000 } });
}
console.log(`✅ 2 users\n`);
// 8. SUPPORTING DATA
console.log('📦 Seeding supporting data...');
await prisma.paymentMethod.upsert({
where: { type: 'TELEBIRR' },
update: {},
create: { type: 'TELEBIRR', displayName: 'Telebirr', region: 'ETHIOPIA', currency: 'ETB', enabled: true, sortOrder: 1 },
});
await prisma.currencyExchangeRate.deleteMany({});
await prisma.currencyExchangeRate.createMany({
data: [
{ fromCurrency: 'ETB', toCurrency: 'ETB', rate: 1.0, effectiveDate: new Date() },
{ fromCurrency: 'ETB', toCurrency: 'USD', rate: 0.018, effectiveDate: new Date() },
{ fromCurrency: 'ETB', toCurrency: 'DJF', rate: 3.2, effectiveDate: new Date() },
],
});
console.log(`✅ Payment methods and currencies\n`);
console.log('✅ SEED COMPLETE!\n');
console.log('📋 Summary:');
console.log(' - 8 Stations');
console.log(' - 2 Seat Classes');
console.log(' - 1 Route with 8 stops');
console.log(' - 1 Train with 1 schedule');
console.log(' - 1 Coach with 20 seats');
console.log(' - 2 Users (Admin + Passenger)');
console.log('\n🔑 Credentials:');
console.log(' Admin: admin@edr-platform.com / admin123');
console.log(' User: abebe@email.com / password123');
}
main()
.catch((e) => {
console.error('❌ Error:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
// import { EventEmitterModule } from '@nestjs/event-emitter';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { PrismaModule } from './common/prisma.module';
import { I18nModule } from './common/i18n/i18n.module';
import { IamModule } from './common/iam.module';
@@ -14,6 +14,7 @@ import ebirrConfig from './config/ebirr.config';
import cardConfig from './config/card.config';
import waafiConfig from './config/waafi.config';
import faydaConfig from './config/fayda.config';
import rabbitmqConfig from './config/rabbitmq.config';
import { AuthModule } from './modules/auth/auth.module';
import { StationsModule } from './modules/stations/stations.module';
import { FleetModule } from './modules/fleet/fleet.module';
@@ -52,6 +53,7 @@ import { VerifaydaModule } from './modules/verifayda/verifayda.module';
cardConfig,
waafiConfig,
faydaConfig,
rabbitmqConfig,
],
}),
ScheduleModule.forRoot(),

View File

@@ -12,6 +12,12 @@ export class HttpExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(HttpExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost): void {
// Global filter — also reached by non-HTTP (e.g. RabbitMQ) handlers. switchToHttp() would
// yield no response object there, so re-throw and let the transport (golevelup) handle it
// (nack/dead-letter) instead of crashing on response.status().
if (host.getType() !== 'http') {
throw exception;
}
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const request = ctx.getRequest();

View File

@@ -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;
}
}

View File

@@ -4,7 +4,14 @@ import { map } from 'rxjs/operators';
@Injectable()
export class ResponseTransformInterceptor<T> implements NestInterceptor<T, any> {
intercept(_ctx: ExecutionContext, next: CallHandler<T>): Observable<any> {
intercept(ctx: ExecutionContext, next: CallHandler<T>): Observable<any> {
// Only wrap HTTP responses. This interceptor is global, so it also runs for RabbitMQ
// message handlers (golevelup uses Nest's context system) — there, wrapping the return
// value would corrupt the handler's contract (e.g. a returned Nack would be swallowed,
// dropping a message instead of dead-lettering it). Let non-HTTP returns pass through.
if (ctx.getType() !== 'http') {
return next.handle();
}
return next.handle().pipe(
map((data) => ({ success: true, data, timestamp: new Date().toISOString() })),
);

View File

@@ -0,0 +1,12 @@
import { registerAs } from '@nestjs/config';
/**
* RabbitMQ connection for the payment-event consumer (payment microservice -> passenger).
* Independent of the IAM/notification module's RABBITMQ_URL so the two broker connections
* never interfere. Points at the dedicated `payment` vhost on the shared broker.
*/
export default registerAs('rabbitmq', () => ({
url: process.env.PAYMENT_RABBITMQ_URL ?? 'amqp://localhost:5672/payment',
/** Max unacked payment events held by this consumer at once. */
prefetch: parseInt(process.env.PAYMENT_EVENTS_PREFETCH ?? '10', 10),
}));

View File

@@ -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',
}));

View File

@@ -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: [
@@ -236,6 +238,7 @@ Payment providers send notifications to:
.addTag("Support", "FAQ management and live chat support")
.addTag("Tickets", "QR ticket generation, PDFs, and gate validation")
.addTag("Wallet", "Wallet balance, top-ups, and transaction ledger")
.addTag("Config", "System configuration and settings")
//.addServer('http://localhost:4000', 'Development')
// .addServer("https://api.edr-platform.com", "Production")
.build();

View File

@@ -1,8 +1,11 @@
import { Body, Controller, Post, HttpCode, HttpStatus, UseGuards, Get, Request, UnauthorizedException } from '@nestjs/common';
import { Body, Controller, Post, HttpCode, HttpStatus, UseGuards, Get, Request, UnauthorizedException, Param, Patch, Delete, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBody, ApiBearerAuth } from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { RegisterDto, LoginDto, RequestOtpDto, VerifyOtpDto, RequestPasswordResetDto, ResetPasswordDto } from './auth.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('Auth')
@Controller('auth')
@@ -137,6 +140,9 @@ export class AuthController {
- **Loyalty Account**: Tier, points balance, lifetime points
- **Wallet Account**: Balance (minor units), currency
#### Devices
- List of registered devices with platform, name, push token, and last seen time
#### User Preferences
- Language, notification settings, etc.
@@ -154,6 +160,8 @@ export class AuthController {
5. **Wallet Balance**: Display available balance
6. **Device Management**: Get list of user's registered devices
---
### Authentication
@@ -196,7 +204,25 @@ export class AuthController {
emailNotifications: true,
smsNotifications: true,
language: 'am'
}
},
devices: [
{
id: 'device-uuid-1',
platform: 'WEB',
name: 'Chrome on Windows',
pushToken: 'token-abc123',
trusted: true,
lastSeenAt: '2024-01-20T14:22:00.000Z'
},
{
id: 'device-uuid-2',
platform: 'IOS',
name: 'iPhone 14',
pushToken: 'token-xyz789',
trusted: false,
lastSeenAt: '2024-01-19T10:15:00.000Z'
}
]
}
}
})
@@ -217,4 +243,61 @@ export class AuthController {
}
return this.service.getProfile(req.user.userId);
}
@Get('users')
@UseGuards(JwtGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.SUPERVISOR)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Get all backoffice users (admin/supervisor only)' })
getUsers(
@Query('search') search?: string,
@Query('role') role?: string,
@Query('status') status?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.service.getUsers({
search,
role,
status,
page: page ? parseInt(page) : 1,
pageSize: pageSize ? parseInt(pageSize) : 10,
});
}
@Post('users')
@UseGuards(JwtGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.SUPERVISOR)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Create new backoffice user (admin/supervisor only)' })
createUser(@Body() dto: any) {
return this.service.createUser(dto);
}
@Patch('users/:id')
@UseGuards(JwtGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.SUPERVISOR)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Update backoffice user (admin/supervisor only)' })
updateUser(@Param('id') id: string, @Body() dto: any) {
return this.service.updateUser(id, dto);
}
@Delete('users/:id')
@UseGuards(JwtGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Delete backoffice user (admin only)' })
deleteUser(@Param('id') id: string) {
return this.service.deleteUser(id);
}
@Post('users/:id/reset-password')
@UseGuards(JwtGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.SUPERVISOR)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Reset user password with temporary password (admin/supervisor only)' })
resetUserPassword(@Param('id') id: string, @Body() dto: { tempPassword: string }) {
return this.service.resetUserPassword(id, dto.tempPassword);
}
}

View File

@@ -1,4 +1,4 @@
import { Injectable, UnauthorizedException, ConflictException, BadRequestException } from '@nestjs/common';
import { Injectable, UnauthorizedException, ConflictException, BadRequestException, NotFoundException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { PrismaService } from '../../common/prisma.service';
import { RegisterDto, LoginDto, RequestOtpDto, VerifyOtpDto, RequestPasswordResetDto, ResetPasswordDto } from './auth.dto';
@@ -58,7 +58,7 @@ export class AuthService {
await this.prisma.user.update({
where: { id: user.id },
data: { failedLoginAttempts: 0, lockedUntil: null }
data: { failedLoginAttempts: 0, lockedUntil: null, lastLoginAt: new Date() }
});
await this.createAuditLog(user.id, 'USER_LOGIN', 'User', user.id, null, null);
@@ -131,6 +131,174 @@ export class AuthService {
return { reset: true };
}
async getUsers(filters: { search?: string; role?: string; status?: string; page?: number; pageSize?: number }) {
const { search, role, status, page = 1, pageSize = 10 } = filters;
const skip = (page - 1) * pageSize;
const where: any = {
role: { not: 'PASSENGER' }, // Exclude passenger accounts
};
if (search) {
where.OR = [
{ email: { contains: search, mode: 'insensitive' } },
{ fullName: { contains: search, mode: 'insensitive' } },
];
}
if (role) {
where.role = role;
}
// For status filtering, we check if user is active (no lock/block) or inactive
if (status === 'ACTIVE') {
where.AND = [
{ blockedUntil: { lte: new Date() } },
{ lockedUntil: { lte: new Date() } }
];
} else if (status === 'INACTIVE') {
where.OR = [
{ blockedUntil: { gt: new Date() } },
{ lockedUntil: { gt: new Date() } }
];
}
const [items, total] = await Promise.all([
this.prisma.user.findMany({
where,
select: {
id: true,
email: true,
fullName: true,
role: true,
lastLoginAt: true,
createdAt: true,
blockedUntil: true,
lockedUntil: true,
},
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
}),
this.prisma.user.count({ where }),
]);
return {
items: items.map(user => ({
id: user.id,
email: user.email,
fullName: user.fullName,
role: user.role,
lastLogin: user.lastLoginAt,
status: (!user.blockedUntil || user.blockedUntil <= new Date()) &&
(!user.lockedUntil || user.lockedUntil <= new Date())
? 'ACTIVE'
: 'INACTIVE',
})),
total,
page,
pageSize,
};
}
async createUser(dto: { email: string; fullName: string; role: string; status?: string; password?: string }) {
const exists = await this.prisma.user.findFirst({
where: { OR: [{ email: dto.email }] },
});
if (exists) throw new ConflictException('Email already registered');
const passwordHash = await bcrypt.hash(dto.password || 'TempPassword123!', 10);
const user = await this.prisma.user.create({
data: {
email: dto.email,
fullName: dto.fullName,
role: dto.role as any,
phone: dto.email, // Use email as phone temporarily for unique constraint
passwordHash,
blockedUntil: dto.status === 'INACTIVE' ? new Date(Date.now() + 365 * 24 * 60 * 60 * 1000) : undefined,
},
select: {
id: true,
email: true,
fullName: true,
role: true,
lastLoginAt: true,
createdAt: true,
},
});
await this.createAuditLog(user.id, 'USER_CREATED', 'User', user.id, null, { email: user.email, role: dto.role });
return user;
}
async updateUser(id: string, dto: Partial<{ email: string; fullName: string; role: string; status: string }>) {
const user = await this.prisma.user.findUnique({ where: { id } });
if (!user) throw new NotFoundException('User not found');
const updateData: any = {};
if (dto.fullName) updateData.fullName = dto.fullName;
if (dto.role) updateData.role = dto.role;
if (dto.status === 'ACTIVE') {
updateData.blockedUntil = null;
updateData.lockedUntil = null;
} else if (dto.status === 'INACTIVE') {
updateData.blockedUntil = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000);
}
const updated = await this.prisma.user.update({
where: { id },
data: updateData,
select: {
id: true,
email: true,
fullName: true,
role: true,
lastLoginAt: true,
createdAt: true,
},
});
await this.createAuditLog(id, 'USER_UPDATED', 'User', id, { oldData: user }, { newData: updateData });
return updated;
}
async deleteUser(id: string) {
const user = await this.prisma.user.findUnique({ where: { id } });
if (!user) throw new NotFoundException('User not found');
// Don't actually delete, just deactivate
await this.prisma.user.update({
where: { id },
data: { blockedUntil: new Date(), lockedUntil: new Date() },
});
await this.createAuditLog(id, 'USER_DELETED', 'User', id, { email: user.email }, null);
return { deleted: true };
}
async resetUserPassword(id: string, tempPassword: string) {
const user = await this.prisma.user.findUnique({ where: { id } });
if (!user) throw new NotFoundException('User not found');
const passwordHash = await bcrypt.hash(tempPassword, 10);
await this.prisma.user.update({
where: { id },
data: {
passwordHash,
failedLoginAttempts: 0,
lockedUntil: null,
},
});
await this.createAuditLog(id, 'PASSWORD_RESET_ADMIN', 'User', id, null, { resetBy: 'admin' });
return { reset: true, tempPassword };
}
private async signToken(userId: string, email: string, role: string, passengerId?: string, agentId?: string) {
// Get the full user data to include fullName
const user = await this.prisma.user.findUnique({
@@ -180,6 +348,7 @@ export class AuthService {
},
},
preferences: true,
devices: true,
},
});
@@ -213,6 +382,14 @@ export class AuthService {
} : null,
} : null,
preferences: user.preferences,
devices: user.devices.map(device => ({
id: device.id,
platform: device.platform,
name: device.name,
pushToken: device.pushToken,
trusted: device.trusted,
lastSeenAt: device.lastSeenAt,
})),
};
}

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards, Query, Req } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards, Query, Req, BadRequestException } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger';
import { BookingsService } from './bookings.service';
import { GuestBookingService } from './guest-booking.service';
@@ -15,7 +15,7 @@ export class BookingsController {
private guestService: GuestBookingService,
) {}
@Get('my/bookings')
@Get('my')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
@@ -43,6 +43,34 @@ export class BookingsController {
pageSize: pageSize ? parseInt(pageSize) : 20
});
}
@Get('by-device')
@ApiOperation({
summary: 'Get bookings by device ID',
description: 'Returns all bookings associated with a device ID (for guest users). Includes saved passenger details and booking history.'
})
@ApiQuery({ name: 'deviceId', required: true, description: 'Device identifier' })
@ApiQuery({ name: 'search', required: false, description: 'Search by booking reference or station names' })
@ApiQuery({ name: 'status', required: false, description: 'Filter by booking status' })
@ApiQuery({ name: 'page', required: false, description: 'Page number' })
@ApiQuery({ name: 'pageSize', required: false, description: 'Items per page' })
@ApiResponse({ status: 200, description: 'List of guest bookings and saved passengers for device' })
@ApiResponse({ status: 400, description: 'Device ID is required' })
getByDevice(
@Query('deviceId') deviceId?: string,
@Query('search') search?: string,
@Query('status') status?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
if (!deviceId) throw new BadRequestException('Device ID is required');
return this.service.findByDeviceId(deviceId, {
search,
status,
page: page ? parseInt(page) : 1,
pageSize: pageSize ? parseInt(pageSize) : 20
});
}
@Get()
@ApiOperation({

View File

@@ -102,6 +102,83 @@ export class BookingsService {
};
}
async findByDeviceId(deviceId: string, filters: BookingFilters = {}) {
const { search, status, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize;
// Find user with this device ID
const device = await this.prisma.device.findUnique({
where: { id: deviceId },
include: { user: { include: { passenger: true } } },
}).catch(() => null);
const searchConditions = search ? [
{ bookingRef: { contains: search, mode: 'insensitive' } },
{ schedule: { originStation: { name: { contains: search, mode: 'insensitive' } } } },
{ schedule: { destinationStation: { name: { contains: search, mode: 'insensitive' } } } },
] : [];
const where: any = {
OR: [
{ userAgent: deviceId },
...(device?.user?.passenger ? [{ passengerId: device.user.passenger.id }] : []),
],
};
if (search) {
where.AND = [{ OR: searchConditions }];
}
if (status) {
where.status = status;
}
const [items, total] = await Promise.all([
this.prisma.booking.findMany({
where,
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
paymentIntent: true,
seats: { include: { seat: true } },
},
}),
this.prisma.booking.count({ where }),
]);
return {
items: items.map(booking => ({
id: booking.id,
bookingRef: booking.bookingRef,
status: booking.status,
totalMinor: booking.totalMinor,
currency: 'ETB',
displayCurrency: booking.displayCurrency,
displayTotalMinor: booking.displayTotalMinor,
adultCount: booking.adultCount,
childCount: booking.childCount,
createdAt: booking.createdAt,
schedule: {
train: booking.schedule.train,
originStation: booking.schedule.originStation,
destinationStation: booking.schedule.destinationStation,
departureAt: booking.schedule.departureAt,
arrivalAt: booking.schedule.arrivalAt,
},
paymentIntent: booking.paymentIntent,
seatCount: booking.seats.length,
})),
meta: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
},
};
}
async findAll(filters: BookingFilters = {}) {
const { search, status, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize;
@@ -218,7 +295,6 @@ export class BookingsService {
passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality });
}
// Use first passenger's nationality for fare lookup (or allow per-passenger pricing)
const primaryNationality = passengersData[0]?.nationality;
const baseFareMinor = await this.getBaseFare(dto.scheduleId, dto.seatClassId, segmentRoute, fullRoute, primaryNationality);
const adultFareMinor = baseFareMinor * adultCount;
@@ -316,7 +392,7 @@ export class BookingsService {
where: { bookingRef },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
seats: { include: { seat: { include: { coach: { include: { seatClass: true } } } } } },
seats: { include: { seat: { include: { coach: true } } } },
paymentIntent: true, ticket: true,
},
});
@@ -332,7 +408,7 @@ export class BookingsService {
destination: { id: booking.schedule.destinationStation.id, name: booking.schedule.destinationStation.name, code: booking.schedule.destinationStation.code, city: booking.schedule.destinationStation.city },
departureAt: booking.schedule.departureAt, arrivalAt: booking.schedule.arrivalAt,
},
passengers: booking.seats.map((bs) => ({
passengers: booking.seats?.map((bs: any) => ({
fullName: bs.passengerName, category: bs.passengerCategory, verifaydaVerified: bs.verifaydaVerified,
seat: { number: bs.seat.label, coach: bs.seat.coach.label, class: bs.seat.coach.seatClass.name },
})),

View File

@@ -250,6 +250,7 @@ export class GuestBookingService {
displayCurrency,
displayTotalMinor,
bookingType: 'ONE_WAY',
userAgent: dto.deviceId,
// contactEmail: firstPassenger.email, // Temporarily disabled until migration
// contactPhone: firstPassenger.phone, // Temporarily disabled until migration
seats: {

View File

@@ -35,7 +35,7 @@ export class DashboardService {
upcomingTicket: upcomingBooking ? {
ticketId: upcomingBooking.ticket?.id, bookingRef: upcomingBooking.bookingRef,
from: upcomingBooking.schedule.originStation.name, to: upcomingBooking.schedule.destinationStation.name,
trainName: upcomingBooking.schedule.train.name, coachLabel: seat?.seat.coach.label, seatLabel: seat?.seat.label,
trainName: upcomingBooking.schedule.train.name, coachLabel: seat?.seat.coach.number, seatLabel: seat?.seat.seatNumber,
departureAt: upcomingBooking.schedule.departureAt,
punctualityLabel: (upcomingBooking.schedule.liveStatus?.delayMinutes ?? 0) > 0 ? 'DELAYED' : 'ON_TIME',
} : null,

View File

@@ -44,7 +44,7 @@ export class FareEngineService {
if (!seatClass) throw new NotFoundException('Seat class not found');
if (!seatClass.isActive) throw new BadRequestException('Seat class is not active');
const ratePerKmMinor = seatClass.basePrice;
const ratePerKmMinor = seatClass.baseFareMinor;
const baseFarePerPassengerMinor = totalDistanceKm * ratePerKmMinor;
const adultCount = dto.adultCount ?? 1;
@@ -129,7 +129,7 @@ export class FareEngineService {
) {
const seatClasses = await this.prisma.seatClass.findMany({
where: { isActive: true },
orderBy: { basePrice: 'asc' },
orderBy: { baseFareMinor: 'asc' },
});
const results = await Promise.all(
@@ -172,7 +172,7 @@ export class FareEngineService {
if (schedule.routeId) {
const seatClasses = await this.prisma.seatClass.findMany({
where: { isActive: true },
orderBy: { basePrice: 'asc' },
orderBy: { baseFareMinor: 'asc' },
});
const results = await Promise.all(
@@ -198,23 +198,25 @@ export class FareEngineService {
validFrom: { lte: now },
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
},
include: { seatClass: true },
orderBy: { seatClass: { basePrice: 'asc' } },
orderBy: [{ seatClass: { baseFareMinor: 'asc' } }],
});
if (fareRules.length > 0) {
const billingCurrency = resolveCurrencyFromNationality(nationality);
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency);
return fareRules.map(rule => ({
seatClassId: rule.seatClassId,
seatClassName: rule.seatClass.name,
baseFareMinor: rule.baseFareMinor,
totalMinor: rule.baseFareMinor,
billingCurrency,
totalInBillingCurrency: Math.round(rule.baseFareMinor * exchangeRate),
exchangeRate,
source: 'FARE_RULE',
}));
return fareRules.map(rule => {
const seatClassId = rule.seatClassId;
return {
seatClassId,
seatClassName: 'Unknown',
baseFareMinor: rule.baseFareMinor,
totalMinor: rule.baseFareMinor,
billingCurrency,
totalInBillingCurrency: Math.round(rule.baseFareMinor * exchangeRate),
exchangeRate,
source: 'FARE_RULE',
};
});
}
throw new BadRequestException(

View File

@@ -1,7 +1,7 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiBody, ApiResponse } from '@nestjs/swagger';
import { FleetService } from './fleet.service';
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, CreateSeatBatchDto, ListCoachesDto } from './fleet.dto';
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto } from './fleet.dto';
import { JwtGuard } from '../../common/jwt.guard';
@ApiTags('Fleet')
@@ -11,16 +11,128 @@ import { JwtGuard } from '../../common/jwt.guard';
export class FleetController {
constructor(private service: FleetService) {}
// Coach Type Endpoints
@Get('coach-types')
@ApiOperation({ summary: 'List all coach types' })
@ApiResponse({ status: 200, description: 'Array of coach types' })
getCoachTypes() {
return this.service.getCoachTypes();
}
@Post('coach-types')
@ApiOperation({ summary: 'Create a coach type' })
@ApiBody({ type: CreateCoachTypeDto })
@ApiResponse({ status: 201, description: 'Coach type created' })
createCoachType(@Body() dto: CreateCoachTypeDto) {
return this.service.createCoachType(dto);
}
@Patch('coach-types/:id')
@ApiOperation({ summary: 'Update a coach type' })
@ApiParam({ name: 'id', description: 'Coach Type UUID' })
@ApiBody({ type: UpdateCoachTypeDto })
@ApiResponse({ status: 200, description: 'Coach type updated' })
@ApiResponse({ status: 404, description: 'Coach type not found' })
updateCoachType(@Param('id') id: string, @Body() dto: UpdateCoachTypeDto) {
return this.service.updateCoachType(id, dto);
}
@Delete('coach-types/:id')
@ApiOperation({ summary: 'Delete a coach type' })
@ApiParam({ name: 'id', description: 'Coach Type UUID' })
@ApiResponse({ status: 200, description: 'Coach type deleted' })
@ApiResponse({ status: 404, description: 'Coach type not found' })
deleteCoachType(@Param('id') id: string) {
return this.service.deleteCoachType(id);
}
// Class Endpoints
@Get('classes')
@ApiOperation({ summary: 'List all classes' })
@ApiQuery({ name: 'coachTypeId', required: false, description: 'Filter by coach type' })
@ApiResponse({ status: 200, description: 'Array of classes' })
getClasses(@Query('coachTypeId') coachTypeId?: string) {
return this.service.getClasses(coachTypeId);
}
@Post('classes')
@ApiOperation({ summary: 'Create a class' })
@ApiBody({ type: CreateClassDto })
@ApiResponse({ status: 201, description: 'Class created' })
createClass(@Body() dto: CreateClassDto) {
return this.service.createClass(dto);
}
@Patch('classes/:id')
@ApiOperation({ summary: 'Update a class' })
@ApiParam({ name: 'id', description: 'Class UUID' })
@ApiBody({ type: UpdateClassDto })
@ApiResponse({ status: 200, description: 'Class updated' })
@ApiResponse({ status: 404, description: 'Class not found' })
updateClass(@Param('id') id: string, @Body() dto: UpdateClassDto) {
return this.service.updateClass(id, dto);
}
@Delete('classes/:id')
@ApiOperation({ summary: 'Delete a class' })
@ApiParam({ name: 'id', description: 'Class UUID' })
@ApiResponse({ status: 200, description: 'Class deleted' })
@ApiResponse({ status: 404, description: 'Class not found' })
deleteClass(@Param('id') id: string) {
return this.service.deleteClass(id);
}
// Seat Class Endpoints (DEPRECATED - use Classes endpoints instead)
@Get('seat-classes')
@ApiOperation({ summary: 'List all classes (DEPRECATED - use /fleet/classes)' })
@ApiQuery({ name: 'coachTypeId', required: false, description: 'Filter by coach type' })
@ApiResponse({ status: 200, description: 'Array of classes' })
getSeatClasses(@Query('coachTypeId') coachTypeId?: string) {
return this.service.getClasses(coachTypeId);
}
@Post('seat-classes')
@ApiOperation({ summary: 'Create a class (DEPRECATED - use /fleet/classes)' })
@ApiBody({ type: CreateClassDto })
@ApiResponse({ status: 201, description: 'Class created' })
createSeatClass(@Body() dto: CreateClassDto) {
return this.service.createClass(dto);
}
@Patch('seat-classes/:id')
@ApiOperation({ summary: 'Update a class (DEPRECATED - use /fleet/classes)' })
@ApiParam({ name: 'id', description: 'Class UUID' })
@ApiBody({ type: UpdateClassDto })
@ApiResponse({ status: 200, description: 'Class updated' })
@ApiResponse({ status: 404, description: 'Class not found' })
updateSeatClass(@Param('id') id: string, @Body() dto: UpdateClassDto) {
return this.service.updateClass(id, dto);
}
@Delete('seat-classes/:id')
@ApiOperation({ summary: 'Delete a class (DEPRECATED - use /fleet/classes)' })
@ApiParam({ name: 'id', description: 'Class UUID' })
@ApiResponse({ status: 200, description: 'Class deleted' })
@ApiResponse({ status: 404, description: 'Class not found' })
deleteSeatClass(@Param('id') id: string) {
return this.service.deleteClass(id);
}
// Train Endpoints
@Get('trains')
@ApiOperation({ summary: 'List all trains with their recent schedules' })
@ApiResponse({ status: 200, description: 'Array of trains each with up to 5 most recent schedules' })
getTrains() { return this.service.getTrains(); }
@ApiResponse({ status: 200, description: 'Array of trains' })
getTrains() {
return this.service.getTrains();
}
@Post('trains')
@ApiOperation({ summary: 'Create a train service' })
@ApiBody({ type: CreateTrainDto })
@ApiResponse({ status: 201, description: 'Train created' })
createTrain(@Body() dto: CreateTrainDto) { return this.service.createTrain(dto); }
createTrain(@Body() dto: CreateTrainDto) {
return this.service.createTrain(dto);
}
@Patch('trains/:id')
@ApiOperation({ summary: 'Update a train service' })
@@ -28,96 +140,95 @@ export class FleetController {
@ApiBody({ type: CreateTrainDto })
@ApiResponse({ status: 200, description: 'Train updated' })
@ApiResponse({ status: 404, description: 'Train not found' })
updateTrain(@Param('id') id: string, @Body() dto: CreateTrainDto) { return this.service.updateTrain(id, dto); }
@Get('coaches')
@ApiOperation({ summary: 'List coaches filtered by status, mode, seat class, or schedule assignment' })
@ApiQuery({ name: 'isActive', required: false, type: Boolean, description: 'true = active only, false = inactive only, omit = all' })
@ApiQuery({ name: 'mode', required: false, description: 'Filter by mode: seat | bed | convertible' })
@ApiQuery({ name: 'seatClassId', required: false, description: 'Filter by SeatClass UUID' })
@ApiQuery({ name: 'scheduleId', required: false, description: 'Filter to coaches assigned to this TrainSchedule UUID' })
@ApiResponse({ status: 200, description: 'Coaches with seat class info, assignment count, and seat status summary (total/available/held/booked/blocked)' })
listCoaches(
@Query('isActive') isActive?: string,
@Query('mode') mode?: string,
@Query('seatClassId') seatClassId?: string,
@Query('scheduleId') scheduleId?: string,
) {
const dto: ListCoachesDto = {
isActive: isActive === 'true' ? true : isActive === 'false' ? false : undefined,
mode,
seatClassId,
scheduleId,
};
return this.service.listCoaches(dto);
updateTrain(@Param('id') id: string, @Body() dto: CreateTrainDto) {
return this.service.updateTrain(id, dto);
}
@Get('coaches/:id')
@ApiOperation({ summary: 'Get a single coach with full seat layout and arrangement' })
@ApiParam({ name: 'id', description: 'Coach UUID' })
@ApiResponse({
status: 200,
description: `Coach detail including:
- seatClass: seat class info
- seatsByRow: seats grouped by row number, each seat includes label, seatNumber, col, kind (STANDARD/PREMIUM/ACCESSIBLE), status (AVAILABLE/HELD/BOOKED/BLOCKED), isWindow, isAisle, bedPosition (bed mode only), premiumFeeMinor
- seatStatusSummary: total/available/held/booked/blocked counts
- assignments: up to 5 most recent schedule assignments with origin/destination`,
})
@ApiResponse({ status: 404, description: 'Coach not found' })
getCoach(@Param('id') id: string) { return this.service.getCoach(id); }
@Post('coaches')
@ApiOperation({ summary: 'Register a new physical coach and auto-generate its seats from arrangement config' })
@ApiBody({ type: CreateCoachDto })
@ApiResponse({ status: 201, description: 'Coach created with seats auto-generated from mode + arrangement + totalUnits' })
@ApiResponse({ status: 400, description: 'Invalid arrangement format' })
createCoach(@Body() dto: CreateCoachDto) { return this.service.createCoach(dto); }
@Patch('coaches/:id')
@ApiOperation({ summary: 'Update coach properties (label, mode, arrangement, etc.)' })
@ApiParam({ name: 'id', description: 'Coach UUID' })
@ApiBody({ type: UpdateCoachDto })
@ApiResponse({ status: 200, description: 'Coach updated' })
@ApiResponse({ status: 404, description: 'Coach not found' })
updateCoach(@Param('id') id: string, @Body() dto: UpdateCoachDto) { return this.service.updateCoach(id, dto); }
@Delete('trains/:id')
@ApiOperation({ summary: 'Delete a train service' })
@ApiParam({ name: 'id', description: 'Train UUID' })
@ApiResponse({ status: 200, description: 'Train deleted' })
@ApiResponse({ status: 404, description: 'Train not found' })
deleteTrain(@Param('id') id: string) { return this.service.deleteTrain(id); }
deleteTrain(@Param('id') id: string) {
return this.service.deleteTrain(id);
}
// Coach Endpoints
@Get('coaches')
@ApiOperation({ summary: 'List coaches with seat status summary' })
@ApiQuery({ name: 'status', required: false, description: 'Filter by status: ACTIVE, INACTIVE' })
@ApiQuery({ name: 'scheduleId', required: false, description: 'Filter coaches assigned to schedule' })
@ApiResponse({ status: 200, description: 'Array of coaches' })
listCoaches(
@Query('status') status?: string,
@Query('scheduleId') scheduleId?: string,
) {
const dto: ListCoachesDto = {
status,
scheduleId,
};
return this.service.listCoaches(dto);
}
@Get('coaches/:id')
@ApiOperation({ summary: 'Get single coach with seat layout' })
@ApiParam({ name: 'id', description: 'Coach UUID' })
@ApiResponse({ status: 200, description: 'Coach detail with seats by row' })
@ApiResponse({ status: 404, description: 'Coach not found' })
getCoach(@Param('id') id: string) {
return this.service.getCoach(id);
}
@Post('coaches')
@ApiOperation({ summary: 'Create a coach with auto-generated seat numbers' })
@ApiBody({ type: CreateCoachDto })
@ApiResponse({ status: 201, description: 'Coach created' })
@ApiResponse({ status: 400, description: 'Invalid arrangement format' })
createCoach(@Body() dto: CreateCoachDto) {
return this.service.createCoach(dto);
}
@Patch('coaches/:id')
@ApiOperation({ summary: 'Update coach properties' })
@ApiParam({ name: 'id', description: 'Coach UUID' })
@ApiBody({ type: UpdateCoachDto })
@ApiResponse({ status: 200, description: 'Coach updated' })
@ApiResponse({ status: 404, description: 'Coach not found' })
updateCoach(@Param('id') id: string, @Body() dto: UpdateCoachDto) {
return this.service.updateCoach(id, dto);
}
@Delete('coaches/:id')
@ApiOperation({ summary: 'Delete a coach' })
@ApiParam({ name: 'id', description: 'Coach UUID' })
@ApiResponse({ status: 200, description: 'Coach deleted' })
@ApiResponse({ status: 404, description: 'Coach not found' })
deleteCoach(@Param('id') id: string) { return this.service.deleteCoach(id); }
deleteCoach(@Param('id') id: string) {
return this.service.deleteCoach(id);
}
@Post('assignments')
@ApiOperation({ summary: 'Assign a physical coach to a train schedule at a given position' })
@ApiOperation({ summary: 'Assign a coach to a schedule' })
@ApiBody({ type: AssignCoachDto })
@ApiResponse({ status: 201, description: 'CoachAssignment created' })
@ApiResponse({ status: 201, description: 'Coach assigned' })
@ApiResponse({ status: 404, description: 'Schedule or coach not found' })
assignCoach(@Body() dto: AssignCoachDto) { return this.service.assignCoach(dto); }
assignCoach(@Body() dto: AssignCoachDto) {
return this.service.assignCoach(dto);
}
@Delete('assignments/:id')
@ApiOperation({ summary: 'Remove a coach assignment from a schedule' })
@ApiParam({ name: 'id', description: 'CoachAssignment UUID' })
@ApiOperation({ summary: 'Remove a coach assignment' })
@ApiParam({ name: 'id', description: 'Assignment UUID' })
@ApiResponse({ status: 200, description: 'Assignment removed' })
@ApiResponse({ status: 404, description: 'Assignment not found' })
removeAssignment(@Param('id') id: string) { return this.service.removeAssignment(id); }
@Post('seats/batch')
@ApiOperation({ summary: 'Batch-generate seats for a coach (rows × cols)' })
@ApiBody({ type: CreateSeatBatchDto })
@ApiResponse({ status: 201, description: 'Returns count of seats created' })
@ApiResponse({ status: 404, description: 'Coach not found' })
createSeatBatch(@Body() dto: CreateSeatBatchDto) { return this.service.createSeatBatch(dto); }
removeAssignment(@Param('id') id: string) {
return this.service.removeAssignment(id);
}
@Get('analytics')
@ApiOperation({ summary: 'Fleet analytics: train count, schedule count, seat occupancy rate' })
@ApiResponse({ status: 200, description: 'Returns totalTrains, totalSchedules, totalSeats, bookedSeats, occupancyRate' })
getAnalytics() { return this.service.getAnalytics(); }
@ApiOperation({ summary: 'Fleet analytics and occupancy metrics' })
@ApiResponse({ status: 200, description: 'Occupancy statistics' })
getAnalytics() {
return this.service.getAnalytics();
}
}

View File

@@ -10,41 +10,57 @@ export class CreateTrainDto {
}
export class CreateCoachDto {
@ApiProperty({ example: 'C-A1', description: 'Unique physical coach identifier' }) @IsString() coachNumber: string;
@ApiProperty({ example: 'A', description: 'Display label shown on tickets' }) @IsString() label: string;
@ApiProperty({ example: 'seat-class-uuid', description: 'SeatClass UUID this coach belongs to' }) @IsString() seatClassId: string;
@ApiPropertyOptional({ example: 'sleeper', description: 'Coach type descriptor' }) @IsOptional() @IsString() coachType?: string;
@ApiPropertyOptional({ example: 'seat', description: 'seat | bed | convertible. Determines which arrangement field is used for seat generation.' }) @IsOptional() @IsString() mode?: string;
@ApiPropertyOptional({ example: '2+2', description: 'Seat arrangement for seat/convertible mode. Format: groups separated by +, e.g. "2+2" (4 cols: A/B aisle C/D) or "1+2+1". Used to derive columns, window and aisle flags. Required when mode=seat and totalUnits>0.' }) @IsOptional() @IsString() seatArrangement?: string;
@ApiPropertyOptional({ example: '2+2', description: 'Bed arrangement for bed mode. First number = tiers per berth: 2 → lower/upper, 3 → lower/middle/upper. E.g. "2+2" = 2-tier berths. Required when mode=bed and totalUnits>0.' }) @IsOptional() @IsString() bedArrangement?: string;
@ApiPropertyOptional({ example: 60, description: 'Total seat/bed units. When >0, seats are auto-generated from the arrangement on coach creation.' }) @IsOptional() @IsInt() totalUnits?: number;
@ApiProperty({ example: 'A-001', description: 'Unique coach number' }) @IsString() number: string;
@ApiProperty({ example: 'coach-type-uuid', description: 'Coach Type UUID' }) @IsString() coachTypeId: string;
@ApiProperty({ example: '2+2', description: 'Seat arrangement (e.g., "2+2", "3+2")' }) @IsString() arrangement: string;
@ApiProperty({ example: 60, description: 'Total seat capacity' }) @IsInt() capacity: number;
@ApiPropertyOptional({ example: 'ACTIVE', description: 'Status: ACTIVE, INACTIVE' }) @IsOptional() @IsString() status?: string;
}
export class UpdateCoachDto extends PartialType(OmitType(CreateCoachDto, ['coachNumber'] as const)) {}
export class UpdateCoachDto extends PartialType(OmitType(CreateCoachDto, ['number'] as const)) {}
export class AssignCoachDto {
@ApiProperty({ example: 'schedule-uuid', description: 'TrainSchedule UUID' }) @IsString() scheduleId: string;
@ApiProperty({ example: 'coach-uuid', description: 'Coach UUID' }) @IsString() coachId: string;
@ApiProperty({ example: 1, description: 'Position in the train consist (1 = first coach)' }) @IsInt() positionNumber: number;
@ApiPropertyOptional({ example: true, description: 'Whether this coach is operational for this schedule' }) @IsOptional() @IsBoolean() isOperational?: boolean;
}
export class CreateSeatBatchDto {
@ApiProperty({ example: 'coach-uuid', description: 'Coach UUID to generate seats for' }) @IsString() coachId: string;
@ApiProperty({ example: 15, description: 'Number of rows to generate' }) @IsInt() rows: number;
@ApiProperty({ example: ['A', 'B', 'C', 'D'], type: [String], description: 'Column labels per row' }) @IsArray() @IsString({ each: true }) cols: string[];
@ApiProperty({ example: 1, description: 'Position in the train consist' }) @IsInt() positionNumber: number;
@ApiPropertyOptional({ example: true, description: 'Whether this coach is operational' }) @IsOptional() @IsBoolean() isOperational?: boolean;
}
export class ListCoachesDto {
@ApiPropertyOptional({ example: true, description: 'Filter by active/inactive status. Omit to return all.' })
@IsOptional() @IsBoolean() isActive?: boolean;
@ApiPropertyOptional({ example: 'ACTIVE', description: 'Filter by status: ACTIVE, INACTIVE' })
@IsOptional() @IsString() status?: string;
@ApiPropertyOptional({ example: 'seat', description: 'Filter by mode: seat | bed | convertible' })
@IsOptional() @IsString() mode?: string;
@ApiPropertyOptional({ example: 'seat-class-uuid', description: 'Filter by SeatClass UUID' })
@IsOptional() @IsString() seatClassId?: string;
@ApiPropertyOptional({ example: 'schedule-uuid', description: 'Filter to coaches assigned to this TrainSchedule UUID' })
@ApiPropertyOptional({ example: 'schedule-uuid', description: 'Filter coaches assigned to this schedule' })
@IsOptional() @IsString() scheduleId?: string;
}
// Legacy DTO types for backward compatibility
export class CreateCoachTypeDto {
@ApiProperty({ example: 'sleeper' }) @IsString() code: string;
@ApiProperty({ example: 'Sleeper Coach' }) @IsString() name: string;
@IsOptional() @IsString() type?: string;
}
export class UpdateCoachTypeDto {
@ApiPropertyOptional({ example: 'sleeper' }) @IsOptional() @IsString() code?: string;
@ApiPropertyOptional({ example: 'Sleeper Coach' }) @IsOptional() @IsString() name?: string;
@ApiPropertyOptional({ example: 'sleeper' }) @IsOptional() @IsString() type?: string;
}
export class CreateClassDto {
@ApiProperty({ example: 'coach-type-uuid' }) @IsString() coachTypeId: string;
@ApiProperty({ example: 'Economy' }) @IsString() name: string;
@IsOptional() @IsString() description?: string;
@ApiProperty({ example: 5000 }) @IsInt() baseFareMinor: number;
}
export class UpdateClassDto {
@ApiPropertyOptional({ example: 'coach-type-uuid' }) @IsOptional() @IsString() coachTypeId?: string;
@ApiPropertyOptional({ example: 'Economy' }) @IsOptional() @IsString() name?: string;
@ApiPropertyOptional() @IsOptional() @IsString() description?: string;
@ApiPropertyOptional({ example: 5000 }) @IsOptional() @IsInt() baseFareMinor?: number;
@ApiPropertyOptional({ example: true })
@IsOptional()
@IsBoolean()
isActive?: boolean;
}

View File

@@ -1,6 +1,6 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, CreateSeatBatchDto, ListCoachesDto } from './fleet.dto';
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto } from './fleet.dto';
import { SeatKind } from '@prisma/client';
// Parses '2+2' → [2, 2], '2+2+2' → [2, 2, 2]
@@ -8,22 +8,20 @@ function parseArrangement(arrangement: string): number[] {
return arrangement.split('+').map((n) => parseInt(n, 10));
}
// Derives column labels from a seat-mode arrangement string.
// '2+2' → ['A','B','C','D'] (A/D window, B/C aisle)
// '1+2+1' → ['A','B','C','D']
// Derives column labels from arrangement: '2+2' → ['A','B','C','D']
function seatCols(arrangement: string): string[] {
const groups = parseArrangement(arrangement);
const total = groups.reduce((s, n) => s + n, 0);
return Array.from({ length: total }, (_, i) => String.fromCharCode(65 + i)); // A, B, C …
return Array.from({ length: total }, (_, i) => String.fromCharCode(65 + i));
}
// Returns true if the column index is a window seat given the arrangement groups.
// Returns true if column is a window seat
function isWindowCol(colIndex: number, groups: number[]): boolean {
const total = groups.reduce((s, n) => s + n, 0);
return colIndex === 0 || colIndex === total - 1;
}
// Returns true if the column index is an aisle seat.
// Returns true if column is an aisle seat
function isAisleCol(colIndex: number, groups: number[]): boolean {
let cursor = 0;
for (const g of groups) {
@@ -35,82 +33,188 @@ function isAisleCol(colIndex: number, groups: number[]): boolean {
return false;
}
// Bed positions for a given tier count: 2 → lower/upper, 3 → lower/middle/upper
const BED_POSITIONS: Record<number, string[]> = {
2: ['lower', 'upper'],
3: ['lower', 'middle', 'upper'],
};
type SeatRow = {
coachId: string;
row: number;
col: string;
label: string;
seatNumber: string;
kind: SeatKind;
isWindow: boolean;
isAisle: boolean;
bedPosition?: string;
};
function buildSeatSeats(coachId: string, coachLabel: string, arrangement: string, totalUnits: number): SeatRow[] {
function buildSeats(coachId: string, coachNumber: string, arrangement: string, capacity: number, seatClass?: string): SeatRow[] {
const cols = seatCols(arrangement);
const groups = parseArrangement(arrangement);
const seats: SeatRow[] = [];
let row = 1;
while (seats.length < totalUnits) {
for (let ci = 0; ci < cols.length && seats.length < totalUnits; ci++) {
let seatNumber = 1;
let seatIndex = 0;
const isBedCoach = seatClass?.toLowerCase().includes('bed');
const totalCols = cols.length;
while (seatIndex < capacity) {
for (let ci = 0; ci < cols.length && seatIndex < capacity; ci++) {
const col = cols[ci];
let bedPosition = null;
// Set bedPosition for bed coaches based on seat number cycling
if (isBedCoach) {
if (totalCols === 3) {
// Economy bed (3 levels): 1L, 2M, 3U, 4L, 5M, 6U...
const posMod = ((seatNumber - 1) % 3);
if (posMod === 0) bedPosition = 'lower';
else if (posMod === 1) bedPosition = 'middle';
else if (posMod === 2) bedPosition = 'upper';
} else if (totalCols === 2) {
// VIP bed (2 levels): 1L, 2U, 3L, 4U...
const posMod = ((seatNumber - 1) % 2);
if (posMod === 0) bedPosition = 'lower';
else if (posMod === 1) bedPosition = 'upper';
}
}
seats.push({
coachId, row, col,
label: `${row}${col}`,
seatNumber: `${coachLabel}${row}${col}`,
coachId,
row,
col,
seatNumber: `${seatNumber}`,
kind: SeatKind.STANDARD,
isWindow: isWindowCol(ci, groups),
isAisle: isAisleCol(ci, groups),
bedPosition,
});
seatNumber++;
seatIndex++;
}
row++;
}
return seats;
}
function buildBedSeats(coachId: string, coachLabel: string, arrangement: string, totalUnits: number): SeatRow[] {
// arrangement for beds describes tiers per berth, e.g. '2+2' = 2 lower+upper on each side
// Each compartment number is the row; each tier is the col (L=lower, M=middle, U=upper)
const groups = parseArrangement(arrangement);
const tiersPerSide = groups[0]; // e.g. 2 → lower+upper
const positions = BED_POSITIONS[tiersPerSide] ?? ['lower', 'upper'];
const tierCols = positions.map((_, i) => String.fromCharCode(65 + i)); // A=lower, B=upper, C=middle
const seats: SeatRow[] = [];
let compartment = 1;
while (seats.length < totalUnits) {
for (let ti = 0; ti < tierCols.length && seats.length < totalUnits; ti++) {
const col = tierCols[ti];
seats.push({
coachId, row: compartment, col,
label: `${compartment}${col}`,
seatNumber: `${coachLabel}${compartment}${col}`,
kind: SeatKind.STANDARD,
isWindow: false,
isAisle: false,
bedPosition: positions[ti],
});
}
compartment++;
}
return seats;
}
type SeatRow = {
coachId: string;
row: number;
col: string;
seatNumber: string;
kind: SeatKind;
bedPosition?: string | null;
};
@Injectable()
export class FleetService {
constructor(private prisma: PrismaService) {}
async createCoachType(dto: CreateCoachTypeDto) {
return this.prisma.coachType.create({
data: {
code: dto.code,
name: dto.name,
type: dto.type || 'passenger',
},
include: {
seatClasses: true,
coaches: true,
},
});
}
async getCoachTypes() {
return this.prisma.coachType.findMany({
include: {
seatClasses: true,
coaches: true,
},
orderBy: { createdAt: 'desc' },
});
}
async updateCoachType(id: string, dto: UpdateCoachTypeDto) {
const coachType = await this.prisma.coachType.findUnique({ where: { id } });
if (!coachType) throw new NotFoundException('Coach type not found');
const data: any = {};
if (dto.code !== undefined) data.code = dto.code;
if (dto.name !== undefined) data.name = dto.name;
if (dto.type !== undefined) data.type = dto.type;
return this.prisma.coachType.update({
where: { id },
data,
include: {
seatClasses: true,
coaches: true,
},
});
}
async deleteCoachType(id: string) {
const coachType = await this.prisma.coachType.findUnique({ where: { id } });
if (!coachType) throw new NotFoundException('Coach type not found');
return this.prisma.coachType.delete({ where: { id } });
}
async createClass(dto: CreateClassDto) {
return this.prisma.seatClass.create({
data: {
coachTypeId: dto.coachTypeId,
name: dto.name,
description: dto.description,
baseFareMinor: dto.baseFareMinor,
},
});
}
async getClasses(coachTypeId?: string) {
const where = coachTypeId ? { coachTypeId } : {};
return this.prisma.seatClass.findMany({
where,
include: { coachType: true },
orderBy: { createdAt: 'desc' },
});
}
async updateClass(id: string, dto: UpdateClassDto) {
const seatClass = await this.prisma.seatClass.findUnique({ where: { id } });
if (!seatClass) throw new NotFoundException('Seat class not found');
const updateData: any = {
coachTypeId: dto.coachTypeId,
name: dto.name,
description: dto.description,
baseFareMinor: dto.baseFareMinor,
};
if (dto.isActive !== undefined) {
updateData.isActive = dto.isActive;
}
return this.prisma.seatClass.update({
where: { id },
data: updateData,
include: { coachType: true },
});
}
async deleteClass(id: string) {
const seatClass = await this.prisma.seatClass.findUnique({ where: { id } });
if (!seatClass) throw new NotFoundException('Seat class not found');
return this.prisma.seatClass.delete({ where: { id } });
}
createSeatClass(dto: CreateClassDto) {
return this.createClass(dto);
}
getSeatClasses(coachTypeId?: string) {
return this.getClasses(coachTypeId);
}
async updateSeatClass(id: string, dto: UpdateClassDto) {
return this.updateClass(id, dto);
}
async deleteSeatClass(id: string) {
return this.deleteClass(id);
}
getTrains() {
return this.prisma.train.findMany({ include: { schedules: { take: 5, orderBy: { departureAt: 'desc' } } } });
}
createTrain(dto: CreateTrainDto) { return this.prisma.train.create({ data: dto }); }
createTrain(dto: CreateTrainDto) {
return this.prisma.train.create({ data: dto });
}
async updateTrain(id: string, dto: CreateTrainDto) {
const train = await this.prisma.train.findUnique({ where: { id } });
@@ -118,120 +222,112 @@ export class FleetService {
return this.prisma.train.update({ where: { id }, data: dto });
}
async getCoach(id: string) {
const coach = await this.prisma.coach.findUnique({
where: { id },
include: {
seatClass: true,
seats: {
orderBy: [{ row: 'asc' }, { col: 'asc' }],
},
assignments: {
include: { schedule: { include: { originStation: true, destinationStation: true } } },
orderBy: { schedule: { departureAt: 'desc' } },
take: 5,
},
_count: { select: { seats: true, assignments: true } },
},
});
if (!coach) throw new NotFoundException('Coach not found');
// Group seats by row to reflect the physical arrangement layout
const rowMap = new Map<number, typeof coach.seats>();
for (const seat of coach.seats) {
if (!rowMap.has(seat.row)) rowMap.set(seat.row, []);
rowMap.get(seat.row)!.push(seat);
}
const seatsByRow = Array.from(rowMap.entries()).map(([row, seats]) => ({ row, seats }));
const seatStatusSummary = {
total: coach.seats.length,
available: coach.seats.filter(s => s.status === 'AVAILABLE').length,
held: coach.seats.filter(s => s.status === 'HELD').length,
booked: coach.seats.filter(s => s.status === 'BOOKED').length,
blocked: coach.seats.filter(s => s.status === 'BLOCKED').length,
};
const { seats, ...coachData } = coach;
return { ...coachData, seatsByRow, seatStatusSummary };
}
async listCoaches(dto: ListCoachesDto) {
const where: any = {};
if (dto.isActive !== undefined) where.isActive = dto.isActive;
if (dto.mode) where.mode = dto.mode;
if (dto.seatClassId) where.seatClassId = dto.seatClassId;
if (dto.scheduleId) where.assignments = { some: { scheduleId: dto.scheduleId } };
const coaches = await this.prisma.coach.findMany({
where,
include: {
seatClass: true,
seats: { select: { status: true } },
_count: { select: { seats: true, assignments: true } },
},
orderBy: [{ isActive: 'desc' }, { label: 'asc' }],
});
return coaches.map(({ seats, ...coach }) => ({
...coach,
seatStatusSummary: {
total: seats.length,
available: seats.filter(s => s.status === 'AVAILABLE').length,
held: seats.filter(s => s.status === 'HELD').length,
booked: seats.filter(s => s.status === 'BOOKED').length,
blocked: seats.filter(s => s.status === 'BLOCKED').length,
},
}));
}
async createCoach(dto: CreateCoachDto) {
const mode = dto.mode ?? 'seat';
const totalUnits = dto.totalUnits ?? 0;
const isBed = mode === 'bed';
const arrangement = isBed
? (dto.bedArrangement ?? dto.seatArrangement ?? '2+2')
: (dto.seatArrangement ?? '2+2');
if (totalUnits > 0) {
const groups = parseArrangement(arrangement);
if (groups.some(isNaN)) {
throw new BadRequestException(`Invalid arrangement format "${arrangement}". Use e.g. "2+2" or "2+2+2"`);
}
}
const coach = await this.prisma.coach.create({ data: dto });
if (totalUnits > 0) {
const seats = isBed
? buildBedSeats(coach.id, coach.label, arrangement, totalUnits)
: buildSeatSeats(coach.id, coach.label, arrangement, totalUnits);
await this.prisma.seat.createMany({ data: seats, skipDuplicates: true });
}
return this.prisma.coach.findUnique({
where: { id: coach.id },
include: { seatClass: true, _count: { select: { seats: true } } },
});
}
async updateCoach(id: string, dto: UpdateCoachDto) {
const coach = await this.prisma.coach.findUnique({ where: { id } });
if (!coach) throw new NotFoundException('Coach not found');
return this.prisma.coach.update({ where: { id }, data: dto });
}
async deleteTrain(id: string) {
const train = await this.prisma.train.findUnique({ where: { id } });
if (!train) throw new NotFoundException('Train not found');
return this.prisma.train.delete({ where: { id } });
}
async getCoach(id: string) {
const coach = await this.prisma.coach.findUnique({
where: { id },
include: {
coachType: true,
seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] },
assignments: {
include: { schedule: { include: { originStation: true, destinationStation: true } } },
orderBy: { schedule: { departureAt: 'desc' } },
take: 5,
},
},
});
if (!coach) throw new NotFoundException('Coach not found');
return coach;
}
async listCoaches(dto: ListCoachesDto) {
const where: any = {};
if (dto.status) where.status = dto.status;
if (dto.scheduleId) where.assignments = { some: { scheduleId: dto.scheduleId } };
return this.prisma.coach.findMany({
where,
include: { coachType: true },
orderBy: { number: 'asc' },
});
}
async createCoach(dto: CreateCoachDto) {
const groups = parseArrangement(dto.arrangement);
if (groups.some(isNaN)) {
throw new BadRequestException(`Invalid arrangement format "${dto.arrangement}". Use e.g. "2+2"`);
}
const coach = await this.prisma.coach.create({
data: {
coachTypeId: dto.coachTypeId,
number: dto.number,
arrangement: dto.arrangement,
capacity: dto.capacity,
status: dto.status || 'ACTIVE',
},
include: { coachType: true },
});
if (dto.capacity > 0) {
const seatClass = coach.coachType?.name || '';
const seats = buildSeats(coach.id, coach.number, dto.arrangement, dto.capacity, seatClass);
await this.prisma.seat.createMany({ data: seats });
}
return coach;
}
async updateCoach(id: string, dto: UpdateCoachDto) {
const coach = await this.prisma.coach.findUnique({ where: { id } });
if (!coach) throw new NotFoundException('Coach not found');
return this.prisma.coach.update({
where: { id },
data: {
arrangement: dto.arrangement,
capacity: dto.capacity,
status: dto.status,
},
include: { coachType: true },
});
}
async deleteCoach(id: string) {
const coach = await this.prisma.coach.findUnique({ where: { id } });
if (!coach) throw new NotFoundException('Coach not found');
// Get all seat IDs for this coach
const seats = await this.prisma.seat.findMany({ where: { coachId: id }, select: { id: true } });
const seatIds = seats.map(s => s.id);
// Delete in order of foreign key dependencies
if (seatIds.length > 0) {
// 1. Delete seat blocks (references seats)
await this.prisma.seatBlock.deleteMany({ where: { seatId: { in: seatIds } } });
// 2. Delete ticket seats (references seats)
await this.prisma.ticketSeat.deleteMany({ where: { seatId: { in: seatIds } } });
// 3. Delete booking seats (references seats)
await this.prisma.bookingSeat.deleteMany({ where: { seatId: { in: seatIds } } });
// 4. Delete journey segments with these seats
await this.prisma.journeySegment.deleteMany({ where: { seatId: { in: seatIds } } });
}
// 5. Delete all associated seats
await this.prisma.seat.deleteMany({ where: { coachId: id } });
// 6. Delete coach assignments
await this.prisma.coachAssignment.deleteMany({ where: { coachId: id } });
// 7. Finally delete the coach
return this.prisma.coach.delete({ where: { id } });
}
@@ -251,19 +347,6 @@ export class FleetService {
return this.prisma.coachAssignment.delete({ where: { id } });
}
async createSeatBatch(dto: CreateSeatBatchDto) {
const coach = await this.prisma.coach.findUnique({ where: { id: dto.coachId } });
if (!coach) throw new NotFoundException('Coach not found');
const seats = [];
for (let row = 1; row <= dto.rows; row++) {
for (const col of dto.cols) {
seats.push({ coachId: dto.coachId, row, col, label: `${row}${col}`, seatNumber: `${coach.label}${row}${col}` });
}
}
await this.prisma.seat.createMany({ data: seats, skipDuplicates: true });
return { created: seats.length };
}
async getAnalytics() {
const [totalTrains, totalSchedules, totalSeats, bookedSeats] = await Promise.all([
this.prisma.train.count(),
@@ -271,6 +354,12 @@ export class FleetService {
this.prisma.seat.count(),
this.prisma.seat.count({ where: { status: 'BOOKED' } }),
]);
return { totalTrains, totalSchedules, totalSeats, bookedSeats, occupancyRate: totalSeats > 0 ? +((bookedSeats / totalSeats) * 100).toFixed(2) : 0 };
return {
totalTrains,
totalSchedules,
totalSeats,
bookedSeats,
occupancyRate: totalSeats > 0 ? +((bookedSeats / totalSeats) * 100).toFixed(2) : 0,
};
}
}

View File

@@ -92,22 +92,22 @@ export class PassengersService {
}
async getProfile(passengerId: string) {
const p = await this.prisma.passenger.findUnique({
const passenger = await this.prisma.passenger.findUnique({
where: { id: passengerId },
include: {
user: { select: { fullName: true, email: true, phone: true } },
bookings: { orderBy: { createdAt: 'desc' }, take: 10, include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: { include: { seatClass: true } } } } } } } },
bookings: { orderBy: { createdAt: 'desc' }, take: 10, include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: true } } } } } },
loyalty: true, wallet: true, travelerProfiles: true, savedRoutes: true,
},
});
if (!p) throw new NotFoundException('Passenger not found');
if (!passenger) throw new NotFoundException('Passenger not found');
return {
id: p.id,
fullName: p.user.fullName,
email: p.user.email,
phone: p.user.phone,
createdAt: p.createdAt,
bookings: p.bookings.map((b) => ({
id: passenger.id,
fullName: passenger.user.fullName,
email: passenger.user.email,
phone: passenger.user.phone,
createdAt: passenger.createdAt,
bookings: passenger.bookings.map((b) => ({
id: b.id, bookingRef: b.bookingRef, status: b.status, totalFare: b.totalMinor / 100, createdAt: b.createdAt,
trip: {
number: b.schedule.train.number,
@@ -115,7 +115,7 @@ export class PassengersService {
destination: { id: b.schedule.destinationStation.id, name: b.schedule.destinationStation.name, code: b.schedule.destinationStation.code, city: b.schedule.destinationStation.city },
departureAt: b.schedule.departureAt,
},
passengers: b.seats.map((bs) => ({ fullName: bs.passengerName, seat: { number: bs.seat.label, coach: bs.seat.coach.label, class: bs.seat.coach.seatClass?.name ?? 'N/A' } })),
passengers: b.seats.map((bs) => ({ fullName: bs.passengerName, seat: { number: bs.seat.seatNumber, coach: bs.seat.coach.number, class: 'N/A' } })),
})),
};
}
@@ -207,7 +207,6 @@ export class PassengersService {
const isLoggedIn = !!dto.userId;
let verifiedData: any = null;
// Auto-verify Ethiopian passengers with national ID if Fayda is enabled
if (isEthiopian && dto.verifyWithFayda !== false) {
try {
const verification = await this.verifaydaService.verifyNationalId(dto.nationalId!);
@@ -215,12 +214,10 @@ export class PassengersService {
verifiedData = verification.passengerData;
}
} catch (error) {
// If verification fails, continue with manual data
console.warn('Fayda verification failed, using manual data:', error);
}
}
// Use verified data if available, otherwise use provided data
const finalData = {
passengerName: verifiedData?.fullName || dto.passengerName,
dateOfBirth: verifiedData?.dateOfBirth || new Date(dto.dateOfBirth),
@@ -230,7 +227,6 @@ export class PassengersService {
email: dto.email,
};
// If logged in, update user profile and link passenger
if (isLoggedIn) {
const user = await this.prisma.user.findUnique({
where: { id: dto.userId },
@@ -241,7 +237,6 @@ export class PassengersService {
throw new BadRequestException('User not found');
}
// Update user record if not already verified
if (!user.faydaVerified && verifiedData) {
await this.prisma.user.update({
where: { id: dto.userId },
@@ -267,7 +262,6 @@ export class PassengersService {
};
}
// Guest user - save to SavedPassengerProfile
const profile = await this.prisma.savedPassengerProfile.create({
data: {
deviceId: dto.deviceId,
@@ -318,4 +312,4 @@ export class PassengersService {
affectedModules: usage,
};
}
}
}

View File

@@ -0,0 +1,35 @@
import {
Body,
Controller,
HttpCode,
HttpStatus,
Post,
UseGuards,
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
import { PaymentEventDto, MarkPaidResponseDto } from "./internal-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);
}
}

View File

@@ -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;
}

View File

@@ -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");
}
}
}

View File

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

View File

@@ -1,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()}` }; }

View File

@@ -1,108 +1,183 @@
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) {}
@Post('initiate')
@ApiOperation({
summary: 'Initiate payment with nationality-based payment methods',
description: `Initiates payment for a booking with support for multiple payment providers:
**Ethiopian Payment Methods:**
- TELEBIRR - Ethiopia's leading mobile money
- CBE_BIRR - Commercial Bank of Ethiopia
- EBIRR - Electronic payment gateway
@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 })
async getAll(
@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,
status,
method,
page: page ? parseInt(page) : 1,
pageSize: pageSize ? parseInt(pageSize) : 10,
});
}
**Djiboutian Payment Methods:**
- WAAFI - Djibouti's mobile money service
**International Payment Methods:**
- CARD - Visa, Mastercard
- WALLET - Internal wallet balance
**Multi-Currency:**
- All transactions processed in ETB
- Display amounts in ETB, DJF, or USD
- 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, '&quot;');
const escaped = url.replace(/\"/g, "&quot;");
return `<!DOCTYPE html>
<html lang="en">
<head>

View File

@@ -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;

View File

@@ -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,49 +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 seatClass = await prisma.seatClass.upsert({
where: { name: 'Economy Regular' },
update: {},
create: { name: 'Economy Regular', description: 'Standard economy seating', basePrice: 45000, isActive: true },
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,
},
});
const coach = await prisma.coach.create({
data: { coachNumber: 'TEST-C1', label: 'A', seatClassId: seatClass.id, mode: 'seat', totalUnits: 10 },
data: {
coachTypeId: coachType.id,
number: "TEST-C1",
arrangement: "2+2",
capacity: 10,
status: "ACTIVE",
},
});
await prisma.coachAssignment.create({ data: { scheduleId: schedule.id, coachId: coach.id, positionNumber: 1 } });
const seat = await prisma.seat.create({ data: { coachId: coach.id, row: 1, col: 'A', label: '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;
});
@@ -73,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/*).
});

View File

@@ -1,38 +1,61 @@
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 { Module } from "@nestjs/common";
import { HttpModule } from "@nestjs/axios";
import { ConfigService } from "@nestjs/config";
import { RabbitMQModule } from "@golevelup/nestjs-rabbitmq";
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';
PAYMENT_EVENTS_DLX,
PAYMENT_EVENTS_EXCHANGE,
PAYMENT_QUEUES,
PaymentService,
paymentServiceBindingPattern,
} from "@edr/types";
import { PaymentsController } from "./payments.controller";
import { PaymentsService } from "./payments.service";
import { InternalPaymentsController } from "./internal-payments.controller";
import { PaymentClientService } from "./payment-client.service";
import { PaymentEventsConsumer } from "./payment-events.consumer";
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
import { SeatsModule } from "../seats/seats.module";
import { TicketsModule } from "../tickets/tickets.module";
const PASSENGER_QUEUE = PAYMENT_QUEUES[PaymentService.PASSENGER];
@Module({
imports: [SeatsModule, TicketsModule, HttpModule.register({ timeout: 10_000 })],
controllers: [PaymentsController, WebhooksController],
imports: [
SeatsModule,
TicketsModule,
HttpModule.register({ timeout: 10_000 }),
RabbitMQModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
uri: config.get<string>("rabbitmq.url") as string,
exchanges: [
{
name: PAYMENT_EVENTS_EXCHANGE,
type: "topic",
options: { durable: true },
},
{ name: PAYMENT_EVENTS_DLX, type: "topic", options: { durable: true } },
],
queues: [
{
name: PASSENGER_QUEUE.dlq,
exchange: PAYMENT_EVENTS_DLX,
routingKey: paymentServiceBindingPattern(PaymentService.PASSENGER),
options: { durable: true },
},
],
prefetchCount: config.get<number>("rabbitmq.prefetch") ?? 10,
connectionInitOptions: { wait: false },
}),
}),
],
controllers: [PaymentsController, InternalPaymentsController],
providers: [
PaymentsService,
TelebirrProvider,
CbeBirrProvider,
EBirrProvider,
CardProvider,
WaafiProvider,
TelebirrWebhookService,
CbeBirrWebhookService,
EBirrWebhookService,
CardWebhookService,
WaafiWebhookService,
PaymentClientService,
PaymentEventsConsumer,
ServiceAuthGuard,
],
})
export class PaymentsModule {}

View File

@@ -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,
);
});

View File

@@ -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,26 +42,67 @@ 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;
}) {
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" } } },
];
}
if (status) {
where.status = status;
}
if (method) {
where.method = method;
}
const [items, total] = await Promise.all([
this.prisma.paymentIntent.findMany({
where,
include: { booking: true },
skip,
take: pageSize,
orderBy: { createdAt: "desc" },
}),
this.prisma.paymentIntent.count({ where }),
]);
return {
items: items.map((item) => ({
id: item.id,
reference: item.id.substring(0, 8),
bookingId: item.bookingId,
booking: { bookingRef: item.booking?.bookingRef },
amountMinor: item.amountMinor,
currency: item.currency,
method: item.method,
status: item.status,
createdAt: item.createdAt,
paidAt: item.paidAt,
})),
total,
page,
pageSize,
};
}
async initiatePayment(dto: InitiatePaymentDto): Promise<InitiateResponseDto> {
@@ -54,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(
@@ -98,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}`,
@@ -113,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);
@@ -144,57 +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 {
@@ -206,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(
@@ -280,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 };
}
@@ -296,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,
@@ -313,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" }],
});
}
@@ -328,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) => {
@@ -348,23 +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" },
});
});
await this.seatsService.confirmSeats(booking.seats.map((s) => s.seatId));
await this.ticketsService.generate(booking.id);
await this.awardLoyaltyPoints(booking.passengerId, booking.totalMinor, booking.id);
this.eventEmitter.emit('payment.succeeded', { booking });
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)}`,
);
}
try {
await this.createJourneySegments(booking);
} catch (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)}`,
);
throw err;
}
try {
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.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;
@@ -373,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
@@ -390,13 +576,93 @@ 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 } }>,
) {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: booking.scheduleId },
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,
);
if (
originSequence < 0 ||
destSequence < 0 ||
originSequence >= destSequence
)
return;
const journey = await this.prisma.journey.create({
data: {
passengerId: booking.passengerId,
status: "CONFIRMED",
totalMinor: booking.totalMinor,
currency: booking.currency,
},
});
const journeySegments = [];
for (const bookingSeat of booking.seats) {
for (let i = originSequence; i < destSequence; i++) {
journeySegments.push({
journeyId: journey.id,
scheduleId: booking.scheduleId,
segmentOrder: i,
seatId: bookingSeat.seatId,
departureStationId: stopTimes[i].stationId,
arrivalStationId: stopTimes[i + 1].stationId,
});
}
}
if (journeySegments.length > 0) {
await this.prisma.journeySegment.createMany({ data: journeySegments });
}
}
}

View File

@@ -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";

View File

@@ -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 },
});
}
}

View File

@@ -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 },
});
}
}

View File

@@ -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 },
});
}
}

View File

@@ -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);
}
}

View File

@@ -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 };
}
}

View File

@@ -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' };
}
}

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { Body, Controller, Get, Param, Post, UseGuards, Query, Patch, Delete } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { PromosService } from './promos.service';
import { CreatePromotionDto } from './promos.dto';
@@ -8,7 +8,66 @@ import { JwtGuard } from '../../common/jwt.guard';
@Controller('promos')
export class PromosController {
constructor(private service: PromosService) {}
@Get() @ApiOperation({ summary: 'Get active promotions' }) getActive() { return this.service.getActive(); }
@Get('validate/:code') @ApiOperation({ summary: 'Validate a promo code' }) validate(@Param('code') code: string) { return this.service.validate(code); }
@Post() @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Create promotion (admin)' }) create(@Body() dto: CreatePromotionDto) { return this.service.create(dto); }
@Get()
@ApiOperation({ summary: 'Get active promotions' })
getActive() {
return this.service.getActive();
}
@Get('all')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Get all promos with filters (admin)' })
getAll(
@Query('search') search?: string,
@Query('active') active?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.service.getAll({
search,
active: active === 'true' ? true : active === 'false' ? false : undefined,
page: page ? parseInt(page) : 1,
pageSize: pageSize ? parseInt(pageSize) : 10,
});
}
@Get(':id')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Get promo by ID' })
getById(@Param('id') id: string) {
return this.service.getById(id);
}
@Get('validate/:code')
@ApiOperation({ summary: 'Validate a promo code' })
validate(@Param('code') code: string) {
return this.service.validate(code);
}
@Post()
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Create promotion (admin)' })
create(@Body() dto: CreatePromotionDto) {
return this.service.create(dto);
}
@Patch(':id')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Update promo (admin)' })
update(@Param('id') id: string, @Body() dto: Partial<CreatePromotionDto>) {
return this.service.update(id, dto);
}
@Delete(':id')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Delete promo (admin)' })
delete(@Param('id') id: string) {
return this.service.delete(id);
}
}

View File

@@ -1,13 +1,46 @@
import { IsString, IsOptional, IsInt } from 'class-validator';
import { IsString, IsOptional, IsInt, IsBoolean } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreatePromotionDto {
@ApiProperty({ example: 'Weekend Special' }) @IsString() title: string;
@ApiPropertyOptional({ example: '15% off all routes' }) @IsOptional() @IsString() subtitle?: string;
@ApiProperty({ example: 'WEEKEND15' }) @IsString() code: string;
@ApiPropertyOptional({ example: 15 }) @IsOptional() @IsInt() percentOff?: number;
@ApiPropertyOptional({ example: 5000 }) @IsOptional() @IsInt() amountOffMinor?: number;
@ApiProperty({ example: '2026-12-31T23:59:59Z' }) @IsString() validUntil: string;
@ApiPropertyOptional({ example: 'Book Now' }) @IsOptional() @IsString() ctaLabel?: string;
@ApiPropertyOptional({ example: 'edr://search' }) @IsOptional() @IsString() deepLink?: string;
@ApiProperty({ example: 'SUMMER2024' })
@IsString()
code: string;
@ApiProperty({ example: 'Summer Discount' })
@IsString()
title: string;
@ApiPropertyOptional({ example: 'Get 15% off' })
@IsOptional()
@IsString()
subtitle?: string;
@ApiPropertyOptional({ example: 15 })
@IsOptional()
@IsInt()
percentOff?: number;
@ApiPropertyOptional({ example: 5000 })
@IsOptional()
@IsInt()
amountOffMinor?: number;
@ApiProperty({ example: '2026-12-31T23:59:59Z' })
@IsString()
validUntil: string;
@ApiPropertyOptional({ example: 'Book Now' })
@IsOptional()
@IsString()
ctaLabel?: string;
@ApiPropertyOptional({ example: 'edr://search' })
@IsOptional()
@IsString()
deepLink?: string;
@ApiPropertyOptional({ example: true })
@IsOptional()
@IsBoolean()
active?: boolean;
}

View File

@@ -1,20 +1,190 @@
import { Injectable } from '@nestjs/common';
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { CreatePromotionDto } from './promos.dto';
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
@Injectable()
export class PromosService {
constructor(private prisma: PrismaService) {}
getActive() { return this.prisma.promotion.findMany({ where: { active: true, validUntil: { gte: new Date() } }, orderBy: { createdAt: 'desc' } }); }
getActive() {
return this.prisma.promotion.findMany({
where: { active: true, validUntil: { gte: new Date() } },
orderBy: { createdAt: 'desc' },
});
}
async getAll(filters: { search?: string; active?: boolean; page?: number; pageSize?: number }) {
const { search, active, page = 1, pageSize = 10 } = filters;
const skip = (page - 1) * pageSize;
const where: any = {};
if (search) {
where.OR = [
{ code: { contains: search, mode: 'insensitive' } },
{ title: { contains: search, mode: 'insensitive' } },
];
}
if (active !== undefined) {
where.active = active;
}
const [items, total] = await Promise.all([
this.prisma.promotion.findMany({
where,
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
}),
this.prisma.promotion.count({ where }),
]);
return { items: this.formatItems(items), total, page, pageSize };
}
async getById(id: string) {
const promo = await this.prisma.promotion.findUnique({ where: { id } });
if (!promo) throw new NotFoundException('Promo not found');
return this.formatItem(promo);
}
async validate(code: string) {
const promo = await this.prisma.promotion.findUnique({ where: { code } });
if (!promo || !promo.active || promo.validUntil < new Date()) return { applicable: false, message: 'Promo code invalid or expired' };
return { code: promo.code, percentOff: promo.percentOff, amountOffMinor: promo.amountOffMinor, validUntil: promo.validUntil, applicable: true, message: promo.percentOff ? `${promo.percentOff}% off` : `ETB ${((promo.amountOffMinor ?? 0) / 100).toFixed(2)} off` };
if (!promo || !promo.active || promo.validUntil < new Date())
return { applicable: false, message: 'Promo code invalid or expired' };
return {
code: promo.code,
percentOff: promo.percentOff,
amountOffMinor: promo.amountOffMinor,
validUntil: promo.validUntil,
applicable: true,
message: promo.percentOff
? `${promo.percentOff}% off`
: `ETB ${((promo.amountOffMinor ?? 0) / 100).toFixed(2)} off`,
};
}
create(dto: CreatePromotionDto) {
return this.prisma.promotion.create({ data: { ...dto, validUntil: new Date(dto.validUntil) } });
async create(dto: CreatePromotionDto & { discountType?: string; discountValue?: number }) {
try {
// Map frontend fields to database fields
let percentOff: number | undefined;
let amountOffMinor: number | undefined;
if (dto.discountType && dto.discountValue !== undefined) {
if (dto.discountType === 'PERCENTAGE') {
percentOff = dto.discountValue;
} else if (dto.discountType === 'FIXED') {
amountOffMinor = dto.discountValue;
}
} else {
// Fallback to direct fields
percentOff = dto.percentOff;
amountOffMinor = dto.amountOffMinor;
}
const promo = await this.prisma.promotion.create({
data: {
code: dto.code,
title: dto.title,
subtitle: dto.subtitle,
percentOff,
amountOffMinor,
validUntil: new Date(dto.validUntil),
ctaLabel: dto.ctaLabel,
deepLink: dto.deepLink,
active: dto.active ?? true,
},
});
return this.formatItem(promo);
} catch (error) {
if (error instanceof PrismaClientKnownRequestError) {
if (error.code === 'P2002') {
const field = (error.meta?.target as string[])?.[0];
throw new BadRequestException(
`A promo code with this ${field} already exists. Please use a different ${field}.`,
);
}
}
throw error;
}
}
async update(id: string, dto: Partial<CreatePromotionDto> & { discountType?: string; discountValue?: number }) {
const promo = await this.prisma.promotion.findUnique({ where: { id } });
if (!promo) throw new NotFoundException('Promo not found');
const updateData: any = {};
// Map frontend fields to database fields
if (dto.discountType && dto.discountValue !== undefined) {
// Clear existing discount fields
updateData.percentOff = null;
updateData.amountOffMinor = null;
if (dto.discountType === 'PERCENTAGE') {
updateData.percentOff = dto.discountValue;
} else if (dto.discountType === 'FIXED') {
updateData.amountOffMinor = dto.discountValue;
}
} else {
// Only include fields that are explicitly provided
if (dto.percentOff !== undefined) updateData.percentOff = dto.percentOff;
if (dto.amountOffMinor !== undefined) updateData.amountOffMinor = dto.amountOffMinor;
}
if (dto.title !== undefined) updateData.title = dto.title;
if (dto.subtitle !== undefined) updateData.subtitle = dto.subtitle;
if (dto.ctaLabel !== undefined) updateData.ctaLabel = dto.ctaLabel;
if (dto.deepLink !== undefined) updateData.deepLink = dto.deepLink;
if (dto.active !== undefined) updateData.active = dto.active;
if (dto.validUntil !== undefined) updateData.validUntil = new Date(dto.validUntil);
// Don't allow updating code - it's immutable after creation
try {
const updated = await this.prisma.promotion.update({
where: { id },
data: updateData,
});
return this.formatItem(updated);
} catch (error) {
if (error instanceof PrismaClientKnownRequestError && error.code === 'P2002') {
const field = (error.meta?.target as string[])?.[0];
throw new BadRequestException(
`A promo code with this ${field} already exists. Please use a different ${field}.`,
);
}
throw error;
}
}
async delete(id: string) {
const promo = await this.prisma.promotion.findUnique({ where: { id } });
if (!promo) throw new NotFoundException('Promo not found');
return this.prisma.promotion.delete({ where: { id } });
}
private formatItem(promo: any) {
return {
id: promo.id,
code: promo.code,
title: promo.title,
discountType: promo.percentOff ? 'PERCENTAGE' : 'FIXED',
discountValue: promo.percentOff || promo.amountOffMinor || 0,
maxDiscount: undefined,
minBookingAmount: undefined,
maxUsagePerUser: undefined,
totalUsageLimit: undefined,
usageCount: 0,
validFrom: promo.createdAt,
validUntil: promo.validUntil,
isActive: promo.active,
createdAt: promo.createdAt,
updatedAt: promo.createdAt,
};
}
private formatItems(promos: any[]) {
return promos.map((promo) => this.formatItem(promo));
}
}

View File

@@ -1,7 +1,7 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, ParseIntPipe, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
import { SchedulesService } from './schedules.service';
import { CreateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto } from './schedules.dto';
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto } from './schedules.dto';
import { JwtGuard } from '../../common/jwt.guard';
import { TripStatus } from '@prisma/client';
@@ -10,14 +10,23 @@ import { TripStatus } from '@prisma/client';
export class SchedulesController {
constructor(private service: SchedulesService) {}
@Post('bulk-generate')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Bulk generate repetitive schedules',
description: 'Creates multiple schedules automatically by repeating every X days for the next Y days. Example: repeat every 2 days for 30 days = 15 schedules.',
})
@ApiResponse({ status: 201, description: 'Schedules generated successfully' })
@ApiResponse({ status: 400, description: 'Invalid parameters or route not found' })
bulkGenerateSchedules(@Body() dto: BulkCreateSchedulesDto) {
return this.service.bulkGenerateSchedules(dto);
}
@Post()
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Create a train schedule from a route template',
description: `Creates a schedule by referencing a Route (routeId).
Stops are automatically copied from the route's RouteStop definitions.
You supply the actual planned arrival/departure times per stop sequence.
Origin and destination are derived from the first and last route stop — no need to specify them manually.`,
description: `Creates a schedule by referencing a Route (routeId).\nStops are automatically copied from the route's RouteStop definitions.\nYou supply the actual planned arrival/departure times per stop sequence.\nOrigin and destination are derived from the first and last route stop — no need to specify them manually.`,
})
@ApiResponse({ status: 201, description: 'Schedule created with stops copied from route template' })
@ApiResponse({ status: 400, description: 'Invalid times, inactive route, or missing planned times for some stops' })
@@ -40,13 +49,42 @@ Origin and destination are derived from the first and last route stop — no nee
return this.service.listSchedules({ date, routeId, trainId, status });
}
// Static routes before parameterised ones
// ===== SPECIFIC ROUTES (must come BEFORE generic :id routes) =====
@Post('fares')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Create a fare rule scoped to a schedule or route code' })
@ApiResponse({ status: 201, description: 'Fare rule created' })
createFareRule(@Body() dto: CreateFareRuleDto) { return this.service.createFareRule(dto); }
@Post('segment-fares')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Create a segment fare rule (stop-to-stop pricing on a route)' })
@ApiResponse({ status: 201, description: 'Segment fare rule created' })
createSegmentFareRule(@Body() dto: any) { return this.service.createSegmentFareRule(dto); }
@Get('routes/:routeId/segment-fares')
@ApiOperation({ summary: 'List all segment fare rules for a route' })
@ApiParam({ name: 'routeId', description: 'Route UUID' })
@ApiResponse({ status: 200, description: 'List of segment fare rules' })
getSegmentFares(@Param('routeId') routeId: string) { return this.service.getSegmentFares(routeId); }
@Patch('segment-fares/:id')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Update a segment fare rule' })
@ApiParam({ name: 'id', description: 'SegmentFareRule UUID' })
@ApiResponse({ status: 200, description: 'Segment fare rule updated' })
updateSegmentFareRule(@Param('id') id: string, @Body() dto: any) { return this.service.updateSegmentFareRule(id, dto); }
@Delete('segment-fares/:id')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Delete a segment fare rule' })
@ApiParam({ name: 'id', description: 'SegmentFareRule UUID' })
@ApiResponse({ status: 200, description: 'Segment fare rule deleted' })
deleteSegmentFareRule(@Param('id') id: string) { return this.service.deleteSegmentFareRule(id); }
// ===== PARAMETRIZED ROUTES (generic :id routes come AFTER specific routes) =====
@Get(':id')
@ApiOperation({ summary: 'Get schedule with train, coaches, seats, and stop timeline' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
@@ -56,12 +94,12 @@ Origin and destination are derived from the first and last route stop — no nee
@Patch(':id')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Update a schedule' })
@ApiOperation({ summary: 'Update a schedule (partial update - times, status, coaches)' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
@ApiResponse({ status: 200, description: 'Schedule updated' })
@ApiResponse({ status: 404, description: 'Schedule not found' })
updateSchedule(@Param('id') id: string, @Body() dto: CreateScheduleDto) {
return this.service.updateSchedule(id, dto);
updateSchedule(@Param('id') id: string, @Body() dto: UpdateScheduleDto) {
return this.service.updateSchedulePartial(id, dto);
}
@Patch(':id/status')
@@ -84,8 +122,6 @@ Origin and destination are derived from the first and last route stop — no nee
return this.service.deleteSchedule(id);
}
// ── Stop Times ─────────────────────────────────────────────────────────────
@Get(':id/stops')
@ApiOperation({ summary: 'List all stops for a schedule ordered by sequence' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
@@ -106,8 +142,6 @@ Origin and destination are derived from the first and last route stop — no nee
@Body() dto: UpdateStopTimeDto,
) { return this.service.updateStop(id, sequence, dto); }
// ── Fares ──────────────────────────────────────────────────────────────────
@Get(':scheduleId/fares')
@ApiOperation({ summary: 'Get fare for a schedule and seat class from the fare engine' })
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
@@ -151,8 +185,6 @@ Origin and destination are derived from the first and last route stop — no nee
return this.service.syncFaresFromEngine(id);
}
// ── Coach Assignments ──────────────────────────────────────────────────────
@Post(':id/coaches')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({

View File

@@ -34,6 +34,13 @@ export class CreateScheduleDto {
plannedTimes: PlannedStopTimeDto[];
}
export class UpdateScheduleDto {
@ApiPropertyOptional({ example: '2026-06-15T08:00:00Z', description: 'Scheduled departure from the first stop (origin)' }) @IsOptional() @IsDateString() departureAt?: string;
@ApiPropertyOptional({ example: '2026-06-15T20:00:00Z', description: 'Scheduled arrival at the last stop (destination)' }) @IsOptional() @IsDateString() arrivalAt?: string;
@ApiPropertyOptional({ enum: TripStatus, example: TripStatus.SCHEDULED }) @IsOptional() @IsEnum(TripStatus) status?: TripStatus;
@ApiPropertyOptional({ type: Array, description: 'List of coaches to assign' }) @IsOptional() @IsArray() coaches?: Array<{ coachId: string; positionNumber: number }>;
}
export class UpdateStopTimeDto {
@ApiPropertyOptional({ example: '2026-06-15T09:30:00Z' }) @IsOptional() @IsDateString() plannedArrivalAt?: string;
@ApiPropertyOptional({ example: '2026-06-15T09:45:00Z' }) @IsOptional() @IsDateString() plannedDepartureAt?: string;
@@ -50,6 +57,17 @@ export class CreateFareRuleDto {
@ApiPropertyOptional({ example: '2026-12-31T23:59:59Z' }) @IsOptional() @IsDateString() validUntil?: string;
}
export class CreateSegmentFareRuleDto {
@ApiProperty({ example: 'route-uuid', description: 'Route UUID' }) @IsString() routeId: string;
@ApiProperty({ example: 1, description: 'Origin stop sequence number' }) @IsInt() @Min(1) originStopSequence: number;
@ApiProperty({ example: 2, description: 'Destination stop sequence number' }) @IsInt() @Min(1) destinationStopSequence: number;
@ApiProperty({ example: 'seat-class-uuid', description: 'Seat class UUID' }) @IsString() seatClassId: string;
@ApiProperty({ example: 45000, description: 'Base fare in minor currency units (ETB cents)' }) @IsInt() baseFareMinor: number;
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Nationality scope (Ethiopian, Djiboutian, Other)' }) @IsOptional() @IsString() nationality?: string;
@ApiProperty({ example: '2026-01-01T00:00:00Z' }) @IsDateString() validFrom: string;
@ApiPropertyOptional({ example: '2026-12-31T23:59:59Z' }) @IsOptional() @IsDateString() validUntil?: string;
}
export class ListSchedulesDto {
@ApiPropertyOptional({ example: '2026-06-15', description: 'Filter by departure date (YYYY-MM-DD). Returns all schedules departing on this calendar day.' })
@IsOptional() @IsDateString() date?: string;
@@ -67,3 +85,21 @@ export class ListSchedulesDto {
export class UpdateScheduleStatusDto {
@ApiProperty({ enum: TripStatus, example: TripStatus.EN_ROUTE }) @IsEnum(TripStatus) status: TripStatus;
}
export class BulkCreateSchedulesDto {
@ApiProperty({ example: 'train-uuid', description: 'Train UUID' }) @IsString() trainId: string;
@ApiProperty({ example: 'route-uuid', description: 'Route UUID' }) @IsString() routeId: string;
@ApiProperty({ example: '2026-06-15T08:00:00Z', description: 'Start date and time for first schedule' }) @IsDateString() startDateTime: string;
@ApiProperty({ example: 12, description: 'Hours duration per schedule' }) @IsInt() @Min(1) durationHours: number;
@ApiProperty({ example: 2, description: 'Repeat every X days' }) @IsInt() @Min(1) repeatEveryDays: number;
@ApiProperty({ example: 30, description: 'Generate schedules for the next Y days' }) @IsInt() @Min(1) forNextDays: number;
@ApiPropertyOptional({ type: [PlannedStopTimeDto], description: 'Optional custom planned times per stop. If not provided, will auto-generate.' })
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto)
plannedTimes?: PlannedStopTimeDto[];
}
export class BulkSchedulesResponseDto {
@ApiProperty() schedulesCreated: number;
@ApiProperty() errors: string[];
@ApiProperty() scheduleIds: string[];
}

View File

@@ -2,7 +2,7 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/comm
import { PrismaService } from '../../common/prisma.service';
import { RoutesService } from './routes.service';
import { FareEngineService } from '../fare-engine/fare-engine.service';
import { CreateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto } from './schedules.dto';
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto } from './schedules.dto';
@Injectable()
export class SchedulesService {
@@ -10,9 +10,55 @@ export class SchedulesService {
private prisma: PrismaService,
private routesService: RoutesService,
private fareEngine: FareEngineService,
) {}
) { }
// ── Schedule CRUD ──────────────────────────────────────────────────────────
async bulkGenerateSchedules(dto: BulkCreateSchedulesDto) {
const startDate = new Date(dto.startDateTime);
const endDate = new Date(startDate.getTime() + dto.forNextDays * 24 * 60 * 60 * 1000);
const errors: string[] = [];
const scheduleIds: string[] = [];
// Validate route and get stops for plannedTimes generation
const route = await this.prisma.route.findUnique({
where: { id: dto.routeId },
include: { stops: { orderBy: { sequence: 'asc' } } },
});
if (!route) throw new NotFoundException('Route not found');
if (!route.active) throw new BadRequestException('Route is not active');
let currentDate = new Date(startDate);
let scheduleCount = 0;
while (currentDate < endDate) {
try {
const departureAt = new Date(currentDate);
const arrivalAt = new Date(departureAt.getTime() + dto.durationHours * 60 * 60 * 1000);
const createDto: CreateScheduleDto = {
trainId: dto.trainId,
routeId: dto.routeId,
departureAt: departureAt.toISOString(),
arrivalAt: arrivalAt.toISOString(),
plannedTimes: dto.plannedTimes || [],
};
const schedule = await this.createSchedule(createDto);
scheduleIds.push(schedule.id);
scheduleCount++;
} catch (error) {
errors.push(`Failed to create schedule for ${currentDate.toISOString()}: ${error instanceof Error ? error.message : String(error)}`);
}
// Move to next repetition
currentDate = new Date(currentDate.getTime() + dto.repeatEveryDays * 24 * 60 * 60 * 1000);
}
return {
schedulesCreated: scheduleCount,
errors,
scheduleIds,
};
}
async listSchedules(dto: ListSchedulesDto) {
const where: any = {};
@@ -58,28 +104,48 @@ export class SchedulesService {
if (!route.active) throw new BadRequestException('Route is not active');
if (route.stops.length < 2) throw new BadRequestException('Route must have at least 2 stops');
// Check for duplicate schedule with same train, route, and date
const depDate = new Date(dep);
depDate.setHours(0, 0, 0, 0);
const nextDay = new Date(depDate);
nextDay.setDate(nextDay.getDate() + 1);
const existingSchedule = await this.prisma.trainSchedule.findFirst({
where: {
trainId: dto.trainId,
routeId: dto.routeId,
departureAt: {
gte: depDate,
lt: nextDay,
},
},
});
if (existingSchedule) {
throw new BadRequestException(
`A schedule for this train, route, and date already exists. Departure: ${new Date(existingSchedule.departureAt).toLocaleString()}`,
);
}
// Auto-generate plannedTimes if not provided or empty
let plannedTimes = dto.plannedTimes;
if (!plannedTimes || plannedTimes.length === 0) {
const totalDuration = arr.getTime() - dep.getTime();
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
plannedTimes = route.stops.map((stop, index) => {
let stopTime: Date;
if (index === 0) {
// First stop - use departure time
stopTime = dep;
} else if (index === route.stops.length - 1) {
// Last stop - use arrival time
stopTime = arr;
} else {
// Intermediate stops - calculate based on distance proportion
const stopDistance = stop.distanceKm || 0;
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
stopTime = new Date(dep.getTime() + totalDuration * progress);
}
return {
sequence: stop.sequence,
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
@@ -113,7 +179,6 @@ export class SchedulesService {
include: { train: true, originStation: true, destinationStation: true },
});
// Copy route stops into TripStopTime with the provided planned times
const plannedTimesMap = Object.fromEntries(
plannedTimes.map(t => [t.sequence, t]),
);
@@ -130,7 +195,7 @@ export class SchedulesService {
originStation: true,
destinationStation: true,
coachAssignments: {
include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] }, seatClass: true } } },
include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] } } } },
orderBy: { positionNumber: 'asc' },
},
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
@@ -138,18 +203,16 @@ export class SchedulesService {
});
if (!schedule) throw new NotFoundException('Schedule not found');
// Compute effective seat statuses from SeatHold + JourneySegment
// (seat.status DB column is no longer written during booking)
const allSeatIds = schedule.coachAssignments.flatMap(a => a.coach.seats.map(s => s.id));
const allSeatIds = schedule.coachAssignments.flatMap((a: any) => a.coach.seats.map((s: any) => s.id));
const effectiveStatuses = await this.resolveEffectiveStatuses(id, allSeatIds);
return {
...schedule,
coachAssignments: schedule.coachAssignments.map(a => ({
coachAssignments: schedule.coachAssignments.map((a: any) => ({
...a,
coach: {
...a.coach,
seats: a.coach.seats.map(s => ({
seats: a.coach.seats.map((s: any) => ({
...s,
status: effectiveStatuses.get(s.id) ?? s.status,
})),
@@ -158,12 +221,6 @@ export class SchedulesService {
};
}
/**
* Computes effective seat status for a schedule by checking active SeatHolds
* and confirmed JourneySegments. The DB seat.status column is not written
* during segment-based booking, so this overlay is required.
* Priority: BLOCKED (physical) > BOOKED (confirmed) > HELD (active hold) > AVAILABLE
*/
private async resolveEffectiveStatuses(
scheduleId: string,
seatIds: string[],
@@ -204,7 +261,6 @@ export class SchedulesService {
const arr = new Date(dto.arrivalAt);
if (arr <= dep) throw new BadRequestException('arrivalAt must be after departureAt');
// Validate route exists and has stops
const route = await this.prisma.route.findUnique({
where: { id: dto.routeId },
include: { stops: { orderBy: { sequence: 'asc' } } },
@@ -213,7 +269,6 @@ export class SchedulesService {
if (!route.active) throw new BadRequestException('Route is not active');
if (route.stops.length < 2) throw new BadRequestException('Route must have at least 2 stops');
// Derive origin and destination from first and last route stop
const firstStop = route.stops[0];
const lastStop = route.stops[route.stops.length - 1];
@@ -231,18 +286,16 @@ export class SchedulesService {
},
});
// Delete existing stop times and recreate
await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } });
// Auto-generate plannedTimes if not provided
let plannedTimes = dto.plannedTimes;
if (!plannedTimes || plannedTimes.length === 0) {
const totalDuration = arr.getTime() - dep.getTime();
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
plannedTimes = route.stops.map((stop, index) => {
let stopTime: Date;
if (index === 0) {
stopTime = dep;
} else if (index === route.stops.length - 1) {
@@ -252,7 +305,7 @@ export class SchedulesService {
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
stopTime = new Date(dep.getTime() + totalDuration * progress);
}
return {
sequence: stop.sequence,
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
@@ -276,19 +329,53 @@ export class SchedulesService {
async deleteSchedule(id: string) {
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id } });
if (!schedule) throw new NotFoundException('Schedule not found');
// Delete related records first (in dependency order)
await this.prisma.journeySegment.deleteMany({ where: { scheduleId: id } });
await this.prisma.seatHold.deleteMany({ where: { scheduleId: id } });
const bookings = await this.prisma.booking.findMany({
where: { scheduleId: id },
select: { id: true },
});
const bookingIds = bookings.map(b => b.id);
if (bookingIds.length > 0) {
const paymentIntents = await this.prisma.paymentIntent.findMany({
where: { bookingId: { in: bookingIds } },
select: { id: true },
});
const paymentIntentIds = paymentIntents.map(pi => pi.id);
if (paymentIntentIds.length > 0) {
await this.prisma.paymentRefund.deleteMany({
where: { paymentIntentId: { in: paymentIntentIds } },
});
}
await this.prisma.ticket.deleteMany({
where: { bookingId: { in: bookingIds } },
});
await this.prisma.bookingSeat.deleteMany({
where: { bookingId: { in: bookingIds } },
});
await this.prisma.bookingModification.deleteMany({
where: { bookingId: { in: bookingIds } },
});
await this.prisma.bookingCancellation.deleteMany({
where: { bookingId: { in: bookingIds } },
});
await this.prisma.paymentIntent.deleteMany({
where: { bookingId: { in: bookingIds } },
});
}
await this.prisma.booking.deleteMany({ where: { scheduleId: id } });
await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } });
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: id } });
return this.prisma.trainSchedule.delete({ where: { id } });
}
// ── Stop Times (per-schedule overrides) ───────────────────────────────────
getStops(scheduleId: string) {
return this.prisma.tripStopTime.findMany({
where: { scheduleId },
@@ -314,8 +401,6 @@ export class SchedulesService {
});
}
// ── Fare Rules ─────────────────────────────────────────────────────────────
createFareRule(dto: CreateFareRuleDto) {
const { validFrom, validUntil, scheduleId, nationality, ...rest } = dto;
return this.prisma.fareRule.create({
@@ -329,6 +414,43 @@ export class SchedulesService {
});
}
createSegmentFareRule(dto: any) {
const { validFrom, validUntil, ...rest } = dto;
return this.prisma.segmentFareRule.create({
data: {
...rest,
validFrom: new Date(validFrom),
validUntil: validUntil ? new Date(validUntil) : null,
},
include: { seatClass: true, route: true },
});
}
getSegmentFares(routeId: string) {
return this.prisma.segmentFareRule.findMany({
where: { routeId },
include: { seatClass: true, route: true },
orderBy: [{ originStopSequence: 'asc' }, { destinationStopSequence: 'asc' }],
});
}
deleteSegmentFareRule(id: string) {
return this.prisma.segmentFareRule.delete({ where: { id } });
}
updateSegmentFareRule(id: string, dto: any) {
const { validFrom, validUntil, ...rest } = dto;
return this.prisma.segmentFareRule.update({
where: { id },
data: {
...rest,
validFrom: validFrom ? new Date(validFrom) : undefined,
validUntil: validUntil ? new Date(validUntil) : null,
},
include: { seatClass: true, route: true },
});
}
getFareFromEngine(scheduleId: string, seatClassId: string, nationality?: string) {
return this.fareEngine.calculateForSchedule(scheduleId, seatClassId, nationality);
}
@@ -337,10 +459,6 @@ export class SchedulesService {
return this.fareEngine.calculateAllForSchedule(scheduleId, nationality);
}
/**
* Recalculate fares for all active seat classes on a schedule using the fare engine
* and upsert them as FareRule records scoped to this schedule.
*/
async syncFaresFromEngine(scheduleId: string): Promise<{ synced: number; errors: string[] }> {
const results = await this.fareEngine.calculateAllForSchedule(scheduleId);
const errors: string[] = [];
@@ -352,7 +470,6 @@ export class SchedulesService {
const seatClass = await this.prisma.seatClass.findFirst({ where: { name: fare.seatClassName } });
if (!seatClass) { errors.push(`Seat class not found: ${fare.seatClassName}`); continue; }
// Expire any existing active rule for this schedule + seat class
await this.prisma.fareRule.updateMany({
where: { tripId: scheduleId, seatClassId: seatClass.id, validUntil: null },
data: { validUntil: now },
@@ -377,8 +494,6 @@ export class SchedulesService {
return { synced, errors };
}
// ── Coach Assignments ──────────────────────────────────────────────────────
async assignCoaches(
scheduleId: string,
coaches: Array<{ coachId: string; positionNumber: number }>,
@@ -386,7 +501,6 @@ export class SchedulesService {
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId } });
if (!schedule) throw new NotFoundException('Schedule not found');
// Validate all coaches exist
const coachIds = coaches.map(c => c.coachId);
const existingCoaches = await this.prisma.coach.findMany({
where: { id: { in: coachIds } },
@@ -395,18 +509,16 @@ export class SchedulesService {
throw new NotFoundException('One or more coaches not found');
}
// Remove existing assignments
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId } });
// Create new assignments
await this.prisma.coachAssignment.createMany({
data: coaches.map(c => ({
scheduleId,
coachId: c.coachId,
positionNumber: c.positionNumber,
isOperational: true,
})),
});
const data = coaches.map((c, idx) => ({
scheduleId,
coachId: c.coachId,
positionNumber: idx + 1,
isOperational: true,
}));
await this.prisma.coachAssignment.createMany({ data });
return { message: 'Coaches assigned successfully', count: coaches.length };
}
@@ -417,7 +529,6 @@ export class SchedulesService {
include: {
coach: {
include: {
seatClass: true,
seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] },
},
},
@@ -426,6 +537,41 @@ export class SchedulesService {
});
}
async updateSchedulePartial(id: string, dto: UpdateScheduleDto) {
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id } });
if (!schedule) throw new NotFoundException('Schedule not found');
const updateData: any = {};
if (dto.departureAt || dto.arrivalAt) {
const dep = dto.departureAt ? new Date(dto.departureAt) : new Date(schedule.departureAt);
const arr = dto.arrivalAt ? new Date(dto.arrivalAt) : new Date(schedule.arrivalAt);
if (arr <= dep) throw new BadRequestException('Arrival time must be after departure time');
updateData.departureAt = dep;
updateData.arrivalAt = arr;
updateData.durationMinutes = Math.round((arr.getTime() - dep.getTime()) / 60_000);
}
if (dto.status) {
updateData.status = dto.status;
}
if (Object.keys(updateData).length > 0) {
await this.prisma.trainSchedule.update({
where: { id },
data: updateData,
});
}
if (dto.coaches && dto.coaches.length > 0) {
await this.assignCoaches(id, dto.coaches);
}
return this.getSchedule(id);
}
async removeCoachAssignment(scheduleId: string, coachId: string) {
const assignment = await this.prisma.coachAssignment.findFirst({
where: { scheduleId, coachId },
@@ -435,4 +581,4 @@ export class SchedulesService {
await this.prisma.coachAssignment.delete({ where: { id: assignment.id } });
return { message: 'Coach assignment removed' };
}
}
}

View File

@@ -22,8 +22,6 @@ export class SearchService {
const nextDay = new Date(date.getTime() + 86_400_000);
const totalPassengers = dto.adultCount + (dto.childCount ?? 0);
// Find all schedules that have BOTH origin and destination as stops
// (not just terminal-to-terminal) and depart on the requested date
const schedules = await this.prisma.trainSchedule.findMany({
where: {
status: { in: ['SCHEDULED', 'BOARDING'] },
@@ -36,7 +34,7 @@ export class SearchService {
destinationStation: true,
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
coachAssignments: {
include: { coach: { include: { seats: true, seatClass: true } } },
include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } },
},
},
});
@@ -44,36 +42,71 @@ export class SearchService {
const results = [];
for (const schedule of schedules) {
const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId);
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
const originStop = schedule.stopTimes.find((s: any) => s.stationId === dto.originStationId);
const destStop = schedule.stopTimes.find((s: any) => s.stationId === dto.destinationStationId);
// Both stops must exist and origin must come before destination
if (!originStop || !destStop || originStop.sequence >= destStop.sequence) continue;
// Compute per-seat availability for the requested segment range
// A seat is available if no active booking/hold overlaps [originSeq, destSeq)
const availabilityByClass: Record<string, number> = {};
for (const assignment of schedule.coachAssignments) {
const className = assignment.coach.seatClass.name;
if (!availabilityByClass[className]) availabilityByClass[className] = 0;
const seatClassNames = assignment.coach.coachType?.seatClasses?.map((sc: any) => sc.name) || ['Standard'];
const isBedCoach = assignment.coach.seats.some((s: any) => s.bedPosition);
for (const seat of assignment.coach.seats) {
if (seat.status === 'BLOCKED') continue;
// Use segment-aware check — a seat booked A→B is still free for B→D
const free = await this.segmentsService.isSeatFreeForLeg(
schedule.id, seat.id,
originStop.sequence, destStop.sequence,
);
if (free) availabilityByClass[className]++;
if (isBedCoach) {
const bedPositions = ['upper', 'middle', 'lower'];
for (const bedPosition of bedPositions) {
let count = 0;
for (const seat of assignment.coach.seats) {
if (seat.bedPosition !== bedPosition) continue;
if (seat.status === 'BLOCKED') continue;
if (!seat.seatNumber || !seat.seatNumber.trim()) continue;
const free = await this.segmentsService.isSeatFreeForLeg(
schedule.id, seat.id,
originStop.sequence, destStop.sequence,
);
if (free) count++;
}
if (count > 0) {
const matchingClass = seatClassNames.find((className: string) => {
const classNameLower = className.toLowerCase();
return (
(bedPosition === 'upper' && classNameLower.includes('upper')) ||
(bedPosition === 'middle' && classNameLower.includes('middle')) ||
(bedPosition === 'lower' && classNameLower.includes('lower'))
);
});
if (matchingClass) {
if (!availabilityByClass[matchingClass]) availabilityByClass[matchingClass] = 0;
availabilityByClass[matchingClass] += count;
}
}
}
} else {
let availableSeatsInCoach = 0;
for (const seat of assignment.coach.seats) {
if (seat.status === 'BLOCKED') continue;
if (!seat.seatNumber || !seat.seatNumber.trim()) continue;
const free = await this.segmentsService.isSeatFreeForLeg(
schedule.id, seat.id,
originStop.sequence, destStop.sequence,
);
if (free) availableSeatsInCoach++;
}
for (const seatClassName of seatClassNames) {
if (!availabilityByClass[seatClassName]) availabilityByClass[seatClassName] = 0;
availabilityByClass[seatClassName] += availableSeatsInCoach;
}
}
}
// Departure/arrival times for the requested leg (not the full schedule)
const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt;
// Fetch fares for all seat classes - need to pass the SEARCH origin/destination, not schedule terminals
const faresByClass = await this.calculateFaresForSegment(
schedule,
dto.originStationId,
@@ -106,14 +139,14 @@ export class SearchService {
),
status: schedule.status,
stops: schedule.stopTimes
.filter(st => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence)
.map(st => ({
stationId: st.stationId,
stationName: st.station.name,
sequence: st.sequence,
plannedArrivalAt: st.plannedArrivalAt,
plannedDepartureAt: st.plannedDepartureAt,
})),
.filter((st: any) => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence)
.map((st: any) => ({
stationId: st.stationId,
stationName: st.station.name,
sequence: st.sequence,
plannedArrivalAt: st.plannedArrivalAt,
plannedDepartureAt: st.plannedDepartureAt,
})),
availabilityByClass,
hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers),
faresByClass,
@@ -134,51 +167,19 @@ export class SearchService {
});
if (!schedule) throw new NotFoundException('Schedule not found');
const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId);
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
const originStop = schedule.stopTimes.find((s: any) => s.stationId === dto.originStationId);
const destStop = schedule.stopTimes.find((s: any) => s.stationId === dto.destinationStationId);
if (!originStop || !destStop || originStop.sequence >= destStop.sequence) {
throw new NotFoundException('Origin or destination not found on this schedule');
}
const seatClass = await this.prisma.seatClass.findFirst({ where: { name: dto.seatClassName } });
// Compute route codes for fare lookup
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
const fullRoute = `${schedule.originStation.code}-${schedule.destinationStation.code}`;
const now = new Date();
const nationality = dto.nationality;
// Query fare rules with specificity ordering:
// 1. schedule+segment+nationality
// 2. schedule+segment
// 3. schedule+full-route+nationality
// 4. schedule+full-route
// 5. schedule+global
// 6. segment+nationality
// 7. segment
// 8. full-route+nationality
// 9. full-route
// 10. global
const fareRule = await this.prisma.fareRule.findFirst({
where: {
seatClassId: seatClass?.id,
validFrom: { lte: now },
OR: [
{ validUntil: null },
{ validUntil: { gte: now } },
],
},
orderBy: [
// Prioritize schedule-specific rules
{ tripId: { sort: 'desc', nulls: 'last' } },
// Then prioritize nationality match
{ nationality: { sort: 'desc', nulls: 'last' } },
// Most recent validFrom
{ validFrom: 'desc' },
],
});
// Manual specificity filtering to find best match
const candidates = await this.prisma.fareRule.findMany({
where: {
seatClassId: seatClass?.id,
@@ -243,38 +244,39 @@ export class SearchService {
};
}
/**
* Calculate fares for a specific segment of a schedule
*/
private async calculateFaresForSegment(
schedule: any,
originStationId: string,
destinationStationId: string,
nationality?: string,
): Promise<Array<{ seatClassName: string; baseFareMinor: number }>> {
// Get seat classes that are actually assigned to this schedule via coaches
const assignedSeatClassIds: string[] = Array.from(
const seatClassIds: string[] = Array.from(
new Set(
schedule.coachAssignments.map((a: any) => a.coach.seatClass.id as string)
schedule.coachAssignments
.flatMap((a: any) => a.coach.coachType?.seatClasses || [])
.map((sc: any) => sc.id)
.filter((id: any) => id)
)
);
// Get only the seat classes that are assigned to this schedule
const seatClasses = await this.prisma.seatClass.findMany({
where: {
isActive: true,
id: { in: assignedSeatClassIds }
},
orderBy: { basePrice: 'asc' },
});
// If no coaches assigned, return empty array
if (seatClasses.length === 0) {
if (seatClassIds.length === 0) {
console.log(`No seat classes assigned to schedule ${schedule.id}`);
return [];
}
// If schedule has a route, use route-based calculation
const seatClasses = await this.prisma.seatClass.findMany({
where: {
isActive: true,
id: { in: seatClassIds }
},
orderBy: { baseFareMinor: 'asc' },
});
if (seatClasses.length === 0) {
console.log(`No active seat classes for schedule ${schedule.id}`);
return [];
}
if (schedule.routeId) {
const results = await Promise.all(
seatClasses.map(async (sc) => {
@@ -303,7 +305,6 @@ export class SearchService {
}
}
// Fallback: Try to get fares from FareRule table
const originStation = await this.prisma.station.findUnique({ where: { id: originStationId } });
const destStation = await this.prisma.station.findUnique({ where: { id: destinationStationId } });
@@ -314,26 +315,25 @@ export class SearchService {
const fareRules = await this.prisma.fareRule.findMany({
where: {
route: segmentRoute,
seatClassId: { in: assignedSeatClassIds },
seatClassId: { in: seatClassIds },
validFrom: { lte: now },
OR: [
{ validUntil: null },
{ validUntil: { gte: now } },
],
},
include: { seatClass: true },
});
if (fareRules.length > 0) {
console.log(`Found ${fareRules.length} fare rules for segment ${segmentRoute}`);
const seatClassMap = Object.fromEntries(seatClasses.map(sc => [sc.id, sc.name]));
return fareRules.map(rule => ({
seatClassName: rule.seatClass.name,
seatClassName: seatClassMap[rule.seatClassId] || 'Unknown',
baseFareMinor: rule.baseFareMinor,
}));
}
}
// Last resort: Return default fares only for assigned seat classes
console.log(`No fares found, using defaults for ${originStationId} to ${destinationStationId}`);
return seatClasses.map(sc => ({
seatClassName: sc.name,
@@ -359,60 +359,6 @@ export class SearchService {
return fares[seatClassName] ?? 45000;
}
/**
* Fallback method to get fares from FareRule table when fare engine fails
*/
private async getFallbackFares(
scheduleId: string,
originCode: string,
destCode: string,
): Promise<Array<{ seatClassName: string; baseFareMinor: number }>> {
const segmentRoute = `${originCode}-${destCode}`;
const now = new Date();
// Try to find fare rules for this segment
const fareRules = await this.prisma.fareRule.findMany({
where: {
route: segmentRoute,
validFrom: { lte: now },
OR: [
{ validUntil: null },
{ validUntil: { gte: now } },
],
},
include: { seatClass: true },
});
if (fareRules.length > 0) {
console.log(`Found ${fareRules.length} fare rules for segment ${segmentRoute}`);
return fareRules.map(rule => ({
seatClassName: rule.seatClass.name,
baseFareMinor: rule.baseFareMinor,
}));
}
// If no segment-specific rules, return default fares
console.log(`No fare rules found for ${segmentRoute}, using defaults`);
return [
{ seatClassName: 'Economy Regular', baseFareMinor: 35000 },
{ seatClassName: 'Economy Bed', baseFareMinor: 49000 },
{ seatClassName: 'VIP Bed', baseFareMinor: 63000 },
];
}
/**
* Select the best matching fare rule based on specificity:
* 1. schedule+segment+nationality
* 2. schedule+segment
* 3. schedule+full-route+nationality
* 4. schedule+full-route
* 5. schedule+global
* 6. segment+nationality
* 7. segment
* 8. full-route+nationality
* 9. full-route
* 10. global
*/
private selectBestFareRule(
candidates: any[],
scheduleId: string,
@@ -421,19 +367,16 @@ export class SearchService {
nationality?: string,
): any | null {
const priorities = [
// Schedule-specific rules
{ tripId: scheduleId, route: segmentRoute, nationality },
{ tripId: scheduleId, route: segmentRoute, nationality: null },
{ tripId: scheduleId, route: fullRoute, nationality },
{ tripId: scheduleId, route: fullRoute, nationality: null },
{ tripId: scheduleId, route: null, nationality },
{ tripId: scheduleId, route: null, nationality: null },
// Route-specific rules (no schedule)
{ tripId: null, route: segmentRoute, nationality },
{ tripId: null, route: segmentRoute, nationality: null },
{ tripId: null, route: fullRoute, nationality },
{ tripId: null, route: fullRoute, nationality: null },
// Global rules
{ tripId: null, route: null, nationality },
{ tripId: null, route: null, nationality: null },
];

View File

@@ -1,41 +1,33 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { CreateSeatClassDto, UpdateSeatClassDto } from './seat-classes.dto';
@Injectable()
export class SeatClassesService {
constructor(private prisma: PrismaService) {}
private readonly coachInclude = {
coaches: {
select: { id: true, coachNumber: true, label: true, mode: true, totalUnits: true, _count: { select: { seats: true } } },
orderBy: { label: 'asc' as const },
},
};
listSeatClasses() {
return this.prisma.seatClass.findMany({ orderBy: { createdAt: 'asc' }, include: this.coachInclude });
return this.prisma.seatClass.findMany({ orderBy: { createdAt: 'asc' } });
}
async getSeatClass(id: string) {
const sc = await this.prisma.seatClass.findUnique({ where: { id }, include: this.coachInclude });
const sc = await this.prisma.seatClass.findUnique({ where: { id } });
if (!sc) throw new NotFoundException('SeatClass not found');
return sc;
}
async createSeatClass(dto: CreateSeatClassDto) {
async createSeatClass(dto: any) {
try {
return await this.prisma.seatClass.create({ data: dto, include: this.coachInclude });
return await this.prisma.seatClass.create({ data: dto });
} catch (e: any) {
if (e.code === 'P2002') throw new ConflictException(`Seat class "${dto.name}" already exists`);
throw e;
}
}
async updateSeatClass(id: string, dto: UpdateSeatClassDto) {
async updateSeatClass(id: string, dto: any) {
const sc = await this.prisma.seatClass.findUnique({ where: { id } });
if (!sc) throw new NotFoundException('SeatClass not found');
return this.prisma.seatClass.update({ where: { id }, data: dto, include: this.coachInclude });
return this.prisma.seatClass.update({ where: { id }, data: dto });
}
async deleteSeatClass(id: string) {

View File

@@ -1,8 +1,9 @@
import { Body, Controller, Delete, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Post, Patch, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
import { SeatsService } from './seats.service';
import { HoldSeatsDto } from './seats.dto';
import { JwtGuard } from '../../common/jwt.guard';
import { IamGuard } from '../../common/iam-adapter';
@ApiTags('Seats')
@Controller('seats')
@@ -78,6 +79,47 @@ This makes it clear which segment of the route each seat is held for, enabling s
@ApiResponse({ status: 404, description: 'Hold not found' })
releaseHold(@Param('holdId') holdId: string) { return this.service.releaseHold(holdId); }
// ── Seat Block / Unblock ───────────────────────────────────────────────────
@Post(':seatId/block')
@UseGuards(IamGuard) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Block a seat (e.g., maintenance, damage)' })
@ApiParam({ name: 'seatId', description: 'Seat UUID' })
@ApiResponse({ status: 200, description: 'Seat blocked' })
blockSeat(@Param('seatId') seatId: string, @Body() body: { reason: string }) {
return this.service.blockSeat(seatId, body.reason);
}
@Delete(':seatId/block')
@UseGuards(IamGuard) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Unblock a seat' })
@ApiParam({ name: 'seatId', description: 'Seat UUID' })
@ApiResponse({ status: 200, description: 'Seat unblocked' })
unblockSeat(@Param('seatId') seatId: string) {
return this.service.unblockSeat(seatId);
}
// ── Remove Seat ────────────────────────────────────────────────────────────
@Patch(':seatId/remove')
@UseGuards(IamGuard) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Remove a seat by marking with negative seatNumber' })
@ApiParam({ name: 'seatId', description: 'Seat UUID' })
@ApiResponse({ status: 200, description: 'Seat removed (seatNumber negated), shows as empty space' })
@ApiResponse({ status: 404, description: 'Seat not found' })
removeSeat(@Param('seatId') seatId: string) {
return this.service.removeSeat(seatId);
}
@Patch(':seatId/undo-remove')
@UseGuards(IamGuard) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Undo seat removal by restoring original seatNumber' })
@ApiParam({ name: 'seatId', description: 'Seat UUID' })
@ApiResponse({ status: 200, description: 'Seat restored (negative seatNumber removed)' })
@ApiResponse({ status: 404, description: 'Seat not found' })
@ApiResponse({ status: 400, description: 'Seat is not removed' })
undoRemoveSeat(@Param('seatId') seatId: string) {
return this.service.undoRemoveSeat(seatId);
}
@Get('export/csv/:scheduleId') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Export seats as CSV' })
async exportCSV(@Param('scheduleId') scheduleId: string) {
const csv = await this.service.exportSeatsCSV(scheduleId);

View File

@@ -1,10 +1,12 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { SeatsController } from './seats.controller';
import { SeatsService } from './seats.service';
import { SegmentsModule } from '../segments/segments.module';
import { IamModule } from '../../common/iam.module';
@Module({
imports: [SegmentsModule],
imports: [SegmentsModule, HttpModule, IamModule],
controllers: [SeatsController],
providers: [SeatsService],
exports: [SeatsService],

View File

@@ -11,48 +11,68 @@ export class SeatsService {
private segmentsService: SegmentsService,
) {}
// ── Seat Map ──────────────────────────────────────────────────────────────
async getSeatMap(scheduleId: string, coachId?: string) {
const assignments = await this.prisma.coachAssignment.findMany({
where: { scheduleId, ...(coachId ? { coachId } : {}) },
include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] }, seatClass: true } } },
include: {
coach: {
include: {
seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] },
coachType: { include: { seatClasses: true } },
},
},
},
orderBy: { positionNumber: 'asc' },
});
const allSeatIds = assignments.flatMap(a => a.coach.seats.map(s => s.id));
console.log(`[getSeatMap] scheduleId=${scheduleId}, coachId=${coachId}, found ${assignments.length} coach assignments`);
const allSeatIds = assignments.flatMap((a: any) => a.coach.seats.map((s: any) => s.id));
const effectiveStatuses = await this.resolveEffectiveStatuses(scheduleId, allSeatIds);
return {
coaches: assignments.map((a) => ({
id: a.coach.id,
assignmentId: a.id,
name: `Coach ${a.coach.label}`,
seatClass: a.coach.seatClass.name,
positionNumber: a.positionNumber,
seats: a.coach.seats.map((s) => ({
id: s.id,
number: s.label,
status: effectiveStatuses.get(s.id) ?? s.status,
kind: s.kind,
row: s.row,
col: s.col,
isWindow: s.isWindow,
isAisle: s.isAisle,
bedPosition: s.bedPosition,
})),
})),
const response = {
coaches: assignments.map((a) => {
const allSeats = a.coach.seats;
const seatClassNames = a.coach.coachType.seatClasses.map((sc: any) => sc.name);
return {
id: a.coach.id,
assignmentId: a.id,
coachNumber: a.coach.number,
label: a.coach.number,
mode: a.coach.status,
name: `Coach ${a.coach.number}`,
seatClasses: seatClassNames,
seatClass: seatClassNames.length > 0 ? seatClassNames[0] : 'Standard',
positionNumber: a.positionNumber,
seatArrangement: a.coach.arrangement,
totalSeats: a.coach.capacity,
seats: allSeats.map((s) => ({
id: s.id,
seatNumber: s.seatNumber,
number: s.seatNumber,
label: s.seatNumber,
status: effectiveStatuses.get(s.id) ?? s.status,
kind: s.kind,
row: s.row,
col: s.col,
isWindow: s.isWindow,
isAisle: s.isAisle,
bedPosition: s.bedPosition,
coach: {
id: a.coach.id,
coachNumber: a.coach.number,
label: a.coach.number,
},
})),
};
}),
};
console.log(`[getSeatMap] returning ${response.coaches.length} coaches with seats`);
return response;
}
/**
* Computes the effective seat status for a set of seats on a specific schedule
* by checking active SeatHolds and confirmed JourneySegments.
*
* Priority: BLOCKED (physical) > BOOKED (confirmed journey) > HELD (active hold) > AVAILABLE
*
* This is needed because seat.status is no longer written during booking —
* availability is segment-scoped, so the DB column stays AVAILABLE even when held.
*/
async resolveEffectiveStatuses(
scheduleId: string,
seatIds: string[],
@@ -61,7 +81,6 @@ export class SeatsService {
if (seatIds.length === 0) return statusMap;
// 1. Active holds — any seat in an unexpired SeatHold for this schedule is HELD
const activeHolds = await this.prisma.seatHold.findMany({
where: {
scheduleId,
@@ -76,8 +95,6 @@ export class SeatsService {
}
}
// 2. Active bookings via JourneySegment — CONFIRMED or PENDING_PAYMENT → BOOKED
// (overwrites HELD if the same seat has a confirmed booking)
const bookedSegments = await this.prisma.journeySegment.findMany({
where: {
scheduleId,
@@ -93,24 +110,21 @@ export class SeatsService {
return statusMap;
}
// ── Hold / Release ────────────────────────────────────────────────────────
async holdSeats(dto: HoldSeatsDto) {
// ── Validate request integrity ───────────────────────────────────────────
const passengerIds = dto.passengers.map(p => p.passengerId);
const seatIds = dto.passengers.map(p => p.seatId);
if (new Set(passengerIds).size !== passengerIds.length)
throw new BadRequestException('Duplicate passengerId in passengers list — each passenger must appear once');
throw new BadRequestException('Duplicate passengerId in passengers list');
if (new Set(seatIds).size !== seatIds.length)
throw new BadRequestException('Duplicate seatId in passengers list — each seat can only be assigned to one passenger');
throw new BadRequestException('Duplicate seatId in passengers list');
const expiresAt = new Date(Date.now() + 5 * 60 * 1000);
const hold = await this.prisma.$transaction(async (tx) => {
// ── 1. Validate seats exist and none are BLOCKED ─────────────────────
const seats = await tx.seat.findMany({
where: { id: { in: seatIds } },
select: { id: true, status: true, label: true },
select: { id: true, status: true, seatNumber: true },
});
if (seats.length !== seatIds.length) {
@@ -121,11 +135,10 @@ export class SeatsService {
const blocked = seats.filter(s => s.status === 'BLOCKED');
if (blocked.length > 0)
throw new ConflictException(`Seat(s) ${blocked.map(s => s.label).join(', ')} are blocked`);
throw new ConflictException(`Seat(s) ${blocked.map(s => s.seatNumber).join(', ')} are blocked`);
const seatLabelById = Object.fromEntries(seats.map(s => [s.id, s.label]));
const seatLabelById = Object.fromEntries(seats.map(s => [s.id, s.seatNumber]));
// ── 2. Resolve requested leg sequences ──────────────────────────────
const stopTimes = await tx.tripStopTime.findMany({
where: { scheduleId: dto.scheduleId },
select: { stationId: true, sequence: true },
@@ -137,17 +150,15 @@ export class SeatsService {
const reqTo = seqOf(dto.destinationStationId);
if (reqFrom === undefined || reqTo === undefined)
throw new BadRequestException('Origin or destination station not found on this schedule');
throw new BadRequestException('Origin or destination station not found');
if (reqFrom >= reqTo)
throw new BadRequestException('Origin must come before destination');
// ── 3. Load active holds for this schedule ───────────────────────────
const activeHolds = await tx.seatHold.findMany({
where: { scheduleId: dto.scheduleId, expiresAt: { gt: new Date() } },
select: { seatIds: true, createdBy: true },
});
// Parse each hold's leg range and passenger list
const parsedHolds: { seatIds: string[]; from: number; to: number; passengerIds: string[] }[] = [];
for (const h of activeHolds) {
try {
@@ -164,32 +175,28 @@ export class SeatsService {
});
}
}
} catch { /* ignore malformed */ }
} catch { /* ignore */ }
}
// ── 4. Per-passenger validation with overlap check ───────────────────
for (const { passengerId, seatId } of dto.passengers) {
for (const hold of parsedHolds) {
const legsOverlap = hold.from < reqTo && reqFrom < hold.to;
if (!legsOverlap) continue; // non-overlapping leg — no conflict
if (!legsOverlap) continue;
// Rule A: seat is held on an overlapping leg
if (hold.seatIds.includes(seatId)) {
throw new ConflictException(
`Seat ${seatLabelById[seatId]} is already held for this leg. Please choose a different seat.`,
`Seat ${seatLabelById[seatId]} is already held for this leg`,
);
}
// Rule B: passenger already holds a seat on an overlapping leg
if (hold.passengerIds.includes(passengerId)) {
throw new ConflictException(
`Passenger already holds a seat on this journey leg. You can only hold one seat per journey.`,
`Passenger already holds a seat on this journey leg`,
);
}
}
}
// Store passenger→seat mapping AND leg in createdBy as JSON
const holdMeta = {
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
@@ -228,12 +235,7 @@ export class SeatsService {
return this.enrichHold(hold);
}
/**
* Resolves the opaque fareQuoteId leg encoding into human-readable station
* names and enriches the hold with schedule, seat, and leg details.
*/
private async enrichHold(hold: any) {
// Decode leg and passenger→seat mapping from createdBy JSON
let originStationId: string | null = null;
let destinationStationId: string | null = null;
let passengerSeatMap: { passengerId: string; seatId: string }[] = [];
@@ -241,7 +243,6 @@ export class SeatsService {
try {
if (hold.createdBy) {
const raw = hold.createdBy;
// Guard: only parse if it looks like a JSON object, not a plain number/string
if (typeof raw === 'string' && raw.trimStart().startsWith('{')) {
const meta = JSON.parse(raw);
originStationId = meta.originStationId ?? null;
@@ -249,7 +250,7 @@ export class SeatsService {
passengerSeatMap = Array.isArray(meta.passengers) ? meta.passengers : [];
}
}
} catch { /* ignore malformed createdBy */ }
} catch { /* ignore */ }
const seatIds = hold.seatIds as string[];
@@ -262,7 +263,7 @@ export class SeatsService {
destinationStationId ? this.prisma.station.findUnique({ where: { id: destinationStationId } }) : null,
this.prisma.seat.findMany({
where: { id: { in: seatIds } },
include: { coach: { include: { seatClass: true } } },
include: { coach: true },
}),
]);
@@ -277,10 +278,8 @@ export class SeatsService {
destinationSequence = stopTimes.find(s => s.stationId === destinationStationId)?.sequence ?? null;
}
// Build seat map keyed by seatId for quick lookup
const seatById = Object.fromEntries(seats.map(s => [s.id, s]));
// Merge passenger→seat mapping with seat details
const passengers = passengerSeatMap.length > 0
? passengerSeatMap.map(({ passengerId, seatId }) => {
const s = seatById[seatId];
@@ -288,26 +287,25 @@ export class SeatsService {
passengerId,
seat: s ? {
id: s.id,
label: s.label,
label: s.seatNumber,
seatNumber: s.seatNumber,
coach: s.coach.label,
seatClass: s.coach.seatClass.name,
coach: s.coach.number,
seatClass: 'Standard',
row: s.row,
col: s.col,
} : { id: seatId },
};
})
// Fallback for holds created before this change
: seatIds.map(seatId => {
const s = seatById[seatId];
return {
passengerId: hold.passengerId,
seat: s ? {
id: s.id,
label: s.label,
label: s.seatNumber,
seatNumber: s.seatNumber,
coach: s.coach.label,
seatClass: s.coach.seatClass.name,
coach: s.coach.number,
seatClass: 'Standard',
row: s.row,
col: s.col,
} : { id: seatId },
@@ -350,22 +348,26 @@ export class SeatsService {
}
async confirmSeats(seatIds: string[]) {
// No-op for status — availability is segment-scoped via JourneySegment
// seat.status = BLOCKED is the only hard gate; BOOKED is not used as a booking flag
}
async releaseSeats(seatIds: string[]) {
// Only reset seats that are physically BLOCKED back to AVAILABLE if needed
// For segment-based bookings, releasing is handled by JourneySegment deletion
// No-op
}
async autoAssignSeats(scheduleId: string, count: number, seatClassName: string, eligibility?: string): Promise<string[]> {
async releaseSeats(seatIds: string[]) {
if (seatIds.length > 0) {
await this.prisma.journeySegment.deleteMany({
where: { seatId: { in: seatIds } },
});
}
}
async autoAssignSeats(scheduleId: string, count: number, seatClassName: string): Promise<string[]> {
const seats = await this.prisma.seat.findMany({
where: {
coach: { seatClass: { name: seatClassName }, assignments: { some: { scheduleId } } },
coach: { assignments: { some: { scheduleId } } },
status: 'AVAILABLE',
...(eligibility ? { eligibility } : {}),
seatNumber: { not: '' },
NOT: { seatNumber: { startsWith: '-' } },
},
orderBy: [{ coach: { label: 'asc' } }, { row: 'asc' }, { col: 'asc' }],
orderBy: [{ coach: { number: 'asc' } }, { row: 'asc' }, { col: 'asc' }],
});
if (seats.length < count) {
@@ -400,10 +402,10 @@ export class SeatsService {
where: { scheduleId },
include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] } } } },
});
const rows = ['coachId,coachLabel,row,col,label,kind,status,premiumFeeMinor,eligibility'];
const rows = ['coachId,coachLabel,row,col,seatNumber,kind,status,premiumFeeMinor'];
for (const a of assignments) {
for (const seat of a.coach.seats) {
rows.push(`${a.coach.id},${a.coach.label},${seat.row},${seat.col},${seat.label},${seat.kind},${seat.status},${seat.premiumFeeMinor},${seat.eligibility || ''}`);
rows.push(`${a.coach.id},${a.coach.number},${seat.row},${seat.col},${seat.seatNumber},${seat.kind},${seat.status},${seat.premiumFeeMinor}`);
}
}
return rows.join('\n');
@@ -422,8 +424,8 @@ export class SeatsService {
invalid++;
continue;
}
const [coachId, coachLabel, row, col, label, kind, status, premiumFeeMinor] = parts;
if (!coachId || !row || !col || !label) {
const [coachId, coachLabel, row, col, seatNumber, kind, status, premiumFeeMinor] = parts;
if (!coachId || !row || !col || !seatNumber) {
errors.push(`Line ${i + 2}: Missing required fields`);
invalid++;
continue;
@@ -440,32 +442,30 @@ export class SeatsService {
let imported = 0;
if (!commit) {
return { imported: 0, errors: ['Preview mode - use commit=true to apply changes'] };
return { imported: 0, errors: ['Preview mode'] };
}
for (let i = 0; i < lines.length; i++) {
try {
const parts = lines[i].split(',');
const [coachId, coachLabel, row, col, label, kind, status, premiumFeeMinor, eligibility] = parts;
const [coachId, coachLabel, row, col, seatNumber, kind, status, premiumFeeMinor] = parts;
await this.prisma.seat.upsert({
where: { coachId_row_col: { coachId, row: parseInt(row), col } },
update: {
label,
seatNumber,
kind: kind as any,
status: status as any,
premiumFeeMinor: parseInt(premiumFeeMinor) || 0,
eligibility: eligibility || null,
},
create: {
coachId,
row: parseInt(row),
col,
label,
seatNumber,
kind: kind as any,
status: status as any,
premiumFeeMinor: parseInt(premiumFeeMinor) || 0,
eligibility: eligibility || null,
},
});
imported++;
@@ -477,9 +477,86 @@ export class SeatsService {
return { imported, errors: errors.slice(0, 10) };
}
async blockSeat(seatId: string, reason: string) {
const seat = await this.prisma.seat.findUnique({ where: { id: seatId } });
if (!seat) throw new NotFoundException('Seat not found');
await this.prisma.seat.update({
where: { id: seatId },
data: { status: 'BLOCKED' },
});
await this.prisma.seatBlock.create({
data: {
seatId,
reason,
blockedBy: 'system',
},
});
return { blocked: true, seatId, reason };
}
async unblockSeat(seatId: string) {
const seat = await this.prisma.seat.findUnique({ where: { id: seatId } });
if (!seat) throw new NotFoundException('Seat not found');
await this.prisma.seat.update({
where: { id: seatId },
data: { status: 'AVAILABLE' },
});
await this.prisma.seatBlock.deleteMany({
where: { seatId },
});
return { unblocked: true, seatId };
}
async removeSeat(seatId: string) {
const seat = await this.prisma.seat.findUnique({ where: { id: seatId } });
if (!seat) throw new NotFoundException('Seat not found');
if (!seat.seatNumber) throw new BadRequestException('Seat already removed');
// Mark removed seat with negative seatNumber (e.g., '1' → '-1') to show empty space
const negatedNumber = `-${seat.seatNumber}`;
await this.prisma.seat.update({
where: { id: seatId },
data: { seatNumber: negatedNumber },
});
return { removed: true, seatId, originalSeatNumber: seat.seatNumber };
}
async undoRemoveSeat(seatId: string) {
const seat = await this.prisma.seat.findUnique({ where: { id: seatId } });
if (!seat) throw new NotFoundException('Seat not found');
if (!seat.seatNumber || !seat.seatNumber.startsWith('-')) {
throw new BadRequestException('Seat is not removed');
}
// Restore original seatNumber by removing the negative sign
const originalNumber = seat.seatNumber.slice(1);
await this.prisma.seat.update({
where: { id: seatId },
data: { seatNumber: originalNumber },
});
return { restored: true, seatId, seatNumber: originalNumber };
}
@Cron(CronExpression.EVERY_MINUTE)
async expireHolds() {
const expired = await this.prisma.seatHold.findMany({ where: { expiresAt: { lt: new Date() } } });
for (const hold of expired) { await this.releaseSeats(hold.seatIds); await this.prisma.seatHold.delete({ where: { id: hold.id } }); }
for (const hold of expired) {
await this.releaseSeats(hold.seatIds);
try {
await this.prisma.seatHold.delete({ where: { id: hold.id } });
} catch (err) {
if (err instanceof Error && !err.message.includes('P2025')) {
throw err;
}
}
}
}
}

View File

@@ -87,7 +87,7 @@ async function holdSeatsTransaction(scheduleId: string, seatIds: string[], passe
for (const seat of seats) {
if (seat.status !== 'AVAILABLE') {
throw new Error(`Seat ${seat.label} is not available (status: ${seat.status})`);
throw new Error(`Seat ${seat.seatNumber} is not available (status: ${seat.status})`);
}
}

View File

@@ -35,14 +35,12 @@ export class EnhancedSeatsService {
for (const seatId of request.seatIds) {
const seat = await tx.seat.findUnique({ where: { id: seatId } });
if (!seat) throw new BadRequestException(`Seat ${seatId} not found`);
// Only BLOCKED seats are hard-rejected — BOOKED/HELD are fine if the
// segment does not overlap (another passenger may occupy a different leg)
if (seat.status === 'BLOCKED') throw new BadRequestException(`Seat ${seat.label} is blocked`);
if (seat.status === 'BLOCKED') throw new BadRequestException(`Seat ${seat.seatNumber} is blocked`);
const free = await this.segmentsService.isSeatFreeForLeg(
request.scheduleId, seatId, reqFrom, reqTo,
);
if (!free) throw new ConflictException(`Seat ${seat.label} is not available for the requested leg`);
if (!free) throw new ConflictException(`Seat ${seat.seatNumber} is not available for the requested leg`);
}
const expiresAt = new Date(Date.now() + 10 * 60 * 1000);
@@ -51,7 +49,6 @@ export class EnhancedSeatsService {
scheduleId: request.scheduleId,
seatIds: request.seatIds,
passengerId: request.passengerId,
// Store leg in createdBy JSON — no fareQuoteId needed
createdBy: JSON.stringify({
originStationId: request.originStationId,
destinationStationId: request.destinationStationId,
@@ -60,7 +57,6 @@ export class EnhancedSeatsService {
},
});
// Do NOT set seat.status = HELD globally — status is segment-scoped
this.eventEmitter.emit('seats.held', { holdId: seatHold.id, scheduleId: request.scheduleId, seatIds: request.seatIds, segments });
return { holdId: seatHold.id, expiresAt, segments, seats: request.seatIds };
});
@@ -81,7 +77,6 @@ export class EnhancedSeatsService {
});
if (!schedule) throw new BadRequestException('Schedule not found');
// Resolve the passenger's leg from createdBy JSON
let originStationId: string | undefined;
let destinationStationId: string | undefined;
try {
@@ -92,15 +87,15 @@ export class EnhancedSeatsService {
}
} catch { /* ignore */ }
const originStop = originStationId ? schedule.stopTimes.find(s => s.stationId === originStationId) : undefined;
const destStop = destinationStationId ? schedule.stopTimes.find(s => s.stationId === destinationStationId) : undefined;
const originStop = originStationId ? schedule.stopTimes.find((s: any) => s.stationId === originStationId) : undefined;
const destStop = destinationStationId ? schedule.stopTimes.find((s: any) => s.stationId === destinationStationId) : undefined;
const fromSeq = originStop?.sequence ?? schedule.stopTimes[0].sequence;
const toSeq = destStop?.sequence ?? schedule.stopTimes[schedule.stopTimes.length - 1].sequence;
const segments: Segment[] = [];
for (let i = fromSeq; i < toSeq; i++) {
const fromStop = schedule.stopTimes.find(s => s.sequence === i);
const toStop = schedule.stopTimes.find(s => s.sequence === i + 1);
const fromStop = schedule.stopTimes.find((s: any) => s.sequence === i);
const toStop = schedule.stopTimes.find((s: any) => s.sequence === i + 1);
if (fromStop && toStop) {
segments.push({
fromStationId: fromStop.stationId,
@@ -132,7 +127,6 @@ export class EnhancedSeatsService {
}
}
// Do NOT set seat.status = BOOKED globally — availability is segment-scoped
await tx.seatHold.delete({ where: { id: request.holdId } });
this.eventEmitter.emit('booking.confirmed', { bookingId: request.bookingId, scheduleId: hold.scheduleId, seatIds: hold.seatIds, segments });
@@ -185,22 +179,20 @@ export class EnhancedSeatsService {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
include: { coachAssignments: { include: { coach: { include: { seats: true, seatClass: true } } } } },
include: { coachAssignments: { include: { coach: { include: { seats: true } } } } },
});
if (!schedule) throw new BadRequestException('Schedule not found');
const availableSeats = [];
for (const assignment of schedule.coachAssignments) {
for (const seat of assignment.coach.seats) {
// Hard-blocked seats are never available
if (seat.status === 'BLOCKED') continue;
// Availability is determined purely by segment overlap — not global seat.status
const free = await this.segmentsService.isSeatFreeForLeg(scheduleId, seat.id, reqFrom, reqTo);
if (free) {
availableSeats.push({
id: seat.id, label: seat.label,
coach: assignment.coach.label,
seatClass: assignment.coach.seatClass.name,
id: seat.id, label: seat.seatNumber,
coach: assignment.coach.number,
seatClass: 'Standard',
row: seat.row, col: seat.col,
kind: seat.kind,
isWindow: seat.isWindow,

View File

@@ -1,16 +1,34 @@
import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete } from '@nestjs/common';
import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { TicketsService } from './tickets.service';
import { JwtGuard } from '../../common/jwt.guard';
@ApiTags('Tickets')
@Controller('tickets')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
export class TicketsController {
constructor(private service: TicketsService) {}
@Post('generate/:bookingId')
@ApiOperation({
summary: 'Generate ticket for booking (confirmation page)',
description: 'Creates a ticket when confirmation page is reached and permanently holds all associated seats with SeatBlock records.'
})
generateTicket(@Param('bookingId') bookingId: string) {
return this.service.generate(bookingId);
}
@Patch('update-seats/:bookingId')
@ApiOperation({
summary: 'Update ticket seats before final confirmation',
description: 'Allows users to change selected seats after ticket generation. Removes old seat blocks and creates new ones for updated seats.'
})
updateSeats(@Param('bookingId') bookingId: string, @Body() body: { seatIds: string[] }) {
return this.service.updateSeats(bookingId, body.seatIds);
}
@Get()
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'List all tickets with optional filters' })
listTickets(
@Query('search') search?: string,
@@ -27,6 +45,8 @@ export class TicketsController {
}
@Get(':bookingRef')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Get ticket with QR code and passenger details',
description: `Returns ticket information including:
@@ -42,6 +62,8 @@ export class TicketsController {
}
@Post(':bookingRef/validate')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Validate ticket at gate with audit logging',
description: 'Validates ticket QR/barcode at station gate. Records validation in audit log with timestamp, gate, and validator.'
@@ -55,27 +77,35 @@ export class TicketsController {
}
@Get(':ticketId/validation-logs')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Get validation logs for ticket' })
getValidationLogs(@Param('ticketId') ticketId: string) {
return this.service.getValidationLogs(ticketId);
}
@Get('offline/export')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Export tickets for offline validation' })
exportOfflineData(@Query('scheduleId') scheduleId: string) {
return this.service.exportOfflineData(scheduleId);
}
@Post('validate/offline')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Batch import offline validations' })
validateOfflineBatch(@Body() body: { validations: any[] }) {
return this.service.validateOfflineBatch(body.validations);
}
@Delete(':id')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Delete ticket (admin only)',
description: 'Permanently deletes a ticket record'
description: 'Permanently deletes a ticket record and removes associated seat blocks'
})
delete(@Param('id') id: string) {
return this.service.delete(id);

View File

@@ -1,6 +1,13 @@
import { Module } from '@nestjs/common';
import { TicketsController } from './tickets.controller';
import { TicketsService } from './tickets.service';
import { JwtGuard } from '../../common/jwt.guard';
@Module({ controllers: [TicketsController], providers: [TicketsService], exports: [TicketsService] })
@Module({
controllers: [TicketsController],
providers: [TicketsService, JwtGuard],
exports: [TicketsService, JwtGuard],
})
export class TicketsModule {}
export { TicketsController } from './tickets.controller';

View File

@@ -19,6 +19,7 @@ export class TicketsService {
where.OR = [
{ bookingRef: { contains: filters.search, mode: 'insensitive' } },
{ barcodePayload: { contains: filters.search, mode: 'insensitive' } },
{ booking: { bookingRef: { contains: filters.search, mode: 'insensitive' } } },
];
}
if (filters.status) {
@@ -44,7 +45,13 @@ export class TicketsService {
items: tickets.map((t) => ({
id: t.id,
ticketNumber: t.barcodePayload,
booking: t.booking,
bookingRef: t.bookingRef,
booking: {
bookingRef: t.booking.bookingRef,
status: t.booking.status,
passenger: t.booking.passenger?.user || { fullName: 'Guest', email: t.booking.contactEmail },
contactEmail: t.booking.contactEmail,
},
schedule: t.booking.schedule,
seat: t.booking.seats[0]?.seat,
status: t.booking.status,
@@ -58,18 +65,96 @@ export class TicketsService {
}
async generate(bookingId: string) {
if (!bookingId) {
throw new BadRequestException('Booking ID is required');
}
const booking = await this.prisma.booking.findUnique({
where: { id: bookingId },
include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: true } } } } },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
seats: { include: { seat: { include: { coach: true } } } }
},
});
if (!booking) throw new NotFoundException('Booking not found');
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
const qrPayload = await QRCode.toDataURL(`edr:tkt:${booking.id}:${booking.bookingRef}`);
const barcodePayload = `EDR${booking.bookingRef}${booking.id.substring(0, 8).toUpperCase()}`;
return this.prisma.ticket.upsert({
where: { bookingId },
update: { qrPayload, barcodePayload },
create: { bookingId, bookingRef: booking.bookingRef, qrPayload, barcodePayload }
const ticket = await this.prisma.ticket.upsert({
where: { bookingId },
update: { qrPayload, barcodePayload },
create: { bookingId, bookingRef: booking.bookingRef, qrPayload, barcodePayload },
});
// Update all booked seats from HELD to BOOKED and create permanent seat blocks
const seatIds = booking.seats.map(bs => bs.seatId);
for (const seatId of seatIds) {
// Update seat status to BOOKED
await this.prisma.seat.update({
where: { id: seatId },
data: { status: 'BOOKED' },
});
// Create permanent seat blocks for all booked seats
await this.prisma.seatBlock.create({
data: {
seatId,
reason: `Permanently booked in ticket ${ticket.id}`,
blockedBy: 'SYSTEM',
approvedBy: 'SYSTEM',
}
}).catch(() => null); // Ignore if already exists
}
return ticket;
}
async updateSeats(bookingId: string, newSeatIds: string[]) {
const booking = await this.prisma.booking.findUnique({
where: { id: bookingId },
include: { seats: true, ticket: true },
});
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
if (!booking.ticket) throw new BadRequestException('No ticket found for this booking');
// Remove old seat blocks
const oldSeatIds = booking.seats.map(bs => bs.seatId);
for (const seatId of oldSeatIds) {
await this.prisma.seatBlock.deleteMany({
where: {
seatId,
reason: { contains: booking.ticket.id }
}
});
}
// Remove old booking seats
await this.prisma.bookingSeat.deleteMany({ where: { bookingId } });
// Create new seat blocks
for (const seatId of newSeatIds) {
await this.prisma.seatBlock.create({
data: {
seatId,
reason: `Permanently booked in ticket ${booking.ticket.id}`,
blockedBy: 'SYSTEM',
approvedBy: 'SYSTEM',
}
}).catch(() => null);
}
// Create new booking seats (placeholder with minimal data)
for (let i = 0; i < newSeatIds.length; i++) {
await this.prisma.bookingSeat.create({
data: {
bookingId,
seatId: newSeatIds[i],
passengerName: `Passenger ${i + 1}`,
}
});
}
return { success: true, updatedSeats: newSeatIds.length };
}
async getByRef(bookingRef: string) {
@@ -83,7 +168,7 @@ export class TicketsService {
id: booking.ticket.id, bookingId: booking.id, bookingRef: booking.bookingRef, status: booking.status,
fromStationName: booking.schedule.originStation.name, toStationName: booking.schedule.destinationStation.name,
departureAt: booking.schedule.departureAt, trainName: booking.schedule.train.name,
coachLabel: seat?.seat.coach.label, seatLabel: seat?.seat.label, passengerName: seat?.passengerName,
coachLabel: seat?.seat.coach.number, seatLabel: seat?.seat.seatNumber, passengerName: seat?.passengerName,
priceMinor: booking.totalMinor, currency: booking.currency, qrPayload: booking.ticket.qrPayload,
barcodePayload: booking.ticket.barcodePayload
};
@@ -128,8 +213,8 @@ export class TicketsService {
bookingRef: b.bookingRef,
ticketId: b.ticket?.id,
passengerName: b.seats[0]?.passengerName,
seatLabel: b.seats[0]?.seat.label,
coachLabel: b.seats[0]?.seat.coach.label,
seatLabel: b.seats[0]?.seat.seatNumber,
coachLabel: b.seats[0]?.seat.coach.number,
qrPayload: b.ticket?.qrPayload,
status: b.status,
validatedAt: b.ticket?.validatedAt,
@@ -197,6 +282,14 @@ export class TicketsService {
if (!ticket) throw new NotFoundException('Ticket not found');
await this.prisma.gateValidationLog.deleteMany({ where: { ticketId: id } });
// Remove seat blocks associated with this ticket
await this.prisma.seatBlock.deleteMany({
where: {
reason: { contains: id }
}
});
await this.prisma.ticket.delete({ where: { id } });
return { deleted: true, ticketId: id };

View File

@@ -1,13 +1,12 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export',
reactStrictMode: true,
transpilePackages: ['@edr/types', '@edr/ui-common'],
env: {
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000',
},
images: {
unoptimized: true, // Required for static export
unoptimized: true,
},
};

View File

@@ -5,7 +5,7 @@
"scripts": {
"dev": "next dev -p 5184",
"build": "next build",
"start": "next start -p 5184",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit"
},

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